[backend/federation] Add support & corresponding tests for http signature pseudo-headers
This commit is contained in:
parent
fd0d6b4fea
commit
d0356fc6ea
2 changed files with 165 additions and 32 deletions
|
@ -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<bool> VerifySignatureAsync(
|
||||
public static async Task<bool> 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<string> 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<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 IEnumerable<string> 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;
|
||||
}
|
||||
}
|
|
@ -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<GracefulException>(async () =>
|
||||
await request.VerifyAsync(MockObjects
|
||||
.UserKeypair
|
||||
.PublicKey));
|
||||
var task = request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
|
||||
var e = await Assert.ThrowsExceptionAsync<GracefulException>(() => 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<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");
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue