using System.Globalization; using System.Security.Cryptography; using System.Text; namespace PrivaPub.Services.Federation { public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature, string Created, string Expires); public static class HttpSignatures { static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12); 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(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(); foreach (var header in parameters.Headers) { switch (header) { case "(request-target)": lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}"); 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) { if (parameters.Algorithm is not ("rsa-sha256" or "hs2019")) return $"unsupported signature algorithm '{parameters.Algorithm}'"; 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"; } if (parameters.Headers.Contains("date")) { if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) return "unreadable Date header"; if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew) return "the Date header is outside the allowed window"; } else if (!parameters.Headers.Contains("(created)")) return "neither date nor (created) is signed"; if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires) && DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew) return "the signature has expired"; return default; } 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; } } } }