153 lines
14 KiB
JavaScript
153 lines
14 KiB
JavaScript
// 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:<regula>*/ 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-<base64>)');
|
|
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 };
|
|
}
|