source: Klonkt/test/activitypub-as2.test.js@ 5d0d3f35

main
Last change on this file since 5d0d3f35 was b1d5f3f, checked in by Bart <bart@…>, 28 hours ago

Identiteitsbewijzen (FEP-c390): een sleutel van elders zegt dat dit account van hem is

Een Ed25519-sleutel die niet op deze server staat -- in Shaer op papier, alleen
ingetypt als hij nodig is -- ondertekent een VerifiableIdentityStatement:
"did:key:... is ook deze actor". Klonkt ondertekent niets. Hij controleert de
verklaring en zet hem onder attachment op de actor, waar iedereen hem zelf
kan nalopen.

Wat dat bewijst: dat dezelfde entiteit achter sleutel en account zit. Verhuist
het account, dan draagt het nieuwe dezelfde did:key, en dat bewijst de
continuiteit ook als de oude server weg of vijandig is -- een Move kan dat niet,
want die moet door de OUDE server getekend worden.

eddsa-jcs-2022, omdat Mitra en streams dat controleren. IdentityProof.js is
puur: JCS (RFC 8785, in JavaScript precies een gesorteerde JSON.stringify),
base58btc, did:key en de proof zelf. De controle volgt c390 regel voor regel,
met een eigen reden per weigering; verificationMethod moet PRECIES de subject
zijn, zonder het #-fragment dat de W3C-voorbeelden gebruiken.

Getoetst tegen de W3C-testvectoren (vc-di-eddsa B.3): dezelfde did:key, dezelfde
canonieke vorm, dezelfde hashes en byte voor byte dezelfde handtekening. Een
implementatie die het alleen met zichzelf eens is, bewijst niets.

POST /ap/users/:slug/identityProofs (alleen de eigenaar) controleert en
publiceert, één per did:key, hoogstens drie; DELETE ?subject= haalt hem weg.
Beide sturen een Update van de actor naar de volgers. De actor noemt het
eindpunt als endpoints.shaer:identityProofs. Elke verklaring draagt haar eigen
@context, zodat de actor er geen bij hoeft -- did/v1 en data-integrity/v1 hebben
beschermde termen die op actorniveau met onze inline context konden botsen.

