A remote actor is believed only from its own origin

S1 and S2 of the roadmap. RemoteActorService:
- FetchObject accepts a document only when its id is the address it was
  served from; a same-origin document naming another address is asked for
  at that address once (how GoToSocial serves its key URIs), anything else
  is dropped;
- GetActorByKeyId accepts a key only when the actor lists it, its owner is
  the actor and it lives on the actor's origin, whether the keyId points at
  the actor or at a key document;
- a refetch for a key or an actor happens at most once per five minutes,
  so a bad signature cannot make us hammer a host;
- the cache row is written by one atomic upsert on ActorURI;
- every fetch is signed by the instance actor, never by the persona that
  happened to receive the activity.

The inbox refuses an activity whose id is not on its actor's origin, and
an Undo of someone else's activity; a Create's object, an Update and a
Delete must be on the actor's origin too, and a cross-origin object is
refetched from its own origin before it is trusted.

Tests: a fake peer on two origins serves forged actors, foreign-owned keys,
cross-origin key documents, aliases and a GoToSocial-style key address
(integration, PRIVAPUB_TEST_MONGOD=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:49:58 +02:00
1 parent ccc3597699
commit a060204dd6
9 files changed
+593 -81

No files matched your search

@@ -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<AppConfiguration>(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<AppConfiguration>(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));
}
}
+40
View File
@@ -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);
}
}
}
+79
View File
@@ -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<string, string> _documents = new();
public int Port { get; }
public string A => $"http://127.0.0.1:{Port}";
public string B => $"http://localhost:{Port}";
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
Peer(WebApplication app, int port)
{
_app = app;
Port = port;
}
public static async Task<Peer> 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<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
NullLogger<FederationHttp>.Instance);
}
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T>
{
public StaticOptions(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => default;
}
}
@@ -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<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
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<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
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<JsonElement>()
};
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;
}
}
}
@@ -60,6 +60,7 @@ namespace PrivaPub.Federation.Actors
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration; readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration) public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{ {
@@ -115,13 +116,7 @@ namespace PrivaPub.Federation.Actors
public async Task<LocalActor> GetInstanceActor(CancellationToken token) public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{ {
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token); var instance = _instanceActor ??= await LoadInstanceActor(token);
if (instance == default)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
}
return new LocalActor return new LocalActor
{ {
@@ -138,6 +133,17 @@ namespace PrivaPub.Federation.Actors
}; };
} }
async Task<InstanceActor> 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<bool> IsUserNameTaken(string userName, CancellationToken token) public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
{ {
userName = userName?.ToLowerInvariant(); userName = userName?.ToLowerInvariant();
@@ -5,6 +5,9 @@ using PrivaPub.StaticServices;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -12,9 +15,9 @@ namespace PrivaPub.Federation.Actors
{ {
public interface IRemoteActorService public interface IRemoteActorService
{ {
Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token); Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
Task<string> ResolveHandle(string handle, CancellationToken token); Task<string> ResolveHandle(string handle, CancellationToken token);
} }
@@ -23,24 +26,45 @@ namespace PrivaPub.Federation.Actors
public const string ActivityJson = "application/activity+json"; public const string ActivityJson = "application/activity+json";
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; 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 CacheLifetime = TimeSpan.FromDays(1);
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
readonly IFederationHttp _http; readonly IFederationHttp _http;
readonly ILocalActorService _localActors;
readonly IMemoryCache _cache;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
public RemoteActorService(IFederationHttp http, DbEntities dbEntities) public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities)
{ {
_http = http; _http = http;
_localActors = localActors;
_cache = cache;
_dbEntities = dbEntities; _dbEntities = dbEntities;
} }
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token) public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
{ {
var fetched = await _http.GetJson(uri, Accept, var signer = await _localActors.GetInstanceActor(token);
signAs == default ? default : request => HttpSignatures.Sign(request, signAs, body: null), token); var fetched = await Get(uri, signer, token);
return fetched?.Document; 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<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token) public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
{ {
if (string.IsNullOrEmpty(actorUri)) if (string.IsNullOrEmpty(actorUri))
return default; return default;
@@ -49,40 +73,47 @@ namespace PrivaPub.Federation.Actors
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime) if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
return cached; return cached;
if (!MayFetch(actorUri))
using var document = await Fetch(actorUri, signAs, token);
if (document == default)
return cached; 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<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token) public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
{ {
if (string.IsNullOrEmpty(keyId)) if (Origin.Of(keyId) == default)
return 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); var owner = Text(fetched.Root, "owner");
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey)) if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
return cached; 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); var key = actor?.Key(keyId);
if (document == default) if (key == default)
return default; return default;
return await Upsert(actor, key, token);
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);
} }
public async Task<string> ResolveHandle(string handle, CancellationToken token) public async Task<string> ResolveHandle(string handle, CancellationToken token)
@@ -108,36 +139,40 @@ namespace PrivaPub.Federation.Actors
return default; return default;
} }
async Task<ForeignAvatar> Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token) async Task<FetchedJson> 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"); var key = "remote-actor:fetched:" + uri;
if (string.IsNullOrEmpty(actorUri)) if (_cache.TryGetValue(key, out _))
return existing; return false;
_cache.Set(key, true, RefetchInterval);
return true;
}
var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow }; static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
avatar.ActorURI = actorUri; {
avatar.UserName = Text(actor, "preferredUsername"); var now = DateTime.UtcNow;
avatar.Name = Text(actor, "name"); return await DB.Default.UpdateAndGet<ForeignAvatar>()
avatar.Biography = Text(actor, "summary"); .Match(a => a.ActorURI == actor.Id)
avatar.Url = Text(actor, "url") ?? actorUri; .Modify(a => a.UserName, actor.PreferredUsername)
avatar.Domain = new Uri(actorUri).Authority; .Modify(a => a.Name, actor.Name)
avatar.InboxURL = Text(actor, "inbox"); .Modify(a => a.Biography, actor.Summary)
avatar.OutboxURL = Text(actor, "outbox"); .Modify(a => a.Url, actor.Url)
avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False; .Modify(a => a.Domain, new Uri(actor.Id).Authority)
avatar.AvatarType = Enum.TryParse<AvatarType>(Text(actor, "type"), out var type) ? type : AvatarType.Person; .Modify(a => a.InboxURL, actor.Inbox)
if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object) .Modify(a => a.OutboxURL, actor.Outbox)
avatar.SharedInboxURL = Text(endpoints, "sharedInbox"); .Modify(a => a.SharedInboxURL, actor.SharedInbox)
if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object) .Modify(a => a.PictureURL, actor.Icon)
{ .Modify(a => a.IsDiscoverable, actor.Discoverable)
avatar.PublicKeyId = Text(publicKey, "id"); .Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
avatar.PublicKey = Text(publicKey, "publicKeyPem"); .Modify(a => a.PublicKeyId, key?.Id)
} .Modify(a => a.PublicKey, key?.Pem)
if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object) .Modify(a => a.UpdatedAt, now)
avatar.PictureURL = Text(icon, "url"); .Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
avatar.UpdatedAt = DateTime.UtcNow; .Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
await DB.Default.SaveAsync(avatar, token);
return avatar;
} }
public static string StripFragment(string uri) public static string StripFragment(string uri)
+19 -13
View File
@@ -14,6 +14,7 @@ using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
@@ -81,14 +82,13 @@ namespace PrivaPub.Federation.Inbox
if (requestProblem != default) if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem); return new(StatusCodes.Status401Unauthorized, requestProblem);
var signAs = recipient ?? await _localActors.GetInstanceActor(token);
var signingString = HttpSignatures.SigningString(request, parameters); 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) if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) 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)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); 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)) if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner"); 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); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
return type switch return type switch
{ {
"Follow" => await Follow(activity, keyOwner, token), "Follow" => await Follow(activity, keyOwner, token),
"Undo" => await Undo(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), "Delete" => await Delete(activity, keyOwner, token),
"Update" => await Update(activity, keyOwner, signAs, token), "Update" => await Update(activity, keyOwner, token),
_ => new(StatusCodes.Status202Accepted) _ => new(StatusCodes.Status202Accepted)
}; };
} }
@@ -147,6 +151,8 @@ namespace PrivaPub.Federation.Inbox
var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner); 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")) if (innerType is not (null or "Follow"))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
@@ -167,13 +173,13 @@ namespace PrivaPub.Federation.Inbox
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
} }
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token) async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, CancellationToken token)
{ {
var note = create["object"]; 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); using var fetched = await _remoteActors.FetchObject(Id(note), token);
note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText()); 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")) if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
@@ -255,7 +261,7 @@ namespace PrivaPub.Federation.Inbox
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token) async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
{ {
var objectUri = Id(delete["object"]); var objectUri = Id(delete["object"]);
if (string.IsNullOrEmpty(objectUri)) if (!Origin.Same(objectUri, actor.ActorURI))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
if (objectUri == actor.ActorURI) if (objectUri == actor.ActorURI)
@@ -278,15 +284,15 @@ namespace PrivaPub.Federation.Inbox
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
} }
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token) async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, CancellationToken token)
{ {
var inner = update["object"]; var inner = update["object"];
if (Id(inner) == actor.ActorURI) 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); 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); return new(StatusCodes.Status202Accepted);
var objectUri = Id(inner); var objectUri = Id(inner);
+24
View File
@@ -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;
}
}
+1 -1
View File
@@ -312,7 +312,7 @@ namespace PrivaPub.Services
var actorUri = await _remoteActors.ResolveHandle(handle, token); var actorUri = await _remoteActors.ResolveHandle(handle, token);
if (actorUri == default) if (actorUri == default)
return 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 }; return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
} }