// AERE Identity, SD-JWT (IETF RFC 9901, noiembrie 2025): acelasi emitent si acelasi detinator ca in identity.mjs, in formatul standardului // (roadmap master punctul 12, "interoperare SD-JWT"; pista B, 2026-09-30). Judecat pe vectorii standardului (RFC 9901 s.5 si A.5), NU // contra altei biblioteci SD-JWT: interoperarea cu o implementare straina e NEMASURATA (spus in README). // // CE FACE: // issueSdJwt emitentul face un SD-JWT in serializarea compacta: JWT semnat de emitent, cu digesturile afirmatiilor dezvaluibile in `_sd` // (si `...` pentru elemente de lista), `_sd_alg` sha-256, cheia detinatorului in `cnf.jwk`, `exp`, plus dezvaluirile, unite prin `~` // presentSdJwt detinatorul alege dezvaluirile (afirmatii de sus dupa nume, elemente dupa lista LOR si valoare) si adauga un Key Binding // JWT (typ kb+jwt, aud, nonce, iat, sd_hash) semnat cu cheia lui // verifySdJwt verificatorul face pasii din RFC 9901 s.7.1 si s.7.3 cu cheia EMITENTULUI data de el (niciodata din jeton), cu publicul si // nonce-ul lui CERUTE cand exista (sau se cere) Key Binding, cu `exp` cerut, cu tipul explicit al jetonului verificat // // ALGORITMII (JWS): ES256 (P-256, cel din exemplele RFC 9901), EdDSA / Ed25519 (RFC 8037) si ML-DSA-65 (post-cuantic; numele si forma // cheii JWK "AKP" dupa draftul IETF draft-ietf-cose-dilithium, NU inca un standard publicat). Un emitent AERE semneaza cu cheia lui Ed25519 // (algoritm inregistrat, cunoscut bibliotecilor JOSE) sau cu cheia ML-DSA-65 (numai pentru verificatorii care stiu numele din draft); o // semnatura hibrida in acelasi jeton ar cere serializarea JSON generala a JWS si nu e facuta aici. // // Revizuirea adversariala din 2026-09-30 (inainte de publicare) a gasit si s-au reparat: emitentul semna structura SD-JWT venita in valorile // afirmatiilor (`_sd`, `{"...": d}`), deci detinatorul putea dezvalui afirmatii pe care emitentul nu le-a vazut; prezentarea arata un element // cu aceeasi valoare din ORICE lista; publicul si nonce-ul nu erau cerute implicit; `exp` nu se putea cere; tipul jetonului nu se verifica // implicit (un KB-JWT trecea drept jeton de emitent); o cheie publica Node nu era primita; exp/nbf ale KB, `crit` stricat, base64url // necanonic si `_sd_alg` imbricat. Fiecare are proba lui in proba-sdjwt.mjs si plantarea lui in control-negativ-sdjwt.mjs. // // CE NU FACE: nu e SD-JWT VC (nu cere `vct` si nu verifica metadatele de tip ale emitentului); nu citeste liste de stare in jeton (pentru // revocare, credentialul AERE din identity.mjs are lista lui); nu dezvaluie recursiv la emitere (verificarea stie dezvaluiri recursive). import crypto from 'node:crypto'; const b64u = (b) => Buffer.from(b).toString('base64url'); // base64url CANONIC: decodat si recodat trebuie sa dea acelasi text (altfel aceeasi semnatura are mai multe scrieri, si un depozit de // reluari care tine sirul jetonului e ocolit schimbatnd bitii nefolositi ai ultimului caracter) const dinB64u = (s, ce) => { if (typeof s !== 'string' || !/^[A-Za-z0-9_-]*$/.test(s)) throw new Error(`sd-jwt: ${ce} is not base64url`); const b = Buffer.from(s, 'base64url'); if (b.toString('base64url') !== s) throw new Error(`sd-jwt: ${ce} is not canonical base64url`); return b; }; const jsonB64u = (s, ce) => { let o; try { o = JSON.parse(dinB64u(s, ce).toString('utf8')); } catch (e) { throw new Error(/canonical/.test(e.message) ? e.message : `sd-jwt: ${ce} is not base64url JSON`); } return o; }; const sha256b64u = (s) => crypto.createHash('sha256').update(Buffer.from(s, 'ascii')).digest('base64url'); const obiect = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); export const ALGS = ['ES256', 'EdDSA', 'Ed25519', 'ML-DSA-65']; export const TYP = 'aere+sd-jwt'; const AERE_ALG = 'ed25519+ml-dsa-65'; // ---------------------------------------------------------------- chei JWK si semnaturi JWS /** * Cheia PUBLICA ca JWK, din: o pereche de chei AERE (identity.mjs; `privat`), cheile publice ale unei identitati AERE ({alg, ed25519, * mldsa65}), un KeyObject public sau privat, sau un JWK public. `alg` alege cheia AERE: 'EdDSA' (Ed25519) sau 'ML-DSA-65'. */ export function publicJwk(k, alg = 'EdDSA') { const ml = alg === 'ML-DSA-65'; if (k && k.privat) k = ml ? k.privat.mldsa65 : k.privat.ed25519; else if (obiect(k) && k.alg === AERE_ALG && typeof k.ed25519 === 'string') k = crypto.createPublicKey({ key: Buffer.from(ml ? k.mldsa65 : k.ed25519, 'base64'), format: 'der', type: 'spki' }); if (k instanceof crypto.KeyObject) return (k.type === 'public' ? k : crypto.createPublicKey(k)).export({ format: 'jwk' }); if (obiect(k) && k.kty) return k; throw new Error('sd-jwt: not a key (a JWK, a Node key object, or AERE identity keys)'); } function cheiePublica(jwk) { if (!obiect(jwk) || !['EC', 'OKP', 'AKP'].includes(jwk.kty)) throw new Error('sd-jwt: the key is not an EC, OKP or AKP JWK'); if ('d' in jwk || 'priv' in jwk) throw new Error('sd-jwt: a private JWK was given where a public key belongs'); return crypto.createPublicKey({ key: jwk, format: 'jwk' }); } // algoritmul trebuie sa fie al cheii: un jeton nu alege el cu ce se verifica (atacul de confuzie a algoritmului, RFC 8725 s.2.1) function potriveste(alg, key) { const t = key.asymmetricKeyType; if (alg === 'ES256') return t === 'ec' && key.asymmetricKeyDetails && key.asymmetricKeyDetails.namedCurve === 'prime256v1'; if (alg === 'EdDSA' || alg === 'Ed25519') return t === 'ed25519'; if (alg === 'ML-DSA-65') return t === 'ml-dsa-65'; return false; } // tipul: un sir exact; sau `undefined` = orice tip explicit de SD-JWT ("...+sd-jwt" sau "sd-jwt"); sau null = orice, in afara de kb+jwt function tipBun(typ, cerut) { if (cerut === undefined) return typeof typ === 'string' && /(^|\+)sd-jwt$/.test(typ); if (cerut === null) return typ !== 'kb+jwt'; return typ === cerut; } function verificaJws(jws, jwk, { typ } = {}) { const parti = String(jws).split('.'); if (parti.length !== 3) throw new Error('sd-jwt: a JWS has three parts'); const antet = jsonB64u(parti[0], 'the JWS header'), corp = jsonB64u(parti[1], 'the JWS payload'); if (!obiect(antet) || !obiect(corp)) throw new Error('sd-jwt: the JWS header and payload must be objects'); if (!ALGS.includes(antet.alg)) throw new Error(`sd-jwt: alg ${JSON.stringify(antet.alg)} is not accepted (accepted: ${ALGS.join(', ')}; never none)`); if (!tipBun(antet.typ, typ)) throw new Error(`sd-jwt: typ ${JSON.stringify(antet.typ)} is not the type expected (${typ === undefined ? 'an explicit ...+sd-jwt' : typ === null ? 'anything but kb+jwt' : typ})`); // `crit` numeste parametri pe care verificatorul TREBUIE sa-i inteleaga; nu intelegem niciunul, deci orice `crit` (bun sau stricat) e refuz if (Object.hasOwn(antet, 'crit')) throw new Error('sd-jwt: critical header parameters (crit) are not understood'); const key = cheiePublica(jwk); if (!potriveste(antet.alg, key)) throw new Error(`sd-jwt: alg ${antet.alg} does not match the ${key.asymmetricKeyType} key given`); const date = Buffer.from(parti[0] + '.' + parti[1], 'ascii'), sig = dinB64u(parti[2], 'the JWS signature'); let ok = false; try { ok = antet.alg === 'ES256' ? sig.length === 64 && crypto.verify('sha256', date, { key, dsaEncoding: 'ieee-p1363' }, sig) : crypto.verify(null, date, key, sig); } catch { ok = false; } if (!ok) throw new Error('sd-jwt: the signature does not verify with the key given'); return { antet, corp }; } function semneazaJws(antet, corp, priv) { const cap = b64u(JSON.stringify(antet)) + '.' + b64u(JSON.stringify(corp)); const date = Buffer.from(cap, 'ascii'); const sig = antet.alg === 'ES256' ? crypto.sign('sha256', date, { key: priv, dsaEncoding: 'ieee-p1363' }) : crypto.sign(null, date, priv); return cap + '.' + b64u(sig); } const cheiePrivata = (k, alg) => (k && k.privat ? (alg === 'ML-DSA-65' ? k.privat.mldsa65 : k.privat.ed25519) : k); // structura SD-JWT intr-o valoare: o cheie `_sd`/`_sd_alg` intr-un obiect sau un element {"...": x} intr-o lista, oriunde in adancime function areStructuraSd(v) { const stiva = [v]; while (stiva.length) { const x = stiva.pop(); if (Array.isArray(x)) { for (const e of x) { if (obiect(e) && Object.hasOwn(e, '...')) return true; stiva.push(e); } } else if (obiect(x)) { if (Object.hasOwn(x, '_sd') || Object.hasOwn(x, '_sd_alg') || Object.hasOwn(x, '__proto__')) return true; for (const k of Object.keys(x)) stiva.push(x[k]); } } return false; } // ---------------------------------------------------------------- emitere si prezentare /** * @param {{issuer, claims:object, disclosable?:string[], arrayDisclosable?:string[], holder, iss:string, iat?:number, exp?:number|null, nbf?:number, * alg?:'EdDSA'|'ML-DSA-65'|'ES256', holderAlg?:'EdDSA'|'ML-DSA-65'|'ES256', decoys?:number}} o * issuer: chei AERE sau KeyObject privat. holder: cheia PUBLICA a detinatorului (sau cheile lui AERE). disclosable: numele afirmatiilor de * sus dezvaluibile; arrayDisclosable: listele de sus ale caror ELEMENTE sunt dezvaluibile una cate una. exp implicit: un an de la iat * (null: fara exp, pe care verifySdJwt il refuza implicit). Intoarce { sdJwt, disclosures:[{name, value, disclosure}] }. */ export function issueSdJwt({ issuer, claims, disclosable = [], arrayDisclosable = [], holder, iss, iat = Math.floor(Date.now() / 1000), exp, nbf, alg = 'EdDSA', holderAlg = 'EdDSA', decoys = 0 }) { if (!obiect(claims)) throw new Error('sd-jwt: claims must be an object'); if (typeof iss !== 'string' || !iss) throw new Error('sd-jwt: iss is required'); if (!ALGS.includes(alg) || !ALGS.includes(holderAlg)) throw new Error('sd-jwt: unknown alg'); // RFC 9901 s.4.1 (regula 7 a emitentului): emitentul nu semneaza digesturi pe care nu le-a facut el; o valoare care poarta deja `_sd`, // `_sd_alg` sau un element {"...": d} ar lasa detinatorul sa dezvaluie afirmatii nevazute de emitent if (areStructuraSd(claims)) throw new Error('sd-jwt: a claim value carries SD-JWT structure (_sd, _sd_alg or a {"...": digest} element) or a __proto__ key; the issuer signs only digests it made'); for (const n of [...disclosable, ...arrayDisclosable]) if (!Object.hasOwn(claims, n) || ['_sd', '...', '_sd_alg', 'cnf', 'iss', 'iat', 'exp', 'nbf'].includes(n)) throw new Error(`sd-jwt: cannot make ${n} disclosable`); for (const n of arrayDisclosable) if (!Array.isArray(claims[n])) throw new Error(`sd-jwt: ${n} is not a list`); const sare = () => crypto.randomBytes(16).toString('base64url'); const dez = [], corp = {}, sd = []; for (const [n, v] of Object.entries(claims)) { if (disclosable.includes(n)) { const d = b64u(JSON.stringify([sare(), n, v])); dez.push({ name: n, value: v, disclosure: d }); sd.push(sha256b64u(d)); } else if (arrayDisclosable.includes(n)) corp[n] = v.map((e) => { const d = b64u(JSON.stringify([sare(), e])); dez.push({ name: n + '[]', value: e, disclosure: d }); return { '...': sha256b64u(d) }; }); else corp[n] = v; } for (let i = 0; i < decoys; i++) sd.push(sha256b64u(b64u(crypto.randomBytes(32)))); if (sd.length) corp._sd = sd.sort(); corp._sd_alg = 'sha-256'; corp.iss = iss; corp.iat = iat; const e = exp === undefined ? iat + 365 * 86400 : exp; if (e != null) corp.exp = e; if (nbf != null) corp.nbf = nbf; corp.cnf = { jwk: publicJwk(holder, holderAlg) }; const jwt = semneazaJws({ alg, typ: TYP }, corp, cheiePrivata(issuer, alg)); return { sdJwt: jwt + '~' + dez.map((x) => x.disclosure + '~').join(''), disclosures: dez }; } /** * Detinatorul pastreaza numai dezvaluirile alese si leaga prezentarea de verificator. reveal: un sir = afirmatia de SUS cu acel nume; * { element, in } = elementul cu acea valoare din lista de SUS `in` (numai din ea). O alegere care nu gaseste nimic e o eroare, nu o tacere. */ export function presentSdJwt({ sdJwt, reveal = [], holder, holderAlg = 'EdDSA', audience, nonce, iat = Math.floor(Date.now() / 1000) }) { if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('sd-jwt: a presentation needs the verifier\'s audience and nonce'); const parti = String(sdJwt).split('~'); if (parti[parti.length - 1] !== '') throw new Error('sd-jwt: this SD-JWT already ends with a key binding JWT'); const jwt = parti[0], dez = parti.slice(1, -1); const corp = jsonB64u(jwt.split('.')[1] || '', 'the issuer-signed JWT payload'); // unde sta fiecare digest in JWT-ul emitentului: proprietate de sus, sau element al unei liste de sus (cu numele listei) const loc = new Map(); for (const dg of Array.isArray(corp._sd) ? corp._sd : []) loc.set(dg, { tip: 'prop' }); for (const [n, v] of Object.entries(corp)) if (Array.isArray(v)) for (const e of v) if (obiect(e) && typeof e['...'] === 'string') loc.set(e['...'], { tip: 'elem', lista: n }); const gasite = new Set(); const alese = dez.filter((d) => { const l = loc.get(sha256b64u(d)); if (!l) return false; const c = JSON.parse(Buffer.from(d, 'base64url').toString('utf8')); for (let i = 0; i < reveal.length; i++) { const r = reveal[i]; const bun = l.tip === 'prop' ? typeof r === 'string' && c.length === 3 && c[1] === r : obiect(r) && r.in === l.lista && c.length === 2 && JSON.stringify(r.element) === JSON.stringify(c[1]); if (bun) { gasite.add(i); return true; } } return false; }); const lipsa = reveal.filter((_, i) => !gasite.has(i)); if (lipsa.length) throw new Error('sd-jwt: nothing to reveal for ' + JSON.stringify(lipsa) + ' (a name reveals a top-level claim; {element, in} an element of the top-level list named)'); const baza = jwt + '~' + alese.map((d) => d + '~').join(''); const kb = semneazaJws({ alg: holderAlg, typ: 'kb+jwt' }, { iat, aud: audience, nonce, sd_hash: sha256b64u(baza) }, cheiePrivata(holder, holderAlg)); return baza + kb; } // ---------------------------------------------------------------- verificare (RFC 9901 s.7.1, s.7.3) /** * @param {string} sdJwt SD-JWT sau SD-JWT+KB, serializarea compacta * @param {{issuerKey, issuerAlg?, expectedIssuer?:string, audience?:string, nonce?:string, requireKeyBinding?:boolean, requireExp?:boolean, * now?:Date, maxAgeS?:number, clockSkewS?:number, expectedTyp?:string|null}} o * issuerKey: cheia EMITENTULUI data de verificator (JWK, KeyObject, cheile AERE ale emitentului); niciodata din jeton. audience si nonce * sunt ale verificatorului si se CER cand exista (sau se cere) Key Binding. expectedTyp: implicit orice tip explicit "...+sd-jwt". * Intoarce { valid, reason, payload, disclosed:[nume], issuer, issuerChecked, keyBinding:{aud, nonce, iat, alg}|null }. */ export function verifySdJwt(sdJwt, { issuerKey, issuerAlg = 'EdDSA', expectedIssuer = null, audience = null, nonce = null, requireKeyBinding = true, requireExp = true, now = new Date(), maxAgeS = 300, clockSkewS = 60, expectedTyp } = {}) { const refuz = (reason) => ({ valid: false, reason, payload: null, disclosed: [], keyBinding: null }); try { if (typeof sdJwt !== 'string' || !sdJwt.includes('~')) return refuz('not an SD-JWT (no ~)'); const parti = sdJwt.split('~'); const jwt = parti[0], kb = parti[parti.length - 1], dez = parti.slice(1, -1); if (dez.some((d) => d === '')) return refuz('an empty disclosure between two ~'); // RFC 9901 s.7.3 pasul 5: publicul si nonce-ul verificatorului se compara; fara ele, o prezentare facuta pentru altul ar trece if ((requireKeyBinding || kb !== '') && (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce)) { return refuz('the verifier must give its audience and nonce to judge a key binding (RFC 9901 s.7.3)'); } // 2. JWT-ul emitentului const { antet, corp } = verificaJws(jwt, publicJwk(issuerKey, issuerAlg), { typ: expectedTyp }); if (expectedIssuer != null && corp.iss !== expectedIssuer) return refuz(`issued by ${JSON.stringify(corp.iss)}, not ${expectedIssuer}`); const alg = corp._sd_alg === undefined ? 'sha-256' : corp._sd_alg; if (alg !== 'sha-256') return refuz(`_sd_alg ${JSON.stringify(corp._sd_alg)} is not understood (only sha-256)`); // 3. dezvaluirile: digest -> continut const dupaDigest = new Map(); for (const d of dez) { const dg = sha256b64u(d); if (dupaDigest.has(dg)) return refuz('the same disclosure is given twice'); let c; try { c = JSON.parse(dinB64u(d, 'a disclosure').toString('utf8')); } catch { return refuz('a disclosure is not base64url JSON'); } if (!Array.isArray(c) || (c.length !== 2 && c.length !== 3) || typeof c[0] !== 'string') return refuz('a disclosure is not [salt, name, value] or [salt, value]'); dupaDigest.set(dg, c); } const vazute = new Set(), folosite = new Set(); const vezi = (dg) => { if (vazute.has(dg)) throw new Error('the digest ' + dg.slice(0, 12) + '.. appears more than once'); vazute.add(dg); }; const proceseaza = (x, sus = false) => { if (Array.isArray(x)) { const out = []; for (const e of x) { if (obiect(e) && Object.keys(e).length === 1 && typeof e['...'] === 'string') { vezi(e['...']); const c = dupaDigest.get(e['...']); if (!c) continue; // nedezvaluit: elementul se scoate if (c.length !== 2) throw new Error('an array element disclosure must be [salt, value]'); folosite.add(e['...']); out.push(proceseaza(c[1])); } else out.push(proceseaza(e)); } return out; } if (!obiect(x)) return x; // JSON.parse face din "__proto__" o proprietate proprie, iar `out[k] = v` pe un obiect obisnuit i-ar schimba PROTOTIPUL (afirmatii // mostenite, nevazute de Object.keys); o afirmatie cu numele asta, in clar sau intr-o valoare dezvaluita, se refuza if (Object.hasOwn(x, '__proto__')) throw new Error('a claim may not be named __proto__'); const out = {}; // `_sd_alg` se scoate NUMAI de sus (RFC 9901 s.7.1 pasul 3.f); mai jos e o afirmatie ca oricare for (const [k, v] of Object.entries(x)) if (k !== '_sd' && !(sus && k === '_sd_alg')) out[k] = proceseaza(v); if (x._sd !== undefined) { if (!Array.isArray(x._sd) || !x._sd.every((s) => typeof s === 'string')) throw new Error('_sd is not a list of strings'); for (const dg of x._sd) { vezi(dg); const c = dupaDigest.get(dg); if (!c) continue; if (c.length !== 3 || typeof c[1] !== 'string') throw new Error('an object property disclosure must be [salt, name, value]'); if (c[1] === '_sd' || c[1] === '...' || c[1] === '__proto__') throw new Error(`a disclosure may not name ${c[1]}`); if (Object.hasOwn(out, c[1])) throw new Error(`the disclosed claim ${c[1]} already exists at its level`); folosite.add(dg); out[c[1]] = proceseaza(c[2]); } } return out; }; let payload; try { payload = proceseaza(corp, true); } catch (e) { return refuz(e.message); } // 5. o dezvaluire nelegata de niciun digest = refuz if (folosite.size !== dupaDigest.size) return refuz('a disclosure is not referenced by any digest of the issuer-signed JWT'); const disclosed = [...dupaDigest.entries()].map(([, c]) => (c.length === 3 ? c[1] : '[]')); // 6. valabilitatea pe ceasul verificatorului (exp cerut si fara toleranta; nbf si iat cu toleranta numai pe inceput, ca identity.mjs) const acum = Math.floor(new Date(now).getTime() / 1000); for (const k of ['exp', 'nbf', 'iat']) if (payload[k] !== undefined && !Number.isFinite(payload[k])) return refuz(`${k} is not a number`); if (requireExp && payload.exp === undefined) return refuz('exp is required and the processed payload has none (RFC 9901 s.9.7)'); if (payload.exp !== undefined && acum >= payload.exp) return refuz(`expired at ${payload.exp}`); if (payload.nbf !== undefined && payload.nbf - clockSkewS > acum) return refuz(`not valid before ${payload.nbf}`); if (payload.iat !== undefined && payload.iat - clockSkewS > acum) return refuz(`issued in the future (${payload.iat})`); const rez = { valid: true, reason: '', payload, disclosed, issuer: corp.iss, issuerChecked: expectedIssuer != null, alg: antet.alg }; // s.7.3: Key Binding JWT if (kb === '') { if (requireKeyBinding) return refuz('key binding is required and the SD-JWT has none (it ends with ~)'); return { ...rez, keyBinding: null }; } if (!obiect(payload.cnf) || !obiect(payload.cnf.jwk)) return refuz('a key binding JWT is given but the SD-JWT names no holder key (cnf.jwk)'); let k; try { k = verificaJws(kb, payload.cnf.jwk, { typ: 'kb+jwt' }); } catch (e) { return refuz('key binding: ' + e.message.replace(/^sd-jwt: /, '')); } const K = k.corp; if (typeof K.nonce !== 'string' || typeof K.aud !== 'string' || !Number.isFinite(K.iat) || typeof K.sd_hash !== 'string') return refuz('key binding: aud, nonce, iat and sd_hash are required'); if (K.aud !== audience) return refuz(`key binding: made for ${K.aud}, not ${audience}`); if (K.nonce !== nonce) return refuz('key binding: another nonce'); if (Math.abs(acum - K.iat) > maxAgeS) return refuz(`key binding: made at ${K.iat}, outside ${maxAgeS} s of this clock`); // RFC 9901 s.7.3 pasul 5.h: si exp/nbf ale KB-JWT, daca le poarta (RFC 7519) if (K.exp !== undefined && (!Number.isFinite(K.exp) || acum >= K.exp)) return refuz(`key binding: expired at ${K.exp}`); if (K.nbf !== undefined && (!Number.isFinite(K.nbf) || K.nbf - clockSkewS > acum)) return refuz(`key binding: not valid before ${K.nbf}`); const baza = sdJwt.slice(0, sdJwt.length - kb.length); if (sha256b64u(baza) !== K.sd_hash) return refuz('key binding: sd_hash is not the hash of what was presented'); return { ...rez, keyBinding: { aud: K.aud, nonce: K.nonce, iat: K.iat, alg: k.antet.alg } }; } catch (e) { return refuz(String(e.message || e).replace(/^sd-jwt: /, '')); } }