diff --git a/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs b/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs index eebcd72f..ca042107 100644 --- a/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs +++ b/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs @@ -17,9 +17,8 @@ public static class HttpSignature if (!requiredHeaders.All(signature.Headers.Contains)) throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing required headers"); - var signingString = GenerateSigningString(signature.Headers, request.Method, - request.Path, - request.Headers); + var signingString = + GenerateSigningString(signature.Headers, request.Method, request.Path, request.Headers, null, signature); if (request.Body.CanSeek) request.Body.Position = 0; return await VerifySignatureAsync(key, signingString, signature, request.Headers, @@ -32,7 +31,8 @@ public static class HttpSignature var signature = Parse(signatureHeader); var signingString = GenerateSigningString(signature.Headers, request.Method.Method, request.RequestUri!.AbsolutePath, - request.Headers.ToHeaderDictionary()); + request.Headers.ToHeaderDictionary(), + null, signature); Stream? body = null; @@ -41,16 +41,31 @@ public static class HttpSignature return await VerifySignatureAsync(key, signingString, signature, request.Headers.ToHeaderDictionary(), body); } - private static async Task VerifySignatureAsync( + public static async Task VerifySignatureAsync( string key, string signingString, HttpSignatureHeader signature, IHeaderDictionary headers, Stream? body ) { - if (!headers.TryGetValue("date", out var date)) - throw new GracefulException(HttpStatusCode.Forbidden, "Date header is missing"); - if (DateTime.Now - DateTime.Parse(date!) > TimeSpan.FromHours(12)) - throw new GracefulException(HttpStatusCode.Forbidden, "Request signature too old"); + var created = signature.Created; + var datePresent = headers.TryGetValue("date", out var date); + if (created == null && !datePresent) + 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 }) { @@ -98,20 +113,24 @@ public static class HttpSignature return request; } - private static string GenerateSigningString( + public static string GenerateSigningString( IEnumerable headers, string requestMethod, string requestPath, - IHeaderDictionary requestHeaders, string? host = null + IHeaderDictionary requestHeaders, string? host = null, HttpSignatureHeader? signature = null ) { 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) { sb.Append($"{header}: "); sb.AppendLine(header switch { "(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]}", _ => string.Join(", ", requestHeaders[header].AsEnumerable()) }); @@ -132,33 +151,44 @@ public static class HttpSignature .Select(s => s.Split('=')) .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 - var signatureBase64 = sig["signature"] ?? - 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"); + if (!sig.TryGetValue("signature", out var signatureBase64)) + throw GracefulException.Forbidden("Signature string is missing the signature field"); - var keyId = sig["keyId"] ?? - throw new GracefulException(HttpStatusCode.Forbidden, - "Signature string is missing the keyId field"); + if (!sig.TryGetValue("headers", out var headers)) + throw GracefulException.Forbidden("Signature string is missing the headers field"); + + if (!sig.TryGetValue("keyId", out var keyId)) + throw GracefulException.Forbidden("Signature string is missing the keyId field"); //TODO: this should fallback to sha256 - var algo = sig["algorithm"] ?? - throw new GracefulException(HttpStatusCode.Forbidden, - "Signature string is missing the algorithm field"); + if (!sig.TryGetValue("algorithm", out var algo)) + throw GracefulException.Forbidden("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); - 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 headers) + public class HttpSignatureHeader( + string keyId, + string algo, + byte[] signature, + IEnumerable headers, + string? created, + string? expires, + string? opaque + ) { public readonly string Algo = algo; public readonly IEnumerable Headers = headers; public readonly string KeyId = keyId; public readonly byte[] Signature = signature; + public readonly string? Created = created; + public readonly string? Expires = expires; + public readonly string? Opaque = opaque; } } \ No newline at end of file diff --git a/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs index 9e7b4dad..44ab91fd 100644 --- a/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs +++ b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs @@ -1,10 +1,12 @@ using System.Net; +using System.Security.Cryptography; using System.Text; using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; @@ -47,12 +49,10 @@ public class HttpSignatureTests request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13); - var e = await Assert.ThrowsExceptionAsync(async () => - await request.VerifyAsync(MockObjects - .UserKeypair - .PublicKey)); + var task = request.VerifyAsync(MockObjects.UserKeypair.PublicKey); + var e = await Assert.ThrowsExceptionAsync(() => task); 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"); } @@ -118,4 +118,107 @@ public class HttpSignatureTests var verify = await request.VerifyAsync(MockObjects.UserKeypair.PublicKey); 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(() => 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(() => task); + ex.Message.Should().Be("Request signature is too old"); + } } \ No newline at end of file