// AERE Identity, SD-JWT VC: profilul "SD-JWT-based Verifiable Digital Credentials" (draft-ietf-oauth-sd-jwt-vc-19, 31 august 2026; la // IESG, "Waiting for AD Go-Ahead", NU inca RFC) peste sdjwt.mjs (RFC 9901). Strat subtire, fara dependinte (pista B, B9, 2026-10-18). // // CE FACE: // issueSdJwtVc un SD-JWT VC: typ "dc+sd-jwt" (s.2.2.1), `vct` obligatoriu (s.2.2.2.3), `iss`, `cnf.jwk`, `iat`/`exp`/`nbf`, `status` // optional (Token Status List, draft-ietf-oauth-status-list-21, s.2.2.2.3 si HAIP 1.0 s.6.1), `vct#integrity` optional; // afirmatiile pe care s.2.2.2.3 le vrea in clar nu pot fi facute dezvaluibile // verifySdJwtVc verifySdJwt cu typ dc+sd-jwt cerut, apoi: `vct` prezent si EGAL cu cel asteptat de verificator (un verificator care nu // numeste tipul pe care il vrea primeste orice credential); nicio afirmatie din s.2.2.2.3 dezvaluita selectiv; `status` // judecat dintr-un Status List Token dat de verificator (offline), si NEjudecat = refuz, daca verificatorul nu spune altfel // issueStatusListJwt un Status List Token in forma JWT (typ statuslist+jwt, sub = URI-ul listei, iat, exp, ttl, status_list {bits, lst}: // octetii starilor impachetati de la bitul cel mai putin semnificativ, DEFLATE in container ZLIB, base64url) // readStatusListJwt il citeste si il verifica (semnatura cu cheia data, typ, sub = URI-ul cerut, iat, exp) si da starea unui index // // REGULILE NUMITE (pentru suita de conformitate tools/aere-conformance): un refuz al lui verifySdJwtVc numeste regula lui (`rule`): una // din REGULI de aici, sau una a lui sdjwt.mjs cand refuzul vine de acolo; fiecare verificare poarta /*PAZA:*/ pe conditia ei buna. // // CE NU FACE: nu aduce nimic de pe internet (nici metadatele de tip ale lui `vct`, s.5, nici lista de stare: verificatorul le aduce si le da, // ca `--status-list` la credentialul AERE); nu rezolva cheia emitentului din `iss` (s.2.5: JWT VC Issuer Metadata sau x5c): cheia e intrarea // verificatorului, niciodata din jeton; nu e mdoc (ISO/IEC 18013-5, CBOR/COSE): formatul de proximitate al ARF e alt format, nefacut aici. import zlib from 'node:zlib'; import { issueSdJwt, presentSdJwt, verifySdJwt, publicJwk, verifyJws, signJws, privateKeyOf, ALGS } from './sdjwt.mjs'; export const TYP_VC = 'dc+sd-jwt'; export const TYP_STATUS_LIST = 'statuslist+jwt'; // draft-ietf-oauth-sd-jwt-vc-19 s.2.2.2.3: "MUST NOT be included in Disclosures and therefore MUST NOT be selectively disclosed" export const NEDEZVALUIBILE = ['iss', 'nbf', 'exp', 'cnf', 'vct', 'vct#integrity', 'aka_vcts', 'status']; // draft-ietf-oauth-status-list-21 s.7: tipurile de stare inregistrate; 0x03 si 0x0C..0x0F sunt ale aplicatiei export const STARI = { 0: 'VALID', 1: 'INVALID', 2: 'SUSPENDED' }; export const MAX_LISTA_OCTETI = 1 << 24; /** regulile proprii ale profilului (cele ale lui sdjwt.mjs trec prin verifySdJwt si isi pastreaza numele) */ export const REGULI = ['vct-expected', 'vct', 'vct-mismatch', 'disclosed-protected', 'form', 'status-list', 'status-index', 'status-revoked', 'status-unjudged']; const obiect = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); const b64u = (b) => Buffer.from(b).toString('base64url'); export { presentSdJwt as presentSdJwtVc }; // forma referintei de stare din jeton (status-list-21 s.6.1): { status_list: { idx: intreg >= 0, uri: sir } }; null = buna, altfel motivul function formaReferintei(ref) { if (!obiect(ref)) return 'status_list is not an object'; if (!Number.isInteger(ref.idx) || ref.idx < 0) return 'idx is not a non-negative integer'; if (typeof ref.uri !== 'string' || !ref.uri) return 'uri is not a string'; return null; } /** * @param {{vct:string, vctIntegrity?:string, status?:{status_list:{idx:number, uri:string}}, claims?:object, disclosable?:string[], * arrayDisclosable?:string[], issuer, holder, iss:string, iat?:number, exp?:number|null, nbf?:number, alg?, holderAlg?, decoys?:number}} o * Restul optiunilor sunt ale lui issueSdJwt. `vct`, `status`, `vct#integrity` se dau aici, nu in `claims`, si nu pot fi dezvaluibile. */ export function issueSdJwtVc({ vct, vctIntegrity, status, claims = {}, disclosable = [], arrayDisclosable = [], ...rest }) { if (typeof vct !== 'string' || !vct) throw new Error('sd-jwt-vc: vct is required (a case-sensitive string; draft-ietf-oauth-sd-jwt-vc s.2.2.2.3)'); if (!obiect(claims)) throw new Error('sd-jwt-vc: claims must be an object'); for (const n of NEDEZVALUIBILE) if (Object.hasOwn(claims, n)) throw new Error(`sd-jwt-vc: ${n} is set by the profile, not given among the claims`); for (const n of [...disclosable, ...arrayDisclosable]) if (NEDEZVALUIBILE.includes(n)) throw new Error(`sd-jwt-vc: ${n} may not be selectively disclosed (s.2.2.2.3)`); if (status !== undefined) { if (!obiect(status) || !Object.hasOwn(status, 'status_list')) throw new Error('sd-jwt-vc: status must be { status_list: { idx, uri } } (Token Status List)'); const e = formaReferintei(status.status_list); if (e) throw new Error('sd-jwt-vc: status.status_list: ' + e); } if (vctIntegrity != null && (typeof vctIntegrity !== 'string' || !/^sha(256|384|512)-[A-Za-z0-9+/=]+/.test(vctIntegrity))) throw new Error('sd-jwt-vc: vct#integrity is an "integrity metadata" string (sha256-)'); const toate = { vct, ...(vctIntegrity != null ? { 'vct#integrity': vctIntegrity } : {}), ...(status !== undefined ? { status } : {}), ...claims }; return issueSdJwt({ ...rest, claims: toate, disclosable, arrayDisclosable, typ: TYP_VC }); } /** * Status List Token (draft-ietf-oauth-status-list-21 s.5, forma JWT). statuses: starea fiecarui index (0 VALID, 1 INVALID, 2 SUSPENDED, ...), * fiecare sub 2^bits; bits din {1, 2, 4, 8}. Impachetarea (s.4.1): indexul i sta la octetul floor(i*bits/8), de la bitul (i*bits) mod 8, adica * de la bitul cel mai putin semnificativ catre cel mai semnificativ. lst = base64url(ZLIB(DEFLATE(octeti))). */ export function issueStatusListJwt({ issuer, alg = 'EdDSA', uri, statuses, bits = 1, iat = Math.floor(Date.now() / 1000), exp, ttl, aggregationUri }) { if (!ALGS.includes(alg)) throw new Error('status-list: unknown alg'); if (typeof uri !== 'string' || !uri) throw new Error('status-list: uri (the sub of the token) is required'); if (![1, 2, 4, 8].includes(bits)) throw new Error('status-list: bits is 1, 2, 4 or 8'); if (!Array.isArray(statuses) || !statuses.length || !statuses.every((s) => Number.isInteger(s) && s >= 0 && s < 2 ** bits)) throw new Error(`status-list: statuses is a non-empty list of integers below 2^${bits}`); const octeti = Buffer.alloc(Math.ceil(statuses.length * bits / 8)); statuses.forEach((s, i) => { octeti[Math.floor(i * bits / 8)] |= s << ((i * bits) % 8); }); const lst = b64u(zlib.deflateSync(octeti, { level: 9 })); const corp = { sub: uri, iat, ...(exp != null ? { exp } : {}), ...(ttl != null ? { ttl } : {}), status_list: { bits, lst, ...(aggregationUri ? { aggregation_uri: aggregationUri } : {}) } }; return signJws({ alg, typ: TYP_STATUS_LIST }, corp, privateKeyOf(issuer, alg)); } /** * Citeste si verifica un Status List Token JWT cu cheia DATA (a emitentului listei; de obicei emitentul credentialului), pentru URI-ul cerut. * Intoarce { ok, reason, entries, bits, statusAt(idx) -> 0..2^bits-1 sau null (dincolo de lista), payload }. Toate verificarile de aici * sunt regula `status-list` a profilului. */ export function readStatusListJwt(jwt, { key, alg = 'EdDSA', uri, now = new Date(), clockSkewS = 60 } = {}) { const nu = (reason) => ({ ok: false, reason, entries: 0, bits: 0, statusAt: () => null, payload: null }); const motiv = (e) => String((e && e.message) || e).replace(/^sd-jwt: /, ''); // semnatura, typ-ul si potrivirea alg/cheie le judeca verifyJws (o lista semnata ML-DSA-65 judecata cu cheia Ed25519 cade acolo: "does not match") let corp = {}; try { ({ corp } = verifyJws(jwt, publicJwk(key, alg), { typ: TYP_STATUS_LIST })); } catch (e) { if (!(false /*PAZA:status-list*/)) return nu(motiv(e)); } if (!(typeof uri === 'string' && !!uri /*PAZA:status-list*/)) return nu('the verifier must give the uri the list was fetched for'); if (!(corp.sub === uri /*PAZA:status-list*/)) return nu(`sub ${JSON.stringify(corp.sub)} is not the uri referenced (${uri})`); const acum = Math.floor(new Date(now).getTime() / 1000); if (!(Number.isFinite(corp.iat) /*PAZA:status-list*/)) return nu('iat is required (status-list s.5.1)'); if (!(corp.iat - clockSkewS <= acum /*PAZA:status-list*/)) return nu(`issued in the future (${corp.iat})`); if (!(corp.exp === undefined || (Number.isFinite(corp.exp) && acum < corp.exp) /*PAZA:status-list*/)) return nu(`expired at ${corp.exp}`); const sl = corp.status_list; if (!(obiect(sl) && [1, 2, 4, 8].includes(sl.bits) && typeof sl.lst === 'string' /*PAZA:status-list*/)) return nu('status_list must be { bits: 1|2|4|8, lst: base64url }'); let octeti = Buffer.alloc(0); try { if (!/^[A-Za-z0-9_-]+$/.test(sl.lst)) throw new Error('lst is not base64url'); octeti = zlib.inflateSync(Buffer.from(sl.lst, 'base64url'), { maxOutputLength: MAX_LISTA_OCTETI }); } catch (e) { if (!(false /*PAZA:status-list*/)) return nu('lst cannot be inflated: ' + String(e.message || e).slice(0, 80)); } const bits = obiect(sl) && [1, 2, 4, 8].includes(sl.bits) ? sl.bits : 1, entries = Math.floor(octeti.length * 8 / bits); const statusAt = (idx) => (Number.isInteger(idx) && idx >= 0 && idx < entries ? (octeti[Math.floor(idx * bits / 8)] >> ((idx * bits) % 8)) & ((1 << bits) - 1) : null); return { ok: true, reason: '', entries, bits, statusAt, payload: corp }; } /** * @param {string} token SD-JWT VC sau SD-JWT VC+KB, serializarea compacta * @param {{expectedVct:string|string[], statusListJwt?:string, statusListKey?, statusListAlg?, requireStatus?:boolean, expectedTyp?:string} & import('./sdjwt.mjs').VerifyOptions} o * expectedVct e CERUT. statusListJwt: Status List Token-ul adus de verificator pentru URI-ul din jeton (cheia lui: statusListKey, implicit * cheia emitentului). requireStatus (implicit true): un `status` prezent si nejudecat e refuz, nu "nerevocat". * Intoarce rezultatul lui verifySdJwt plus { vct, status: { present, judged, mechanism?, uri?, idx?, value?, name? } }; `rule` numeste * regula care a refuzat (a profilului sau a lui sdjwt.mjs), null cand e valid. */ export function verifySdJwtVc(token, { expectedVct, statusListJwt = null, statusListKey = null, statusListAlg = null, requireStatus = true, expectedTyp = TYP_VC, ...o } = {}) { const refuz = (rule, reason, extra = {}) => ({ valid: false, rule, reason, payload: null, disclosed: [], keyBinding: null, vct: null, status: null, ...extra }); const vcts = Array.isArray(expectedVct) ? expectedVct : expectedVct == null ? null : [expectedVct]; if (!(Array.isArray(vcts) && vcts.length > 0 && vcts.every((v) => typeof v === 'string' && !!v) /*PAZA:vct-expected*/)) return refuz('vct-expected', 'the verifier must name the vct it expects (expectedVct)'); const tipuri = Array.isArray(vcts) ? vcts : []; const r = verifySdJwt(token, { ...o, expectedTyp }); if (!r.valid) return { ...r, vct: null, status: null }; const p = r.payload; if (!(typeof p.vct === 'string' && !!p.vct /*PAZA:vct*/)) return refuz('vct', 'vct is required and the payload has none (draft-ietf-oauth-sd-jwt-vc s.2.2.2.3)'); if (!(tipuri.includes(p.vct) /*PAZA:vct-mismatch*/)) return refuz('vct-mismatch', `vct ${JSON.stringify(p.vct)} is not the type expected (${tipuri.join(', ')})`); const dez = r.disclosed.filter((n) => NEDEZVALUIBILE.includes(n)); if (!(dez.length === 0 /*PAZA:disclosed-protected*/)) return refuz('disclosed-protected', `${dez[0]} was selectively disclosed; an SD-JWT VC carries it in the clear (s.2.2.2.3)`); if (!(p['vct#integrity'] === undefined || typeof p['vct#integrity'] === 'string' /*PAZA:form*/)) return refuz('form', 'vct#integrity is not a string'); let status = { present: false, judged: false }; if (p.status !== undefined) { if (!(obiect(p.status) /*PAZA:form*/)) return refuz('form', 'status is not an object'); const ref = obiect(p.status) ? p.status.status_list : undefined; status = { present: true, judged: false, mechanism: ref !== undefined ? 'status_list' : (Object.keys(obiect(p.status) ? p.status : {}).join(',') || 'none') }; if (ref !== undefined) { const e = formaReferintei(ref); if (!(e === null /*PAZA:form*/)) return refuz('form', 'status.status_list: ' + e); status.uri = ref.uri; status.idx = ref.idx; if (statusListJwt != null) { const l = readStatusListJwt(statusListJwt, { key: statusListKey ?? o.issuerKey, alg: statusListAlg ?? o.issuerAlg ?? 'EdDSA', uri: ref.uri, now: o.now, clockSkewS: o.clockSkewS }); if (!(l.ok /*PAZA:status-list*/)) return refuz('status-list', 'status list: ' + l.reason, { vct: p.vct, status }); const v = l.statusAt(ref.idx); if (!(v !== null /*PAZA:status-index*/)) return refuz('status-index', `status list: index ${ref.idx} is beyond the list (${l.entries} entries)`, { vct: p.vct, status }); status.judged = true; status.value = v; status.name = STARI[v] ?? 'application-specific'; if (!(v === 0 /*PAZA:status-revoked*/)) return refuz('status-revoked', `status ${v} (${status.name}) in ${ref.uri} at index ${ref.idx}`, { vct: p.vct, status }); } } if (!(status.judged || !requireStatus /*PAZA:status-unjudged*/)) return refuz('status-unjudged', `the credential names a status (${status.mechanism}) that was not judged: give statusListJwt fetched from ${status.uri ?? 'the issuer'}, or set requireStatus:false`, { vct: p.vct, status }); } return { ...r, vct: p.vct, status }; }