[backend/federation] Add support & corresponding tests for http signature pseudo-headers

This commit is contained in:
Laura Hausmann 2024-04-29 20:35:51 +02:00
parent fd0d6b4fea
commit d0356fc6ea
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
2 changed files with 165 additions and 32 deletions

View file

@ -17,9 +17,8 @@ public static class HttpSignature
if (!requiredHeaders.All(signature.Headers.Contains)) if (!requiredHeaders.All(signature.Headers.Contains))
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing required headers"); throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing required headers");
var signingString = GenerateSigningString(signature.Headers, request.Method, var signingString =
request.Path, GenerateSigningString(signature.Headers, request.Method, request.Path, request.Headers, null, signature);
request.Headers);
if (request.Body.CanSeek) request.Body.Position = 0; if (request.Body.CanSeek) request.Body.Position = 0;
return await VerifySignatureAsync(key, signingString, signature, request.Headers, return await VerifySignatureAsync(key, signingString, signature, request.Headers,
@ -32,7 +31,8 @@ public static class HttpSignature
var signature = Parse(signatureHeader); var signature = Parse(signatureHeader);
var signingString = GenerateSigningString(signature.Headers, request.Method.Method, var signingString = GenerateSigningString(signature.Headers, request.Method.Method,
request.RequestUri!.AbsolutePath, request.RequestUri!.AbsolutePath,
request.Headers.ToHeaderDictionary()); request.Headers.ToHeaderDictionary(),
null, signature);
Stream? body = null; Stream? body = null;
@ -41,16 +41,31 @@ public static class HttpSignature
return await VerifySignatureAsync(key, signingString, signature, request.Headers.ToHeaderDictionary(), body); return await VerifySignatureAsync(key, signingString, signature, request.Headers.ToHeaderDictionary(), body);
} }
private static async Task<bool> VerifySignatureAsync( public static async Task<bool> VerifySignatureAsync(
string key, string signingString, string key, string signingString,
HttpSignatureHeader signature, HttpSignatureHeader signature,
IHeaderDictionary headers, Stream? body IHeaderDictionary headers, Stream? body
) )
{ {
if (!headers.TryGetValue("date", out var date)) var created = signature.Created;
throw new GracefulException(HttpStatusCode.Forbidden, "Date header is missing"); var datePresent = headers.TryGetValue("date", out var date);
if (DateTime.Now - DateTime.Parse(date!) > TimeSpan.FromHours(12)) if (created == null && !datePresent)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature too old"); throw new GracefulException(HttpStatusCode.Forbidden, "Neither date nor (created) are present, refusing");
var dateCheck = datePresent && DateTime.Now - DateTime.Parse(date!) > TimeSpan.FromHours(12);
var createdCheck = created != null &&
DateTime.UtcNow - (DateTime.UnixEpoch + TimeSpan.FromSeconds(long.Parse(created))) >
TimeSpan.FromHours(12);
if (dateCheck || createdCheck)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature is too old");
var expiryCheck = signature.Expires != null &&
DateTime.UtcNow - (DateTime.UnixEpoch + TimeSpan.FromSeconds(long.Parse(signature.Expires))) >
TimeSpan.Zero;
if (expiryCheck)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature is expired");
if (body is { Length: > 0 }) if (body is { Length: > 0 })
{ {
@ -98,20 +113,24 @@ public static class HttpSignature
return request; return request;
} }
private static string GenerateSigningString( public static string GenerateSigningString(
IEnumerable<string> headers, string requestMethod, string requestPath, IEnumerable<string> headers, string requestMethod, string requestPath,
IHeaderDictionary requestHeaders, string? host = null IHeaderDictionary requestHeaders, string? host = null, HttpSignatureHeader? signature = null
) )
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
//TODO: handle additional params, see https://github.com/Chocobozzz/node-http-signature/blob/master/lib/parser.js#L294-L310
foreach (var header in headers) foreach (var header in headers)
{ {
sb.Append($"{header}: "); sb.Append($"{header}: ");
sb.AppendLine(header switch sb.AppendLine(header switch
{ {
"(request-target)" => $"{requestMethod.ToLowerInvariant()} {requestPath}", "(request-target)" => $"{requestMethod.ToLowerInvariant()} {requestPath}",
"(created)" => signature?.Created ?? throw new Exception("Signature is missing created param"),
"(keyid)" => signature?.KeyId ?? throw new Exception("Signature is missing keyId param"),
"(algorithm)" => signature?.Algo ?? throw new Exception("Signature is missing algorithm param"),
"(expires)" => signature?.Expires ?? throw new Exception("Signature is missing expires param"),
"(opaque)" => signature?.Opaque ?? throw new Exception("Signature is missing opaque param"),
"host" => $"{host ?? requestHeaders[header]}", "host" => $"{host ?? requestHeaders[header]}",
_ => string.Join(", ", requestHeaders[header].AsEnumerable()) _ => string.Join(", ", requestHeaders[header].AsEnumerable())
}); });
@ -132,33 +151,44 @@ public static class HttpSignature
.Select(s => s.Split('=')) .Select(s => s.Split('='))
.ToDictionary(p => p[0], p => (p[1] + new string('=', p.Length - 2)).Trim('"')); .ToDictionary(p => p[0], p => (p[1] + new string('=', p.Length - 2)).Trim('"'));
//TODO: these fail if the dictionary doesn't contain the key, use TryGetValue instead if (!sig.TryGetValue("signature", out var signatureBase64))
var signatureBase64 = sig["signature"] ?? throw GracefulException.Forbidden("Signature string is missing the signature field");
throw new GracefulException(HttpStatusCode.Forbidden,
"Signature string is missing the signature field");
var headers = sig["headers"].Split(" ") ??
throw new GracefulException(HttpStatusCode.Forbidden,
"Signature data is missing the headers field");
var keyId = sig["keyId"] ?? if (!sig.TryGetValue("headers", out var headers))
throw new GracefulException(HttpStatusCode.Forbidden, throw GracefulException.Forbidden("Signature string is missing the headers field");
"Signature string is missing the keyId field");
if (!sig.TryGetValue("keyId", out var keyId))
throw GracefulException.Forbidden("Signature string is missing the keyId field");
//TODO: this should fallback to sha256 //TODO: this should fallback to sha256
var algo = sig["algorithm"] ?? if (!sig.TryGetValue("algorithm", out var algo))
throw new GracefulException(HttpStatusCode.Forbidden, throw GracefulException.Forbidden("Signature string is missing the algorithm field");
"Signature string is missing the algorithm field");
sig.TryGetValue("created", out var created);
sig.TryGetValue("expires", out var expires);
sig.TryGetValue("opaque", out var opaque);
var signature = Convert.FromBase64String(signatureBase64); var signature = Convert.FromBase64String(signatureBase64);
return new HttpSignatureHeader(keyId, algo, signature, headers); return new HttpSignatureHeader(keyId, algo, signature, headers.Split(" "), created, expires, opaque);
} }
public class HttpSignatureHeader(string keyId, string algo, byte[] signature, IEnumerable<string> headers) public class HttpSignatureHeader(
string keyId,
string algo,
byte[] signature,
IEnumerable<string> headers,
string? created,
string? expires,
string? opaque
)
{ {
public readonly string Algo = algo; public readonly string Algo = algo;
public readonly IEnumerable<string> Headers = headers; public readonly IEnumerable<string> Headers = headers;
public readonly string KeyId = keyId; public readonly string KeyId = keyId;
public readonly byte[] Signature = signature; public readonly byte[] Signature = signature;
public readonly string? Created = created;
public readonly string? Expires = expires;
public readonly string? Opaque = opaque;
} }
} }

