about summary refs log tree commit diff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--LibMatrix.Federation/FederationTypes/FederationBackfillResponse.cs14
-rw-r--r--LibMatrix.Federation/FederationTypes/FederationEvent.cs30
-rw-r--r--LibMatrix.Federation/FederationTypes/FederationGetMissingEventsRequest.cs34
-rw-r--r--LibMatrix.Federation/FederationTypes/FederationTransaction.cs26
-rw-r--r--LibMatrix.Federation/FederationTypes/RoomInvite.cs14
-rw-r--r--LibMatrix.Federation/XMatrixAuthorizationScheme.cs29
-rw-r--r--LibMatrix/Responses/Federation/SignedObject.cs3
-rw-r--r--Utilities/LibMatrix.FederationTest/Controllers/Spec/DirectoryController.cs51
-rw-r--r--Utilities/LibMatrix.FederationTest/Controllers/Spec/MembershipsController.cs41
-rw-r--r--Utilities/LibMatrix.FederationTest/FedTest.http13
-rw-r--r--Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml19
-rw-r--r--Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml.cs9
-rw-r--r--Utilities/LibMatrix.FederationTest/Program.cs26
-rw-r--r--Utilities/LibMatrix.FederationTest/Services/ServerAuthService.cs58
14 files changed, 356 insertions, 11 deletions
diff --git a/LibMatrix.Federation/FederationTypes/FederationBackfillResponse.cs b/LibMatrix.Federation/FederationTypes/FederationBackfillResponse.cs
new file mode 100644

