using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using PrivaPub.Federation.Signing; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Support { public sealed class RemoteActor { readonly RSA _key = RSA.Create(2048); readonly bool _namesSharedInbox; readonly bool _hasWall; // sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall: // whether it has a wall (sm:wall, Smithereen's) public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false) { Name = $"{name}{Guid.NewGuid():N}"[..20]; Id = $"{origin ?? peer.A}/users/{Name}"; Type = type; _namesSharedInbox = sharedInbox; _hasWall = wall; peer.Serve($"/users/{Name}", Document().ToJsonString()); } public string Type { get; } public string Name { get; } public string Id { get; } public string KeyId => Id + "#main-key"; public string SharedInbox => Id.Split("/users/")[0] + "/inbox"; public string Wall => Id + "/wall"; public JsonObject Document() { var document = new JsonObject { ["id"] = Id, ["type"] = Type, ["preferredUsername"] = Name, ["inbox"] = Id + "/inbox", ["followers"] = Id + "/followers", ["outbox"] = Id + "/outbox", ["featured"] = Id + "/featured", ["publicKey"] = new JsonObject { ["id"] = KeyId, ["owner"] = Id, ["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem() } }; if (_namesSharedInbox) document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox }; if (_hasWall) document["wall"] = Wall; return document; } public HttpRequestMessage SignedGet(string path, string host = "privapub.test") { var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.TryAddWithoutValidation("Date", date); request.Headers.TryAddWithoutValidation("Accept", "application/activity+json"); request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date")); return request; } public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default) { var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var digest = HttpSignatures.Digest(body); var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) }; request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); request.Headers.TryAddWithoutValidation("Date", date); request.Headers.TryAddWithoutValidation("Digest", digest); request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest")); return request; } // the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the // request goes public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test") { var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) }; request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body); request.RequestUri = new Uri(origin + path); return request; } public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem(); public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem(); string Signature(string signingString, string headers) { var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\""; } public HttpRequest Get(string host, string path) { var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var context = new DefaultHttpContext(); context.Request.Method = "GET"; context.Request.Host = new HostString(host); context.Request.Path = path; context.Features.Get().RawTarget = path; context.Request.Headers["Date"] = date; context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"); return context.Request; } public HttpRequest Post(string host, string path, JsonNode activity) { var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var digest = HttpSignatures.Digest(body); var context = new DefaultHttpContext(); context.Request.Method = "POST"; context.Request.Host = new HostString(host); context.Request.Path = path; context.Features.Get().RawTarget = path; context.Request.Headers["Date"] = date; context.Request.Headers["Digest"] = digest; context.Request.Headers["Content-Type"] = "application/activity+json"; context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"); context.Request.Body = new MemoryStream(body); context.Request.ContentLength = body.Length; return context.Request; } } }