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(); 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 { "@method", "@target-uri" }; var values = new List { 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 Members(string field) { var members = new Dictionary(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 Parameters(string text) { var parameters = new Dictionary(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()?.RawTarget; return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw; } } }