source: Klonkt/src/services/IdentityProof.js

main
Last change on this file was b1d5f3f, checked in by Bart <bart@…>, 26 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: 9.9 KB
RevLine 
[b1d5f3f]1/**
2 * Identiteitsbewijzen (FEP-c390, shaer-jpxj).
3 *
4 * Een sleutel die NIET op deze server staat -- bij Shaer op papier, 24 woorden,
5 * alleen ingetypt als hij nodig is -- ondertekent een verklaring: "did:key:...
6 * is ook <deze actor>". Deze server ondertekent NIETS. Hij controleert de
7 * verklaring en zet hem onder `attachment` op de actor, waar iedereen hem zelf
8 * kan nalopen.
9 *
10 * Wat dat bewijst, en wat niet: dat dezelfde entiteit achter de sleutel en het
11 * account zit. Verhuist het account, dan draagt het nieuwe dezelfde did:key, en
12 * dat bewijst de continuiteit ook als de oude server weg of vijandig is -- een
13 * Move kan dat niet, want die moet door de OUDE server ondertekend worden. Het
14 * bewijst NIET dat elke post van jou is: een gecompromitteerde server tekent nog
15 * steeds met zijn eigen sleutel.
16 *
17 * DE CRYPTOGRAFIE is Data Integrity met eddsa-jcs-2022 (W3C vc-di-eddsa), omdat
18 * Mitra en streams dat controleren. Getoetst tegen de testvectoren uit B.3 van
19 * die specificatie, zodat dit niet alleen het met zichzelf eens is.
20 *
21 * Puur, zonder database: de regels moeten te toetsen zijn zonder server.
22 */
23import crypto from 'crypto';
24
25// ── base58btc (Bitcoin-alfabet), en multibase met het 'z'-voorvoegsel ──
26
27const ALFABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
28const WAARDE = new Map([...ALFABET].map((c, i) => [c, i]));
29
30export function base58Encode(bytes) {
31 const b = Buffer.from(bytes);
32 let nullen = 0;
33 while (nullen < b.length && b[nullen] === 0) nullen++;
34 let n = BigInt('0x' + (b.length ? b.toString('hex') : '0'));
35 let uit = '';
36 while (n > 0n) { uit = ALFABET[Number(n % 58n)] + uit; n /= 58n; }
37 return '1'.repeat(nullen) + uit;
38}
39
40export function base58Decode(str) {
41 if (typeof str !== 'string') return null;
42 let nullen = 0;
43 while (nullen < str.length && str[nullen] === '1') nullen++;
44 let n = 0n;
45 for (const c of str) {
46 const v = WAARDE.get(c);
47 if (v === undefined) return null;
48 n = n * 58n + BigInt(v);
49 }
50 let hex = n === 0n ? '' : n.toString(16);
51 if (hex.length % 2) hex = '0' + hex;
52 return Buffer.concat([Buffer.alloc(nullen), Buffer.from(hex, 'hex')]);
53}
54
55// ── JCS (RFC 8785) ─────────────────────────────────────────────────────
56//
57// RFC 8785 is precies om ECMAScript heen gebouwd: getallen en strings zoals
58// JSON.stringify ze schrijft, sleutels op UTF-16-code-eenheden gesorteerd --
59// wat Array.prototype.sort op strings al doet. In JavaScript is de canonieke
60// vorm dus een gesorteerde JSON.stringify, en niets eigen.
61
62export function jcs(value) {
63 if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
64 if (typeof value === 'number') {
65 if (!Number.isFinite(value)) throw new Error('JCS: geen NaN of oneindig');
66 return JSON.stringify(value);
67 }
68 if (Array.isArray(value)) return `[${value.map(jcs).join(',')}]`;
69 if (typeof value === 'object') {
70 const sleutels = Object.keys(value).filter((k) => value[k] !== undefined).sort();
71 return `{${sleutels.map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`).join(',')}}`;
72 }
73 throw new Error(`JCS: ${typeof value} is geen JSON`);
74}
75
76// ── did:key voor Ed25519 ───────────────────────────────────────────────
77//
78// did:key:z<base58btc(0xed 0x01 || publieke sleutel van 32 bytes)>. Alleen
79// Ed25519 (het "z6Mk"-begin): dat is wat eddsa-jcs-2022 kan controleren.
80
81const ED25519_PUB = Buffer.from([0xed, 0x01]);
82const ED25519_PRIV = Buffer.from([0x80, 0x26]);
83
84export function didKeyFromPublicKey(raw) {
85 const b = Buffer.from(raw);
86 if (b.length !== 32) throw new Error('een Ed25519-sleutel is 32 bytes');
87 return `did:key:z${base58Encode(Buffer.concat([ED25519_PUB, b]))}`;
88}
89
90/** De ruwe publieke sleutel achter een did:key, of null als het geen Ed25519-did:key is. */
91export function publicKeyFromDidKey(did) {
92 const m = /^did:key:z([1-9A-HJ-NP-Za-km-z]+)$/.exec(String(did || ''));
93 if (!m) return null;
94 const b = base58Decode(m[1]);
95 if (!b || b.length !== 34 || b[0] !== 0xed || b[1] !== 0x01) return null;
96 return b.subarray(2);
97}
98
99/** Een multibase-geheime-sleutel (z3u2...) naar zijn zaad van 32 bytes. Voor de toetsen. */
100export function seedFromSecretKeyMultibase(mb) {
101 const b = typeof mb === 'string' && mb[0] === 'z' ? base58Decode(mb.slice(1)) : null;
102 if (!b || b.length !== 34 || b[0] !== ED25519_PRIV[0] || b[1] !== ED25519_PRIV[1]) return null;
103 return b.subarray(2);
104}
105
106const publicKeyObject = (raw) => crypto.createPublicKey({
107 key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk',
108});
109const privateKeyObject = (seed, raw) => crypto.createPrivateKey({
110 key: {
111 kty: 'OKP', crv: 'Ed25519',
112 d: Buffer.from(seed).toString('base64url'),
113 x: Buffer.from(raw).toString('base64url'),
114 },
115 format: 'jwk',
116});
117
118/** De publieke sleutel bij een zaad van 32 bytes. */
119export function publicKeyFromSeed(seed) {
120 // Node leidt de publieke sleutel af als je alleen `d` geeft via PKCS#8.
121 const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.from(seed)]);
122 const priv = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
123 const jwk = crypto.createPublicKey(priv).export({ format: 'jwk' });
124 return Buffer.from(jwk.x, 'base64url');
125}
126
127// ── eddsa-jcs-2022 ────────────────────────────────────────────────────
128
129const sha256 = (s) => crypto.createHash('sha256').update(s, 'utf8').digest();
130
131/** De 64 bytes die ondertekend worden: hash(proofConfig) || hash(document). */
132export function hashData(proofConfig, unsecured) {
133 return Buffer.concat([sha256(jcs(proofConfig)), sha256(jcs(unsecured))]);
134}
135
136/**
137 * Onderteken een document (vc-di-eddsa 3.3.1). De server doet dit nooit voor een
138 * identiteitsbewijs; het staat hier voor de toetsen en om de andere kant te
139 * kunnen nabootsen.
140 */
141export function signEddsaJcs2022(unsecured, seed, options) {
142 const raw = publicKeyFromSeed(seed);
143 const proofConfig = { ...options, type: 'DataIntegrityProof', cryptosuite: 'eddsa-jcs-2022' };
144 if (unsecured['@context'] !== undefined) proofConfig['@context'] = unsecured['@context'];
145 const sig = crypto.sign(null, hashData(proofConfig, unsecured), privateKeyObject(seed, raw));
146 return { ...unsecured, proof: { ...proofConfig, proofValue: `z${base58Encode(sig)}` } };
147}
148
149/**
150 * Controleer een ondertekend document (vc-di-eddsa 3.3.2) met een ruwe publieke
151 * sleutel. Geeft true of false; nooit een uitzondering voor kwade invoer.
152 */
153export function verifyEddsaJcs2022(secured, publicKeyRaw) {
154 try {
155 if (!secured || typeof secured !== 'object' || Array.isArray(secured)) return false;
156 const { proof, ...unsecured } = secured;
157 if (!proof || typeof proof !== 'object') return false;
158 if (proof.type !== 'DataIntegrityProof' || proof.cryptosuite !== 'eddsa-jcs-2022') return false;
159 const { proofValue, ...proofOptions } = proof;
160 if (typeof proofValue !== 'string' || proofValue[0] !== 'z') return false;
161 // "If proofOptions.@context exists": het document moet met precies die
162 // contexten beginnen, in die volgorde, en krijgt ze dan voor het hashen.
163 if (proofOptions['@context'] !== undefined) {
164 const pc = [].concat(proofOptions['@context']);
165 const dc = [].concat(unsecured['@context'] ?? []);
166 if (pc.some((c, i) => jcs(c) !== jcs(dc[i]))) return false;
167 unsecured['@context'] = proofOptions['@context'];
168 }
169 const sig = base58Decode(proofValue.slice(1));
170 if (!sig || sig.length !== 64) return false;
171 return crypto.verify(null, hashData(proofOptions, unsecured), publicKeyObject(publicKeyRaw), sig);
172 } catch {
173 return false;
174 }
175}
176
177// ── FEP-c390: de verklaring ──────────────────────────────────────────
178
179/** Groter dan dit is geen identiteitsverklaring meer maar iets anders. */
180export const MAX_STATEMENT_BYTES = 4096;
181
182/**
183 * Is dit een geldige verklaring voor deze actor? Geeft `{ ok: true, subject }`
184 * of `{ ok: false, error }`. De volgorde van de controles is de volgorde van
185 * FEP-c390, en elke weigering zegt welke regel het was.
186 */
187export function checkStatement(stmt, actorId) {
188 if (!stmt || typeof stmt !== 'object' || Array.isArray(stmt)) return { ok: false, error: 'not_an_object' };
189 let grootte = 0;
190 try { grootte = Buffer.byteLength(JSON.stringify(stmt), 'utf8'); } catch { return { ok: false, error: 'not_json' }; }
191 if (grootte > MAX_STATEMENT_BYTES) return { ok: false, error: 'too_large' };
192 if (stmt.type !== 'VerifiableIdentityStatement') return { ok: false, error: 'wrong_type' };
193 if (stmt.alsoKnownAs !== actorId) return { ok: false, error: 'alsoKnownAs_is_not_this_actor' };
194 const raw = publicKeyFromDidKey(stmt.subject);
195 if (!raw) return { ok: false, error: 'subject_is_not_an_ed25519_did_key' };
196 const proof = stmt.proof;
197 if (!proof || typeof proof !== 'object') return { ok: false, error: 'no_proof' };
198 // c390: "verificationMethod MUST match subject". Precies, zonder #-fragment.
199 if (proof.verificationMethod !== stmt.subject) return { ok: false, error: 'verificationMethod_is_not_subject' };
200 if (proof.proofPurpose !== 'assertionMethod') return { ok: false, error: 'wrong_proofPurpose' };
201 if (!verifyEddsaJcs2022(stmt, raw)) return { ok: false, error: 'bad_signature' };
202 return { ok: true, subject: stmt.subject };
203}
204
205/** De contexten die een verklaring zelf draagt. Zo hoeft de actor er geen bij,
206 * waar did/v1 en data-integrity/v1 met hun beschermde termen met Klonkts eigen
207 * inline context zouden kunnen botsen. */
208export const STATEMENT_CONTEXT = [
209 'https://www.w3.org/ns/activitystreams',
210 'https://www.w3.org/ns/did/v1',
211 'https://w3id.org/security/data-integrity/v1',
212 'https://w3id.org/fep/c390',
213];
Note: See TracBrowser for help on using the repository browser.