| [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 | */
|
|---|
| 23 | import crypto from 'crypto';
|
|---|
| 24 |
|
|---|
| 25 | // ── base58btc (Bitcoin-alfabet), en multibase met het 'z'-voorvoegsel ──
|
|---|
| 26 |
|
|---|
| 27 | const ALFABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|---|
| 28 | const WAARDE = new Map([...ALFABET].map((c, i) => [c, i]));
|
|---|
| 29 |
|
|---|
| 30 | export 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 |
|
|---|
| 40 | export 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 |
|
|---|
| 62 | export 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 |
|
|---|
| 81 | const ED25519_PUB = Buffer.from([0xed, 0x01]);
|
|---|
| 82 | const ED25519_PRIV = Buffer.from([0x80, 0x26]);
|
|---|
| 83 |
|
|---|
| 84 | export 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. */
|
|---|
| 91 | export 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. */
|
|---|
| 100 | export 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 |
|
|---|
| 106 | const publicKeyObject = (raw) => crypto.createPublicKey({
|
|---|
| 107 | key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk',
|
|---|
| 108 | });
|
|---|
| 109 | const 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. */
|
|---|
| 119 | export 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 |
|
|---|
| 129 | const sha256 = (s) => crypto.createHash('sha256').update(s, 'utf8').digest();
|
|---|
| 130 |
|
|---|
| 131 | /** De 64 bytes die ondertekend worden: hash(proofConfig) || hash(document). */
|
|---|
| 132 | export 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 | */
|
|---|
| 141 | export 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 | */
|
|---|
| 153 | export 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. */
|
|---|
| 180 | export 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 | */
|
|---|
| 187 | export 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. */
|
|---|
| 208 | export 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 | ];
|
|---|