// 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) si, din // 2026-10-18, contra @sd-jwt/core in ambele sensuri (interop/interop-sdjwt.mjs; fixturile lor, judecate offline, in proba-sdjwt.mjs). // // 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" din RFC 9964, mai 2026). O semnatura hibrida in acelasi jeton ar cere serializarea JSON generala a JWS si nu e facuta aici. // // JUMATATEA POST-CUANTICA (2026-10-19, AIP-25 constatarea C1, D-535): cheile unei identitati AERE sunt Ed25519 SI ML-DSA-65 (identity.mjs // cere amandoua semnaturile). Un JWS poarta UNA. Pana pe 19 oct emiterea, cnf-ul detinatorului si verificarea alegeau implicit jumatatea // Ed25519, deci cine spargea NUMAI cheia clasica a emitentului facea credentiale primite contra identitatii lui intregi (masurat de // aips/aip25-inventar/masoara-constatari.mjs). De atunci, cu chei AERE (perechea privata, sau cheile publice {alg, ed25519, mldsa65}): // - emiterea (alg), cnf-ul detinatorului (holderAlg), Key Binding-ul si lista de stare (sdjwt-vc.mjs) folosesc implicit ML-DSA-65; // - verificarea cere implicit jumatatea ML-DSA-65; un jeton semnat cu jumatatea Ed25519 e refuzat pe regula `hybrid-half`; // - jumatatea Ed25519 se foloseste si se judeca NUMAI la cererea explicita a apelantului (`classicalHalf: true`, pentru interoperarea // clasica), si atunci verificatorul primeste jumatatea pe care o numeste jetonul (EdDSA sau ML-DSA-65); // - verdictul spune `postQuantum`: true numai cand FIECARE semnatura judecata (a emitentului si, daca exista, Key Binding-ul) e ML-DSA-65. // Un JWK simplu (o cheie straina, ES256 sau EdDSA, @sd-jwt/core, portofelele europene) ramane cum era: nu exista o jumatate post-cuantica // de pierdut, iar verdictul spune la fel postQuantum false. Cine isi transforma singur cheile AERE intr-un JWK Ed25519 simplu // (publicJwk(chei, 'EdDSA', { classicalHalf: true })) a cerut jumatatea clasica, si verdictul o spune. // // 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. // // REGULILE NUMITE (2026-10-18, pentru suita de conformitate tools/aere-conformance): fiecare refuz al lui verifySdJwt numeste regula lui // (`rule`), din lista REGULI; fiecare verificare a unei reguli poarta marcajul /*PAZA:*/ pe conditia ei BUNA, pe care controlul // negativ al suitei o face adevarata intr-o copie. Verificatorul se opreste la primul refuz (un SD-JWT invalid nu se mai citeste mai departe). // // CE NU FACE: nu e SD-JWT VC (profilul e sdjwt-vc.mjs); nu citeste liste de stare in jeton (sdjwt-vc.mjs citeste Token Status List); nu // dezvaluie recursiv la emitere (verificarea stie dezvaluiri recursive). import crypto from 'node:crypto'; const b64u = (b) => Buffer.from(b).toString('base64url'); export const ALGS = ['ES256', 'EdDSA', 'Ed25519', 'ML-DSA-65']; export const TYP = 'aere+sd-jwt'; const AERE_ALG = 'ed25519+ml-dsa-65'; /** regulile pe care le poate numi un refuz al lui verifySdJwt (ordinea e cea a verificarii; `form` si pentru ceasul verificatorului) */ export const REGULI = ['form', 'alg', 'typ', 'crit', 'key', 'hybrid-half', 'signature', 'issuer', 'sd-alg', 'disclosure-form', 'disclosure-twice', 'digest-twice', 'disclosure-name', 'claim-conflict', 'unreferenced', 'time', 'kb-context', 'kb-required', 'kb-cnf', 'kb-claims', 'kb-audience', 'kb-nonce', 'kb-freshness', 'kb-sd-hash']; // numele JOSE ale semnaturii Ed25519: EdDSA (RFC 8037) si Ed25519 (numele complet, RFC 9864) const CLASIC = ['EdDSA', 'Ed25519']; class Refuz extends Error { constructor(rule, message) { super(message); this.rule = rule; } } const nu = (rule, msg) => { if (!REGULI.includes(rule)) throw new Error('sd-jwt: undeclared rule ' + rule); return new Refuz(rule, 'sd-jwt: ' + msg); }; // 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) /*PAZA:form*/)) throw nu('form', `${ce} is not base64url`); const b = Buffer.from(s, 'base64url'); if (!(b.toString('base64url') === s /*PAZA:form*/)) throw nu('form', `${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 e instanceof Refuz ? e : nu('form', `${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); // ---------------------------------------------------------------- chei JWK si semnaturi JWS /** Chei AERE: perechea (identity.mjs, cu `privat`) sau cheile publice ale unei identitati ({alg: 'ed25519+ml-dsa-65', ed25519, mldsa65}). */ const esteAere = (k) => !!k && !(k instanceof crypto.KeyObject) && (obiect(k.privat) || (obiect(k) && k.alg === AERE_ALG)); /** Algoritmul implicit al unei chei la EMITERE: ML-DSA-65 pentru chei AERE (AIP-25 C1), EdDSA altfel (ca pana acum). */ export const defaultAlg = (k) => (esteAere(k) ? 'ML-DSA-65' : 'EdDSA'); /** * Jumatatea unei chei AERE ceruta de `alg` (implicit ML-DSA-65). Jumatatea Ed25519 NUMAI cu `classicalHalf` (AIP-25 C1): altfel refuz * `hybrid-half`. Un algoritm pe care o identitate AERE nu il are (ES256) e refuz `key`, nu cheia Ed25519 data in tacere (pana pe 19 oct). */ function jumatateaAere(alg, classicalHalf) { const a = alg == null ? 'ML-DSA-65' : alg; if (a === 'ML-DSA-65') return 'mldsa65'; if (!CLASIC.includes(a)) throw nu('key', `an AERE hybrid identity has an Ed25519 and an ML-DSA-65 key, no ${a} key`); if (!(classicalHalf === true /*PAZA:hybrid-half*/)) throw nu('hybrid-half', `${a} names the Ed25519 half of an AERE hybrid identity (Ed25519 + ML-DSA-65); that half alone is not post-quantum and is used only when the caller asks for it (classicalHalf: true); the post-quantum half is ML-DSA-65, the default`); return 'ed25519'; } /** * 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. Pentru chei AERE, `alg` alege jumatatea: implicit 'ML-DSA-65'; 'EdDSA' / * 'Ed25519' (jumatatea clasica) numai cu { classicalHalf: true } (AIP-25 C1). Pentru celelalte chei `alg` nu conteaza (cheia e cea data). */ export function publicJwk(k, alg, { classicalHalf = false } = {}) { if (esteAere(k)) { const j = jumatateaAere(alg, classicalHalf); let x = null; try { x = k.privat ? k.privat[j] : crypto.createPublicKey({ key: Buffer.from(k[j], 'base64'), format: 'der', type: 'spki' }); } catch { x = null; } if (!(x instanceof crypto.KeyObject)) throw nu('key', `the ${j === 'mldsa65' ? 'ML-DSA-65' : 'Ed25519'} half of the AERE identity keys cannot be read`); k = x; } if (k instanceof crypto.KeyObject) return (k.type === 'public' ? k : crypto.createPublicKey(k)).export({ format: 'jwk' }); if (obiect(k) && k.kty) return k; throw nu('key', 'not a key (a JWK, a Node key object, or AERE identity keys)'); } /** * Cheia cu care se judeca un JWS semnat de cheia `k` a emitentului (sau a listei de stare), data de verificator (AIP-25 C1, 2026-10-19). * O cheie care nu e AERE: ea insasi, ca pana acum. Chei AERE: jumatatea `alg` daca verificatorul o numeste; altfel ML-DSA-65, sau, numai * cu `classicalHalf`, jumatatea pe care o numeste antetul (EdDSA / Ed25519; verdictul spune atunci postQuantum false). Un jeton semnat cu * jumatatea Ed25519, judecat de un verificator care cere jumatatea ML-DSA-65, e refuzat pe NUMELE lui (`hybrid-half`), nu ca "alta cheie". */ export function judgedJwk(k, alg, { classicalHalf = false, headerAlg } = {}) { if (!esteAere(k)) return publicJwk(k, alg); const a = alg != null ? alg : classicalHalf === true && CLASIC.includes(headerAlg) ? headerAlg : 'ML-DSA-65'; if (!(!(a === 'ML-DSA-65' && CLASIC.includes(headerAlg)) /*PAZA:hybrid-half*/)) throw nu('hybrid-half', `the JWS is signed ${headerAlg} with the Ed25519 half of an AERE hybrid identity alone (Ed25519 + ML-DSA-65): that half is not post-quantum, so it is judged only when the verifier asks for it (classicalHalf: true); by default the ML-DSA-65 half is required`); return publicJwk(k, a, { classicalHalf }); } /** Algoritmul din antetul unui JWS compact, citit fara sa fie crezut (numai ca sa se numeasca un refuz); undefined daca nu se citeste. */ export function headerAlgOf(jws) { try { const h = jsonB64u(String(jws).split('.')[0], 'the JWS header'); return obiect(h) && typeof h.alg === 'string' ? h.alg : undefined; } catch { return undefined; } } function cheiePublica(jwk) { if (!(obiect(jwk) && ['EC', 'OKP', 'AKP'].includes(jwk.kty) /*PAZA:key*/)) throw nu('key', 'the key is not an EC, OKP or AKP JWK'); if (!(!('d' in jwk) && !('priv' in jwk) /*PAZA:key*/)) throw nu('key', 'a private JWK was given where a public key belongs'); try { return crypto.createPublicKey({ key: jwk, format: 'jwk' }); } catch (e) { throw nu('key', 'the key cannot be read: ' + String(e.message || e).slice(0, 120)); } } // 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. // RFC 7515 s.4.1.9: un typ fara "/" se citeste ca si cum ar avea prefixul "application/", deci "application/dc+sd-jwt" si "dc+sd-jwt" sunt // acelasi tip (2026-10-18, la interoperare: bibliotecile JOSE scriu oricare din cele doua forme) const faraPrefix = (t) => (typeof t === 'string' && t.startsWith('application/') && !t.slice(12).includes('/') ? t.slice(12) : t); function tipBun(typ, cerut) { const t = faraPrefix(typ); if (cerut === undefined) return typeof t === 'string' && /(^|\+)sd-jwt$/.test(t); if (cerut === null) return t !== 'kb+jwt'; return t === faraPrefix(cerut); } function verificaJws(jws, jwk, { typ } = {}) { const parti = String(jws).split('.'); if (!(parti.length === 3 /*PAZA:form*/)) throw nu('form', '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) /*PAZA:form*/)) throw nu('form', 'the JWS header and payload must be objects'); if (!(ALGS.includes(antet.alg) /*PAZA:alg*/)) throw nu('alg', `alg ${JSON.stringify(antet.alg)} is not accepted (accepted: ${ALGS.join(', ')}; never none)`); if (!(tipBun(antet.typ, typ) /*PAZA:typ*/)) throw nu('typ', `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') /*PAZA:crit*/)) throw nu('crit', 'critical header parameters (crit) are not understood'); const key = cheiePublica(jwk); if (!(potriveste(antet.alg, key) /*PAZA:key*/)) throw nu('key', `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 /*PAZA:signature*/)) throw nu('signature', '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); } // cheia privata care semneaza: pentru chei AERE jumatatea numita de alg (Ed25519 numai cu classicalHalf, AIP-25 C1); altfel cheia data function cheiePrivata(k, alg, classicalHalf = false) { if (!(k && k.privat)) return k; const j = jumatateaAere(alg, classicalHalf); if (!k.privat[j]) throw new Error(`sd-jwt: these AERE keys have no private ${j === 'mldsa65' ? 'ML-DSA-65' : 'Ed25519'} half`); return k.privat[j]; } // pentru profilurile de deasupra (sdjwt-vc.mjs: Status List Token), ca semnatura JWS si alegerea jumatatii AERE sa se faca intr-un singur loc export { verificaJws as verifyJws, semneazaJws as signJws, cheiePrivata as privateKeyOf }; // 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, typ?:string}} 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). typ: tipul explicit al jetonului (implicit TYP; un profil ca SD-JWT VC isi * pune al lui, "dc+sd-jwt"; RFC 9901 s.9.11). alg / holderAlg: implicit ML-DSA-65 pentru chei AERE, EdDSA pentru celelalte; jumatatea * Ed25519 a unor chei AERE (emitent sau detinator) numai cu classicalHalf: true (AIP-25 C1). Intoarce { sdJwt, disclosures:[{name, value, disclosure}] }. */ export function issueSdJwt({ issuer, claims, disclosable = [], arrayDisclosable = [], holder, iss, iat = Math.floor(Date.now() / 1000), exp, nbf, alg, holderAlg, classicalHalf = false, decoys = 0, typ = TYP }) { 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'); const algE = alg ?? defaultAlg(issuer), algH = holderAlg ?? defaultAlg(holder); if (!ALGS.includes(algE) || !ALGS.includes(algH)) throw new Error('sd-jwt: unknown alg'); if (!tipBun(typ, undefined)) throw new Error('sd-jwt: typ must be an explicit SD-JWT type ("...+sd-jwt", RFC 9901 s.9.11)'); // 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, algH, { classicalHalf }) }; const jwt = semneazaJws({ alg: algE, typ }, corp, cheiePrivata(issuer, algE, classicalHalf)); 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, classicalHalf = false, 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'); // AIP-25 C1 (2026-10-19): un detinator AERE semneaza Key Binding-ul cu jumatatea pe care emitentul a legat-o in cnf.jwk (ML-DSA-65 implicit; // o cheie OKP in cnf inseamna ca emitentul a cerut jumatatea Ed25519, iar verdictul spune postQuantum false); un holderAlg dat castiga const cnfKty = obiect(corp.cnf) && obiect(corp.cnf.jwk) ? corp.cnf.jwk.kty : null; const urmeazaCnf = holderAlg == null && esteAere(holder) && cnfKty === 'OKP'; const algH = holderAlg ?? (urmeazaCnf ? 'EdDSA' : defaultAlg(holder)); // 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: algH, typ: 'kb+jwt' }, { iat, aud: audience, nonce, sd_hash: sha256b64u(baza) }, cheiePrivata(holder, algH, classicalHalf || urmeazaCnf)); 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". * Cu chei AERE (AIP-25 C1): implicit se cere jumatatea ML-DSA-65 (issuerAlg o poate numi); jumatatea Ed25519 numai cu classicalHalf: true. * Intoarce { valid, rule, reason, payload, disclosed:[nume], issuer, issuerChecked, alg, postQuantum, keyBinding:{aud, nonce, iat, alg}|null }; * `rule` e regula (din REGULI) care a refuzat, null cand e valid; `postQuantum` e true numai cand fiecare semnatura judecata (a emitentului * si Key Binding-ul, daca exista) e ML-DSA-65, si false pe orice refuz. */ export function verifySdJwt(sdJwt, { issuerKey, issuerAlg, classicalHalf = false, expectedIssuer = null, audience = null, nonce = null, requireKeyBinding = true, requireExp = true, now = new Date(), maxAgeS = 300, clockSkewS = 60, expectedTyp } = {}) { const refuz = (rule, reason) => ({ valid: false, rule, reason, payload: null, disclosed: [], keyBinding: null, postQuantum: false }); const motiv = (e) => String((e && e.message) || e).replace(/^sd-jwt: /, ''); const regula = (e) => (e instanceof Refuz ? e.rule : 'form'); try { if (!(typeof sdJwt === 'string' && sdJwt.includes('~') /*PAZA:form*/)) return refuz('form', 'not an SD-JWT (no ~)'); // ceasul verificatorului: un `now` care nu e un timp ar lasa toate comparatiile de timp sa treaca (NaN nu e mai mare decat nimic) const acum = Math.floor(new Date(now).getTime() / 1000); if (!(Number.isFinite(acum) /*PAZA:form*/)) return refuz('form', 'the verifier clock (now) is not a time'); const parti = sdJwt.split('~'); const jwt = parti[0], kb = parti[parti.length - 1], dez = parti.slice(1, -1); if (!(!dez.some((d) => d === '') /*PAZA:form*/)) return refuz('form', '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 const context = typeof audience === 'string' && !!audience && typeof nonce === 'string' && !!nonce; if (!(!(requireKeyBinding || kb !== '') || context /*PAZA:kb-context*/)) return refuz('kb-context', 'the verifier must give its audience and nonce to judge a key binding (RFC 9901 s.7.3)'); // 2. JWT-ul emitentului, cu jumatatea ceruta a unor chei AERE (AIP-25 C1: implicit ML-DSA-65; Ed25519 numai cu classicalHalf) const { antet, corp } = verificaJws(jwt, judgedJwk(issuerKey, issuerAlg, { classicalHalf, headerAlg: headerAlgOf(jwt) }), { typ: expectedTyp }); if (!(expectedIssuer == null || corp.iss === expectedIssuer /*PAZA:issuer*/)) return refuz('issuer', `issued by ${JSON.stringify(corp.iss)}, not ${expectedIssuer}`); const alg = corp._sd_alg === undefined ? 'sha-256' : corp._sd_alg; if (!(alg === 'sha-256' /*PAZA:sd-alg*/)) return refuz('sd-alg', `_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) /*PAZA:disclosure-twice*/)) return refuz('disclosure-twice', 'the same disclosure is given twice'); let c; try { c = JSON.parse(dinB64u(d, 'a disclosure').toString('utf8')); } catch { if (!(false /*PAZA:disclosure-form*/)) return refuz('disclosure-form', 'a disclosure is not base64url JSON'); continue; } if (!(Array.isArray(c) && (c.length === 2 || c.length === 3) && typeof c[0] === 'string' /*PAZA:disclosure-form*/)) return refuz('disclosure-form', '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) /*PAZA:digest-twice*/)) throw nu('digest-twice', '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 /*PAZA:disclosure-form*/)) throw nu('disclosure-form', '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__') /*PAZA:disclosure-name*/)) throw nu('disclosure-name', 'a claim may not be named __proto__'); const out = {}; // `_sd_alg` se scoate de sus (RFC 9901 s.7.1 pasul 3.f). Mai jos NU are voie sa existe: "It MUST NOT be used in any object nested // within the payload" (s.4.1.1), deci un jeton care il poarta imbricat (in clar sau intr-o valoare dezvaluita) nu e un SD-JWT valid. // Pana pe 2026-10-18 era pastrat ca o afirmatie oarecare; la interoperare, @sd-jwt/core 0.23.0 il refuza, si textul s.4.1.1 ii da dreptate. for (const [k, v] of Object.entries(x)) { if (k === '_sd') continue; if (k === '_sd_alg') { if (!(sus /*PAZA:sd-alg*/)) throw nu('sd-alg', '_sd_alg below the top level of the payload (RFC 9901 s.4.1.1)'); continue; } out[k] = proceseaza(v); } if (x._sd !== undefined) { if (!(Array.isArray(x._sd) && x._sd.every((s) => typeof s === 'string') /*PAZA:disclosure-form*/)) throw nu('disclosure-form', '_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' /*PAZA:disclosure-form*/)) throw nu('disclosure-form', 'an object property disclosure must be [salt, name, value]'); // `_sd_alg` dezvaluit: s.7.1 l-ar insera (3.c.ii.4) si apoi l-ar scoate (3.f); refuzam in loc sa il scoatem in tacere, ca lista // `disclosed` sa nu numeasca o afirmatie pe care payload-ul procesat nu o poarta (la fel face si @sd-jwt/core 0.23.0) if (!(c[1] !== '_sd' && c[1] !== '...' && c[1] !== '_sd_alg' && c[1] !== '__proto__' /*PAZA:disclosure-name*/)) throw nu('disclosure-name', `a disclosure may not name ${c[1]}`); if (!(!Object.hasOwn(out, c[1]) /*PAZA:claim-conflict*/)) throw nu('claim-conflict', `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(regula(e), motiv(e)); } // 5. o dezvaluire nelegata de niciun digest = refuz if (!(folosite.size === dupaDigest.size /*PAZA:unreferenced*/)) return refuz('unreferenced', '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) for (const k of ['exp', 'nbf', 'iat']) if (!(payload[k] === undefined || Number.isFinite(payload[k]) /*PAZA:time*/)) return refuz('time', `${k} is not a number`); if (!(!requireExp || payload.exp !== undefined /*PAZA:time*/)) return refuz('time', 'exp is required and the processed payload has none (RFC 9901 s.9.7)'); if (!(payload.exp === undefined || acum < payload.exp /*PAZA:time*/)) return refuz('time', `expired at ${payload.exp}`); if (!(payload.nbf === undefined || payload.nbf - clockSkewS <= acum /*PAZA:time*/)) return refuz('time', `not valid before ${payload.nbf}`); if (!(payload.iat === undefined || payload.iat - clockSkewS <= acum /*PAZA:time*/)) return refuz('time', `issued in the future (${payload.iat})`); // AIP-25 C1: verdictul spune daca semnatura emitentului judecata e post-cuantica (Key Binding-ul o poate cobori, mai jos) const pqEmitent = antet.alg === 'ML-DSA-65'; const rez = { valid: true, rule: null, reason: '', payload, disclosed, issuer: corp.iss, issuerChecked: expectedIssuer != null, alg: antet.alg, postQuantum: pqEmitent }; // s.7.3: Key Binding JWT if (kb === '') { if (!(!requireKeyBinding /*PAZA:kb-required*/)) return refuz('kb-required', '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) /*PAZA:kb-cnf*/)) return refuz('kb-cnf', '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(regula(e), 'key binding: ' + motiv(e)); } const K = k.corp; if (!(typeof K.nonce === 'string' && typeof K.aud === 'string' && Number.isFinite(K.iat) && typeof K.sd_hash === 'string' /*PAZA:kb-claims*/)) return refuz('kb-claims', 'key binding: aud, nonce, iat and sd_hash are required'); if (!(K.aud === audience /*PAZA:kb-audience*/)) return refuz('kb-audience', `key binding: made for ${K.aud}, not ${audience}`); if (!(K.nonce === nonce /*PAZA:kb-nonce*/)) return refuz('kb-nonce', 'key binding: another nonce'); if (!(Math.abs(acum - K.iat) <= maxAgeS /*PAZA:kb-freshness*/)) return refuz('kb-freshness', `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) /*PAZA:kb-freshness*/)) return refuz('kb-freshness', `key binding: expired at ${K.exp}`); if (!(K.nbf === undefined || (Number.isFinite(K.nbf) && K.nbf - clockSkewS <= acum) /*PAZA:kb-freshness*/)) return refuz('kb-freshness', `key binding: not valid before ${K.nbf}`); const baza = sdJwt.slice(0, sdJwt.length - kb.length); if (!(sha256b64u(baza) === K.sd_hash /*PAZA:kb-sd-hash*/)) return refuz('kb-sd-hash', 'key binding: sd_hash is not the hash of what was presented'); return { ...rez, postQuantum: rez.postQuantum && k.antet.alg === 'ML-DSA-65', keyBinding: { aud: K.aud, nonce: K.nonce, iat: K.iat, alg: k.antet.alg } }; } catch (e) { return refuz(regula(e), motiv(e)); } }