From 6975119b7d21cafdd0620d35b9542fb5d47ef392 Mon Sep 17 00:00:00 2001 From: Rory& Date: Fri, 20 Jun 2025 04:50:00 +0200 Subject: Basic federation, move some response classes to the right namespace --- ArcaneLibs | 2 +- .../Extensions/Ed25519Extensions.cs | 8 ++ LibMatrix.Federation/Utilities/JsonSigning.cs | 108 +++++++++++++++++++++ LibMatrix.Federation/Utilities/UnpaddedBase64.cs | 17 ++++ LibMatrix.Federation/XMatrixAuthorizationScheme.cs | 9 +- LibMatrix/EventIdResponse.cs | 8 -- LibMatrix/Extensions/CanonicalJsonSerializer.cs | 9 ++ LibMatrix/Homeservers/FederationClient.cs | 65 +++++++++++++ LibMatrix/MessagesResponse.cs | 17 ---- LibMatrix/Responses/EventIdResponse.cs | 8 ++ LibMatrix/Responses/MessagesResponse.cs | 17 ++++ LibMatrix/Responses/UserIdAndReason.cs | 11 +++ LibMatrix/Responses/WhoAmIResponse.cs | 14 +++ LibMatrix/RoomTypes/GenericRoom.cs | 2 +- LibMatrix/RoomTypes/SpaceRoom.cs | 1 + LibMatrix/UserIdAndReason.cs | 11 --- LibMatrix/WhoAmIResponse.cs | 14 --- Tests/LibMatrix.Tests/DataTests/WhoAmITests.cs | 2 + .../Controllers/Rooms/RoomStateController.cs | 1 + .../Controllers/Users/UserController.cs | 1 + .../Interfaces/CommandContext.cs | 1 + 21 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 LibMatrix.Federation/Extensions/Ed25519Extensions.cs create mode 100644 LibMatrix.Federation/Utilities/JsonSigning.cs create mode 100644 LibMatrix.Federation/Utilities/UnpaddedBase64.cs delete mode 100644 LibMatrix/EventIdResponse.cs delete mode 100644 LibMatrix/MessagesResponse.cs create mode 100644 LibMatrix/Responses/EventIdResponse.cs create mode 100644 LibMatrix/Responses/MessagesResponse.cs create mode 100644 LibMatrix/Responses/UserIdAndReason.cs create mode 100644 LibMatrix/Responses/WhoAmIResponse.cs delete mode 100644 LibMatrix/UserIdAndReason.cs delete mode 100644 LibMatrix/WhoAmIResponse.cs diff --git a/ArcaneLibs b/ArcaneLibs index 3fe5c48..7cbf235 160000 --- a/ArcaneLibs +++ b/ArcaneLibs @@ -1 +1 @@ -Subproject commit 3fe5c483d7ce6d00a9bb7389f63858959c77dff1 +Subproject commit 7cbf235cceb82dc711622314c49edfc7e2614dc7 diff --git a/LibMatrix.Federation/Extensions/Ed25519Extensions.cs b/LibMatrix.Federation/Extensions/Ed25519Extensions.cs new file mode 100644 index 0000000..69baf58 --- /dev/null +++ b/LibMatrix.Federation/Extensions/Ed25519Extensions.cs @@ -0,0 +1,8 @@ +using LibMatrix.FederationTest.Utilities; +using Org.BouncyCastle.Crypto.Parameters; + +namespace LibMatrix.Federation.Extensions; + +public static class Ed25519Extensions { + public static string ToUnpaddedBase64(this Ed25519PublicKeyParameters key) => UnpaddedBase64.Encode(key.GetEncoded()); +} \ No newline at end of file diff --git a/LibMatrix.Federation/Utilities/JsonSigning.cs b/LibMatrix.Federation/Utilities/JsonSigning.cs new file mode 100644 index 0000000..c727cde --- /dev/null +++ b/LibMatrix.Federation/Utilities/JsonSigning.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ArcaneLibs.Extensions; +using LibMatrix.Extensions; +using LibMatrix.FederationTest.Utilities; +using LibMatrix.Homeservers; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace LibMatrix.Federation.Utilities; + +public static class JsonSigning { } + +[JsonConverter(typeof(SignedObjectConverterFactory))] +public class SignedObject { + [JsonPropertyName("signatures")] + public Dictionary> Signatures { get; set; } = new(); + + [JsonIgnore] + public Dictionary> VerifyKeysById { + get => Signatures.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (ServerKeysResponse.VersionedKeyId)key.Key, key => key.Value)); + set => Signatures = value.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (string)key.Key, key => key.Value)); + } + + [JsonExtensionData] + public required JsonObject Content { get; set; } + + [JsonIgnore] + public T TypedContent { + get => Content.Deserialize() ?? throw new JsonException("Failed to deserialize TypedContent from Content."); + set => Content = JsonSerializer.Deserialize(JsonSerializer.Serialize(value)) ?? new JsonObject(); + } +} + +// Content needs to be merged at toplevel +public class SignedObjectConverter : JsonConverter> { + public override SignedObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + var jsonObject = JsonSerializer.Deserialize(ref reader, options); + if (jsonObject == null) { + throw new JsonException("Failed to deserialize SignedObject, JSON object is null."); + } + + var signatures = jsonObject["signatures"] ?? throw new JsonException("Failed to find 'signatures' property in JSON object."); + jsonObject.Remove("signatures"); + + var signedObject = new SignedObject { + Content = jsonObject, + Signatures = signatures.Deserialize>>() + ?? throw new JsonException("Failed to deserialize 'signatures' property into Dictionary>.") + }; + + return signedObject; + } + + public override void Write(Utf8JsonWriter writer, SignedObject value, JsonSerializerOptions options) { + var targetObj = value.Content.DeepClone(); + targetObj["signatures"] = value.Signatures.ToJsonNode(); + JsonSerializer.Serialize(writer, targetObj, options); + } +} + +internal class SignedObjectConverterFactory : JsonConverterFactory { + public override bool CanConvert(Type typeToConvert) { + if (!typeToConvert.IsGenericType) return false; + return typeToConvert.GetGenericTypeDefinition() == typeof(SignedObject<>); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { + var wrappedType = typeToConvert.GetGenericArguments()[0]; + var converter = (JsonConverter)Activator.CreateInstance(typeof(SignedObjectConverter<>).MakeGenericType(wrappedType))!; + return converter; + } +} + +public static class ObjectExtensions { + public static SignedObject Sign(this SignedObject content, string serverName, string keyName, Ed25519PrivateKeyParameters key) { + var signResult = Sign(content.Content, serverName, keyName, key); + var signedObject = new SignedObject { + Signatures = content.Signatures, + Content = signResult.Content + }; + + if (!signedObject.Signatures.ContainsKey(serverName)) + signedObject.Signatures[serverName] = new Dictionary(); + + signedObject.Signatures[serverName][keyName] = signResult.Signatures[serverName][keyName]; + return signedObject; + } + + public static SignedObject Sign(this T content, string serverName, string keyName, Ed25519PrivateKeyParameters key) { + SignedObject signedObject = new() { + Signatures = [], + Content = JsonSerializer.Deserialize(JsonSerializer.Serialize(content)) ?? new JsonObject(), + }; + + var contentBytes = CanonicalJsonSerializer.SerializeToUtf8Bytes(signedObject.Content); + var signature = new byte[Ed25519.SignatureSize]; + key.Sign(Ed25519.Algorithm.Ed25519, null, contentBytes, 0, contentBytes.Length, signature, 0); + + if (!signedObject.Signatures.ContainsKey(serverName)) + signedObject.Signatures[serverName] = new Dictionary(); + + signedObject.Signatures[serverName][keyName] = UnpaddedBase64.Encode(signature); + return signedObject; + } +} \ No newline at end of file diff --git a/LibMatrix.Federation/Utilities/UnpaddedBase64.cs b/LibMatrix.Federation/Utilities/UnpaddedBase64.cs new file mode 100644 index 0000000..06f84b2 --- /dev/null +++ b/LibMatrix.Federation/Utilities/UnpaddedBase64.cs @@ -0,0 +1,17 @@ +namespace LibMatrix.FederationTest.Utilities; + +public static class UnpaddedBase64 { + public static string Encode(byte[] data) { + return Convert.ToBase64String(data).TrimEnd('='); + } + + public static byte[] Decode(string base64) { + string paddedBase64 = base64; + switch (paddedBase64.Length % 4) { + case 2: paddedBase64 += "=="; break; + case 3: paddedBase64 += "="; break; + } + + return Convert.FromBase64String(paddedBase64); + } +} \ No newline at end of file diff --git a/LibMatrix.Federation/XMatrixAuthorizationScheme.cs b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs index cb349c9..5025434 100644 --- a/LibMatrix.Federation/XMatrixAuthorizationScheme.cs +++ b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs @@ -40,8 +40,15 @@ public class XMatrixAuthorizationScheme { { Console.WriteLine(headerValues.ToJson()); } + + return new() { + Destination = "", + Key = "", + Origin = "", + Signature = "" + }; } - public string ToHeaderValue() { } + public string ToHeaderValue() => $"{Scheme} origin=\"{Origin}\", destination=\"{Destination}\", key=\"{Key}\", sig=\"{Signature}\""; } } \ No newline at end of file diff --git a/LibMatrix/EventIdResponse.cs b/LibMatrix/EventIdResponse.cs deleted file mode 100644 index 6a04229..0000000 --- a/LibMatrix/EventIdResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix; - -public class EventIdResponse { - [JsonPropertyName("event_id")] - public required string EventId { get; set; } -} \ No newline at end of file diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs index 55a4b1a..ae535aa 100644 --- a/LibMatrix/Extensions/CanonicalJsonSerializer.cs +++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; using ArcaneLibs.Extensions; @@ -57,6 +58,14 @@ public static class CanonicalJsonSerializer { // public static String Serialize(TValue value, JsonTypeInfo jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options); // public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo) + public static byte[] SerializeToUtf8Bytes(T value, JsonSerializerOptions? options = null) { + var newOptions = MergeOptions(null); + return JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here... + .SortProperties()! + .CanonicalizeNumbers()! + .ToJsonString(newOptions).AsBytes().ToArray(); + } + #endregion // ReSharper disable once UnusedType.Local diff --git a/LibMatrix/Homeservers/FederationClient.cs b/LibMatrix/Homeservers/FederationClient.cs index 617b737..a2cb12d 100644 --- a/LibMatrix/Homeservers/FederationClient.cs +++ b/LibMatrix/Homeservers/FederationClient.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using LibMatrix.Extensions; using LibMatrix.Services; +using Microsoft.VisualBasic.CompilerServices; namespace LibMatrix.Homeservers; @@ -17,6 +18,70 @@ public class FederationClient { public HomeserverResolverService.WellKnownUris WellKnownUris { get; set; } public async Task GetServerVersionAsync() => await HttpClient.GetFromJsonAsync("/_matrix/federation/v1/version"); + public async Task GetServerKeysAsync() => await HttpClient.GetFromJsonAsync("/_matrix/key/v2/server"); +} + +public class ServerKeysResponse { + [JsonPropertyName("server_name")] + public string ServerName { get; set; } + + [JsonPropertyName("valid_until_ts")] + public ulong ValidUntilTs { get; set; } + + [JsonIgnore] + public DateTime ValidUntil { + get => DateTimeOffset.FromUnixTimeMilliseconds((long)ValidUntilTs).DateTime; + set => ValidUntilTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds(); + } + + [JsonPropertyName("verify_keys")] + public Dictionary VerifyKeys { get; set; } = new(); + + [JsonIgnore] + public Dictionary VerifyKeysById { + get => VerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value); + set => VerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value); + } + + [JsonPropertyName("old_verify_keys")] + public Dictionary OldVerifyKeys { get; set; } = new(); + + [JsonIgnore] + public Dictionary OldVerifyKeysById { + get => OldVerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value); + set => OldVerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value); + } + + public class VersionedKeyId { + public required string Algorithm { get; set; } + public required string KeyId { get; set; } + + public static implicit operator VersionedKeyId(string key) { + var parts = key.Split(':', 2); + if (parts.Length != 2) throw new ArgumentException("Invalid key format. Expected 'algorithm:keyId'.", nameof(key)); + return new VersionedKeyId { Algorithm = parts[0], KeyId = parts[1] }; + } + + public static implicit operator string(VersionedKeyId key) => $"{key.Algorithm}:{key.KeyId}"; + public static implicit operator (string, string)(VersionedKeyId key) => (key.Algorithm, key.KeyId); + public static implicit operator VersionedKeyId((string algorithm, string keyId) key) => (key.algorithm, key.keyId); + } + + public class CurrentVerifyKey { + [JsonPropertyName("key")] + public string Key { get; set; } + } + + public class ExpiredVerifyKey : CurrentVerifyKey { + [JsonPropertyName("expired_ts")] + public ulong ExpiredTs { get; set; } + + [JsonIgnore] + public DateTime Expired { + get => DateTimeOffset.FromUnixTimeMilliseconds((long)ExpiredTs).DateTime; + set => ExpiredTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds(); + } + } } public class ServerVersionResponse { diff --git a/LibMatrix/MessagesResponse.cs b/LibMatrix/MessagesResponse.cs deleted file mode 100644 index 526da74..0000000 --- a/LibMatrix/MessagesResponse.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix; - -public class MessagesResponse { - [JsonPropertyName("start")] - public string Start { get; set; } - - [JsonPropertyName("end")] - public string? End { get; set; } - - [JsonPropertyName("chunk")] - public List Chunk { get; set; } = new(); - - [JsonPropertyName("state")] - public List State { get; set; } = new(); -} \ No newline at end of file diff --git a/LibMatrix/Responses/EventIdResponse.cs b/LibMatrix/Responses/EventIdResponse.cs new file mode 100644 index 0000000..9e23210 --- /dev/null +++ b/LibMatrix/Responses/EventIdResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class EventIdResponse { + [JsonPropertyName("event_id")] + public required string EventId { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Responses/MessagesResponse.cs b/LibMatrix/Responses/MessagesResponse.cs new file mode 100644 index 0000000..4912add --- /dev/null +++ b/LibMatrix/Responses/MessagesResponse.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class MessagesResponse { + [JsonPropertyName("start")] + public string Start { get; set; } + + [JsonPropertyName("end")] + public string? End { get; set; } + + [JsonPropertyName("chunk")] + public List Chunk { get; set; } = new(); + + [JsonPropertyName("state")] + public List State { get; set; } = new(); +} \ No newline at end of file diff --git a/LibMatrix/Responses/UserIdAndReason.cs b/LibMatrix/Responses/UserIdAndReason.cs new file mode 100644 index 0000000..176cf7c --- /dev/null +++ b/LibMatrix/Responses/UserIdAndReason.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +internal class UserIdAndReason(string userId = null!, string reason = null!) { + [JsonPropertyName("user_id")] + public string UserId { get; set; } = userId; + + [JsonPropertyName("reason")] + public string? Reason { get; set; } = reason; +} \ No newline at end of file diff --git a/LibMatrix/Responses/WhoAmIResponse.cs b/LibMatrix/Responses/WhoAmIResponse.cs new file mode 100644 index 0000000..db47152 --- /dev/null +++ b/LibMatrix/Responses/WhoAmIResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class WhoAmIResponse { + [JsonPropertyName("user_id")] + public required string UserId { get; set; } + + [JsonPropertyName("device_id")] + public string? DeviceId { get; set; } + + [JsonPropertyName("is_guest")] + public bool? IsGuest { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs index 2eb1dba..fd4db4d 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs @@ -1,6 +1,5 @@ using System.Collections.Frozen; using System.Net.Http.Json; -using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -12,6 +11,7 @@ using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Filters; using LibMatrix.Helpers; using LibMatrix.Homeservers; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs index 0c74be5..96abd77 100644 --- a/LibMatrix/RoomTypes/SpaceRoom.cs +++ b/LibMatrix/RoomTypes/SpaceRoom.cs @@ -1,5 +1,6 @@ using ArcaneLibs.Extensions; using LibMatrix.Homeservers; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; diff --git a/LibMatrix/UserIdAndReason.cs b/LibMatrix/UserIdAndReason.cs deleted file mode 100644 index 99c9eaf..0000000 --- a/LibMatrix/UserIdAndReason.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix; - -internal class UserIdAndReason(string userId = null!, string reason = null!) { - [JsonPropertyName("user_id")] - public string UserId { get; set; } = userId; - - [JsonPropertyName("reason")] - public string? Reason { get; set; } = reason; -} \ No newline at end of file diff --git a/LibMatrix/WhoAmIResponse.cs b/LibMatrix/WhoAmIResponse.cs deleted file mode 100644 index 10fff35..0000000 --- a/LibMatrix/WhoAmIResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix; - -public class WhoAmIResponse { - [JsonPropertyName("user_id")] - public required string UserId { get; set; } - - [JsonPropertyName("device_id")] - public string? DeviceId { get; set; } - - [JsonPropertyName("is_guest")] - public bool? IsGuest { get; set; } -} \ No newline at end of file diff --git a/Tests/LibMatrix.Tests/DataTests/WhoAmITests.cs b/Tests/LibMatrix.Tests/DataTests/WhoAmITests.cs index e1da3d5..2a25056 100644 --- a/Tests/LibMatrix.Tests/DataTests/WhoAmITests.cs +++ b/Tests/LibMatrix.Tests/DataTests/WhoAmITests.cs @@ -1,3 +1,5 @@ +using LibMatrix.Responses; + namespace LibMatrix.Tests.DataTests; public static class WhoAmITests { diff --git a/Utilities/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs b/Utilities/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs index 74c70a3..485c028 100644 --- a/Utilities/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs +++ b/Utilities/LibMatrix.HomeserverEmulator/Controllers/Rooms/RoomStateController.cs @@ -2,6 +2,7 @@ using System.Collections.Frozen; using System.Text.Json.Nodes; using LibMatrix.HomeserverEmulator.Extensions; using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; using Microsoft.AspNetCore.Mvc; namespace LibMatrix.HomeserverEmulator.Controllers.Rooms; diff --git a/Utilities/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs b/Utilities/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs index 40f3667..339e686 100644 --- a/Utilities/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs +++ b/Utilities/LibMatrix.HomeserverEmulator/Controllers/Users/UserController.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.HomeserverEmulator.Services; +using LibMatrix.Responses; using Microsoft.AspNetCore.Mvc; namespace LibMatrix.HomeserverEmulator.Controllers; diff --git a/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs b/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs index c6abde2..71ecbed 100644 --- a/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs +++ b/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs @@ -1,5 +1,6 @@ using LibMatrix.EventTypes.Spec; using LibMatrix.Homeservers; +using LibMatrix.Responses; using LibMatrix.RoomTypes; namespace LibMatrix.Utilities.Bot.Interfaces; -- cgit 1.5.1