diff --git a/Iceshrimp.Backend/Controllers/SignatureTestController.cs b/Iceshrimp.Backend/Controllers/SignatureTestController.cs index 42c481fa..d61c7556 100644 --- a/Iceshrimp.Backend/Controllers/SignatureTestController.cs +++ b/Iceshrimp.Backend/Controllers/SignatureTestController.cs @@ -2,18 +2,21 @@ using System.Data; using System.Net.Mime; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Federation.Cryptography; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Linq; namespace Iceshrimp.Backend.Controllers; [ApiController] -[Produces("application/json")] [Route("/inbox")] +[Produces("application/json")] +[EnableRequestBuffering(1024 * 1024)] public class SignatureTestController(ILogger logger, DatabaseContext db) : Controller { [HttpPost] [Consumes(MediaTypeNames.Application.Json)] - public async Task Inbox() { + public async Task Inbox([FromBody] JToken content) { if (!Request.Headers.TryGetValue("signature", out var sigHeader)) throw new ConstraintException("Signature string is missing the signature header"); @@ -22,7 +25,7 @@ public class SignatureTestController(ILogger logger, Da var verified = key != null && await HttpSignature.Verify(Request, sig, ["(request-target)", "digest", "host", "date"], key.KeyPem); - + logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId); return verified ? Ok() : StatusCode(StatusCodes.Status403Forbidden); } diff --git a/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs b/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs index 07fb937b..47dbf79e 100644 --- a/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs +++ b/Iceshrimp.Backend/Core/Federation/Cryptography/HttpSignature.cs @@ -7,8 +7,8 @@ using Microsoft.Extensions.Primitives; namespace Iceshrimp.Backend.Core.Federation.Cryptography; public static class HttpSignature { - public static Task Verify(HttpRequest request, HttpSignatureHeader signature, - IEnumerable requiredHeaders, string key) { + public static async Task Verify(HttpRequest request, HttpSignatureHeader signature, + IEnumerable requiredHeaders, string key) { if (!requiredHeaders.All(signature.Headers.Contains)) throw new ConstraintException("Request is missing required headers"); @@ -16,19 +16,22 @@ public static class HttpSignature { request.Path, request.Headers); - return VerifySignature(key, signingString, signature, request.Headers, request.Body); + request.Body.Position = 0; + return await VerifySignature(key, signingString, signature, request.Headers, request.Body); } - //TODO: make this share code with the the regular Verify function - public static Task Verify(this HttpRequestMessage request, string key) { + public static async Task Verify(this HttpRequestMessage request, string key) { var signatureHeader = request.Headers.GetValues("Signature").First(); var signature = Parse(signatureHeader); var signingString = GenerateSigningString(signature.Headers, request.Method.Method, request.RequestUri!.AbsolutePath, request.Headers.ToHeaderDictionary()); - return VerifySignature(key, signingString, signature, request.Headers.ToHeaderDictionary(), - request.Content?.ReadAsStream()); + Stream? body = null; + + if (request.Content != null) body = await request.Content.ReadAsStreamAsync(); + + return await VerifySignature(key, signingString, signature, request.Headers.ToHeaderDictionary(), body); } private static async Task VerifySignature(string key, string signingString, HttpSignatureHeader signature, @@ -36,13 +39,15 @@ public static class HttpSignature { if (!headers.TryGetValue("date", out var date)) throw new Exception("Date header is missing"); if (DateTime.Now - DateTime.Parse(date!) > TimeSpan.FromHours(12)) throw new Exception("Signature too old"); - //TODO: does this break for requests without a body? - if (body != null) { + if (body is { Length: > 0 }) { + if (body.Position != 0) + body.Position = 0; var digest = await SHA256.HashDataAsync(body); + body.Position = 0; - //TODO: check for the SHA256= prefix instead of blindly removing the first 8 chars + //TODO: check for the SHA-256= prefix instead of blindly removing the first 8 chars if (Convert.ToBase64String(digest) != headers["digest"].ToString().Remove(0, 8)) - throw new ConstraintException("Request digest mismatch"); + return false; } var rsa = RSA.Create(); @@ -123,9 +128,9 @@ public static class HttpSignature { } public class HttpSignatureHeader(string keyId, string algo, byte[] signature, IEnumerable headers) { - public readonly string KeyId = keyId; public readonly string Algo = algo; - public readonly byte[] Signature = signature; public readonly IEnumerable Headers = headers; + public readonly string KeyId = keyId; + public readonly byte[] Signature = signature; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Helpers/AppExtensions.cs b/Iceshrimp.Backend/Core/Helpers/AppExtensions.cs new file mode 100644 index 00000000..2c177d99 --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/AppExtensions.cs @@ -0,0 +1,11 @@ +using Iceshrimp.Backend.Core.Middleware; + +namespace Iceshrimp.Backend.Core.Helpers; + +public static class AppExtensions { + public static WebApplication UseCustomMiddleware(this WebApplication app) { + app.UseMiddleware(); + + return app; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Middleware/RequestBufferingMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/RequestBufferingMiddleware.cs new file mode 100644 index 00000000..efc70219 --- /dev/null +++ b/Iceshrimp.Backend/Core/Middleware/RequestBufferingMiddleware.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http.Features; + +namespace Iceshrimp.Backend.Core.Middleware; + +public class RequestBufferingMiddleware(RequestDelegate next) { + public async Task InvokeAsync(HttpContext context) { + var endpoint = context.Features.Get()?.Endpoint; + var attribute = endpoint?.Metadata.GetMetadata(); + + if (attribute != null) context.Request.EnableBuffering(attribute.MaxLength); + + await next(context); + } +} + +public class EnableRequestBufferingAttribute(long maxLength) : Attribute { + internal long MaxLength = maxLength; +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/HttpRequestService.cs b/Iceshrimp.Backend/Core/Services/HttpRequestService.cs index ce60f859..6a0aaf54 100644 --- a/Iceshrimp.Backend/Core/Services/HttpRequestService.cs +++ b/Iceshrimp.Backend/Core/Services/HttpRequestService.cs @@ -14,7 +14,7 @@ public class HttpRequestService(IOptions options) { IEnumerable? accept = null) { var message = new HttpRequestMessage { RequestUri = new Uri(url), - Method = method, + Method = method //Headers = { UserAgent = { ProductInfoHeaderValue.Parse(options.Value.UserAgent) } } }; @@ -26,10 +26,9 @@ public class HttpRequestService(IOptions options) { message.Content = new StringContent(body, MediaTypeHeaderValue.Parse(contentType)); } - if (accept != null) { + if (accept != null) foreach (var type in accept.Select(MediaTypeWithQualityHeaderValue.Parse)) message.Headers.Accept.Add(type); - } return message; } @@ -56,7 +55,7 @@ public class HttpRequestService(IOptions options) { // Generate and attach digest header var content = await message.Content.ReadAsStreamAsync(); var digest = await SHA256.HashDataAsync(content); - message.Headers.Add("Digest", Convert.ToBase64String(digest)); + message.Headers.Add("Digest", "SHA-256=" + Convert.ToBase64String(digest)); // Return the signed message return message.Sign(["(request-target)", "date", "host", "digest"], keypair.PrivateKey, diff --git a/Iceshrimp.Backend/Startup.cs b/Iceshrimp.Backend/Startup.cs index 92819ca4..16b50248 100644 --- a/Iceshrimp.Backend/Startup.cs +++ b/Iceshrimp.Backend/Startup.cs @@ -4,7 +4,6 @@ using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Helpers; using Microsoft.EntityFrameworkCore; using Vite.AspNetCore.Extensions; -using StringExtensions = AngleSharp.Text.StringExtensions; var builder = WebApplication.CreateBuilder(args); @@ -59,6 +58,8 @@ app.UseSwagger(); app.UseSwaggerUI(options => { options.DocumentTitle = "Iceshrimp API documentation"; }); app.UseStaticFiles(); app.UseAuthorization(); +app.UseCustomMiddleware(); + app.MapControllers(); app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback"); app.MapRazorPages(); diff --git a/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs new file mode 100644 index 00000000..90487757 --- /dev/null +++ b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs @@ -0,0 +1,106 @@ +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.Services; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; + +namespace Iceshrimp.Tests.Cryptography; + +[TestClass] +public class HttpSignatureTests { + private ASActor _actor = null!; + private JArray _expanded = null!; + + [TestInitialize] + public void Initialize() { + _actor = MockObjects.ASActor; + _expanded = LdHelpers.Expand(_actor)!; + _expanded.Should().NotBeNull(); + } + + [TestMethod] + public async Task SignedGetTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = httpRqSvc!.GetSigned("https://example.org/users/1234", ["application/ld+json"], + MockObjects.User, MockObjects.UserKeypair); + + var verify = await request.Verify(MockObjects.UserKeypair.PublicKey); + verify.Should().BeTrue(); + } + + [TestMethod] + public async Task InvalidSignatureDateTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = httpRqSvc!.GetSigned("https://example.org/users/1234", ["application/ld+json"], + MockObjects.User, MockObjects.UserKeypair); + + request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13); + + await Assert.ThrowsExceptionAsync(async () => + await request.Verify(MockObjects.UserKeypair.PublicKey)); + } + + [TestMethod] + public async Task InvalidSignatureTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = httpRqSvc!.GetSigned("https://example.org/users/1234", ["application/ld+json"], + MockObjects.User, MockObjects.UserKeypair); + + var sig = request.Headers.GetValues("Signature").First(); + sig = new StringBuilder(sig) { [sig.Length - 10] = (char)(sig[10] + 1) }.ToString(); + + request.Headers.Remove("Signature"); + request.Headers.Add("Signature", sig); + + var verify = await request.Verify(MockObjects.UserKeypair.PublicKey); + verify.Should().BeFalse(); + } + + [TestMethod] + public async Task ModifiedUriTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = httpRqSvc!.GetSigned("https://example.org/users/1234", ["application/ld+json"], + MockObjects.User, MockObjects.UserKeypair); + + request.RequestUri = new Uri(request.RequestUri + "5"); + + var verify = await request.Verify(MockObjects.UserKeypair.PublicKey); + verify.Should().BeFalse(); + } + + [TestMethod] + public async Task SignedPostTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = await httpRqSvc!.PostSigned("https://example.org/users/1234", "body", "text/plain", + MockObjects.User, MockObjects.UserKeypair); + + var verify = await request.Verify(MockObjects.UserKeypair.PublicKey); + verify.Should().BeTrue(); + } + + [TestMethod] + public async Task ModifiedBodyTest() { + var provider = MockObjects.ServiceProvider; + + var httpRqSvc = provider.GetService(); + var request = await httpRqSvc!.PostSigned("https://example.org/users/1234", "body", "text/plain", + MockObjects.User, MockObjects.UserKeypair); + + request.Content = new StringContent("modified-body"); + + var verify = await request.Verify(MockObjects.UserKeypair.PublicKey); + verify.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs b/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs index ab2c8cb8..0083cb59 100644 --- a/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs +++ b/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs @@ -2,32 +2,19 @@ using System.Security.Cryptography; using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.Cryptography; -using Iceshrimp.Backend.Core.Helpers; using Newtonsoft.Json.Linq; namespace Iceshrimp.Tests.Cryptography; [TestClass] public class LdSignatureTests { - private ASActor _actor = null!; - private RSA _keypair = null!; - private JArray _expanded = null!; - private JObject _signed = null!; + private readonly ASActor _actor = MockObjects.ASActor; + private readonly RSA _keypair = MockObjects.Keypair; + private JArray _expanded = null!; + private JObject _signed = null!; [TestInitialize] public async Task Initialize() { - _keypair = RSA.Create(); - _actor = new ASActor { - Id = $"https://example.org/users/{IdHelpers.GenerateSlowflakeId()}", - Type = ["https://www.w3.org/ns/activitystreams#Person"], - Url = new ASLink("https://example.org/@test"), - Username = "test", - DisplayName = "Test account", - IsCat = false, - IsDiscoverable = true, - IsLocked = true - }; - _expanded = LdHelpers.Expand(_actor)!; _signed = await LdSignature.Sign(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key"); @@ -70,25 +57,30 @@ public class LdSignatureTests { var verify = await LdSignature.Verify(data, _keypair.ExportRSAPublicKeyPem()); verify.Should().BeFalse(); } - + [TestMethod] public async Task InvalidSignatureTest() { var data = (_signed.DeepClone() as JObject)!; data.Should().NotBeNull(); - var signature = data["https://w3id.org/security#signature"]?[0]?["https://w3id.org/security#signatureValue"]?[0]?["@value"]; + var signature = + data["https://w3id.org/security#signature"]?[0]?["https://w3id.org/security#signatureValue"]?[0]?["@value"]; signature.Should().NotBeNull(); - data["https://w3id.org/security#signature"]![0]!["https://w3id.org/security#signatureValue"]![0]!["@value"] += "test"; - await Assert.ThrowsExceptionAsync(async () => await LdSignature.Verify(data, _keypair.ExportRSAPublicKeyPem())); + data["https://w3id.org/security#signature"]![0]!["https://w3id.org/security#signatureValue"]![0]!["@value"] += + "test"; + await Assert.ThrowsExceptionAsync(async () => + await LdSignature.Verify(data, + _keypair.ExportRSAPublicKeyPem())); } - + [TestMethod] public async Task InvalidSignatureOptionsTest() { var data = (_signed.DeepClone() as JObject)!; data.Should().NotBeNull(); - var creator = data["https://w3id.org/security#signature"]?[0]?["http://purl.org/dc/terms/creator"]?[0]?["@value"]; + var creator = + data["https://w3id.org/security#signature"]?[0]?["http://purl.org/dc/terms/creator"]?[0]?["@value"]; creator.Should().NotBeNull(); data["https://w3id.org/security#signature"]![0]!["http://purl.org/dc/terms/creator"]![0]!["@value"] += "test"; diff --git a/Iceshrimp.Tests/MockObjects.cs b/Iceshrimp.Tests/MockObjects.cs new file mode 100644 index 00000000..43c93b57 --- /dev/null +++ b/Iceshrimp.Tests/MockObjects.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; +using Iceshrimp.Backend.Core.Helpers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Iceshrimp.Tests; + +public static class MockObjects { + public static readonly ASActor ASActor = new() { + Id = $"https://example.org/users/{IdHelpers.GenerateSlowflakeId()}", + Type = ["https://www.w3.org/ns/activitystreams#Person"], + Url = new ASLink("https://example.org/@test"), + Username = "test", + DisplayName = "Test account", + IsCat = false, + IsDiscoverable = true, + IsLocked = true + }; + + public static readonly User User = new() { + Id = IdHelpers.GenerateSlowflakeId() + }; + + public static readonly RSA Keypair = RSA.Create(4096); + + public static readonly UserKeypair UserKeypair = new() { + UserId = User.Id, + PrivateKey = Keypair.ExportPkcs8PrivateKeyPem(), + PublicKey = Keypair.ExportSubjectPublicKeyInfoPem() + }; + + private static readonly ServiceProvider DefaultServiceProvider = GetServiceProvider(); + public static IServiceProvider ServiceProvider => DefaultServiceProvider.CreateScope().ServiceProvider; + + private static ServiceProvider GetServiceProvider() { + var config = new ConfigurationManager(); + config.AddIniFile("configuration.ini", false); + + var collection = new ServiceCollection(); + collection.AddServices(); + collection.ConfigureServices(config); + + return collection.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/Iceshrimp.Tests/Serialization/JsonLdTests.cs b/Iceshrimp.Tests/Serialization/JsonLdTests.cs index efab413d..0a51d317 100644 --- a/Iceshrimp.Tests/Serialization/JsonLdTests.cs +++ b/Iceshrimp.Tests/Serialization/JsonLdTests.cs @@ -1,6 +1,5 @@ using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; -using Iceshrimp.Backend.Core.Helpers; namespace Iceshrimp.Tests.Serialization; @@ -10,16 +9,7 @@ public class JsonLdTests { [TestInitialize] public void Initialize() { - _actor = new ASActor { - Id = $"https://example.org/users/{IdHelpers.GenerateSlowflakeId()}", - Type = ["https://www.w3.org/ns/activitystreams#Person"], - Url = new ASLink("https://example.org/@test"), - Username = "test", - DisplayName = "Test account", - IsCat = false, - IsDiscoverable = true, - IsLocked = true - }; + _actor = MockObjects.ASActor; } [TestMethod]