index 0000000..0fe72bd --- /dev/null +++ b/LibMatrix.Federation/FederationTypes/FederationBackfillResponse.cs
@@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Federation.FederationTypes; + +public class FederationBackfillResponse { + [JsonPropertyName("origin")] + public required string Origin { get; set; } + + [JsonPropertyName("origin_server_ts")] + public required long OriginServerTs { get; set; } + + [JsonPropertyName("pdus")] + public required List<SignedFederationEvent> Pdus { get; set; } +} \ No newline at end of file diff --git a/LibMatrix.Federation/FederationTypes/FederationEvent.cs b/LibMatrix.Federation/FederationTypes/FederationEvent.cs new file mode 100644
index 0000000..05bdcc9 --- /dev/null +++ b/LibMatrix.Federation/FederationTypes/FederationEvent.cs
@@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Federation.FederationTypes; + +public class FederationEvent : MatrixEventResponse { + [JsonPropertyName("auth_events")] + public required List<string> AuthEvents { get; set; } = []; + + [JsonPropertyName("prev_events")] + public required List<string> PrevEvents { get; set; } = []; + + [JsonPropertyName("depth")] + public required int Depth { get; set; } +} + +public class SignedFederationEvent : FederationEvent { + [JsonPropertyName("signatures")] + public required Dictionary<string, Dictionary<string, string>> Signatures { get; set; } = new(); + + [JsonPropertyName("hashes")] + public required Dictionary<string, string> Hashes { get; set; } = new(); +} + +public class FederationEphemeralEvent { + [JsonPropertyName("edu_type")] + public required string Type { get; set; } + + [JsonPropertyName("content")] + public required Dictionary<string, object> Content { get; set; } = new(); +} \ No newline at end of file diff --git a/LibMatrix.Federation/FederationTypes/FederationGetMissingEventsRequest.cs b/LibMatrix.Federation/FederationTypes/FederationGetMissingEventsRequest.cs new file mode 100644
index 0000000..f43dd49 --- /dev/null +++ b/LibMatrix.Federation/FederationTypes/FederationGetMissingEventsRequest.cs
@@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Federation.FederationTypes; + +public class FederationGetMissingEventsRequest { + /// <summary> + /// Latest event IDs we already have (aka earliest to return) + /// </summary> + [JsonPropertyName("earliest_events")] + public required List<string> EarliestEvents { get; set; } + + /// <summary> + /// Events we want to get events before + /// </summary> + [JsonPropertyName("latest_events")] + public required List<string> LatestEvents { get; set; } + + /// <summary> + /// 10 by default + /// </summary> + [JsonPropertyName("limit")] + public int Limit { get; set; } + + /// <summary> + /// 0 by default + /// </summary> + [JsonPropertyName("min_depth")] + public long MinDepth { get; set; } +} + +public class FederationGetMissingEventsResponse { + [JsonPropertyName("events")] + public required List<SignedFederationEvent> Events { get; set; } +} \ No newline at end of file diff --git a/LibMatrix.Federation/FederationTypes/FederationTransaction.cs b/LibMatrix.Federation/FederationTypes/FederationTransaction.cs new file mode 100644
index 0000000..0581a08 --- /dev/null +++ b/LibMatrix.Federation/FederationTypes/FederationTransaction.cs
@@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Federation.FederationTypes; + +/// <summary> +/// This only covers v12 rooms for now? +/// </summary> +public class FederationTransaction { + /// <summary> + /// Up to 100 EDUs per transaction + /// </summary> + [JsonPropertyName("edus")] + public List<FederationEvent>? EphemeralEvents { get; set; } + + [JsonPropertyName("origin")] + public required string Origin { get; set; } + + [JsonPropertyName("origin_server_ts")] + public required long OriginServerTs { get; set; } + + /// <summary> + /// Up to 50 PDUs per transaction + /// </summary> + [JsonPropertyName("pdus")] + public List<SignedFederationEvent>? PersistentEvents { get; set; } +} \ No newline at end of file diff --git a/LibMatrix.Federation/FederationTypes/RoomInvite.cs b/LibMatrix.Federation/FederationTypes/RoomInvite.cs new file mode 100644
index 0000000..dc550f3 --- /dev/null +++ b/LibMatrix.Federation/FederationTypes/RoomInvite.cs
@@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Federation.FederationTypes; + +public class RoomInvite { + [JsonPropertyName("event")] + public required SignedFederationEvent Event { get; set; } + + [JsonPropertyName("invite_room_state")] + public required List<MatrixEventResponse> InviteRoomState { get; set; } = []; + + [JsonPropertyName("room_version")] + public required string RoomVersion { get; set; } +} \ No newline at end of file diff --git a/LibMatrix.Federation/XMatrixAuthorizationScheme.cs b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs
index 392cd93..c6be906 100644 --- a/LibMatrix.Federation/XMatrixAuthorizationScheme.cs +++ b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ArcaneLibs.Extensions; using LibMatrix.Abstractions; +using LibMatrix.Extensions; using LibMatrix.Responses.Federation; using Microsoft.Extensions.Primitives; @@ -37,17 +38,27 @@ public class XMatrixAuthorizationScheme { ErrorCode = MatrixException.ErrorCodes.M_UNAUTHORIZED }; - var headerValues = new StringValues(header.Parameter); - foreach (var value in headerValues) { - Console.WriteLine(headerValues.ToJson()); + var headerValues = new Dictionary<string, string>(); + var parts = header.Parameter.Split(','); + foreach (var part in parts) { + var kv = part.Split('=', 2); + if (kv.Length != 2) + continue; + var key = kv[0].Trim(); + var value = kv[1].Trim().Trim('"'); + headerValues[key] = value; } - return new() { - Destination = "", - Key = "", - Origin = "", - Signature = "" + Console.WriteLine("X-Matrix parts: " + headerValues.ToJson(unsafeContent: true)); + + var xma = new XMatrixAuthorizationHeader() { + Destination = headerValues["destination"], + Key = headerValues["key"], + Origin = headerValues["origin"], + Signature = headerValues["sig"] }; + Console.WriteLine("Parsed X-Matrix Auth Header: " + xma.ToJson()); + return xma; } public static XMatrixAuthorizationHeader FromSignedObject(SignedObject<XMatrixRequestSignature> signedObj, VersionedHomeserverPrivateKey currentKey) => @@ -74,7 +85,7 @@ public class XMatrixAuthorizationScheme { [JsonPropertyName("destination")] public required string DestinationServerName { get; set; } - [JsonPropertyName("content"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("content"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonObject? Content { get; set; } } } \ No newline at end of file diff --git a/LibMatrix/Responses/Federation/SignedObject.cs b/LibMatrix/Responses/Federation/SignedObject.cs
index 3f6ffd6..517bb1f 100644 --- a/LibMatrix/Responses/Federation/SignedObject.cs +++ b/LibMatrix/Responses/Federation/SignedObject.cs
@@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -19,7 +20,7 @@ public class SignedObject<T> { } [JsonExtensionData] - public required JsonObject Content { get; set; } + public JsonObject Content { get; set; } = null!; [JsonIgnore] public T TypedContent { diff --git a/Utilities/LibMatrix.FederationTest/Controllers/Spec/DirectoryController.cs b/Utilities/LibMatrix.FederationTest/Controllers/Spec/DirectoryController.cs new file mode 100644
index 0000000..707a149 --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/Controllers/Spec/DirectoryController.cs
@@ -0,0 +1,51 @@ +using System.Net.Http.Headers; +using LibMatrix.Federation; +using LibMatrix.FederationTest.Services; +using LibMatrix.Homeservers; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.FederationTest.Controllers.Spec; + +[ApiController] +[Route("_matrix/federation/")] +public class DirectoryController(ServerAuthService serverAuth) : ControllerBase { + [HttpGet("v1/publicRooms")] + [HttpPost("v1/publicRooms")] + public async Task<IActionResult> GetPublicRooms() { + if (Request.Headers.ContainsKey("Authorization")) { + Console.WriteLine("INFO | Authorization header found."); + await serverAuth.AssertValidAuthentication(); + } + else Console.WriteLine("INFO | Room directory request without auth"); + + var rooms = new List<PublicRoomDirectoryResult.PublicRoomListItem> { + new() { + GuestCanJoin = false, + RoomId = "!tuiLEoMqNOQezxILzt:rory.gay", + NumJoinedMembers = Random.Shared.Next(), + WorldReadable = false, + CanonicalAlias = "#libmatrix:rory.gay", + Name = "Rory&::LibMatrix", + Topic = $"A .NET {Environment.Version.Major} library for interacting with Matrix" + } + }; + return Ok(new PublicRoomDirectoryResult() { + Chunk = rooms, + TotalRoomCountEstimate = rooms.Count + }); + } + + [HttpGet("v1/query/profile")] + public async Task<IActionResult> GetProfile([FromQuery(Name = "user_id")] string userId) { + if (Request.Headers.ContainsKey("Authorization")) { + Console.WriteLine("INFO | Authorization header found."); + await serverAuth.AssertValidAuthentication(); + } + else Console.WriteLine("INFO | Profile request without auth"); + + return Ok(new { + avatar_url = "mxc://rory.gay/ocRVanZoUTCcifcVNwXgbtTg", + displayname = "Rory&::LibMatrix.FederationTest" + }); + } +} \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/Controllers/Spec/MembershipsController.cs b/Utilities/LibMatrix.FederationTest/Controllers/Spec/MembershipsController.cs new file mode 100644
index 0000000..7c561ad --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/Controllers/Spec/MembershipsController.cs
@@ -0,0 +1,41 @@ +using System.Net.Http.Headers; +using LibMatrix.Federation; +using LibMatrix.Federation.FederationTypes; +using LibMatrix.FederationTest.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LibMatrix.FederationTest.Controllers.Spec; + +[ApiController] +[Route("_matrix/federation/")] +public class MembershipsController(ServerAuthService sas) : ControllerBase { + [HttpGet("v1/make_join/{roomId}/{userId}")] + [HttpPut("v1/send_join/{roomId}/{eventId}")] + [HttpPut("v2/send_join/{roomId}/{eventId}")] + [HttpGet("v1/make_knock/{roomId}/{userId}")] + [HttpPut("v1/send_knock/{roomId}/{eventId}")] + [HttpGet("v1/make_leave/{roomId}/{eventId}")] + [HttpPut("v1/send_leave/{roomId}/{eventId}")] + [HttpPut("v2/send_leave/{roomId}/{eventId}")] + public async Task<IActionResult> JoinKnockMemberships() { + await sas.AssertValidAuthentication(); + return NotFound(new MatrixException() { + ErrorCode = MatrixException.ErrorCodes.M_NOT_FOUND, + Error = "Rory&::LibMatrix.FederationTest does not support membership events." + }.GetAsObject()); + } + + // [HttpPut("v1/invite/{roomId}/{eventId}")] + [HttpPut("v2/invite/{roomId}/{eventId}")] + public async Task<IActionResult> InviteHandler([FromBody] RoomInvite invite) { + await sas.AssertValidAuthentication(); + + Console.WriteLine($"Received invite event from {invite.Event.Sender} for room {invite.Event.RoomId} (version {invite.RoomVersion})\n" + + $"{invite.InviteRoomState.Count} invite room state events."); + + return NotFound(new MatrixException() { + ErrorCode = MatrixException.ErrorCodes.M_NOT_FOUND, + Error = "Rory&::LibMatrix.FederationTest does not support membership events." + }.GetAsObject()); + } +} \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/FedTest.http b/Utilities/LibMatrix.FederationTest/FedTest.http new file mode 100644
index 0000000..26b1cd0 --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/FedTest.http
@@ -0,0 +1,13 @@ +POST https://libmatrix-fed-test.rory.gay/ping +Accept: application/json +Content-Type: application/json + +[ + "matrix.org", + "rory.gay", + "element.io", + "4d2.org", + "mozilla.org", + "fedora.im", + "opensuse.org" +] \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml b/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml new file mode 100644
index 0000000..283c13e --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml
@@ -0,0 +1,19 @@ +@page "/" +@model LibMatrix.FederationTest.Pages.IndexPage + +@{ + Layout = null; +} + +<!DOCTYPE html> + +<html> + <head> + <title>LibMatrix.FederationTest</title> + </head> + <body> + <div> + If you're seeing this, LibMatrix.FederationTest is running! + </div> + </body> +</html> \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml.cs b/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml.cs new file mode 100644
index 0000000..0d372b0 --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/Pages/IndexPage.cshtml.cs
@@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace LibMatrix.FederationTest.Pages; + +public class IndexPage : PageModel { + public void OnGet() { + + } +} \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/Program.cs b/Utilities/LibMatrix.FederationTest/Program.cs
index 18d3421..3e9cb80 100644 --- a/Utilities/LibMatrix.FederationTest/Program.cs +++ b/Utilities/LibMatrix.FederationTest/Program.cs
@@ -1,12 +1,33 @@ +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using ArcaneLibs.Extensions; +using LibMatrix.Extensions; +using LibMatrix.Federation; using LibMatrix.FederationTest.Services; using LibMatrix.Services; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers() + .ConfigureApiBehaviorOptions(options => { + options.InvalidModelStateResponseFactory = context => { + var problemDetails = new ValidationProblemDetails(context.ModelState) { + Status = StatusCodes.Status400BadRequest, + Title = "One or more validation errors occurred.", + Detail = "See the errors property for more details.", + Instance = context.HttpContext.Request.Path + }; + + Console.WriteLine("Model validation failed: " + problemDetails.ToJson()); + + return new BadRequestObjectResult(problemDetails) { + ContentTypes = { "application/problem+json", "application/problem+xml" } + }; + }; + }) .AddJsonOptions(options => { options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.JsonSerializerOptions.WriteIndented = true; @@ -20,13 +41,15 @@ builder.Services.AddHttpLogging(options => { options.RequestHeaders.Add("X-Forwarded-Host"); options.RequestHeaders.Add("X-Forwarded-Port"); }); +builder.Services.AddRazorPages(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddRoryLibMatrixServices(); builder.Services.AddSingleton<FederationTestConfiguration>(); builder.Services.AddSingleton<FederationKeyStore>(); +builder.Services.AddScoped<ServerAuthService>(); var app = builder.Build(); - // Configure the HTTP request pipeline. if (true || app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -35,6 +58,7 @@ if (true || app.Environment.IsDevelopment()) { // app.UseAuthorization(); app.MapControllers(); +app.MapRazorPages(); // app.UseHttpLogging(); app.Run(); \ No newline at end of file diff --git a/Utilities/LibMatrix.FederationTest/Services/ServerAuthService.cs b/Utilities/LibMatrix.FederationTest/Services/ServerAuthService.cs new file mode 100644
index 0000000..58274eb --- /dev/null +++ b/Utilities/LibMatrix.FederationTest/Services/ServerAuthService.cs
@@ -0,0 +1,58 @@ +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using LibMatrix.Extensions; +using LibMatrix.Federation; +using LibMatrix.FederationTest.Utilities; +using LibMatrix.Responses.Federation; +using LibMatrix.Services; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Features; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace LibMatrix.FederationTest.Services; + +public class ServerAuthService(HomeserverProviderService hsProvider, IHttpContextAccessor httpContextAccessor) { + private static Dictionary<string, SignedObject<ServerKeysResponse>> _serverKeysCache = new(); + + public async Task AssertValidAuthentication(XMatrixAuthorizationScheme.XMatrixAuthorizationHeader authHeader) { + var httpContext = httpContextAccessor.HttpContext!; + var hs = await hsProvider.GetFederationClient(authHeader.Origin, ""); + var serverKeys = (_serverKeysCache.TryGetValue(authHeader.Origin, out var sk) && sk.TypedContent.ValidUntil > DateTimeOffset.UtcNow) + ? sk + : _serverKeysCache[authHeader.Origin] = await hs.GetServerKeysAsync(); + var publicKeyBase64 = serverKeys.TypedContent.VerifyKeys[authHeader.Key].Key; + var publicKey = Ed25519Utils.LoadPublicKeyFromEncoded(publicKeyBase64); + var requestAuthenticationData = new XMatrixAuthorizationScheme.XMatrixRequestSignature() { + Method = httpContext.Request.Method, + Uri = httpContext.Features.Get<IHttpRequestFeature>()!.RawTarget, + OriginServerName = authHeader.Origin, + DestinationServerName = authHeader.Destination, + Content = httpContext.Request.HasJsonContentType() ? await httpContext.Request.ReadFromJsonAsync<JsonObject?>() : null + }; + var contentBytes = CanonicalJsonSerializer.SerializeToUtf8Bytes(requestAuthenticationData); + var signatureBytes = UnpaddedBase64.Decode(authHeader.Signature); + + Console.WriteLine($"Validating X-Matrix authorized request\n" + + $" - From: {requestAuthenticationData.OriginServerName}, To: {requestAuthenticationData.DestinationServerName}\n" + + $" - Key: {authHeader.Key} ({publicKeyBase64})\n" + + $" - Signature: {authHeader.Signature}\n" + + $" - Request: {requestAuthenticationData.Method} {requestAuthenticationData.Uri}\n" + + $" - Has request body: {requestAuthenticationData.Content is not null}\n" + + // $" - Canonicalized request body (or null if missing): {(requestAuthenticationData.Content is null ? "(null)" : CanonicalJsonSerializer.Serialize(requestAuthenticationData.Content))}\n" + + $" - Canonicalized message to verify: {System.Text.Encoding.UTF8.GetString(contentBytes)}"); + + if (!publicKey.Verify(Ed25519.Algorithm.Ed25519, null, contentBytes, 0, contentBytes.Length, signatureBytes, 0)) { + throw new UnauthorizedAccessException("Invalid signature in X-Matrix authorization header."); + } + + Console.WriteLine("INFO | Valid X-Matrix authorization header."); + } + + public async Task AssertValidAuthentication() { + await AssertValidAuthentication( + XMatrixAuthorizationScheme.XMatrixAuthorizationHeader.FromHeaderValue( + httpContextAccessor.HttpContext!.Request.GetTypedHeaders().Get<AuthenticationHeaderValue>("Authorization")! + ) + ); + } +} \ No newline at end of file