Implement http signatures properly
This commit is contained in:
parent
6351afb39c
commit
1efd5a8673
4 changed files with 123 additions and 41 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
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;
|
||||||
|
@ -13,10 +14,16 @@ public class SignatureTestController(ILogger<SignatureTestController> logger, Da
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Consumes(MediaTypeNames.Application.Json)]
|
[Consumes(MediaTypeNames.Application.Json)]
|
||||||
public async Task<IActionResult> Inbox() {
|
public async Task<IActionResult> Inbox() {
|
||||||
var sig = new HttpSignature(Request, ["(request-target)", "digest", "host", "date"]);
|
if (!Request.Headers.TryGetValue("signature", out var sigHeader))
|
||||||
|
throw new ConstraintException("Signature string is missing the signature header");
|
||||||
|
|
||||||
|
var sig = HttpSignature.Parse(sigHeader.ToString());
|
||||||
var key = await db.UserPublickeys.SingleOrDefaultAsync(p => p.KeyId == sig.KeyId);
|
var key = await db.UserPublickeys.SingleOrDefaultAsync(p => p.KeyId == sig.KeyId);
|
||||||
var verified = key != null && sig.Verify(key.KeyPem);
|
var verified = key != null &&
|
||||||
logger.LogDebug("sig.Verify returned {result} for key {keyId}", verified, sig.KeyId);
|
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);
|
return verified ? Ok() : StatusCode(StatusCodes.Status403Forbidden);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,59 +1,102 @@
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Federation.Cryptography;
|
namespace Iceshrimp.Backend.Core.Federation.Cryptography;
|
||||||
|
|
||||||
public class HttpSignature {
|
public static class HttpSignature {
|
||||||
public readonly string KeyId;
|
public static async Task<bool> Verify(HttpRequest request, HttpSignatureHeader signature,
|
||||||
private readonly string _algo;
|
IEnumerable<string> requiredHeaders, string key) {
|
||||||
private readonly byte[] _signature;
|
if (!requiredHeaders.All(signature.Headers.Contains))
|
||||||
private readonly byte[] _signatureData;
|
throw new ConstraintException("Request is missing required headers");
|
||||||
|
|
||||||
public HttpSignature(HttpRequest request, IEnumerable<string> requiredHeaders) {
|
var signingString = GenerateSigningString(signature.Headers, request.Method,
|
||||||
if (!request.Headers.TryGetValue("signature", out var sigHeader))
|
request.Path,
|
||||||
throw new ConstraintException("Signature string is missing the signature header");
|
request.Headers);
|
||||||
|
|
||||||
//TODO: does this break for requests without a body?
|
//TODO: does this break for requests without a body?
|
||||||
var digest = SHA256.HashDataAsync(request.BodyReader.AsStream()).AsTask();
|
var digest = await SHA256.HashDataAsync(request.BodyReader.AsStream());
|
||||||
|
|
||||||
var sig = sigHeader.ToString().Split(",")
|
//TODO: this definitely breaks if there's no body
|
||||||
.Select(s => s.Split('='))
|
//TODO: check for the SHA256= prefix instead of blindly removing the first 8 chars
|
||||||
.ToDictionary(p => p[0], p => (p[1] + new string('=', p.Length - 2)).Trim('"'));
|
if (Convert.ToBase64String(digest) != request.Headers["digest"].ToString().Remove(0, 8))
|
||||||
|
throw new ConstraintException("Request digest mismatch");
|
||||||
|
|
||||||
var signatureBase64 = sig["signature"] ?? throw new ConstraintException("Signature string is missing the signature field");
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(key);
|
||||||
|
return rsa.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature,
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
|
||||||
KeyId = sig["keyId"] ?? throw new ConstraintException("Signature string is missing the keyId field");
|
public static HttpRequestMessage Sign(this HttpRequestMessage request, IEnumerable<string> requiredHeaders,
|
||||||
_algo = sig["algorithm"] ?? throw new ConstraintException("Signature string is missing the algorithm field");
|
string key, string keyId) {
|
||||||
_signature = Convert.FromBase64String(signatureBase64);
|
ArgumentNullException.ThrowIfNull(request.RequestUri);
|
||||||
|
|
||||||
var headers = sig["headers"].Split(" ") ??
|
var requiredHeadersEnum = requiredHeaders.ToList();
|
||||||
throw new ConstraintException("Signature data is missing the headers field");
|
var signingString = GenerateSigningString(requiredHeadersEnum, request.Method.Method,
|
||||||
|
request.RequestUri.AbsolutePath,
|
||||||
|
request.Headers.ToHeaderDictionary());
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(key);
|
||||||
|
var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(signingString),
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||||
|
var signatureHeader = $"""
|
||||||
|
keyId="{keyId}",headers="{string.Join(' ', requiredHeadersEnum)}",signature="{signatureBase64}"
|
||||||
|
""";
|
||||||
|
|
||||||
|
|
||||||
|
request.Headers.Add("Signature", signatureHeader);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateSigningString(IEnumerable<string> headers, string requestMethod, string requestPath,
|
||||||
|
IHeaderDictionary requestHeaders) {
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
if (!requiredHeaders.All(headers.Contains))
|
|
||||||
throw new ConstraintException("Request is missing required headers");
|
|
||||||
|
|
||||||
//TODO: handle additional params, see https://github.com/Chocobozzz/node-http-signature/blob/master/lib/parser.js#L294-L310
|
//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)" => $"{request.Method.ToLower()} {request.Path}",
|
"(request-target)" => $"{requestMethod.ToLowerInvariant()} {requestPath}",
|
||||||
_ => request.Headers[header]
|
_ => requestHeaders[header]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_signatureData = Encoding.UTF8.GetBytes(sb.ToString(0, sb.Length - 1)); // remove trailing newline
|
return sb.ToString()[..^1]; // remove trailing newline
|
||||||
|
|
||||||
//TODO: this definitely breaks if there's no body
|
|
||||||
//TODO: check for the SHA256= prefix instead of blindly removing the first 8 chars
|
|
||||||
if (Convert.ToBase64String(digest.Result) != request.Headers["digest"].ToString().Remove(0, 8))
|
|
||||||
throw new ConstraintException("Request digest mismatch");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Verify(string key) {
|
private static HeaderDictionary ToHeaderDictionary(this HttpRequestHeaders headers) {
|
||||||
var rsa = RSA.Create();
|
return new HeaderDictionary(headers.ToDictionary(p => p.Key, p => new StringValues(p.Value.ToArray())));
|
||||||
rsa.ImportFromPem(key);
|
}
|
||||||
return rsa.VerifyData(_signatureData, _signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
|
public static HttpSignatureHeader Parse(string header) {
|
||||||
|
//if (!request.Headers.TryGetValue("signature", out var sigHeader))
|
||||||
|
// throw new ConstraintException("Signature string is missing the signature header");
|
||||||
|
|
||||||
|
var sig = header.Split(",")
|
||||||
|
.Select(s => s.Split('='))
|
||||||
|
.ToDictionary(p => p[0], p => (p[1] + new string('=', p.Length - 2)).Trim('"'));
|
||||||
|
|
||||||
|
var signatureBase64 = sig["signature"] ??
|
||||||
|
throw new ConstraintException("Signature string is missing the signature field");
|
||||||
|
var headers = sig["headers"].Split(" ") ??
|
||||||
|
throw new ConstraintException("Signature data is missing the headers field");
|
||||||
|
|
||||||
|
var keyId = sig["keyId"] ?? throw new ConstraintException("Signature string is missing the keyId field");
|
||||||
|
var algo = sig["algorithm"] ?? throw new ConstraintException("Signature string is missing the algorithm field");
|
||||||
|
|
||||||
|
var signature = Convert.FromBase64String(signatureBase64);
|
||||||
|
|
||||||
|
return new HttpSignatureHeader(keyId, algo, signature, 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 byte[] Signature = signature;
|
||||||
|
public readonly IEnumerable<string> Headers = headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,11 +1,16 @@
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using Iceshrimp.Backend.Core.Configuration;
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
|
using Iceshrimp.Backend.Core.Federation.Cryptography;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Services;
|
namespace Iceshrimp.Backend.Core.Services;
|
||||||
|
|
||||||
public class HttpRequestService(IOptions<Config.InstanceSection> options) {
|
public class HttpRequestService(IOptions<Config.InstanceSection> options) {
|
||||||
private HttpRequestMessage GenerateRequest(string url, IEnumerable<string>? accept, HttpMethod method) {
|
private HttpRequestMessage GenerateRequest(string url, HttpMethod method,
|
||||||
|
string? body = null,
|
||||||
|
string? contentType = 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,
|
||||||
|
@ -15,6 +20,11 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
|
||||||
//TODO: fix the user-agent so the commented out bit above works
|
//TODO: fix the user-agent so the commented out bit above works
|
||||||
message.Headers.TryAddWithoutValidation("User-Agent", options.Value.UserAgent);
|
message.Headers.TryAddWithoutValidation("User-Agent", options.Value.UserAgent);
|
||||||
|
|
||||||
|
if (body != null) {
|
||||||
|
ArgumentNullException.ThrowIfNull(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);
|
||||||
|
@ -24,6 +34,28 @@ public class HttpRequestService(IOptions<Config.InstanceSection> options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpRequestMessage Get(string url, IEnumerable<string>? accept) {
|
public HttpRequestMessage Get(string url, IEnumerable<string>? accept) {
|
||||||
return GenerateRequest(url, accept, HttpMethod.Get);
|
return GenerateRequest(url, HttpMethod.Get, accept: accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequestMessage Post(string url, string body, string contentType) {
|
||||||
|
return GenerateRequest(url, HttpMethod.Post, body, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequestMessage GetSigned(string url, IEnumerable<string>? accept, string key, string keyId) {
|
||||||
|
return Get(url, accept).Sign(["(request-target)", "date", "host", "accept"], key, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HttpRequestMessage> PostSigned(string url, string body, string contentType, string key,
|
||||||
|
string keyId) {
|
||||||
|
var message = Post(url, body, contentType);
|
||||||
|
ArgumentNullException.ThrowIfNull(message.Content);
|
||||||
|
|
||||||
|
// Generate and attach digest header
|
||||||
|
var content = await message.Content.ReadAsStreamAsync();
|
||||||
|
var digest = await SHA256.HashDataAsync(content);
|
||||||
|
message.Headers.Add("Digest", Convert.ToBase64String(digest));
|
||||||
|
|
||||||
|
// Return the signed message
|
||||||
|
return message.Sign(["(request-target)", "date", "host", "digest"], key, keyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -21,7 +21,7 @@
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_FIELDS/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_FIELDS/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_METHODS/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_METHODS/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_NESTED_TERNARY/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_NESTED_TERNARY/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_PARAMETERS/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_PARAMETERS/@EntryValue">False</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_PROPERTIES/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_PROPERTIES/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_SWITCH_EXPRESSIONS/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_SWITCH_EXPRESSIONS/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_SWITCH_SECTIONS/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_SWITCH_SECTIONS/@EntryValue">True</s:Boolean>
|
||||||
|
|
Loading…
Add table
Reference in a new issue