Personas prove what goes to relays; the server says what it reads
FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of being read again from its origin. Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e). Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon unchanged (165 in all). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
c47b6e5533
commit
9ab87b2779
22 files changed
+702
-30
No files matched your search
@@ -0,0 +1,175 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
// FEP-8b32 proofs (eddsa-jcs-2022) by a persona's Ed25519 key, which its actor names as a FEP-521a Multikey
|
||||
public sealed class IntegrityProofTests
|
||||
{
|
||||
[Fact]
|
||||
public void Canonical_json_is_rfc_8785s()
|
||||
{
|
||||
// RFC 8785, section 3.2.2
|
||||
var input = JsonNode.Parse("{\"numbers\":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001],"
|
||||
+ "\"string\":\"\\u20ac$\\u000F\\u000aA'\\u0042\\u0022\\u005c\\\\\\\"\\/\",\"literals\":[null,true,false]}");
|
||||
|
||||
Assert.Equal("{\"literals\":[null,true,false],\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27],\"string\":\"€$\\u000f\\nA'B\\\"\\\\\\\\\\\"/\"}",
|
||||
Jcs.Serialize(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base58_and_multikeys_read_back_what_they_write()
|
||||
{
|
||||
Assert.Equal("2NEpo7TZRRrLZSi2U", IntegrityProofs.Base58(Encoding.ASCII.GetBytes("Hello World!")));
|
||||
Assert.Equal("112", IntegrityProofs.Base58(new byte[] { 0, 0, 1 }));
|
||||
Assert.Equal(new byte[] { 0, 0, 1 }, IntegrityProofs.FromBase58("112"));
|
||||
|
||||
var publicKey = IntegrityProofs.PublicKey(IntegrityProofs.NewSeed());
|
||||
var multikey = IntegrityProofs.Multikey(publicKey);
|
||||
Assert.StartsWith("z6Mk", multikey);
|
||||
Assert.Equal(publicKey, IntegrityProofs.FromMultikey(multikey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_proof_verifies_with_its_key_and_with_nothing_changed()
|
||||
{
|
||||
var seed = IntegrityProofs.NewSeed();
|
||||
var activity = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(), ["id"] = "https://privapub.test/peasants/alice/grunts/1", ["type"] = "Create",
|
||||
["actor"] = "https://privapub.test/peasants/alice", ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "<p>ciao, è così</p>", ["width"] = 4.5 }
|
||||
};
|
||||
activity["proof"] = IntegrityProofs.Create(activity, "https://privapub.test/peasants/alice#ed25519-key", seed, DateTime.UtcNow);
|
||||
|
||||
var delivered = JsonNode.Parse(activity.ToJsonString())!.AsObject();
|
||||
Assert.True(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(seed)));
|
||||
Assert.Equal("assertionMethod", delivered["proof"]!["proofPurpose"]!.GetValue<string>());
|
||||
|
||||
var changed = delivered.DeepClone().AsObject();
|
||||
changed["object"]!["content"] = "<p>something else</p>";
|
||||
Assert.False(IntegrityProofs.Verify(changed, IntegrityProofs.PublicKey(seed)));
|
||||
Assert.False(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(IntegrityProofs.NewSeed())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_persona_with_a_key_names_it_as_a_multikey()
|
||||
{
|
||||
var seed = IntegrityProofs.NewSeed();
|
||||
var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = LocalActorKind.Person, SigningKey = seed });
|
||||
var keyless = ActivityPubRenderer.Actor(new LocalActor { Id = "b", UserName = "bob", BaseAddress = Harness.Base, Kind = LocalActorKind.Person });
|
||||
|
||||
var key = Assert.Single(actor["assertionMethod"]!.AsArray())!;
|
||||
Assert.Equal(($"{Harness.Base}/peasants/alice#ed25519-key", "Multikey", $"{Harness.Base}/peasants/alice"),
|
||||
(key["id"]!.GetValue<string>(), key["type"]!.GetValue<string>(), key["controller"]!.GetValue<string>()));
|
||||
Assert.Equal(IntegrityProofs.PublicKey(seed), IntegrityProofs.FromMultikey(key["publicKeyMultibase"]!.GetValue<string>()));
|
||||
Assert.Null(keyless["assertionMethod"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Xunit.Collection(nameof(Exclusive))]
|
||||
public sealed class DeliveredProofTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
// what goes to a relay carries the persona's proof, to be forwarded on its strength; what goes to a server directly
|
||||
// does not (Mitra refuses a proof by a key it has not read yet); another's activity passed on never gets ours
|
||||
[Fact]
|
||||
public async Task A_personas_activity_goes_to_a_relay_with_its_proof_and_elsewhere_without()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, persona) = await _harness.Persona("alice");
|
||||
var seed = IntegrityProofs.NewSeed();
|
||||
await DB.Default.Update<Avatar>().MatchID(persona.Id).Modify(a => a.SigningKey, seed).ExecuteAsync(token);
|
||||
var alice = _harness.Local.FromAvatar(await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteSingleAsync(token));
|
||||
var relayInbox = $"{_harness.Peer.A}/relay-{Guid.NewGuid():N}/inbox";
|
||||
var serverInbox = _harness.Peer.A + "/proofs/inbox";
|
||||
await DB.Default.SaveAsync(new RelaySubscription
|
||||
{
|
||||
Configured = relayInbox, ActorURI = relayInbox.Replace("/inbox", "/actor"), InboxURL = relayInbox, State = RelayState.Accepted
|
||||
}, token);
|
||||
var own = new JsonObject { ["id"] = alice.ActivityUri(Guid.NewGuid().ToString("N")), ["type"] = "Create", ["actor"] = alice.Uri, ["object"] = new JsonObject { ["type"] = "Note" } };
|
||||
var passedOn = new JsonObject { ["id"] = $"https://elsewhere.example/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = "https://elsewhere.example/users/bob" };
|
||||
|
||||
try
|
||||
{
|
||||
await _harness.Delivery.Enqueue(alice, new[] { relayInbox, serverInbox }, own, token);
|
||||
await _harness.Delivery.Enqueue(alice, new[] { relayInbox }, passedOn, token);
|
||||
|
||||
var toRelay = await _harness.Outgoing(relayInbox);
|
||||
var signed = Assert.Single(toRelay, a => a["actor"]!.GetValue<string>() == alice.Uri);
|
||||
Assert.Equal(alice.AssertionKeyId, signed["proof"]!["verificationMethod"]!.GetValue<string>());
|
||||
Assert.True(IntegrityProofs.Verify(signed, IntegrityProofs.PublicKey(seed)));
|
||||
Assert.Null(Assert.Single(toRelay, a => a["actor"]!.GetValue<string>() != alice.Uri)["proof"]);
|
||||
Assert.Null(Assert.Single(await _harness.Outgoing(serverInbox))["proof"]);
|
||||
Assert.Null(own["proof"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DB.Default.DeleteAsync<RelaySubscription>(s => s.InboxURL == relayInbox);
|
||||
}
|
||||
}
|
||||
|
||||
// a post forwarded by another server, which its origin does not serve: taken on its author's proof, never without
|
||||
[Fact]
|
||||
public async Task A_forwarded_post_with_its_authors_proof_is_taken_as_it_came()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var author = new RemoteActor(_harness.Peer, "author", ed25519: true);
|
||||
var forwarder = new RemoteActor(_harness.Peer, "forwarder", _harness.Peer.B);
|
||||
await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following
|
||||
{
|
||||
AvatarId = alice.Id, TargetActorURI = author.Id, TargetInboxURL = author.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted
|
||||
}, token);
|
||||
JsonObject Create(string text)
|
||||
{
|
||||
var noteId = $"{new Uri(author.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}";
|
||||
return new JsonObject
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
|
||||
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
|
||||
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
}
|
||||
var proven = author.Prove(Create("proven"));
|
||||
var tampered = author.Prove(Create("as written"));
|
||||
tampered["object"]!["content"] = "<p>changed on the way</p>";
|
||||
var bare = Create("unproven");
|
||||
|
||||
await _harness.Deliver(forwarder, "/human-centipede", proven);
|
||||
await _harness.Deliver(forwarder, "/human-centipede", tampered);
|
||||
await _harness.Deliver(forwarder, "/human-centipede", bare);
|
||||
|
||||
Task<bool> Held(JsonObject create) => DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == create["object"]!["id"]!.GetValue<string>()).ExecuteAnyAsync(token);
|
||||
Assert.True(await Held(proven));
|
||||
Assert.False(await Held(tampered));
|
||||
Assert.False(await Held(bare));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,31 @@ namespace PrivaPub.Tests.Http
|
||||
static string Link(JsonObject document, string rel) =>
|
||||
document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue<string>() == rel)!["href"]!.GetValue<string>();
|
||||
|
||||
// FEP-d556, FEP-2677 and FEP-844e: the server's own actor is found from its origin and from NodeInfo, and says what
|
||||
// the server reads that its documents do not show
|
||||
[Fact]
|
||||
public async Task The_server_actor_is_found_from_the_origin_and_nodeinfo_and_tells_what_it_implements()
|
||||
{
|
||||
var instance = $"{Base}/peasants/privapub";
|
||||
foreach (var resource in new[] { Base, Base + "/" })
|
||||
{
|
||||
var found = await WebFinger(resource);
|
||||
Assert.Equal(HttpStatusCode.OK, found.Status);
|
||||
Assert.Equal(resource, found.Json["subject"]!.GetValue<string>());
|
||||
Assert.Equal(instance, Link(found.Json, "https://www.w3.org/ns/activitystreams#Service"));
|
||||
}
|
||||
var nodeinfo = await _client.Fetch("/.well-known/nodeinfo", "application/json");
|
||||
Assert.Equal(instance, Link(nodeinfo.Json, "https://www.w3.org/ns/activitystreams#Application"));
|
||||
|
||||
var actor = (await _client.Fetch("/peasants/privapub")).Json;
|
||||
Assert.Contains(actor["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
|
||||
var persona = await _host.Persona(await _host.SignUp(), "generated");
|
||||
var personaActor = (await _client.Fetch($"/peasants/{persona.UserName}")).Json;
|
||||
Assert.Equal("Application", personaActor["generator"]!["type"]!.GetValue<string>());
|
||||
Assert.Contains(personaActor["generator"]!["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
|
||||
Assert.StartsWith("z6Mk", personaActor["assertionMethod"]![0]!["publicKeyMultibase"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri()
|
||||
{
|
||||
|
||||
@@ -15,11 +15,15 @@ namespace PrivaPub.Tests.Support
|
||||
readonly RSA _key = RSA.Create(2048);
|
||||
readonly bool _namesSharedInbox;
|
||||
readonly bool _hasWall;
|
||||
readonly string _ed25519;
|
||||
|
||||
// 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)
|
||||
// whether it has a wall (sm:wall, Smithereen's); ed25519: whether it has an Ed25519 key (FEP-521a) to prove what it
|
||||
// sends (FEP-8b32)
|
||||
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false,
|
||||
bool ed25519 = false)
|
||||
{
|
||||
_ed25519 = ed25519 ? PrivaPub.Federation.Signing.IntegrityProofs.NewSeed() : default;
|
||||
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||
Type = type;
|
||||
@@ -36,6 +40,13 @@ namespace PrivaPub.Tests.Support
|
||||
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
||||
public string Wall => Id + "/wall";
|
||||
|
||||
// the activity with its proof by this actor's Ed25519 key
|
||||
public JsonObject Prove(JsonObject activity)
|
||||
{
|
||||
activity["proof"] = PrivaPub.Federation.Signing.IntegrityProofs.Create(activity, Id + "#ed25519-key", _ed25519, DateTime.UtcNow);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public JsonObject Document()
|
||||
{
|
||||
var document = new JsonObject
|
||||
@@ -58,6 +69,12 @@ namespace PrivaPub.Tests.Support
|
||||
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
|
||||
if (_hasWall)
|
||||
document["wall"] = Wall;
|
||||
if (_ed25519 != default)
|
||||
document["assertionMethod"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["id"] = Id + "#ed25519-key", ["type"] = "Multikey", ["controller"] = Id,
|
||||
["publicKeyMultibase"] = PrivaPub.Federation.Signing.IntegrityProofs.Multikey(PrivaPub.Federation.Signing.IntegrityProofs.PublicKey(_ed25519))
|
||||
});
|
||||
return document;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user