216 lines
9.1 KiB
C#
216 lines
9.1 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|