Add unit tests for http signatures
This commit is contained in:
parent
5f088ba66f
commit
af054faa05
10 changed files with 227 additions and 55 deletions
|
@ -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<SignatureTestController> logger, DatabaseContext db) : Controller {
|
||||
[HttpPost]
|
||||
[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))
|
||||
throw new ConstraintException("Signature string is missing the signature header");
|
||||
|
||||
|
@ -22,7 +25,7 @@ public class SignatureTestController(ILogger<SignatureTestController> 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);
|
||||
}
|
||||
|
|
|
@ -7,8 +7,8 @@ using Microsoft.Extensions.Primitives;
|
|||
namespace Iceshrimp.Backend.Core.Federation.Cryptography;
|
||||
|
||||
public static class HttpSignature {
|
||||
public static Task<bool> Verify(HttpRequest request, HttpSignatureHeader signature,
|
||||
IEnumerable<string> requiredHeaders, string key) {
|
||||
public static async Task<bool> Verify(HttpRequest request, HttpSignatureHeader signature,
|
||||
IEnumerable<string> 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<bool> Verify(this HttpRequestMessage request, string key) {
|
||||
public static async Task<bool> 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<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 (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<string> headers) {
|
||||
public readonly string KeyId = keyId;
|
||||
public readonly string Algo = algo;
|
||||
public readonly byte[] Signature = signature;
|
||||
public readonly IEnumerable<string> Headers = headers;
|
||||
public readonly string KeyId = keyId;
|
||||
public readonly byte[] Signature = signature;
|
||||
}
|
||||
}
|
11
Iceshrimp.Backend/Core/Helpers/AppExtensions.cs
Normal file
11
Iceshrimp.Backend/Core/Helpers/AppExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -14,7 +14,7 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
|
|||
IEnumerable<string>? 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<Config.InstanceSection> 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<Config.InstanceSection> 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,
|
||||
|
|
|
@ -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();
|
||||
|
|
106
Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs
Normal file
106
Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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<FormatException>(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<FormatException>(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";
|
||||
|
|
47
Iceshrimp.Tests/MockObjects.cs
Normal file
47
Iceshrimp.Tests/MockObjects.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
Loading…
Add table
Reference in a new issue