From c5a69d240afa8ed9a47ebf16bedf1b14fa46bd0a Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 06:48:22 +0200 Subject: [PATCH] RFC 9421 signatures are verified, not refused WordPress's ActivityPub plugin (and Ghost and Fedify) sign with RFC 9421 first and fall back to draft-cavage only after a refusal, so each first delivery cost two requests and a 401 in our statistics. Now a request carrying Signature-Input is verified as an HTTP message signature: its covered components (the method and our own public target, the body's Content-Digest), its created and expires, with the actor's RSA key under PKCS#1 v1.5 or PSS. Deliveries and signed fetches both take it; the ledger names the scheme (rfc9421:rsa-v1_5-sha256). What PrivaPub sends stays draft-cavage, which every server reads. Ed25519 waits for FEP-521a keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 6 +- FEDERATION.md | 5 +- PrivaPub.Tests/Http/MessageSignatureTests.cs | 103 +++++++++ PrivaPub.Tests/Support/RemoteActor.cs | 16 ++ PrivaPub/Federation/Inbox/InboxReceiver.cs | 33 +-- .../Federation/Signing/MessageSignatures.cs | 216 ++++++++++++++++++ .../Federation/Signing/RequestSignature.cs | 45 ++++ .../Signing/SignedFetchAuthorizer.cs | 21 +- docs/INTEROP.md | 5 +- docs/ROADMAP.md | 3 +- 10 files changed, 423 insertions(+), 30 deletions(-) create mode 100644 PrivaPub.Tests/Http/MessageSignatureTests.cs create mode 100644 PrivaPub/Federation/Signing/MessageSignatures.cs create mode 100644 PrivaPub/Federation/Signing/RequestSignature.cs diff --git a/CLAUDE.md b/CLAUDE.md index a65971d..b043f22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 (authoritative fetch, key verification, WebFinger), ActorDocument (parser) Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer Moderation/ DomainBlocks (suspend / silence / reject media) - Signing/ HttpSignatures (draft-cavage sign/verify) + Signing/ HttpSignatures (draft-cavage sign/verify), MessageSignatures (RFC 9421 verify), + RequestSignature (whichever a request carries) Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject, Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors); RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down) @@ -159,7 +160,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. `HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download (its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in). 2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting - avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). + avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound, + an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. 3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if the actor lists it, its `owner` is the actor and it shares the actor's origin. diff --git a/FEDERATION.md b/FEDERATION.md index 67766b8..fcab2eb 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -8,6 +8,8 @@ PrivaPub is an ActivityPub server written in C#. This document follows - [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server) - [WebFinger](https://webfinger.net/) - [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys +- [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421) and Content-Digest (RFC 9530), verified on + deliveries and signed fetches: `rsa-v1_5-sha256` and `rsa-pss-sha512` with RSA keys - [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1 ## Tested against @@ -251,4 +253,5 @@ Posts with a location (shown to nearby users of this server) never leave the ser - Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's follower and following counts, and the outbox's `totalItems` is the persona's post count, though only public posts are listed. `/api/v1/instance/peers` is empty: which servers this one talks to is not published. -- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. +- Only RSA keys are verified, under draft-cavage or RFC 9421; Ed25519 (FEP-521a) is planned. What PrivaPub sends is + signed with draft-cavage only, which every server reads. diff --git a/PrivaPub.Tests/Http/MessageSignatureTests.cs b/PrivaPub.Tests/Http/MessageSignatureTests.cs new file mode 100644 index 0000000..1195bcf --- /dev/null +++ b/PrivaPub.Tests/Http/MessageSignatureTests.cs @@ -0,0 +1,103 @@ +using MongoDB.Entities; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +using PrivaPub.Federation.Signing; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + // RFC 9421 message signatures, as WordPress's ActivityPub plugin, Ghost and Fedify send them first: a delivery so signed + // is taken in one request, and refused when its body or its target is not what was signed + [Trait("Category", "Integration")] + public sealed class MessageSignatureTests : IAsyncLifetime + { + PrivaPubHost _host; + Peer _peer; + + static CancellationToken Token => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + [Fact] + public async Task A_delivery_signed_as_rfc9421_is_taken_and_one_whose_body_or_target_differs_is_refused() + { + var alice = await _host.Mastodon("alice"); + var bob = new RemoteActor(_peer, "bob"); + var create = bob.Create("

