| 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.
|
|---|
| 4 | import { test } from 'node:test';
|
|---|
| 5 | import assert from 'node:assert/strict';
|
|---|
| 6 | import crypto from 'crypto';
|
|---|
| 7 |
|
|---|
| 8 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 9 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 10 |
|
|---|
| 11 | const dbMod = await import('../src/config/database.js');
|
|---|
| 12 | const db = dbMod.default;
|
|---|
| 13 | dbMod.initializeDatabase();
|
|---|
| 14 | const 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.
|
|---|
| 21 | const W3C_SECRET = 'z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq';
|
|---|
| 22 | const W3C_DID = 'did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2';
|
|---|
| 23 | const 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 | };
|
|---|
| 33 | const seed = I.seedFromSecretKeyMultibase(W3C_SECRET);
|
|---|
| 34 |
|
|---|
| 35 | test('W3C: de sleutel geeft de did:key uit de specificatie', () => {
|
|---|
| 36 | assert.equal(I.didKeyFromPublicKey(I.publicKeyFromSeed(seed)), W3C_DID);
|
|---|
| 37 | });
|
|---|
| 38 |
|
|---|
| 39 | test('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 |
|
|---|
| 45 | test('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 |
|
|---|
| 54 | test('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 |
|
|---|
| 62 | test('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 |
|
|---|
| 70 | test('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 |
|
|---|
| 79 | const ACTOR = 'https://test.example/ap/users/kind';
|
|---|
| 80 | function 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 |
|
|---|
| 85 | test('een goede verklaring wordt aangenomen', () => {
|
|---|
| 86 | assert.deepEqual(I.checkStatement(verklaring(), ACTOR), { ok: true, subject: W3C_DID });
|
|---|
| 87 | });
|
|---|
| 88 |
|
|---|
| 89 | test('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 |
|
|---|
| 106 | test('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 | });
|
|---|