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:
thepraandClaude Opus 5.5 committed 2026-10-05 06:48:22 +02:00
1 parent 26dac40720
commit c5a69d240a
10 files changed
+423 -30

No files matched your search

+17 -16
View File
@@ -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);
@@ -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 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<ForeignAvatar> 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<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)