Add unit tests for http signatures

This commit is contained in:
Laura Hausmann 2024-01-23 04:13:54 +01:00
parent 5f088ba66f
commit af054faa05
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
10 changed files with 227 additions and 55 deletions

View file

@ -2,18 +2,21 @@ using System.Data;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
namespace Iceshrimp.Backend.Controllers; namespace Iceshrimp.Backend.Controllers;
[ApiController] [ApiController]
[Produces("application/json")]
[Route("/inbox")] [Route("/inbox")]
[Produces("application/json")]
[EnableRequestBuffering(1024 * 1024)]
public class SignatureTestController(ILogger<SignatureTestController> logger, DatabaseContext db) : Controller { public class SignatureTestController(ILogger<SignatureTestController> logger, DatabaseContext db) : Controller {
[HttpPost] [HttpPost]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
public async Task<IActionResult> Inbox() { public async Task<IActionResult> Inbox([FromBody] JToken content) {
if (!Request.Headers.TryGetValue("signature", out var sigHeader)) if (!Request.Headers.TryGetValue("signature", out var sigHeader))
throw new ConstraintException("Signature string is missing the signature header"); throw new ConstraintException("Signature string is missing the signature header");

View file

@ -7,7 +7,7 @@ using Microsoft.Extensions.Primitives;
namespace Iceshrimp.Backend.Core.Federation.Cryptography; namespace Iceshrimp.Backend.Core.Federation.Cryptography;
public static class HttpSignature { public static class HttpSignature {
public static Task<bool> Verify(HttpRequest request, HttpSignatureHeader signature, public static async Task<bool> Verify(HttpRequest request, HttpSignatureHeader signature,
IEnumerable<string> requiredHeaders, string key) { IEnumerable<string> requiredHeaders, string key) {
if (!requiredHeaders.All(signature.Headers.Contains)) if (!requiredHeaders.All(signature.Headers.Contains))
throw new ConstraintException("Request is missing required headers"); throw new ConstraintException("Request is missing required headers");
@ -16,19 +16,22 @@ public static class HttpSignature {
request.Path, request.Path,
request.Headers); 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 async Task<bool> Verify(this HttpRequestMessage request, string key) {
public static Task<bool> Verify(this HttpRequestMessage request, string key) {
var signatureHeader = request.Headers.GetValues("Signature").First(); var signatureHeader = request.Headers.GetValues("Signature").First();
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());
return VerifySignature(key, signingString, signature, request.Headers.ToHeaderDictionary(), Stream? body = null;
request.Content?.ReadAsStream());
if (request.Content != null) body = await request.Content.ReadAsStreamAsync();
return await VerifySignature(key, signingString, signature, request.Headers.ToHeaderDictionary(), body);
} }
private static async Task<bool> VerifySignature(string key, string signingString, HttpSignatureHeader signature, private static async Task<bool> 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 (!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"); 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 is { Length: > 0 }) {
if (body != null) { if (body.Position != 0)
body.Position = 0;
var digest = await SHA256.HashDataAsync(body); 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)) if (Convert.ToBase64String(digest) != headers["digest"].ToString().Remove(0, 8))
throw new ConstraintException("Request digest mismatch"); return false;
} }
var rsa = RSA.Create(); var rsa = RSA.Create();
@ -123,9 +128,9 @@ public static class HttpSignature {
} }
public class HttpSignatureHeader(string keyId, string algo, byte[] signature, IEnumerable<string> headers) { public class HttpSignatureHeader(string keyId, string algo, byte[] signature, IEnumerable<string> headers) {
public readonly string KeyId = keyId;
public readonly string Algo = algo; public readonly string Algo = algo;
public readonly byte[] Signature = signature;
public readonly IEnumerable<string> Headers = headers; public readonly IEnumerable<string> Headers = headers;
public readonly string KeyId = keyId;
public readonly byte[] Signature = signature;
} }
} }

View file

@ -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<RequestBufferingMiddleware>();
return app;
}
}

View file

@ -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<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<EnableRequestBufferingAttribute>();
if (attribute != null) context.Request.EnableBuffering(attribute.MaxLength);
await next(context);
}
}
public class EnableRequestBufferingAttribute(long maxLength) : Attribute {
internal long MaxLength = maxLength;
}

View file

