diff --git a/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs b/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs new file mode 100644 index 0000000..3a99c27 --- /dev/null +++ b/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs @@ -0,0 +1,244 @@ +using Microsoft.Extensions.Caching.Memory; + +using PrivaPub.Federation.Actors; +using PrivaPub.Models; +using PrivaPub.StaticServices; +using PrivaPub.Tests.Support; + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + [Trait("Category", "Integration")] + public sealed class RemoteActorServiceTests : IAsyncLifetime + { + Peer _peer; + RemoteActorService _service; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _peer = await Peer.Start(); + var cache = new MemoryCache(new MemoryCacheOptions()); + var local = new LocalActorService(new DbEntities(), + new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })); + _service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities()); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") => + new JsonObject + { + ["id"] = id, + ["type"] = type, + ["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..], + ["inbox"] = id + "/inbox", + ["publicKey"] = new JsonObject + { + ["id"] = keyId ?? id + "#main-key", + ["owner"] = owner ?? id, + ["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem + } + }.ToJsonString(); + + string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}"; + + [Fact] + public async Task Accepts_an_actor_whose_document_names_its_own_address() + { + var path = Unique("alice"); + _peer.Serve(path, Actor("{A}" + path)); + + var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken); + + Assert.NotNull(actor); + Assert.Equal(_peer.A + path, actor.ActorURI); + Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId); + } + + [Fact] + public async Task Refuses_a_document_that_claims_another_origin() + { + var victim = Unique("bob"); + var mallory = Unique("mallory"); + _peer.Serve(mallory, Actor("{B}" + victim)); + + var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken); + var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken); + + Assert.Null(actor); + Assert.Null(byKey); + } + + [Fact] + public async Task Refuses_a_key_owned_by_someone_else() + { + var eve = Unique("eve"); + _peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice")); + + Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Refuses_a_key_document_whose_owner_is_on_another_origin() + { + var alice = Unique("alice"); + var key = $"/keys/{Guid.NewGuid():N}"; + _peer.Serve(alice, Actor("{B}" + alice)); + _peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString()); + + Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Refuses_a_key_the_actor_does_not_list() + { + var alice = Unique("alice"); + _peer.Serve(alice, Actor("{A}" + alice)); + + Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Follows_a_same_origin_alias_to_the_actor_it_names() + { + var alice = Unique("alice"); + var alias = $"/@alias{Guid.NewGuid():N}"; + _peer.Serve(alice, Actor("{A}" + alice)); + _peer.Serve(alias, Actor("{A}" + alice)); + + var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken); + + Assert.NotNull(actor); + Assert.Equal(_peer.A + alice, actor.ActorURI); + } + + [Fact] + public async Task Resolves_a_gotosocial_style_key_address() + { + var gts = Unique("gts"); + var keyPath = gts + "/main-key"; + var pem = Keys.NewKeyPair().PublicKeyPem; + _peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); + _peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); + + var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken); + + Assert.NotNull(actor); + Assert.Equal(_peer.A + gts, actor.ActorURI); + Assert.Equal(pem, actor.PublicKey); + } + + [Fact] + public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval() + { + var alice = Unique("alice"); + var keyId = $"{_peer.A}{alice}#main-key"; + var first = Keys.NewKeyPair().PublicKeyPem; + var second = Keys.NewKeyPair().PublicKeyPem; + var third = Keys.NewKeyPair().PublicKeyPem; + _peer.Serve(alice, Actor("{A}" + alice, pem: first)); + var token = TestContext.Current.CancellationToken; + + Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey); + _peer.Serve(alice, Actor("{A}" + alice, pem: second)); + Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); + + var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(), + new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })), + new MemoryCache(new MemoryCacheOptions()), new DbEntities()); + Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); + _peer.Serve(alice, Actor("{A}" + alice, pem: third)); + Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); + Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token)); + } + + [Fact] + public async Task Signs_every_fetch() + { + var alice = Unique("alice"); + _peer.Serve(alice, Actor("{A}" + alice)); + + await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken); + + var request = Assert.Single(_peer.Requests, r => r.Path == alice); + Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature); + } + } + + public class ActorDocumentTests + { + [Fact] + public void Reads_a_mastodon_actor() + { + var document = ActorDocument.Parse(JsonDocument.Parse(""" + { + "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], + "id": "https://mastodon.example/users/alice", + "type": "Person", + "preferredUsername": "alice", + "name": "Alice", + "inbox": "https://mastodon.example/users/alice/inbox", + "outbox": "https://mastodon.example/users/alice/outbox", + "url": "https://mastodon.example/@alice", + "discoverable": false, + "endpoints": { "sharedInbox": "https://mastodon.example/inbox" }, + "icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" }, + "publicKey": { + "id": "https://mastodon.example/users/alice#main-key", + "owner": "https://mastodon.example/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n" + } + } + """).RootElement); + + Assert.Equal("alice", document.PreferredUsername); + Assert.Equal("https://mastodon.example/inbox", document.SharedInbox); + Assert.Equal("https://files.mastodon.example/a.png", document.Icon); + Assert.False(document.Discoverable); + Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key")); + } + + [Fact] + public void Keeps_only_keys_owned_by_the_actor_on_its_origin() + { + var document = ActorDocument.Parse(JsonDocument.Parse(""" + { + "id": "https://a.example/users/alice", + "type": "Person", + "publicKey": [ + { "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" }, + { "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" }, + { "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" }, + { "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" } + ] + } + """).RootElement); + + Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id); + } + + [Fact] + public void Refuses_an_inbox_on_another_origin() + { + var document = ActorDocument.Parse(JsonDocument.Parse(""" + { "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" } + """).RootElement); + + Assert.Null(document.Inbox); + } + + [Theory] + [InlineData("Note")] + [InlineData("Tombstone")] + [InlineData("Collection")] + public void Refuses_non_actor_types(string type) => + Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement)); + } +} diff --git a/PrivaPub.Tests/Support/MongoFixture.cs b/PrivaPub.Tests/Support/MongoFixture.cs new file mode 100644 index 0000000..b4972ca --- /dev/null +++ b/PrivaPub.Tests/Support/MongoFixture.cs @@ -0,0 +1,40 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; +using MongoDB.Entities; + +[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))] + +namespace PrivaPub.Tests.Support +{ + public sealed class MongoFixture : IAsyncLifetime + { + public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod"; + + public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1"; + + public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}"; + + public async ValueTask InitializeAsync() + { + if (!Enabled) + return; + try + { + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + } + catch (BsonSerializationException) + { + } + var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017"; + await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection)); + } + + public async ValueTask DisposeAsync() + { + if (Enabled) + await DB.Default.Database().Client.DropDatabaseAsync(Database); + } + } +} diff --git a/PrivaPub.Tests/Support/Peer.cs b/PrivaPub.Tests/Support/Peer.cs new file mode 100644 index 0000000..98ce2c6 --- /dev/null +++ b/PrivaPub.Tests/Support/Peer.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using PrivaPub.Infrastructure.Http; + +using System.Collections.Concurrent; + +namespace PrivaPub.Tests.Support +{ + public sealed class Peer : IAsyncDisposable + { + readonly WebApplication _app; + readonly ConcurrentDictionary _documents = new(); + + public int Port { get; } + public string A => $"http://127.0.0.1:{Port}"; + public string B => $"http://localhost:{Port}"; + public ConcurrentQueue Requests { get; } = new(); + + Peer(WebApplication app, int port) + { + _app = app; + Port = port; + } + + public static async Task Start() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + var app = builder.Build(); + Peer peer = default; + app.Run(async context => + { + peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString())); + var key = context.Request.Path.Value; + if (!peer._documents.TryGetValue(key, out var document)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + context.Response.ContentType = "application/activity+json"; + await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B)); + }); + await app.StartAsync(); + peer = new Peer(app, new Uri(app.Urls.First()).Port); + return peer; + } + + public void Serve(string path, string json) => _documents[path] = json; + + public static FederationHttp Http(IMemoryCache cache = default) + { + var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }; + var services = new ServiceCollection(); + services.AddHttpClient(FederationHttp.ClientName) + .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); + return new FederationHttp(services.BuildServiceProvider().GetRequiredService(), + cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions(options), + NullLogger.Instance); + } + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); + } + + public sealed record HttpRequestRecord(string Method, string Path, string Signature); + + public sealed class StaticOptions : IOptionsMonitor + { + public StaticOptions(T value) => CurrentValue = value; + public T CurrentValue { get; } + public T Get(string name) => CurrentValue; + public IDisposable OnChange(Action listener) => default; + } +} diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs new file mode 100644 index 0000000..ac657f1 --- /dev/null +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -0,0 +1,78 @@ +using System.Text.Json; + +using PrivaPub.Federation.Objects; + +namespace PrivaPub.Federation.Actors +{ + public sealed record ActorKey(string Id, string Pem); + + public sealed class ActorDocument + { + static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" }; + + public string Id { get; init; } + public string Type { get; init; } + public string PreferredUsername { get; init; } + public string Name { get; init; } + public string Summary { get; init; } + public string Url { get; init; } + public string Inbox { get; init; } + public string Outbox { get; init; } + public string SharedInbox { get; init; } + public string Icon { get; init; } + public bool Discoverable { get; init; } = true; + public IReadOnlyList Keys { get; init; } = Array.Empty(); + + public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId); + + public static ActorDocument Parse(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + return default; + var id = RemoteActorService.Text(root, "id"); + var type = RemoteActorService.Text(root, "type"); + if (Origin.Of(id) == default || !ActorTypes.Contains(type)) + return default; + + var inbox = RemoteActorService.Text(root, "inbox"); + return new ActorDocument + { + Id = id, + Type = type, + PreferredUsername = RemoteActorService.Text(root, "preferredUsername"), + Name = RemoteActorService.Text(root, "name"), + Summary = RemoteActorService.Text(root, "summary"), + Url = RemoteActorService.Text(root, "url") ?? id, + Inbox = Origin.Same(inbox, id) ? inbox : default, + Outbox = RemoteActorService.Text(root, "outbox"), + SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default, + Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default, + Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False, + Keys = ParseKeys(root, id) + }; + } + + static List ParseKeys(JsonElement root, string actorId) + { + var keys = new List(); + if (!root.TryGetProperty("publicKey", out var publicKey)) + return keys; + var candidates = publicKey.ValueKind switch + { + JsonValueKind.Object => new[] { publicKey }, + JsonValueKind.Array => publicKey.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(), + _ => Array.Empty() + }; + foreach (var key in candidates) + { + var keyId = RemoteActorService.Text(key, "id"); + var owner = RemoteActorService.Text(key, "owner"); + var pem = RemoteActorService.Text(key, "publicKeyPem"); + if (string.IsNullOrEmpty(pem) || owner != actorId || !Origin.Same(keyId, actorId)) + continue; + keys.Add(new ActorKey(keyId, pem)); + } + return keys; + } + } +} diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 5312b38..347a746 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -60,6 +60,7 @@ namespace PrivaPub.Federation.Actors readonly DbEntities _dbEntities; readonly IOptionsMonitor _appConfiguration; + InstanceActor _instanceActor; public LocalActorService(DbEntities dbEntities, IOptionsMonitor appConfiguration) { @@ -115,13 +116,7 @@ namespace PrivaPub.Federation.Actors public async Task GetInstanceActor(CancellationToken token) { - var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token); - if (instance == default) - { - var (privateKey, publicKey) = Keys.NewKeyPair(); - instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey }; - await DB.Default.SaveAsync(instance, token); - } + var instance = _instanceActor ??= await LoadInstanceActor(token); return new LocalActor { @@ -138,6 +133,17 @@ namespace PrivaPub.Federation.Actors }; } + async Task LoadInstanceActor(CancellationToken token) + { + var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token); + if (instance != default) + return instance; + var (privateKey, publicKey) = Keys.NewKeyPair(); + instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey }; + await DB.Default.SaveAsync(instance, token); + return instance; + } + public async Task IsUserNameTaken(string userName, CancellationToken token) { userName = userName?.ToLowerInvariant(); diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index f7d67b0..c37408a 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -5,6 +5,9 @@ using PrivaPub.StaticServices; using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; + +using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Http; @@ -12,9 +15,9 @@ namespace PrivaPub.Federation.Actors { public interface IRemoteActorService { - Task Fetch(string uri, LocalActor signAs, CancellationToken token); - Task GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token); - Task GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token); + Task FetchObject(string uri, CancellationToken token); + Task GetActor(string actorUri, bool refresh, CancellationToken token); + Task GetActorByKeyId(string keyId, bool refresh, CancellationToken token); Task ResolveHandle(string handle, CancellationToken token); } @@ -23,24 +26,45 @@ namespace PrivaPub.Federation.Actors public const string ActivityJson = "application/activity+json"; const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1); + static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5); readonly IFederationHttp _http; + readonly ILocalActorService _localActors; + readonly IMemoryCache _cache; readonly DbEntities _dbEntities; - public RemoteActorService(IFederationHttp http, DbEntities dbEntities) + public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities) { _http = http; + _localActors = localActors; + _cache = cache; _dbEntities = dbEntities; } - public async Task Fetch(string uri, LocalActor signAs, CancellationToken token) + public async Task FetchObject(string uri, CancellationToken token) { - var fetched = await _http.GetJson(uri, Accept, - signAs == default ? default : request => HttpSignatures.Sign(request, signAs, body: null), token); - return fetched?.Document; + var signer = await _localActors.GetInstanceActor(token); + var fetched = await Get(uri, signer, token); + if (fetched == default) + return default; + + var id = Text(fetched.Root, "id"); + if (Origin.IsDocumentAt(id, fetched.FinalUri)) + return fetched; + + var finalUri = fetched.FinalUri; + fetched.Dispose(); + if (!Origin.Same(id, finalUri.AbsoluteUri)) + return default; + + var named = await Get(id, signer, token); + if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri)) + return named; + named?.Dispose(); + return default; } - public async Task GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token) + public async Task GetActor(string actorUri, bool refresh, CancellationToken token) { if (string.IsNullOrEmpty(actorUri)) return default; @@ -49,40 +73,47 @@ namespace PrivaPub.Federation.Actors var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime) return cached; - - using var document = await Fetch(actorUri, signAs, token); - if (document == default) + if (!MayFetch(actorUri)) return cached; - return await Upsert(document.RootElement, cached, token); + using var fetched = await FetchObject(actorUri, token); + var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root); + if (actor == default) + return cached; + + var key = cached == default ? default : actor.Key(cached.PublicKeyId); + return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token); } - public async Task GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token) + public async Task GetActorByKeyId(string keyId, bool refresh, CancellationToken token) { - if (string.IsNullOrEmpty(keyId)) + if (Origin.Of(keyId) == default) return default; - if (!refresh) + var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token); + if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh) + return cached; + if (!MayFetch(keyId)) + return cached; + + using var fetched = await FetchObject(StripFragment(keyId), token); + if (fetched == default) + return cached; + + var actor = ActorDocument.Parse(fetched.Root); + if (actor == default) { - var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token); - if (cached != default && !string.IsNullOrEmpty(cached.PublicKey)) - return cached; + var owner = Text(fetched.Root, "owner"); + if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId)) + return default; + using var ownerDocument = await FetchObject(owner, token); + actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root); } - using var document = await Fetch(StripFragment(keyId), signAs, token); - if (document == default) + var key = actor?.Key(keyId); + if (key == default) return default; - - var root = document.RootElement; - if (root.TryGetProperty("publicKey", out _)) - { - var actorUri = Text(root, "id"); - var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); - return await Upsert(root, existing, token); - } - - var owner = Text(root, "owner"); - return owner == default ? default : await GetActor(owner, signAs, refresh: true, token); + return await Upsert(actor, key, token); } public async Task ResolveHandle(string handle, CancellationToken token) @@ -108,36 +139,40 @@ namespace PrivaPub.Federation.Actors return default; } - async Task Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token) + async Task Get(string uri, LocalActor signer, CancellationToken token) => + await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token); + + bool MayFetch(string uri) { - var actorUri = Text(actor, "id"); - if (string.IsNullOrEmpty(actorUri)) - return existing; + var key = "remote-actor:fetched:" + uri; + if (_cache.TryGetValue(key, out _)) + return false; + _cache.Set(key, true, RefetchInterval); + return true; + } - var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow }; - avatar.ActorURI = actorUri; - avatar.UserName = Text(actor, "preferredUsername"); - avatar.Name = Text(actor, "name"); - avatar.Biography = Text(actor, "summary"); - avatar.Url = Text(actor, "url") ?? actorUri; - avatar.Domain = new Uri(actorUri).Authority; - avatar.InboxURL = Text(actor, "inbox"); - avatar.OutboxURL = Text(actor, "outbox"); - avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False; - avatar.AvatarType = Enum.TryParse(Text(actor, "type"), out var type) ? type : AvatarType.Person; - if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object) - avatar.SharedInboxURL = Text(endpoints, "sharedInbox"); - if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object) - { - avatar.PublicKeyId = Text(publicKey, "id"); - avatar.PublicKey = Text(publicKey, "publicKeyPem"); - } - if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object) - avatar.PictureURL = Text(icon, "url"); - avatar.UpdatedAt = DateTime.UtcNow; - - await DB.Default.SaveAsync(avatar, token); - return avatar; + static async Task Upsert(ActorDocument actor, ActorKey key, CancellationToken token) + { + var now = DateTime.UtcNow; + return await DB.Default.UpdateAndGet() + .Match(a => a.ActorURI == actor.Id) + .Modify(a => a.UserName, actor.PreferredUsername) + .Modify(a => a.Name, actor.Name) + .Modify(a => a.Biography, actor.Summary) + .Modify(a => a.Url, actor.Url) + .Modify(a => a.Domain, new Uri(actor.Id).Authority) + .Modify(a => a.InboxURL, actor.Inbox) + .Modify(a => a.OutboxURL, actor.Outbox) + .Modify(a => a.SharedInboxURL, actor.SharedInbox) + .Modify(a => a.PictureURL, actor.Icon) + .Modify(a => a.IsDiscoverable, actor.Discoverable) + .Modify(a => a.AvatarType, Enum.TryParse(actor.Type, out var type) ? type : AvatarType.Person) + .Modify(a => a.PublicKeyId, key?.Id) + .Modify(a => a.PublicKey, key?.Pem) + .Modify(a => a.UpdatedAt, now) + .Modify(b => b.SetOnInsert(a => a.CreatedAt, now)) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); } public static string StripFragment(string uri) diff --git a/PrivaPub/Federation/Inbox/InboxService.cs b/PrivaPub/Federation/Inbox/InboxService.cs index 11a5659..daaeee3 100644 --- a/PrivaPub/Federation/Inbox/InboxService.cs +++ b/PrivaPub/Federation/Inbox/InboxService.cs @@ -14,6 +14,7 @@ using PostEntity = PrivaPub.Models.Post.Post; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Signing; using PrivaPub.Federation.Rendering; +using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; namespace PrivaPub.Federation.Inbox @@ -81,14 +82,13 @@ namespace PrivaPub.Federation.Inbox if (requestProblem != default) return new(StatusCodes.Status401Unauthorized, requestProblem); - var signAs = recipient ?? await _localActors.GetInstanceActor(token); var signingString = HttpSignatures.SigningString(request, parameters); - var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: false, token); + var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) return new(StatusCodes.Status202Accepted); if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) { - keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: true, token); + keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); } @@ -96,15 +96,19 @@ namespace PrivaPub.Federation.Inbox if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal)) return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner"); + var activityId = Id(activity); + if (activityId != default && !Origin.Same(activityId, actorUri)) + return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin"); + _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri); return type switch { "Follow" => await Follow(activity, keyOwner, token), "Undo" => await Undo(activity, keyOwner, token), - "Create" => await Create(activity, keyOwner, signAs, token), + "Create" => await Create(activity, keyOwner, token), "Delete" => await Delete(activity, keyOwner, token), - "Update" => await Update(activity, keyOwner, signAs, token), + "Update" => await Update(activity, keyOwner, token), _ => new(StatusCodes.Status202Accepted) }; } @@ -147,6 +151,8 @@ namespace PrivaPub.Federation.Inbox var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerId = Id(inner); + if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI) + return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities"); if (innerType is not (null or "Follow")) return new(StatusCodes.Status202Accepted); @@ -167,13 +173,13 @@ namespace PrivaPub.Federation.Inbox return new(StatusCodes.Status202Accepted); } - async Task Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token) + async Task Create(JsonNode create, ForeignAvatar author, CancellationToken token) { var note = create["object"]; - if (note is JsonValue) + if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI)) { - using var fetched = await _remoteActors.Fetch(Id(note), signAs, token); - note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText()); + using var fetched = await _remoteActors.FetchObject(Id(note), token); + note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); } if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question")) return new(StatusCodes.Status202Accepted); @@ -255,7 +261,7 @@ namespace PrivaPub.Federation.Inbox async Task Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token) { var objectUri = Id(delete["object"]); - if (string.IsNullOrEmpty(objectUri)) + if (!Origin.Same(objectUri, actor.ActorURI)) return new(StatusCodes.Status202Accepted); if (objectUri == actor.ActorURI) @@ -278,15 +284,15 @@ namespace PrivaPub.Federation.Inbox return new(StatusCodes.Status202Accepted); } - async Task Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token) + async Task Update(JsonNode update, ForeignAvatar actor, CancellationToken token) { var inner = update["object"]; if (Id(inner) == actor.ActorURI) { - await _remoteActors.GetActor(actor.ActorURI, signAs, refresh: true, token); + await _remoteActors.GetActor(actor.ActorURI, refresh: true, token); return new(StatusCodes.Status202Accepted); } - if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI) + if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI)) return new(StatusCodes.Status202Accepted); var objectUri = Id(inner); diff --git a/PrivaPub/Federation/Objects/Origin.cs b/PrivaPub/Federation/Objects/Origin.cs new file mode 100644 index 0000000..227628f --- /dev/null +++ b/PrivaPub/Federation/Objects/Origin.cs @@ -0,0 +1,24 @@ +namespace PrivaPub.Federation.Objects +{ + public static class Origin + { + public static string Of(string uri) => + Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Of(parsed) : default; + + public static string Of(Uri uri) => + uri is { IsAbsoluteUri: true } && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp) + ? $"{uri.Scheme}://{uri.IdnHost.ToLowerInvariant()}:{uri.Port}" + : default; + + public static bool Same(string first, string second) + { + var origin = Of(first); + return origin != default && origin == Of(second); + } + + public static bool IsDocumentAt(string id, Uri location) => + Uri.TryCreate(id, UriKind.Absolute, out var parsed) + && location is { IsAbsoluteUri: true } + && Uri.Compare(parsed, location, UriComponents.HttpRequestUrl, UriFormat.UriEscaped, StringComparison.Ordinal) == 0; + } +} diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 348d5e5..a4496f2 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -312,7 +312,7 @@ namespace PrivaPub.Services var actorUri = await _remoteActors.ResolveHandle(handle, token); if (actorUri == default) return default; - var foreign = await _remoteActors.GetActor(actorUri, await _localActors.GetInstanceActor(token), refresh: false, token); + var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token); return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; }