source: Klonkt/test/identity-proofs.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: 8.9 KB
Line 
1// Identiteitsbewijzen (FEP-c390, shaer-jpxj): een sleutel van elders zegt dat
2// dit account van hem is; Klonkt controleert en publiceert, en ondertekent zelf
3// niets.
4import { test } from 'node:test';
5import assert from 'node:assert/strict';
6import crypto from 'crypto';
7
8process.env.DATABASE_PATH = ':memory:';
9process.env.PUBLIC_BASE_URL = 'https://test.example';
10
11const dbMod = await import('../src/config/database.js');
12const db = dbMod.default;
13dbMod.initializeDatabase();
14const I = await import('../src/services/IdentityProof.js');
15
16// ── De W3C-testvector (vc-di-eddsa, B.3) ──────────────────────────
17//
18// Ed25519 is deterministisch, dus dezelfde sleutel en hetzelfde document moeten
19// byte voor byte dezelfde handtekening geven. Is dat zo, dan kloppen JCS, de
20// hashes en de volgorde ervan allemaal -- niet alleen met zichzelf.
21const W3C_SECRET = 'z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq';
22const W3C_DID = 'did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2';
23const W3C_DOC = {
24 '@context': ['https://www.w3.org/ns/credentials/v2', 'https://www.w3.org/ns/credentials/examples/v2'],
25 id: 'urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33',
26 type: ['VerifiableCredential', 'AlumniCredential'],
27 name: 'Alumni Credential',
28 description: 'A minimum viable example of an Alumni Credential.',
29 issuer: 'https://vc.example/issuers/5678',
30 validFrom: '2023-01-01T00:00:00Z',
31 credentialSubject: { id: 'did:example:abcdefgh', alumniOf: 'The School of Examples' },
32};
33const seed = I.seedFromSecretKeyMultibase(W3C_SECRET);
34
35test('W3C: de sleutel geeft de did:key uit de specificatie', () => {
36 assert.equal(I.didKeyFromPublicKey(I.publicKeyFromSeed(seed)), W3C_DID);
37});
38
39test('W3C: de canonieke vorm en zijn hash zijn die uit voorbeeld 31 en 32', () => {
40 const canon = I.jcs(W3C_DOC);
41 assert.equal(canon, '{"@context":["https://www.w3.org/ns/credentials/v2","https://www.w3.org/ns/credentials/examples/v2"],"credentialSubject":{"alumniOf":"The School of Examples","id":"did:example:abcdefgh"},"description":"A minimum viable example of an Alumni Credential.","id":"urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33","issuer":"https://vc.example/issuers/5678","name":"Alumni Credential","type":["VerifiableCredential","AlumniCredential"],"validFrom":"2023-01-01T00:00:00Z"}');
42 assert.equal(crypto.createHash('sha256').update(canon).digest('hex'), '59b7cb6251b8991add1ce0bc83107e3db9dbbab5bd2c28f687db1a03abc92f19');
43});
44
45test('W3C: de handtekening is byte voor byte die uit voorbeeld 38, en klopt', () => {
46 const vm = `${W3C_DID}#${W3C_DID.slice('did:key:'.length)}`;
47 const s = I.signEddsaJcs2022(W3C_DOC, seed, { created: '2023-02-24T23:36:38Z', verificationMethod: vm, proofPurpose: 'assertionMethod' });
48 assert.equal(s.proof.proofValue, 'z2HnFSSPPBzR36zdDgK8PbEHeXbR56YF24jwMpt3R1eHXQzJDMWS93FCzpvJpwTWd3GAVFuUfjoJdcnTMuVor51aX');
49 assert.equal(I.verifyEddsaJcs2022(s, I.publicKeyFromDidKey(W3C_DID)), true);
50});
51
52// ── De bouwstenen ─────────────────────────────────────────────────
53
54test('JCS sorteert op UTF-16-code-eenheden, en schrijft getallen zoals ECMAScript', () => {
55 // U+1F600 (😀) is in UTF-16 😀, en dat valt VOOR U+FB01 (fi, fi)
56 // -- op codepunt zou het erna komen. RFC 8785 kiest UTF-16.
57 assert.equal(I.jcs({ 'fi': 1, '😀': 2, a: 3 }), '{"a":3,"😀":2,"fi":1}');
58 assert.equal(I.jcs({ n: 1e21, m: 0.1, z: -0 }), '{"m":0.1,"n":1e+21,"z":0}');
59 assert.throws(() => I.jcs({ x: NaN }));
60});
61
62test('base58 overleeft voorloopnullen en een retour', () => {
63 for (const hex of ['', '00', '0000ff', 'deadbeef', '00'.repeat(5) + '01']) {
64 const b = Buffer.from(hex, 'hex');
65 assert.deepEqual(I.base58Decode(I.base58Encode(b)), b, hex);
66 }
67 assert.equal(I.base58Decode('0OIl'), null, 'tekens buiten het alfabet');
68});
69
70test('alleen een Ed25519-did:key wordt als sleutel gelezen', () => {
71 assert.ok(I.publicKeyFromDidKey(W3C_DID));
72 assert.equal(I.publicKeyFromDidKey('did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169'), null, 'P-256');
73 assert.equal(I.publicKeyFromDidKey('did:web:example.com'), null);
74 assert.equal(I.publicKeyFromDidKey(`${W3C_DID}#frag`), null);
75});
76
77// ── FEP-c390: de verklaring ───────────────────────────────────────
78
79const ACTOR = 'https://test.example/ap/users/kind';
80function verklaring(extra = {}, { actor = ACTOR, vm = W3C_DID } = {}) {
81 const doc = { '@context': I.STATEMENT_CONTEXT, type: 'VerifiableIdentityStatement', subject: W3C_DID, alsoKnownAs: actor, ...extra };
82 return I.signEddsaJcs2022(doc, seed, { created: '2026-10-24T10:00:00Z', verificationMethod: vm, proofPurpose: 'assertionMethod' });
83}
84
85test('een goede verklaring wordt aangenomen', () => {
86 assert.deepEqual(I.checkStatement(verklaring(), ACTOR), { ok: true, subject: W3C_DID });
87});
88
89test('elke regel van c390 weigert met zijn eigen reden', () => {
90 assert.equal(I.checkStatement(verklaring({}, { actor: 'https://elders.example/users/x' }), ACTOR).error,
91 'alsoKnownAs_is_not_this_actor', 'een verklaring voor een ander account');
92 assert.equal(I.checkStatement(verklaring({}, { vm: `${W3C_DID}#${W3C_DID.slice(8)}` }), ACTOR).error,
93 'verificationMethod_is_not_subject', 'c390 eist precies de subject, zonder fragment');
94 const anders = { ...verklaring(), type: 'Note' };
95 assert.equal(I.checkStatement(anders, ACTOR).error, 'wrong_type');
96 const geknoeid = verklaring(); geknoeid.proof = { ...geknoeid.proof, created: '2030-01-01T00:00:00Z' };
97 assert.equal(I.checkStatement(geknoeid, ACTOR).error, 'bad_signature', 'een gewijzigd veld in de proof');
98 const ander = verklaring(); ander.extra = 'erbij';
99 assert.equal(I.checkStatement(ander, ACTOR).error, 'bad_signature', 'een veld erbij in het document');
100 assert.equal(I.checkStatement({ ...verklaring(), proof: undefined }, ACTOR).error, 'no_proof');
101 assert.equal(I.checkStatement(verklaring({ ballast: 'x'.repeat(5000) }), ACTOR).error, 'too_large');
102});
103
104// ── Door de route en de actor heen ───────────────────────────────
105
106test('POST zet de verklaring op de actor, DELETE haalt hem weg, en alleen de eigenaar', async (t) => {
107 const express = (await import('express')).default;
108 const routes = (await import('../src/routes/activitypub.js')).default;
109 const AP = (await import('../src/services/ActivityPubService.js')).default;
110
111 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
112 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary, profile_links) VALUES (?,?,?,?,1,1,?)')
113 .run('s1', 'kind', 'Kind', 'u1', JSON.stringify([{ platform: 'Website', url: 'https://kind.example' }]));
114 const bearer = 'test-token-' + 'c'.repeat(24);
115 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
116 .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
117
118 const app = express(); app.use(routes);
119 const server = app.listen(0);
120 t.after(() => server.close());
121 const url = `http://127.0.0.1:${server.address().port}/ap/users/kind/identityProofs`;
122 const post = (body, auth = true) => fetch(url, {
123 method: 'POST',
124 headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: `Bearer ${bearer}` } : {}) },
125 body: JSON.stringify(body),
126 });
127
128 assert.equal((await post(verklaring(), false)).status, 403, 'zonder token');
129 const fout = await post(verklaring({}, { actor: 'https://elders.example/users/x' }));
130 assert.equal(fout.status, 422);
131 assert.equal((await fout.json()).error, 'alsoKnownAs_is_not_this_actor');
132
133 const goed = await post(verklaring());
134 assert.equal(goed.status, 201);
135
136 const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
137 const actor = AP.buildActor('https://test.example', site());
138 assert.equal(actor.endpoints['shaer:identityProofs'], 'https://test.example/ap/users/kind/identityProofs');
139 assert.equal(actor.attachment[0].type, 'PropertyValue', 'de profiellink blijft vooraan staan');
140 const bewijs = actor.attachment.find((a) => a.type === 'VerifiableIdentityStatement');
141 assert.ok(bewijs, 'de verklaring staat onder attachment');
142 assert.equal(I.checkStatement(bewijs, actor.id).ok, true, 'en is daar nog steeds na te lopen');
143
144 // Dezelfde sleutel opnieuw vervangt, verdubbelt niet.
145 assert.equal((await post(verklaring())).status, 201);
146 assert.equal(JSON.parse(site().identity_proofs).length, 1);
147
148 const weg = await fetch(`${url}?subject=${encodeURIComponent(W3C_DID)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${bearer}` } });
149 assert.equal(weg.status, 204);
150 assert.equal(site().identity_proofs, null);
151 assert.ok(!AP.buildActor('https://test.example', site()).attachment.some((a) => a.type === 'VerifiableIdentityStatement'));
152});
Note: See TracBrowser for help on using the repository browser.