@ -14,7 +14,7 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
IEnumerable<string>? accept = null) { IEnumerable<string>? accept = null) {
var message = new HttpRequestMessage { var message = new HttpRequestMessage {
RequestUri = new Uri(url), RequestUri = new Uri(url),
Method = method, Method = method
//Headers = { UserAgent = { ProductInfoHeaderValue.Parse(options.Value.UserAgent) } } //Headers = { UserAgent = { ProductInfoHeaderValue.Parse(options.Value.UserAgent) } }
}; };
@ -26,10 +26,9 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
message.Content = new StringContent(body, MediaTypeHeaderValue.Parse(contentType)); message.Content = new StringContent(body, MediaTypeHeaderValue.Parse(contentType));
} }
if (accept != null) { if (accept != null)
foreach (var type in accept.Select(MediaTypeWithQualityHeaderValue.Parse)) foreach (var type in accept.Select(MediaTypeWithQualityHeaderValue.Parse))
message.Headers.Accept.Add(type); message.Headers.Accept.Add(type);
}
return message; return message;
} }
@ -56,7 +55,7 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
// Generate and attach digest header // Generate and attach digest header
var content = await message.Content.ReadAsStreamAsync(); var content = await message.Content.ReadAsStreamAsync();
var digest = await SHA256.HashDataAsync(content); 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 the signed message
return message.Sign(["(request-target)", "date", "host", "digest"], keypair.PrivateKey, return message.Sign(["(request-target)", "date", "host", "digest"], keypair.PrivateKey,

View file

@ -4,7 +4,6 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Vite.AspNetCore.Extensions; using Vite.AspNetCore.Extensions;
using StringExtensions = AngleSharp.Text.StringExtensions;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -59,6 +58,8 @@ app.UseSwagger();
app.UseSwaggerUI(options => { options.DocumentTitle = "Iceshrimp API documentation"; }); app.UseSwaggerUI(options => { options.DocumentTitle = "Iceshrimp API documentation"; });
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthorization(); app.UseAuthorization();
app.UseCustomMiddleware();
app.MapControllers(); app.MapControllers();
app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback"); app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback");
app.MapRazorPages(); app.MapRazorPages();

View file

@ -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<HttpRequestService>();
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<HttpRequestService>();
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<Exception>(async () =>
await request.Verify(MockObjects.UserKeypair.PublicKey));
}
[TestMethod]
public async Task InvalidSignatureTest() {
var provider = MockObjects.ServiceProvider;
var httpRqSvc = provider.GetService<HttpRequestService>();
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<HttpRequestService>();
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<HttpRequestService>();
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<HttpRequestService>();
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();
}
}

View file

@ -2,32 +2,19 @@ using System.Security.Cryptography;
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.Helpers;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace Iceshrimp.Tests.Cryptography; namespace Iceshrimp.Tests.Cryptography;
[TestClass] [TestClass]
public class LdSignatureTests { public class LdSignatureTests {
private ASActor _actor = null!; private readonly ASActor _actor = MockObjects.ASActor;
private RSA _keypair = null!; private readonly RSA _keypair = MockObjects.Keypair;
private JArray _expanded = null!; private JArray _expanded = null!;
private JObject _signed = null!; private JObject _signed = null!;
[TestInitialize] [TestInitialize]
public async Task Initialize() { 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)!; _expanded = LdHelpers.Expand(_actor)!;
_signed = await LdSignature.Sign(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key"); _signed = await LdSignature.Sign(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key");
@ -76,11 +63,15 @@ public class LdSignatureTests {
var data = (_signed.DeepClone() as JObject)!; var data = (_signed.DeepClone() as JObject)!;
data.Should().NotBeNull(); 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(); signature.Should().NotBeNull();
data["https://w3id.org/security#signature"]![0]!["https://w3id.org/security#signatureValue"]![0]!["@value"] += "test"; data["https://w3id.org/security#signature"]![0]!["https://w3id.org/security#signatureValue"]![0]!["@value"] +=
await Assert.ThrowsExceptionAsync<FormatException>(async () => await LdSignature.Verify(data, _keypair.ExportRSAPublicKeyPem())); "test";
await Assert.ThrowsExceptionAsync<FormatException>(async () =>
await LdSignature.Verify(data,
_keypair.ExportRSAPublicKeyPem()));
} }
[TestMethod] [TestMethod]
@ -88,7 +79,8 @@ public class LdSignatureTests {
var data = (_signed.DeepClone() as JObject)!; var data = (_signed.DeepClone() as JObject)!;
data.Should().NotBeNull(); 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(); creator.Should().NotBeNull();
data["https://w3id.org/security#signature"]![0]!["http://purl.org/dc/terms/creator"]![0]!["@value"] += "test"; data["https://w3id.org/security#signature"]![0]!["http://purl.org/dc/terms/creator"]![0]!["@value"] += "test";

View file

@ -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();
}
}

View file

@ -1,6 +1,5 @@
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.Helpers;
namespace Iceshrimp.Tests.Serialization; namespace Iceshrimp.Tests.Serialization;
@ -10,16 +9,7 @@ public class JsonLdTests {
[TestInitialize] [TestInitialize]
public void Initialize() { public void Initialize() {
_actor = new ASActor { _actor = MockObjects.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
};
} }
[TestMethod] [TestMethod]