signed the new way

", new[] { alice.Uri }); + var noteId = create["object"]!["id"]!.GetValue(); + using var client = _host.Client(); + + var tampered = bob.MessageSignedPost(alice.Mouth, create); + tampered.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(create.ToJsonString().Replace("new way", "other way"))); + tampered.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); + tampered.Content.Headers.TryAddWithoutValidation("Content-Digest", bob.MessageSignedPost(alice.Mouth, create).Content!.Headers.GetValues("Content-Digest").First()); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(tampered, Token)).StatusCode); + var elsewhere = bob.MessageSignedPost(alice.Mouth, create, signedFor: "/human-centipede"); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(elsewhere, Token)).StatusCode); + + Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.MessageSignedPost(alice.Mouth, create), Token)).StatusCode); + Assert.Equal(1, await _host.RunInbox(Id(create), Token)); + Assert.Contains("signed the new way", (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml); + } + + static string Id(JsonObject activity) => activity["id"]!.GetValue(); + + // a signed fetch (a GET, no body) verifies the same way, and so does RSA-PSS with SHA-512 + [Fact] + public void A_signed_fetch_and_an_rsa_pss_signature_verify() + { + using var key = RSA.Create(2048); + var message = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1"); + MessageSignatures.Sign(message, "https://peer.example/users/bob#main-key", key.ExportPkcs8PrivateKeyPem(), body: null); + var request = Request(message); + var signature = RequestSignature.Of(request); + Assert.Equal("rfc9421:rsa-v1_5-sha256", signature.Scheme); + Assert.Null(signature.Problem(request, body: null)); + Assert.True(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://privapub.test"))); + Assert.False(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://elsewhere.example"))); + + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var parameters = $"(\"@method\" \"@target-uri\");created={created};keyid=\"bob\";alg=\"rsa-pss-sha512\""; + var signatureBase = $"\"@method\": GET\n\"@target-uri\": https://privapub.test/peasants/alice/scribbles/1\n\"@signature-params\": {parameters}"; + var signed = key.SignData(Encoding.UTF8.GetBytes(signatureBase), HashAlgorithmName.SHA512, RSASignaturePadding.Pss); + var pss = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1"); + pss.Headers.TryAddWithoutValidation("Signature-Input", $"pss={parameters}"); + pss.Headers.TryAddWithoutValidation("Signature", $"pss=:{Convert.ToBase64String(signed)}:"); + var pssRequest = Request(pss); + var pssSignature = RequestSignature.Of(pssRequest); + Assert.True(pssSignature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), pssSignature.Signed(pssRequest, "https://privapub.test"))); + } + + static HttpRequest Request(HttpRequestMessage message) + { + var context = new DefaultHttpContext(); + context.Request.Method = message.Method.Method; + context.Request.Host = new HostString(message.RequestUri!.Host); + context.Request.Path = message.RequestUri.AbsolutePath; + context.Features.Get()!.RawTarget = message.RequestUri.PathAndQuery; + foreach (var (name, values) in message.Headers) + context.Request.Headers[name] = values.ToArray(); + return context.Request; + } + } +} diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index 8fdfb06..2ebafcc 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -68,6 +68,22 @@ namespace PrivaPub.Tests.Support return request; } + // the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the + // request goes + public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test") + { + var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); + var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) }; + request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); + MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body); + request.RequestUri = new Uri(origin + path); + return request; + } + + public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem(); + + public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem(); + string Signature(string signingString, string headers) { var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 66400f9..6c94adf 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -66,7 +66,7 @@ namespace PrivaPub.Federation.Inbox public InboxResult NoSuchRecipient(HttpRequest request) { var result = new InboxResult(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient"); - var keyId = HttpSignatures.Parse(request.Headers["Signature"].ToString())?.KeyId; + var keyId = RequestSignature.Of(request)?.KeyId; Record(new Receipt { ClaimedHost = HostOf(keyId), Inbox = "personal", Bytes = request.ContentLength }, result, 0); return result; } @@ -141,32 +141,33 @@ namespace PrivaPub.Federation.Inbox if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri)) return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor"); - var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); - if (parameters == default) + // draft-cavage's Signature, or RFC 9421's Signature-Input and Signature + var signature = RequestSignature.Of(request); + if (signature == default) return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature"); - receipt.Signature = "cavage:" + parameters.Algorithm; - receipt.ClaimedHost ??= HostOf(parameters.KeyId); - if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri))) + receipt.Signature = signature.Scheme; + receipt.ClaimedHost ??= HostOf(signature.KeyId); + if (_domainBlocks.IsSuspended(HostOf(signature.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri))) { receipt.Blocked = true; - receipt.ClaimedHost = _domainBlocks.IsSuspended(HostOf(actorUri)) ? HostOf(actorUri) : HostOf(parameters.KeyId); + receipt.ClaimedHost = _domainBlocks.IsSuspended(HostOf(actorUri)) ? HostOf(actorUri) : HostOf(signature.KeyId); return new(StatusCodes.Status202Accepted, Reason: "suspended"); } - var requestProblem = HttpSignatures.CheckRequest(request, parameters, body); + var requestProblem = signature.Problem(request, body); if (requestProblem != default) return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem)); - var signingString = HttpSignatures.SigningString(request, parameters); - var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); + var signed = signature.Signed(request, _localActors.BaseAddress); + var keyOwner = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: false, token); if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) return new(StatusCodes.Status202Accepted, Reason: "self-delete-unknown-key"); - if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) + if (keyOwner == default || !signature.VerifiedBy(keyOwner.PublicKey, signed)) { - keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); - if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId)) + keyOwner = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: true, token); + if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(signature.KeyId)) return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds, "key-unavailable"); - if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) + if (keyOwner == default || !signature.VerifiedBy(keyOwner.PublicKey, signed)) return new(StatusCodes.Status401Unauthorized, "the signature does not verify", Reason: "signature-invalid"); } @@ -179,8 +180,8 @@ namespace PrivaPub.Federation.Inbox return shapeProblem; var activityId = Id(activity); - var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId, - parameters.Algorithm, parameters.Headers, DateTime.UtcNow); + var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", signature.KeyId, + signature.Algorithm, signature.Covered, DateTime.UtcNow); var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload), new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri); diff --git a/PrivaPub/Federation/Signing/MessageSignatures.cs b/PrivaPub/Federation/Signing/MessageSignatures.cs new file mode 100644 index 0000000..1e830f8 --- /dev/null +++ b/PrivaPub/Federation/Signing/MessageSignatures.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Http.Features; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace PrivaPub.Federation.Signing +{ + // One signature of RFC 9421 (HTTP Message Signatures): its label, the components it covers, its parameters as they were + // sent (the base ends with them, byte for byte) and the signature itself. + public sealed record MessageSignature(string Label, string KeyId, string Algorithm, string[] Components, string Parameters, + byte[] Signature, long? Created, long? Expires); + + // RFC 9421, as WordPress's ActivityPub plugin, Ghost and Fedify sign: Signature-Input names what is covered, Signature + // carries it, and Content-Digest (RFC 9530) the body. Verified with the key of the actor that keyid names, RSA with + // PKCS#1 v1.5 or PSS; a server that tries it first and falls back to draft-cavage (the "double knock") needs only one + // request once this answers. The target is our own public address, as the sender addressed it. + public static class MessageSignatures + { + public static bool Present(HttpRequest request) => request.Headers.ContainsKey("Signature-Input"); + + public static MessageSignature Parse(string signatureInput, string signature) + { + if (string.IsNullOrWhiteSpace(signatureInput) || string.IsNullOrWhiteSpace(signature)) + return default; + var signatures = Members(signature); + foreach (var (label, value) in Members(signatureInput)) + { + if (!signatures.TryGetValue(label, out var bytes) || !value.StartsWith('(')) + continue; + var close = value.IndexOf(')'); + if (close < 0) + continue; + var components = value[1..close].Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim('"').ToLowerInvariant()).ToArray(); + var parameters = Parameters(value[(close + 1)..]); + if (!parameters.TryGetValue("keyid", out var keyId) || bytes.Length < 2 || bytes[0] != ':' || bytes[^1] != ':') + continue; + byte[] decoded; + try + { + decoded = Convert.FromBase64String(bytes[1..^1]); + } + catch (FormatException) + { + continue; + } + return new MessageSignature(label, keyId, parameters.GetValueOrDefault("alg"), components, value, decoded, + Number(parameters.GetValueOrDefault("created")), Number(parameters.GetValueOrDefault("expires"))); + } + return default; + } + + // the signature base (RFC 9421 §2.5), or null when a covered component is missing from the request + public static string Base(HttpRequest request, MessageSignature signature, string baseAddress) + { + var lines = new List(); + var target = RequestTarget(request); + var origin = new Uri(baseAddress); + foreach (var component in signature.Components) + { + var value = component switch + { + "@method" => request.Method.ToUpperInvariant(), + "@target-uri" => origin.GetLeftPart(UriPartial.Authority) + target, + "@authority" => origin.IsDefaultPort ? origin.Host.ToLowerInvariant() : origin.Authority.ToLowerInvariant(), + "@scheme" => origin.Scheme, + "@request-target" => target, + "@path" => target.Split('?')[0], + "@query" => target.Contains('?') ? target[target.IndexOf('?')..] : "?", + _ when component.StartsWith('@') => default, + _ => request.Headers.TryGetValue(component, out var header) ? string.Join(", ", header.Select(v => v.Trim())) : default + }; + if (value == default) + return default; + lines.Add($"\"{component}\": {value}"); + } + lines.Add($"\"@signature-params\": {signature.Parameters}"); + return string.Join("\n", lines); + } + + public static string CheckRequest(HttpRequest request, MessageSignature signature, byte[] body) => + CheckRequest(request, signature, body, DateTimeOffset.UtcNow); + + public static string CheckRequest(HttpRequest request, MessageSignature signature, byte[] body, DateTimeOffset now) + { + if (signature.Algorithm is not (null or "rsa-v1_5-sha256" or "rsa-pss-sha512")) + return $"unsupported signature algorithm '{signature.Algorithm}'"; + if (!signature.Components.Contains("@method")) + return "@method is not signed"; + if (!signature.Components.Contains("@target-uri") && !(signature.Components.Contains("@path") && signature.Components.Contains("@authority"))) + return "the target is not signed"; + if (body is { Length: > 0 }) + { + if (!signature.Components.Contains("content-digest")) + return "the digest is not signed"; + if (!DigestMatches(request.Headers["Content-Digest"].ToString(), body)) + return "the digest does not match the body"; + } + if (signature.Created is not { } created) + return "neither date nor (created) is signed"; + var signedAt = DateTimeOffset.FromUnixTimeSeconds(created); + if (signedAt < now - HttpSignatures.MaxAge || signedAt > now + HttpSignatures.MaxClockSkew) + return "(created) is outside the allowed window"; + if (signature.Expires is { } expires && DateTimeOffset.FromUnixTimeSeconds(expires) < now - HttpSignatures.MaxClockSkew) + return "the signature has expired"; + return default; + } + + public static bool Verify(string publicKeyPem, MessageSignature signature, string signatureBase) + { + if (string.IsNullOrEmpty(publicKeyPem) || signatureBase == null) + return false; + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + var data = Encoding.UTF8.GetBytes(signatureBase); + return signature.Algorithm == "rsa-pss-sha512" + ? rsa.VerifyData(data, signature.Signature, HashAlgorithmName.SHA512, RSASignaturePadding.Pss) + : rsa.VerifyData(data, signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + catch (Exception ex) when (ex is CryptographicException or ArgumentException) + { + return false; + } + } + + // signs a request as WordPress does (rsa-v1_5-sha256 over the method, the target and the body's digest) + public static void Sign(HttpRequestMessage request, string keyId, string privateKeyPem, byte[] body, DateTimeOffset? at = default) + { + var components = new List { "@method", "@target-uri" }; + var values = new List { request.Method.Method.ToUpperInvariant(), request.RequestUri!.AbsoluteUri }; + if (body != null) + { + var digest = $"sha-256=:{Convert.ToBase64String(SHA256.HashData(body))}:"; + request.Content!.Headers.TryAddWithoutValidation("Content-Digest", digest); + components.Add("content-digest"); + values.Add(digest); + } + var parameters = $"({string.Join(' ', components.Select(c => $"\"{c}\""))});created={(at ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds()};keyid=\"{keyId}\";alg=\"rsa-v1_5-sha256\""; + var signatureBase = string.Join("\n", components.Zip(values, (c, v) => $"\"{c}\": {v}").Append($"\"@signature-params\": {parameters}")); + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKeyPem); + var signed = rsa.SignData(Encoding.UTF8.GetBytes(signatureBase), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.Headers.TryAddWithoutValidation("Signature-Input", $"sig1={parameters}"); + request.Headers.TryAddWithoutValidation("Signature", $"sig1=:{Convert.ToBase64String(signed)}:"); + } + + static bool DigestMatches(string header, byte[] body) + { + foreach (var (algorithm, value) in Members(header)) + { + if (value.Length < 2 || value[0] != ':' || value[^1] != ':') + continue; + var expected = algorithm.ToLowerInvariant() switch + { + "sha-256" => Convert.ToBase64String(SHA256.HashData(body)), + "sha-512" => Convert.ToBase64String(SHA512.HashData(body)), + _ => default + }; + if (expected != default && expected == value[1..^1]) + return true; + } + return false; + } + + // a structured-field dictionary's members, their values as written (RFC 8941; commas inside strings and inner lists + // do not separate members) + static Dictionary Members(string field) + { + var members = new Dictionary(StringComparer.Ordinal); + var depth = 0; + var quoted = false; + var start = 0; + for (var i = 0; i <= field.Length; i++) + { + var c = i < field.Length ? field[i] : ','; + if (c == '"' && (i == 0 || field[i - 1] != '\\')) + quoted = !quoted; + else if (!quoted && c == '(') + depth++; + else if (!quoted && c == ')') + depth--; + if (c != ',' || quoted || depth > 0) + continue; + var member = field[start..i].Trim(); + start = i + 1; + var equals = member.IndexOf('='); + if (equals > 0) + members.TryAdd(member[..equals].Trim(), member[(equals + 1)..].Trim()); + } + return members; + } + + static Dictionary Parameters(string text) + { + var parameters = new Dictionary(StringComparer.Ordinal); + foreach (var part in text.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var equals = part.IndexOf('='); + if (equals > 0) + parameters[part[..equals].Trim()] = part[(equals + 1)..].Trim().Trim('"'); + } + return parameters; + } + + static long? Number(string value) => + long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ? number : null; + + static string RequestTarget(HttpRequest request) + { + var raw = request.HttpContext.Features.Get()?.RawTarget; + return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw; + } + } +} diff --git a/PrivaPub/Federation/Signing/RequestSignature.cs b/PrivaPub/Federation/Signing/RequestSignature.cs new file mode 100644 index 0000000..6612f31 --- /dev/null +++ b/PrivaPub/Federation/Signing/RequestSignature.cs @@ -0,0 +1,45 @@ +namespace PrivaPub.Federation.Signing +{ + // What signed a request: an RFC 9421 message signature when it carries Signature-Input, else a draft-cavage Signature; + // the inbox and signed fetches check either the same way. + public sealed class RequestSignature + { + readonly SignatureParameters _cavage; + readonly MessageSignature _message; + + RequestSignature(SignatureParameters cavage, MessageSignature message) + { + _cavage = cavage; + _message = message; + } + + public string KeyId => _message?.KeyId ?? _cavage.KeyId; + public string Algorithm => _message != default ? _message.Algorithm ?? "rsa-v1_5-sha256" : _cavage.Algorithm; + // how the ledger names it: "cavage:rsa-sha256", "rfc9421:rsa-v1_5-sha256" + public string Scheme => _message != default ? "rfc9421:" + Algorithm : "cavage:" + Algorithm; + public string[] Covered => _message?.Components ?? _cavage.Headers; + + // the request's signature, or null when it has none it can read + public static RequestSignature Of(HttpRequest request) + { + if (MessageSignatures.Present(request)) + { + var message = MessageSignatures.Parse(request.Headers["Signature-Input"].ToString(), request.Headers["Signature"].ToString()); + return message == default ? default : new RequestSignature(default, message); + } + var cavage = HttpSignatures.Parse(request.Headers["Signature"].ToString()); + return cavage == default ? default : new RequestSignature(cavage, default); + } + + // what is wrong with the request as signed (unsigned target or digest, a stale date...), or null + public string Problem(HttpRequest request, byte[] body) => + _message != default ? MessageSignatures.CheckRequest(request, _message, body) : HttpSignatures.CheckRequest(request, _cavage, body); + + // the string the signature is over: the signing string, or RFC 9421's signature base against our public address + public string Signed(HttpRequest request, string baseAddress) => + _message != default ? MessageSignatures.Base(request, _message, baseAddress) : HttpSignatures.SigningString(request, _cavage); + + public bool VerifiedBy(string publicKeyPem, string signed) => + _message != default ? MessageSignatures.Verify(publicKeyPem, _message, signed) : HttpSignatures.Verify(publicKeyPem, signed, _cavage.Signature); + } +} diff --git a/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs b/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs index 8984c45..8983cc2 100644 --- a/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs +++ b/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs @@ -25,23 +25,28 @@ namespace PrivaPub.Federation.Signing readonly IRemoteActorService _remoteActors; readonly DbEntities _dbEntities; - public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities) + readonly ILocalActorService _localActors; + + public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors = default) { _remoteActors = remoteActors; _dbEntities = dbEntities; + _localActors = localActors; } + // the actor whose signature (draft-cavage, or RFC 9421 when there is a base address to check its target against) + // verifies on the request public async Task Requester(HttpRequest request, CancellationToken token) { - var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); - if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default) + var signature = RequestSignature.Of(request); + if (signature == default || signature.Problem(request, body: default) != default) return default; - var signingString = HttpSignatures.SigningString(request, parameters); - var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); - if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature)) + var signed = signature.Signed(request, _localActors?.BaseAddress ?? $"{request.Scheme}://{request.Host}"); + var actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: false, token); + if (actor != default && signature.VerifiedBy(actor.PublicKey, signed)) return actor; - actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); - return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default; + actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: true, token); + return actor != default && signature.VerifiedBy(actor.PublicKey, signed) ? actor : default; } public async Task MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token) diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 140e45f..f48bf71 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -697,7 +697,8 @@ a `preview` Note fallback. - `id` is `?p=123`, different from `url`. - The blog actor is a Group with `attributionDomains`. - It sends an `Update` on every save, and **signs with RFC 9421 first** (9.3.0), falling back to draft-cavage after - any 4xx. + any 4xx. Seen in the pasture (2026-10-05): until PrivaPub verified RFC 9421, its first delivery got 401 and was sent + again with draft-cavage, which it then kept using. - It drops followers-only replies. - **Ghost 6** (its ActivityPub service is separate, built on Fedify): - Article with `image` as a bare string and `preview`; members-only parts removed. @@ -882,7 +883,7 @@ snapshots; `/api/privapub/v1/cdns` and `/cdns/:domain` group servers by CDN, wee | Topic | State on 2026-10-01 | PrivaPub | P | |---|---|---|---| -| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 | +| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA (**done** 2026-10-05) and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 | | RFC 9421 outbound | Mastodon 4.7 double-knocks | draft-cavage first; RFC 9421 after a 401; remember per host | P2 | | 400 vs 401 | A 400 or 401 makes Mastodon 4.7 and WordPress retry with the other scheme | 401 only for signature failures (we do this); 400 only for bodies that are really malformed | P1 (keep) | | Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 627e122..572b5e3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -638,7 +638,8 @@ it, raw where it doesn't. #### P8 Signatures, discovery and the long tail - **Signatures:** - - RFC 9421 inbound (RSA and Ed25519, Content-Digest); + - RFC 9421 inbound (RSA and Ed25519, Content-Digest): **RSA done 2026-10-05** (PKCS#1 v1.5 and PSS, Content-Digest, + deliveries and signed fetches); Ed25519 waits for FEP-521a keys; - outbound double-knock, remembered per host; - `publicKey` arrays and FEP-521a Multikey; - FEP-8b32 proof verification;