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

@@ -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);
}
}