sdjwt.mjs: an issuer-signed JWT with the digests of the disclosable claims (_sd, list elements as {"...": digest}, _sd_alg sha-256),
the holder's key in cnf.jwk and exp, followed by the disclosures; the holder keeps the ones it picks and adds a Key Binding JWT
(kb+jwt, aud, nonce, iat, sd_hash); the verifier follows RFC 9901 sections 7.1 and 7.3 with the issuer key it chose, and requires its
audience and nonce, exp, and an explicitly typed token. Algorithms: ES256, EdDSA (Ed25519, the key every AERE identity has) and
ML-DSA-65 (JWK type AKP, names from the IETF draft draft-ietf-cose-dilithium, not yet a published standard).
Checked against the standard's own vectors (fixturi-rfc9901.json, from RFC 9901 Section 5 and Appendix A.5): the ten example
disclosure digests, the example SD-JWT signed by someone else with the A.5 key, and the example presentation with key binding, which
gives exactly the processed payload printed in the RFC. Not checked against another SD-JWT library.
An adversarial review before publication found ten defects, all fixed with a test and a planted negative control each (among them:
the issuer signed SD-JWT structure coming in claim values; the holder revealed an element with the same value from any list; audience
and nonce were not required). Tests: SD-JWT 22/22, negative control 28/28; identity 44/44.
284 lines
22 KiB
JavaScript
284 lines
22 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), 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: /, '')); }
|
|
}
|