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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
26dac40720
commit
c5a69d240a
10 files changed
+423
-30
No files matched your search
@@ -83,7 +83,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
|||||||
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
|
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
|
||||||
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
|
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
|
||||||
Moderation/ DomainBlocks (suspend / silence / reject media)
|
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,
|
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
|
||||||
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors);
|
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors);
|
||||||
RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down)
|
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
|
`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).
|
(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
|
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
|
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
|
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.
|
the actor lists it, its `owner` is the actor and it shares the actor's origin.
|
||||||
|
|||||||
+4
-1
@@ -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)
|
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
|
||||||
- [WebFinger](https://webfinger.net/)
|
- [WebFinger](https://webfinger.net/)
|
||||||
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
|
- [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
|
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
|
||||||
|
|
||||||
## Tested against
|
## 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
|
- 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
|
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.
|
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.
|
||||||
@@ -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("<p>signed the new way</p>", new[] { alice.Uri });
|
||||||
|
var noteId = create["object"]!["id"]!.GetValue<string>();
|
||||||
|
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<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
|
||||||
|
|
||||||
|
// 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<IHttpRequestFeature>()!.RawTarget = message.RequestUri.PathAndQuery;
|
||||||
|
foreach (var (name, values) in message.Headers)
|
||||||
|
context.Request.Headers[name] = values.ToArray();
|
||||||
|
return context.Request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,22 @@ namespace PrivaPub.Tests.Support
|
|||||||
return request;
|
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)
|
string Signature(string signingString, string headers)
|
||||||
{
|
{
|
||||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
public InboxResult NoSuchRecipient(HttpRequest request)
|
public InboxResult NoSuchRecipient(HttpRequest request)
|
||||||
{
|
{
|
||||||
var result = new InboxResult(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
|
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);
|
Record(new Receipt { ClaimedHost = HostOf(keyId), Inbox = "personal", Bytes = request.ContentLength }, result, 0);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -141,32 +141,33 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
|
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
|
||||||
return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor");
|
return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor");
|
||||||
|
|
||||||
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
// draft-cavage's Signature, or RFC 9421's Signature-Input and Signature
|
||||||
if (parameters == default)
|
var signature = RequestSignature.Of(request);
|
||||||
|
if (signature == default)
|
||||||
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature");
|
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature");
|
||||||
receipt.Signature = "cavage:" + parameters.Algorithm;
|
receipt.Signature = signature.Scheme;
|
||||||
receipt.ClaimedHost ??= HostOf(parameters.KeyId);
|
receipt.ClaimedHost ??= HostOf(signature.KeyId);
|
||||||
if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
|
if (_domainBlocks.IsSuspended(HostOf(signature.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
|
||||||
{
|
{
|
||||||
receipt.Blocked = true;
|
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");
|
return new(StatusCodes.Status202Accepted, Reason: "suspended");
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
|
var requestProblem = signature.Problem(request, body);
|
||||||
if (requestProblem != default)
|
if (requestProblem != default)
|
||||||
return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem));
|
return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem));
|
||||||
|
|
||||||
var signingString = HttpSignatures.SigningString(request, parameters);
|
var signed = signature.Signed(request, _localActors.BaseAddress);
|
||||||
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
|
var keyOwner = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: false, token);
|
||||||
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
|
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
|
||||||
return new(StatusCodes.Status202Accepted, Reason: "self-delete-unknown-key");
|
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);
|
keyOwner = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: true, token);
|
||||||
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
|
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(signature.KeyId))
|
||||||
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds, "key-unavailable");
|
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");
|
return new(StatusCodes.Status401Unauthorized, "the signature does not verify", Reason: "signature-invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +180,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
return shapeProblem;
|
return shapeProblem;
|
||||||
|
|
||||||
var activityId = Id(activity);
|
var activityId = Id(activity);
|
||||||
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
|
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", signature.KeyId,
|
||||||
parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
|
signature.Algorithm, signature.Covered, DateTime.UtcNow);
|
||||||
var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
|
var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
|
||||||
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
|
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
|
||||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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<string> { "@method", "@target-uri" };
|
||||||
|
var values = new List<string> { 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<string, string> Members(string field)
|
||||||
|
{
|
||||||
|
var members = new Dictionary<string, string>(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<string, string> Parameters(string text)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, string>(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<IHttpRequestFeature>()?.RawTarget;
|
||||||
|
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,23 +25,28 @@ namespace PrivaPub.Federation.Signing
|
|||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
|
|
||||||
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities)
|
readonly ILocalActorService _localActors;
|
||||||
|
|
||||||
|
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors = default)
|
||||||
{
|
{
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
_dbEntities = dbEntities;
|
_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<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
|
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
|
||||||
{
|
{
|
||||||
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
var signature = RequestSignature.Of(request);
|
||||||
if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default)
|
if (signature == default || signature.Problem(request, body: default) != default)
|
||||||
return default;
|
return default;
|
||||||
var signingString = HttpSignatures.SigningString(request, parameters);
|
var signed = signature.Signed(request, _localActors?.BaseAddress ?? $"{request.Scheme}://{request.Host}");
|
||||||
var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
|
var actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: false, token);
|
||||||
if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature))
|
if (actor != default && signature.VerifiedBy(actor.PublicKey, signed))
|
||||||
return actor;
|
return actor;
|
||||||
actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
|
actor = await _remoteActors.GetActorByKeyId(signature.KeyId, refresh: true, token);
|
||||||
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
|
return actor != default && signature.VerifiedBy(actor.PublicKey, signed) ? actor : default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)
|
public async Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)
|
||||||
|
|||||||
+3
-2
@@ -697,7 +697,8 @@ a `preview` Note fallback.
|
|||||||
- `id` is `?p=123`, different from `url`.
|
- `id` is `?p=123`, different from `url`.
|
||||||
- The blog actor is a Group with `attributionDomains`.
|
- 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
|
- 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.
|
- It drops followers-only replies.
|
||||||
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
|
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
|
||||||
- Article with `image` as a bare string and `preview`; members-only parts removed.
|
- 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 |
|
||||||
|
|||||||
+2
-1
@@ -638,7 +638,8 @@ it, raw where it doesn't.
|
|||||||
|
|
||||||
#### P8 Signatures, discovery and the long tail
|
#### P8 Signatures, discovery and the long tail
|
||||||
- **Signatures:**
|
- **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;
|
- outbound double-knock, remembered per host;
|
||||||
- `publicKey` arrays and FEP-521a Multikey;
|
- `publicKey` arrays and FEP-521a Multikey;
|
||||||
- FEP-8b32 proof verification;
|
- FEP-8b32 proof verification;
|
||||||
|
|||||||
Reference in new issue
Block a user