Files
SocialPub/PrivaPub/Federation/Signing/HttpSignatures.cs
T
thepraandClaude Opus 5.5 4fa53f63bd M2: every inbox answer is recorded
InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json,
not-activity, missing-type-or-actor, no-signature, the signature check's own codes
(headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired,
algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable,
id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued,
duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object
type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown
/mouth and a rate-limited inbox (429, from OnRejected) are recorded too.

Until the signature verifies, the host is only claimed, so it is kept only if the server is
already known. A suspended server is recorded under its own name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:59:55 +02:00

231 lines
8.0 KiB
C#

using Microsoft.AspNetCore.Http.Features;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Federation.Signing
{
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
string Created, string Expires);
public static class HttpSignatures
{
public static readonly TimeSpan MaxAge = TimeSpan.FromHours(1);
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(15);
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
public static void Sign(HttpRequestMessage request, LocalActor signer, byte[] body)
{
var date = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signed = new List<(string Name, string Value)>
{
("(request-target)", $"{request.Method.Method.ToLowerInvariant()} {request.RequestUri.PathAndQuery}"),
("host", request.RequestUri.IsDefaultPort ? request.RequestUri.Host : request.RequestUri.Authority),
("date", date)
};
request.Headers.TryAddWithoutValidation("Date", date);
if (body != null)
{
var digest = Digest(body);
request.Headers.TryAddWithoutValidation("Digest", digest);
signed.Add(("digest", digest));
}
var signingString = string.Join("\n", signed.Select(h => $"{h.Name}: {h.Value}"));
using var rsa = RSA.Create();
rsa.ImportFromPem(signer.PrivateKeyPem);
var signature = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(signingString),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
request.Headers.TryAddWithoutValidation("Signature",
$"keyId=\"{signer.KeyId}\",algorithm=\"rsa-sha256\",headers=\"{string.Join(' ', signed.Select(h => h.Name))}\",signature=\"{signature}\"");
}
public static SignatureParameters Parse(string header)
{
if (string.IsNullOrWhiteSpace(header))
return default;
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
while (i < header.Length)
{
while (i < header.Length && (header[i] == ',' || header[i] == ' '))
i++;
var equals = header.IndexOf('=', i);
if (equals < 0)
break;
var key = header[i..equals].Trim();
i = equals + 1;
string value;
if (i < header.Length && header[i] == '"')
{
var close = header.IndexOf('"', i + 1);
if (close < 0)
return default;
value = header[(i + 1)..close];
i = close + 1;
}
else
{
var comma = header.IndexOf(',', i);
value = comma < 0 ? header[i..] : header[i..comma];
i = comma < 0 ? header.Length : comma;
}
values[key] = value.Trim();
}
if (!values.TryGetValue("keyId", out var keyId) || !values.TryGetValue("signature", out var signature))
return default;
byte[] signatureBytes;
try
{
signatureBytes = Convert.FromBase64String(signature);
}
catch (FormatException)
{
return default;
}
var headers = values.TryGetValue("headers", out var headerList)
? headerList.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(h => h.ToLowerInvariant()).ToArray()
: new[] { "date" };
return new(keyId, values.GetValueOrDefault("algorithm") ?? "hs2019", headers, signatureBytes,
values.GetValueOrDefault("created"), values.GetValueOrDefault("expires"));
}
public static string SigningString(HttpRequest request, SignatureParameters parameters)
{
var lines = new List<string>();
foreach (var header in parameters.Headers)
{
switch (header)
{
case "(request-target)":
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {RequestTarget(request)}");
break;
case "(created)":
lines.Add($"(created): {parameters.Created}");
break;
case "(expires)":
lines.Add($"(expires): {parameters.Expires}");
break;
case "host":
lines.Add($"host: {request.Host.Value}");
break;
default:
if (!request.Headers.TryGetValue(header, out var value))
return default;
lines.Add($"{header}: {string.Join(", ", value.Select(v => v.Trim()))}");
break;
}
}
return string.Join("\n", lines);
}
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) =>
CheckRequest(request, parameters, body, DateTimeOffset.UtcNow);
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body, DateTimeOffset now)
{
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
return $"unsupported signature algorithm '{parameters.Algorithm}'";
if (!parameters.Headers.Contains("(request-target)"))
return "(request-target) is not signed";
if (!parameters.Headers.Contains("host"))
return "host is not signed";
if (body is { Length: > 0 })
{
if (!parameters.Headers.Contains("digest"))
return "the digest is not signed";
var expectedHash = Convert.ToBase64String(SHA256.HashData(body));
var matches = request.Headers["Digest"].ToString().Split(',').Select(d => d.Trim())
.Any(d => d.StartsWith("SHA-256=", StringComparison.OrdinalIgnoreCase) && d[8..] == expectedHash);
if (!matches)
return "the digest does not match the body";
}
var signsDate = parameters.Headers.Contains("date");
var signsCreated = parameters.Headers.Contains("(created)");
if (!signsDate && !signsCreated)
return "neither date nor (created) is signed";
if (signsDate)
{
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var date))
return "unreadable Date header";
if (!IsFresh(date, now))
return "the Date header is outside the allowed window";
}
if (signsCreated)
{
if (!long.TryParse(parameters.Created, NumberStyles.Integer, CultureInfo.InvariantCulture, out var created))
return "unreadable (created)";
if (!IsFresh(DateTimeOffset.FromUnixTimeSeconds(created), now))
return "(created) is outside the allowed window";
}
if (!string.IsNullOrEmpty(parameters.Expires) || parameters.Headers.Contains("(expires)"))
{
if (!long.TryParse(parameters.Expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expires))
return "unreadable (expires)";
if (DateTimeOffset.FromUnixTimeSeconds(expires) < now - MaxClockSkew)
return "the signature has expired";
}
return default;
}
public static string ProblemCode(string problem) => problem switch
{
null => default,
_ when problem.StartsWith("unsupported signature algorithm", StringComparison.Ordinal) => "algorithm-unsupported",
_ when problem.EndsWith("is not signed", StringComparison.Ordinal) || problem.StartsWith("neither", StringComparison.Ordinal) => "headers-unsigned",
_ when problem.StartsWith("the digest does not match", StringComparison.Ordinal) => "digest-mismatch",
_ when problem.StartsWith("unreadable", StringComparison.Ordinal) => "header-unreadable",
_ when problem.Contains("outside the allowed window", StringComparison.Ordinal) => "date-skew",
"the signature has expired" => "expired",
_ => "signature-problem"
};
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
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;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
{
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
return false;
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(Encoding.UTF8.GetBytes(signingString), signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch (CryptographicException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
}
}