aere-quantum/identity/sdjwt.mjs

313 lines
26 KiB
JavaScript

// 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). Un emitent AERE semneaza cu cheia lui Ed25519 (algoritm cunoscut tuturor bibliotecilor JOSE)
// sau cu cheia ML-DSA-65 (masurat 2026-10-18: jose 6.2.12 pe Node 24 o semneaza si o verifica); 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.
//
// 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:<regula>*/ 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', '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'];
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
/**
* 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 nu('key', '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) /*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);
}
const cheiePrivata = (k, alg) => (k && k.privat ? (alg === 'ML-DSA-65' ? k.privat.mldsa65 : k.privat.ed25519) : k);
// pentru profilurile de deasupra (sdjwt-vc.mjs: Status List Token), ca semnatura JWS 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). 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, 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');
if (!ALGS.includes(alg) || !ALGS.includes(holderAlg)) 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, holderAlg) };
const jwt = semneazaJws({ alg, 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, rule, reason, payload, disclosed:[nume], issuer, issuerChecked, keyBinding:{aud, nonce, iat, alg}|null }; `rule` e
* regula (din REGULI) care a refuzat, null cand e valid.
*/
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 = (rule, reason) => ({ valid: false, rule, reason, payload: null, disclosed: [], keyBinding: null });
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
const { antet, corp } = verificaJws(jwt, publicJwk(issuerKey, issuerAlg), { 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})`);
const rez = { valid: true, rule: null, reason: '', payload, disclosed, issuer: corp.iss, issuerChecked: expectedIssuer != null, alg: antet.alg };
// 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, keyBinding: { aud: K.aud, nonce: K.nonce, iat: K.iat, alg: k.antet.alg } };
} catch (e) { return refuz(regula(e), motiv(e)); }
}