shaer-jpxj
Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 6.7 KB
Line 
1// AS2 / JSON-LD validity guard.
2//
3// Every property key and `type` value our ActivityPub objects emit MUST be either an AS2-core
4// (or security/v1) term OR declared in the federation @context (AP_CONTEXT). A new feature that
5// emits an undeclared term fails this test → declare it in AP_CONTEXT (extensions) or add it to
6// the AS2 allowlist below. This keeps Klonkt's output valid AS2/JSON-LD forever — not just
7// "Mastodon tolerates it". No extra deps; in-memory SQLite. Run: npm test
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15const db = dbMod.default;
16dbMod.initializeDatabase();
17const AP = (await import('../src/services/ActivityPubService.js')).default;
18
19const BASE = 'https://test.example';
20
21// AS2-core + security/v1 vocabulary Klonkt uses (stable — only extend when AS2/security itself
22// adds a term we adopt). JSON-LD keywords included.
23const AS2 = new Set([
24 // `@container` hoort in dit rijtje omdat de verzamelaar hieronder OOK door
25 // @context zelf loopt, en een termdefinitie mag naast @id en @type ook een
26 // container dragen (artist_credit is bij Funkwhale een @list). Het is een
27 // JSON-LD-sleutelwoord, geen vocabulaire -- die laatste is wat deze test moet
28 // vangen. Zonder deze regel faalde hij op een correcte declaratie.
29 '@context', '@id', '@type', '@container',
30 'id', 'type', 'actor', 'object', 'target', 'to', 'cc',
31 'content', 'name', 'summary', 'url', 'href', 'mediaType',
32 'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
33 // AS2-kern: de context waarbinnen een object bestaat. Een track wijst ermee
34 // naar de post die hem uitbrengt (shaer-0nh).
35 'context',
36 'attachment', 'tag', 'icon', 'image', 'duration',
37 'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
38 'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
39 'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
40 // ActivityPub §5.6: the private blocked collection (owner-only GET).
41 'blocked',
42 // ActivityPub §4.1: supplementary collections on the actor — Klonkt wijst
43 // ermee naar de playlist-lijst (shaer-ayc).
44 'streams',
45 // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
46 // sub-keys are the daemon-contract collection names the Shaer clients read.
47 // `guardians` is the availability queue (3.6.1: never public, owner-only).
48 // `outgoingFollows` is §5.3 turned around: the ward's own follow requests,
49 // waiting for the guardians (shaer-p729).
50 // `help` is de vragenlijst met haar STAAT (5.2.1): de apps lazen hulpvragen uit
51 // de feed en wisten niet of er al iemand op af was.
52 'shaer:queues', 'offers', 'follows', 'outgoingFollows', 'wards', 'guardians',
53 // §4.2: het logboek staat NAAST de wachtrijen, want het wacht nergens op.
54 'shaer:log', 'help',
55 // Elke OPEN hulpvraag zit in de collectie; daarom mag een app uit afwezigheid
56 // concluderen dat iets niet open is (shaer-6wt).
57 'shaer:openComplete',
58 // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
59 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
60 // FEP-c390 (shaer-jpxj): waar de eigenaar een identiteitsbewijs aanbiedt. De
61 // verklaringen zelf staan onder `attachment` en dragen hun eigen @context
62 // (did/v1, data-integrity/v1, fep/c390), dus hun termen horen niet hier.
63 'shaer:identityProofs',
64 'publicKey', 'owner', 'publicKeyPem',
65 'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
66 'Accept', 'Reject', 'Undo', 'Add', 'Remove', 'Flag', 'Document', 'Image', 'Audio', 'Video',
67 'Mention', 'Link', 'Collection', 'OrderedCollection', 'OrderedCollectionPage',
68]);
69
70// The extension terms = exactly the keys declared in AP_CONTEXT's term-definition object.
71const ctxTerms = new Set();
72for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k);
73const allowed = new Set([...AS2, ...ctxTerms]);
74
75// Collect every property key + every `type` string value, recursively.
76function collect(obj, keys = new Set()) {
77 if (Array.isArray(obj)) { for (const x of obj) collect(x, keys); return keys; }
78 if (obj && typeof obj === 'object') {
79 for (const [k, v] of Object.entries(obj)) {
80 keys.add(k);
81 if (k === 'type' && typeof v === 'string') keys.add(v);
82 if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
83 collect(v, keys);
84 }
85 }
86 return keys;
87}
88function assertValid(obj, label) {
89 const undeclared = [...collect(obj)].filter((k) => !allowed.has(k));
90 assert.deepEqual(undeclared, [],
91 `${label}: undeclared AS2/JSON-LD term(s) — declare in AP_CONTEXT (extension) or the AS2 allowlist: ${undeclared.join(', ')}`);
92}
93
94// Seed one site that exercises the extension-heavy actor fields (profile links → PropertyValue,
95// photo → icon, primary → featured).
96db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
97db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, profile_links, profile_photo) VALUES (?,?,?,?,?,?,?)')
98 .run('s1', 'demo', 'Demo', 'u1', 1, JSON.stringify([{ platform: 'website', url: 'https://x.test' }]), '/media/x.png');
99const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
100site.primary_slug = 'demo';
101
102// Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment).
103const post = {
104 id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
105 nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
106 cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
107 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
108};
109
110test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor'));
111test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note'));
112test('outbox/followers/featured collections are valid AS2', () => {
113 assertValid(AP.buildOutbox(BASE, site, [post]), 'outbox');
114 assertValid(AP.buildFollowers(BASE, site, 3), 'followers');
115 assertValid(AP.buildFollowing(BASE, site, 2), 'following');
116 assertValid(AP.buildFeatured(BASE, site, [post]), 'featured');
117});
118test('AP_CONTEXT declares every extension term we rely on', () => {
119 for (const t of ['sensitive', 'Hashtag', 'manuallyApprovesFollowers', 'discoverable', 'featured', 'PropertyValue', 'embedUrl'])
120 assert.ok(ctxTerms.has(t), `AP_CONTEXT must declare "${t}"`);
121});
Note: See TracBrowser for help on using the repository browser.