hs2019 signatures hashed with SHA-512, and SHA-512 digests

A draft-cavage hs2019 signature leaves the hash to the key: with an RSA key it is tried with SHA-256, as nearly everyone
signs, then with SHA-512, as some do; rsa-sha256 stays SHA-256 only. A Digest of SHA-512= is checked as SHA-256= is.

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-06 11:13:59 +02:00
1 parent 0310bbe0f9
commit e708f1ad2e
5 files changed
+39 -7

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ 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
(`hs2019` hashed with SHA-256, or SHA-512 as some sign it; a `SHA-256=` or `SHA-512=` digest)
- [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
@@ -101,6 +101,33 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
// hs2019 leaves the hash to the key: some sign RSA with SHA-512, which rsa-sha256 does not allow; and a SHA-512 digest
[Fact]
public void Accepts_hs2019_hashed_with_sha512_and_a_sha512_digest()
{
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(Host);
context.Request.Path = "/peasants/bob/mouth";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
var date = Now.ToString("r", CultureInfo.InvariantCulture);
var digest = "SHA-512=" + Convert.ToBase64String(SHA512.HashData(Body));
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
var signingString = $"(request-target): post /peasants/bob/mouth\nhost: {Host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1));
string Signed(string algorithm) => $"keyId=\"{KeyId}\",algorithm=\"{algorithm}\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Headers["Signature"] = Signed("hs2019");
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body, Now));
Assert.True(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
context.Request.Headers["Signature"] = Signed("rsa-sha256");
parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.False(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
}
[Fact]
public void Signs_with_the_raw_request_target()
{
@@ -152,9 +152,11 @@ namespace PrivaPub.Federation.Signing
{
if (!parameters.Headers.Contains("digest"))
return "the digest is not signed";
var expectedHash = Convert.ToBase64String(SHA256.HashData(body));
var sha256 = Convert.ToBase64String(SHA256.HashData(body));
var sha512 = Convert.ToBase64String(SHA512.HashData(body));
var matches = request.Headers["Digest"].ToString().Split(',').Select(d => d.Trim())
.Any(d => d.StartsWith("SHA-256=", StringComparison.OrdinalIgnoreCase) && d[8..] == expectedHash);
.Any(d => d.StartsWith("SHA-256=", StringComparison.OrdinalIgnoreCase) && d[8..] == sha256
|| d.StartsWith("SHA-512=", StringComparison.OrdinalIgnoreCase) && d[8..] == sha512);
if (!matches)
return "the digest does not match the body";
}
@@ -213,7 +215,8 @@ namespace PrivaPub.Federation.Signing
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
// hs2019 leaves the hash to the key: RSA with SHA-256, as nearly everyone signs, else with SHA-512, as some do
public static bool Verify(string publicKeyPem, string signingString, byte[] signature, string algorithm = "rsa-sha256")
{
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
return false;
@@ -221,8 +224,9 @@ namespace PrivaPub.Federation.Signing
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(Encoding.UTF8.GetBytes(signingString), signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var data = Encoding.UTF8.GetBytes(signingString);
return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
|| algorithm == "hs2019" && rsa.VerifyData(data, signature, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
}
catch (CryptographicException)
{
@@ -40,6 +40,6 @@ namespace PrivaPub.Federation.Signing
_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);
_message != default ? MessageSignatures.Verify(publicKeyPem, _message, signed) : HttpSignatures.Verify(publicKeyPem, signed, _cavage.Signature, _cavage.Algorithm);
}
}
+1 -1
View File
@@ -692,7 +692,7 @@ it, raw where it doesn't.
Mitra refuses a proof by a key it has not read and does not read the actor again; a forwarded activity with a
proof its actor's key verifies is taken without reading it again; Mastodon accepts PrivaPub's, so Activity-Relay's
forwards reach it);
- `hs2019` with SHA-512.
- `hs2019` with SHA-512: **done 2026-10-06** (and `SHA-512=` digests).
- **Discovery:**
- a relay client for both relay styles: **done 2026-10-05/06** (`Federation:Relays`; forwarded posts read again
from their origin, announces unwrapped; personas' public posts sent to them, owner decision; checked live against