S6 of the roadmap. An inbox POST is refused unless its signature covers (request-target) and host, as well as the digest and a date or (created). The Date or (created) may be at most an hour old and fifteen minutes ahead (it was twelve hours either way), and an (expires) in the past is refused. The request target is read raw from the server, so a percent-encoded path verifies as the sender signed it. Tests cover a Mastodon-shaped delivery and each way of tampering with it, plus a round trip of our own outbound signature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
219 lines
7.3 KiB
C#
219 lines
7.3 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|