Services/Federation and Controllers/ServerToServer become
Federation/{Actors,Signing,Inbox,Outbox,Rendering,Controllers}, the first step
of the roadmap's layout. No type, route or behaviour changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
31f6015591
commit
034b792801
14 files changed
+40
-20
No files matched your search
@@ -0,0 +1,184 @@
|
||||
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
|
||||
{
|
||||
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<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()} {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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user