aere-quantum/identity/sdjwt-vc.mjs

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 };
}