View file

@ -1,10 +1,12 @@
using System.Net; using System.Net;
using System.Security.Cryptography;
using System.Text; using System.Text;
using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -47,12 +49,10 @@ public class HttpSignatureTests
request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13); request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13);
var e = await Assert.ThrowsExceptionAsync<GracefulException>(async () => var task = request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
await request.VerifyAsync(MockObjects var e = await Assert.ThrowsExceptionAsync<GracefulException>(() => task);
.UserKeypair
.PublicKey));
e.StatusCode.Should().Be(HttpStatusCode.Forbidden); e.StatusCode.Should().Be(HttpStatusCode.Forbidden);
e.Message.Should().Be("Request signature too old"); e.Message.Should().Be("Request signature is too old");
e.Error.Should().Be("Forbidden"); e.Error.Should().Be("Forbidden");
} }
@ -118,4 +118,107 @@ public class HttpSignatureTests
var verify = await request.VerifyAsync(MockObjects.UserKeypair.PublicKey); var verify = await request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
verify.Should().BeFalse(); verify.Should().BeFalse();
} }
[TestMethod]
public async Task PseudoHeaderTest()
{
const string keyId = "https://example.org/users/user#main-key";
const string algo = "hs2019";
const string headers = "(request-target) host (created) (expires) (opaque)";
const string opaque = "stub";
var created = (int)(DateTime.UtcNow - TimeSpan.FromHours(6) - DateTime.UnixEpoch).TotalSeconds;
var expires = (int)(DateTime.UtcNow + TimeSpan.FromHours(1) - DateTime.UnixEpoch).TotalSeconds;
var sigHeader =
$"keyId=\"{keyId}\",algorithm=\"{algo}\",created=\"{created}\",expires=\"{expires}\",headers=\"{headers}\",opaque=\"{opaque}\",signature=\"stub\"";
var parsed = HttpSignature.Parse(sigHeader);
var dict = new HeaderDictionary { { "host", "example.org" } };
var signingString =
HttpSignature.GenerateSigningString(headers.Split(" "), "GET", "/", dict, "example.org", parsed);
var keypair = MockObjects.UserKeypair;
var rsa = RSA.Create();
rsa.ImportFromPem(keypair.PrivateKey);
var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
sigHeader = sigHeader.Replace("signature=\"stub\"", $"signature=\"{Convert.ToBase64String(signatureBytes)}\"");
parsed = HttpSignature.Parse(sigHeader);
parsed.KeyId.Should().Be(keyId);
parsed.Algo.Should().Be(algo);
parsed.Opaque.Should().Be(opaque);
parsed.Created.Should().Be(created.ToString());
parsed.Expires.Should().Be(expires.ToString());
parsed.Headers.Should().BeEquivalentTo(headers.Split(' '));
parsed.Signature.Should().BeEquivalentTo(signatureBytes);
var res = await HttpSignature.VerifySignatureAsync(keypair.PublicKey, signingString, parsed, dict, null);
res.Should().BeTrue();
}
[TestMethod]
public async Task PseudoHeaderExpiredTest()
{
const string keyId = "https://example.org/users/user#main-key";
const string algo = "hs2019";
const string headers = "(request-target) host (created) (expires) (opaque)";
const string opaque = "stub";
var created = (int)(DateTime.UtcNow - TimeSpan.FromHours(6) - DateTime.UnixEpoch).TotalSeconds;
var expires = (int)(DateTime.UtcNow - TimeSpan.FromHours(1) - DateTime.UnixEpoch).TotalSeconds;
var sigHeader =
$"keyId=\"{keyId}\",algorithm=\"{algo}\",created=\"{created}\",expires=\"{expires}\",headers=\"{headers}\",opaque=\"{opaque}\",signature=\"stub\"";
var parsed = HttpSignature.Parse(sigHeader);
var dict = new HeaderDictionary { { "host", "example.org" } };
var signingString =
HttpSignature.GenerateSigningString(headers.Split(" "), "GET", "/", dict, "example.org", parsed);
var keypair = MockObjects.UserKeypair;
var rsa = RSA.Create();
rsa.ImportFromPem(keypair.PrivateKey);
var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
sigHeader = sigHeader.Replace("signature=\"stub\"", $"signature=\"{Convert.ToBase64String(signatureBytes)}\"");
parsed = HttpSignature.Parse(sigHeader);
var task = HttpSignature.VerifySignatureAsync(keypair.PublicKey, signingString, parsed, dict, null);
var ex = await Assert.ThrowsExceptionAsync<GracefulException>(() => task);
ex.Message.Should().Be("Request signature is expired");
}
[TestMethod]
public async Task PseudoHeaderTooOldTest()
{
const string keyId = "https://example.org/users/user#main-key";
const string algo = "hs2019";
const string headers = "(request-target) host (created) (expires) (opaque)";
const string opaque = "stub";
var created = (int)(DateTime.UtcNow - TimeSpan.FromHours(24) - DateTime.UnixEpoch).TotalSeconds;
var expires = (int)(DateTime.UtcNow + TimeSpan.FromHours(1) - DateTime.UnixEpoch).TotalSeconds;
var sigHeader =
$"keyId=\"{keyId}\",algorithm=\"{algo}\",created=\"{created}\",expires=\"{expires}\",headers=\"{headers}\",opaque=\"{opaque}\",signature=\"stub\"";
var parsed = HttpSignature.Parse(sigHeader);
var dict = new HeaderDictionary { { "host", "example.org" } };
var signingString =
HttpSignature.GenerateSigningString(headers.Split(" "), "GET", "/", dict, "example.org", parsed);
var keypair = MockObjects.UserKeypair;
var rsa = RSA.Create();
rsa.ImportFromPem(keypair.PrivateKey);
var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
sigHeader = sigHeader.Replace("signature=\"stub\"", $"signature=\"{Convert.ToBase64String(signatureBytes)}\"");
parsed = HttpSignature.Parse(sigHeader);
var task = HttpSignature.VerifySignatureAsync(keypair.PublicKey, signingString, parsed, dict, null);
var ex = await Assert.ThrowsExceptionAsync<GracefulException>(() => task);
ex.Message.Should().Be("Request signature is too old");
}
} }