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"] = "

ciao, è così

", ["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()); var changed = delivered.DeepClone().AsObject(); changed["object"]!["content"] = "

something else

"; 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(), key["type"]!.GetValue(), key["controller"]!.GetValue())); Assert.Equal(IntegrityProofs.PublicKey(seed), IntegrityProofs.FromMultikey(key["publicKeyMultibase"]!.GetValue())); 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().MatchID(persona.Id).Modify(a => a.SigningKey, seed).ExecuteAsync(token); var alice = _harness.Local.FromAvatar(await DB.Default.Find().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() == alice.Uri); Assert.Equal(alice.AssertionKeyId, signed["proof"]!["verificationMethod"]!.GetValue()); Assert.True(IntegrityProofs.Verify(signed, IntegrityProofs.PublicKey(seed))); Assert.Null(Assert.Single(toRelay, a => a["actor"]!.GetValue() != alice.Uri)["proof"]); Assert.Null(Assert.Single(await _harness.Outgoing(serverInbox))["proof"]); Assert.Null(own["proof"]); } finally { await DB.Default.DeleteAsync(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"] = $"

{text}

", ["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"] = "

changed on the way

"; 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 Held(JsonObject create) => DB.Default.Find().Match(p => p.ObjectURI == create["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token); Assert.True(await Held(proven)); Assert.False(await Held(tampered)); Assert.False(await Held(bare)); } } }