init
This commit is contained in:
commit
f6629fbb33
32 changed files with 1608 additions and 0 deletions
72
Foxchat.Core/Federation/RequestSigningService.Client.cs
Normal file
72
Foxchat.Core/Federation/RequestSigningService.Client.cs
Normal file
|
@ -0,0 +1,72 @@
|
|||
using System.Net.Http.Headers;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace Foxchat.Core.Federation;
|
||||
|
||||
public partial class RequestSigningService
|
||||
{
|
||||
public const string USER_AGENT_HEADER = "User-Agent";
|
||||
public const string USER_AGENT = "Foxchat.NET";
|
||||
public const string DATE_HEADER = "Date";
|
||||
public const string CONTENT_LENGTH_HEADER = "Content-Length";
|
||||
public const string CONTENT_TYPE_HEADER = "Content-Type";
|
||||
public const string CONTENT_TYPE = "application/json; charset=utf-8";
|
||||
|
||||
public const string SERVER_HEADER = "X-Foxchat-Server";
|
||||
public const string SIGNATURE_HEADER = "X-Foxchat-Signature";
|
||||
public const string USER_HEADER = "X-Foxchat-User";
|
||||
|
||||
private static readonly JsonSerializerSettings _jsonSerializerSettings = new()
|
||||
{
|
||||
ContractResolver = new DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
};
|
||||
|
||||
public async Task<T> RequestAsync<T>(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null)
|
||||
{
|
||||
var request = BuildHttpRequest(method, domain, requestPath, userId, body);
|
||||
_logger.Debug("Content length in header: '{ContentLength}'", request.Headers.Where(c => c.Key == "Content-Length"));
|
||||
var resp = await _httpClient.SendAsync(request);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await resp.Content.ReadAsStringAsync();
|
||||
_logger.Error("Received {Status}, body: {Error}", resp.StatusCode, error);
|
||||
// TODO: replace this with specific exception type
|
||||
throw new Exception("oh no a request error");
|
||||
}
|
||||
|
||||
var bodyString = await resp.Content.ReadAsStringAsync();
|
||||
// TODO: replace this with specific exception type
|
||||
return DeserializeObject<T>(bodyString) ?? throw new Exception("oh no invalid json");
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null)
|
||||
{
|
||||
var body = bodyData != null ? SerializeObject(bodyData) : null;
|
||||
|
||||
var now = _clock.GetCurrentInstant();
|
||||
var url = $"https://{domain}{requestPath}";
|
||||
var signature = GenerateSignature(new SignatureData(now, domain, requestPath, body?.Length, userId));
|
||||
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
request.Headers.Clear();
|
||||
request.Headers.Add(USER_AGENT_HEADER, USER_AGENT);
|
||||
request.Headers.Add(DATE_HEADER, FormatTime(now));
|
||||
request.Headers.Add(SERVER_HEADER, _config.Domain);
|
||||
request.Headers.Add(SIGNATURE_HEADER, signature);
|
||||
if (userId != null)
|
||||
request.Headers.Add(USER_HEADER, userId);
|
||||
if (body != null)
|
||||
{
|
||||
request.Content = new StringContent(body, new MediaTypeHeaderValue("application/json", "utf-8"));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
public static string SerializeObject(object data) => JsonConvert.SerializeObject(data, _jsonSerializerSettings);
|
||||
public static T? DeserializeObject<T>(string data) => JsonConvert.DeserializeObject<T>(data, _jsonSerializerSettings);
|
||||
}
|
78
Foxchat.Core/Federation/RequestSigningService.cs
Normal file
78
Foxchat.Core/Federation/RequestSigningService.cs
Normal file
|
@ -0,0 +1,78 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Foxchat.Core.Database;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using NodaTime;
|
||||
using NodaTime.Text;
|
||||
using Serilog;
|
||||
|
||||
namespace Foxchat.Core.Federation;
|
||||
|
||||
public partial class RequestSigningService(ILogger logger, IClock clock, IDatabaseContext context, CoreConfig config)
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<RequestSigningService>();
|
||||
private readonly IClock _clock = clock;
|
||||
private readonly CoreConfig _config = config;
|
||||
private readonly RSA _rsa = context.GetInstanceKeysAsync().GetAwaiter().GetResult();
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
public string GenerateSignature(SignatureData data)
|
||||
{
|
||||
var plaintext = GeneratePlaintext(data);
|
||||
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var hash = SHA256.HashData(plaintextBytes);
|
||||
|
||||
var formatter = new RSAPKCS1SignatureFormatter(_rsa);
|
||||
formatter.SetHashAlgorithm(nameof(SHA256));
|
||||
var signature = formatter.CreateSignature(hash);
|
||||
|
||||
_logger.Debug("Generated signature for {Host} {RequestPath}: {Signature}", data.Host, data.RequestPath, WebEncoders.Base64UrlEncode(signature));
|
||||
return WebEncoders.Base64UrlEncode(signature);
|
||||
}
|
||||
|
||||
public bool VerifySignature(
|
||||
string publicKey, string encodedSignature, string dateHeader, string host, string requestPath, int? contentLength, string? userId)
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKey);
|
||||
|
||||
var now = _clock.GetCurrentInstant();
|
||||
var time = ParseTime(dateHeader);
|
||||
if ((now + Duration.FromMinutes(1)) < time)
|
||||
{
|
||||
// TODO: replace this with specific exception type
|
||||
throw new Exception("Request was made in the future");
|
||||
}
|
||||
else if ((now - Duration.FromMinutes(1)) > time)
|
||||
{
|
||||
// TODO: replace this with specific exception type
|
||||
throw new Exception("Request was made too long ago");
|
||||
}
|
||||
|
||||
var plaintext = GeneratePlaintext(new SignatureData(time, host, requestPath, contentLength, userId));
|
||||
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var hash = SHA256.HashData(plaintextBytes);
|
||||
var signature = WebEncoders.Base64UrlDecode(encodedSignature);
|
||||
|
||||
var deformatter = new RSAPKCS1SignatureDeformatter(rsa);
|
||||
deformatter.SetHashAlgorithm(nameof(SHA256));
|
||||
|
||||
return deformatter.VerifySignature(hash, signature);
|
||||
}
|
||||
|
||||
private static string GeneratePlaintext(SignatureData data)
|
||||
{
|
||||
var time = FormatTime(data.Time);
|
||||
var contentLength = data.ContentLength != null ? data.ContentLength.ToString() : "";
|
||||
var userId = data.UserId ?? "";
|
||||
|
||||
Log.Information("Plaintext string: {Plaintext}", $"{time}:{data.Host}:{data.RequestPath}:{contentLength}:{userId}");
|
||||
|
||||
return $"{time}:{data.Host}:{data.RequestPath}:{contentLength}:{userId}";
|
||||
}
|
||||
|
||||
private static readonly InstantPattern _pattern = InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US"));
|
||||
private static string FormatTime(Instant time) => _pattern.Format(time);
|
||||
private static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow();
|
||||
}
|
11
Foxchat.Core/Federation/SignatureData.cs
Normal file
11
Foxchat.Core/Federation/SignatureData.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using NodaTime;
|
||||
|
||||
namespace Foxchat.Core.Federation;
|
||||
|
||||
public record SignatureData(
|
||||
Instant Time,
|
||||
string Host,
|
||||
string RequestPath,
|
||||
int? ContentLength,
|
||||
string? UserId
|
||||
) { }
|
Loading…
Add table
Add a link
Reference in a new issue