[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))
|
if (!requiredHeaders.All(signature.Headers.Contains))
|
||||||
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing required headers");
|
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing required headers");
|
||||||
|
|
||||||
var signingString = GenerateSigningString(signature.Headers, request.Method,
|
var signingString =
|
||||||
request.Path,
|
GenerateSigningString(signature.Headers, request.Method, request.Path, request.Headers, null, signature);
|
||||||
request.Headers);
|
|
||||||
|
|
||||||
if (request.Body.CanSeek) request.Body.Position = 0;
|
if (request.Body.CanSeek) request.Body.Position = 0;
|
||||||
return await VerifySignatureAsync(key, signingString, signature, request.Headers,
|
return await VerifySignatureAsync(key, signingString, signature, request.Headers,
|
||||||
|
@ -32,7 +31,8 @@ public static class HttpSignature
|
||||||
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(),
|
||||||
|
null, signature);
|
||||||
|
|
||||||
Stream? body = null;
|
Stream? body = null;
|
||||||
|
|
||||||
|
@ -41,16 +41,31 @@ public static class HttpSignature
|
||||||
return await VerifySignatureAsync(key, signingString, signature, request.Headers.ToHeaderDictionary(), body);
|
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,
|
string key, string signingString,
|
||||||
HttpSignatureHeader signature,
|
HttpSignatureHeader signature,
|
||||||
IHeaderDictionary headers, Stream? body
|
IHeaderDictionary headers, Stream? body
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!headers.TryGetValue("date", out var date))
|
var created = signature.Created;
|
||||||
throw new GracefulException(HttpStatusCode.Forbidden, "Date header is missing");
|
var datePresent = headers.TryGetValue("date", out var date);
|
||||||
if (DateTime.Now - DateTime.Parse(date!) > TimeSpan.FromHours(12))
|
if (created == null && !datePresent)
|
||||||
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature too old");
|
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 })
|
if (body is { Length: > 0 })
|
||||||
{
|
{
|
||||||
|
@ -98,20 +113,24 @@ public static class HttpSignature
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateSigningString(
|
public static string GenerateSigningString(
|
||||||
IEnumerable<string> headers, string requestMethod, string requestPath,
|
IEnumerable<string> headers, string requestMethod, string requestPath,
|
||||||
IHeaderDictionary requestHeaders, string? host = null
|
IHeaderDictionary requestHeaders, string? host = null, HttpSignatureHeader? signature = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
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)
|
foreach (var header in headers)
|
||||||
{
|
{
|
||||||
sb.Append($"{header}: ");
|
sb.Append($"{header}: ");
|
||||||
sb.AppendLine(header switch
|
sb.AppendLine(header switch
|
||||||
{
|
{
|
||||||
"(request-target)" => $"{requestMethod.ToLowerInvariant()} {requestPath}",
|
"(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]}",
|
"host" => $"{host ?? requestHeaders[header]}",
|
||||||
_ => string.Join(", ", requestHeaders[header].AsEnumerable())
|
_ => string.Join(", ", requestHeaders[header].AsEnumerable())
|
||||||
});
|
});
|
||||||
|
@ -132,33 +151,44 @@ public static class HttpSignature
|
||||||
.Select(s => s.Split('='))
|
.Select(s => s.Split('='))
|
||||||
.ToDictionary(p => p[0], p => (p[1] + new string('=', p.Length - 2)).Trim('"'));
|
.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
|
if (!sig.TryGetValue("signature", out var signatureBase64))
|
||||||
var signatureBase64 = sig["signature"] ??
|
throw GracefulException.Forbidden("Signature string is missing the signature field");
|
||||||
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");
|
|
||||||
|
|
||||||
var keyId = sig["keyId"] ??
|
if (!sig.TryGetValue("headers", out var headers))
|
||||||
throw new GracefulException(HttpStatusCode.Forbidden,
|
throw GracefulException.Forbidden("Signature string is missing the headers field");
|
||||||
"Signature string is missing the keyId field");
|
|
||||||
|
if (!sig.TryGetValue("keyId", out var keyId))
|
||||||
|
throw GracefulException.Forbidden("Signature string is missing the keyId field");
|
||||||
|
|
||||||
//TODO: this should fallback to sha256
|
//TODO: this should fallback to sha256
|
||||||
var algo = sig["algorithm"] ??
|
if (!sig.TryGetValue("algorithm", out var algo))
|
||||||
throw new GracefulException(HttpStatusCode.Forbidden,
|
throw GracefulException.Forbidden("Signature string is missing the algorithm field");
|
||||||
"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);
|
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 string Algo = algo;
|
||||||
public readonly IEnumerable<string> Headers = headers;
|
public readonly IEnumerable<string> Headers = headers;
|
||||||
public readonly string KeyId = keyId;
|
public readonly string KeyId = keyId;
|
||||||
public readonly byte[] Signature = signature;
|
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.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
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.Middleware;
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
using Iceshrimp.Backend.Core.Services;
|
using Iceshrimp.Backend.Core.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
@ -47,12 +49,10 @@ public class HttpSignatureTests
|
||||||
|
|
||||||
request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13);
|
request.Headers.Date = DateTimeOffset.Now - TimeSpan.FromHours(13);
|
||||||
|
|
||||||
var e = await Assert.ThrowsExceptionAsync<GracefulException>(async () =>
|
var task = request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
|
||||||
await request.VerifyAsync(MockObjects
|
var e = await Assert.ThrowsExceptionAsync<GracefulException>(() => task);
|
||||||
.UserKeypair
|
|
||||||
.PublicKey));
|
|
||||||
e.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
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");
|
e.Error.Should().Be("Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,4 +118,107 @@ public class HttpSignatureTests
|
||||||
var verify = await request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
|
var verify = await request.VerifyAsync(MockObjects.UserKeypair.PublicKey);
|
||||||
verify.Should().BeFalse();
|
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