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:
1 parent
ccc3597699
commit
a060204dd6
9 files changed
+593
-81
No files matched your search
@@ -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 IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
InstanceActor _instanceActor;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
@@ -115,13 +116,7 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
public async Task<LocalActor> 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<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)
|
||||
{
|
||||
userName = userName?.ToLowerInvariant();
|
||||
|
||||
@@ -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<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token);
|
||||
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||
Task<string> 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<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
|
||||
public async Task<FetchedJson> 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<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))
|
||||
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<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;
|
||||
|
||||
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<string> ResolveHandle(string handle, CancellationToken token)
|
||||
@@ -108,36 +139,40 @@ namespace PrivaPub.Federation.Actors
|
||||
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");
|
||||
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<AvatarType>(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<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await DB.Default.UpdateAndGet<ForeignAvatar>()
|
||||
.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<AvatarType>(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)
|
||||
|
||||
Reference in new issue
Block a user