153 lines
17 KiB
JavaScript
153 lines
17 KiB
JavaScript
// Proba SD-JWT VC (sdjwt-vc.mjs): profilul draft-ietf-oauth-sd-jwt-vc-19 peste sdjwt.mjs, lista de stare (Token Status List,
|
|
// draft-ietf-oauth-status-list-21), si fixturile de interoperare DERIVATE din iesirea reala a bibliotecilor straine
|
|
// (@sd-jwt/sd-jwt-vc + @owf/token-status-list, scrise de interop/interop-sdjwt-vc.mjs --scrie), judecate offline. Fiecare refuz e o proba numita.
|
|
// node proba-sdjwt-vc.mjs iesire 0 = toate cum trebuia
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import zlib from 'node:zlib';
|
|
import os from 'node:os';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import * as I from './identity.mjs';
|
|
import * as J from './sdjwt.mjs';
|
|
import * as V from './sdjwt-vc.mjs';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
let treceri = 0; const esecuri = [];
|
|
function test(nume, fn) { try { fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' RAU ' + nume + ' -- ' + (e.message || e)); } }
|
|
const cere = (c, m) => { if (!c) throw new Error(m); };
|
|
const sortat = (o) => (Array.isArray(o) ? o.map(sortat) : o && typeof o === 'object' ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, sortat(o[k])])) : o);
|
|
const egal = (a, b) => JSON.stringify(sortat(a)) === JSON.stringify(sortat(b));
|
|
const b64u = (x) => Buffer.from(x).toString('base64url');
|
|
const refuz = (r, re) => !r.valid && re.test(r.reason);
|
|
const NOW = 1792281600, LA = new Date((NOW + 60) * 1000);
|
|
const iss = I.generateKeys(), hol = I.generateKeys(), strain = I.generateKeys();
|
|
const AUD = 'https://rp.example', NONCE = 'n-' + crypto.randomBytes(4).toString('hex');
|
|
const VCT = 'urn:eudi:pid:1', URI = 'https://issuer.example/status/pid/1', IDX = 5;
|
|
const CL = { given_name: 'Ana', family_name: 'Pop', birthdate: '1990-01-01', age_over_18: true, nationalities: ['RO', 'DE'], address: { locality: 'Cluj', country: 'RO' }, issuing_authority: 'Example Authority', issuing_country: 'RO' };
|
|
const emite = (o = {}) => V.issueSdJwtVc({ issuer: iss, holder: hol, vct: VCT, claims: CL, disclosable: ['given_name', 'family_name', 'birthdate', 'age_over_18', 'address'], arrayDisclosable: ['nationalities'], iss: 'https://issuer.example', iat: NOW, nbf: NOW, exp: NOW + 365 * 86400, status: { status_list: { idx: IDX, uri: URI } }, decoys: 2, ...o });
|
|
const arata = (sd, reveal, o = {}) => V.presentSdJwtVc({ sdJwt: sd, reveal, holder: hol, audience: AUD, nonce: NONCE, iat: NOW, ...o });
|
|
const judeca = (p, o = {}) => V.verifySdJwtVc(p, { issuerKey: iss, audience: AUD, nonce: NONCE, now: LA, expectedVct: VCT, ...o });
|
|
const lista = (statuses, bits = 1, o = {}) => V.issueStatusListJwt({ issuer: iss, uri: URI, statuses, bits, iat: NOW, exp: NOW + 3600, ...o });
|
|
const TOATE0 = new Array(16).fill(0), REVOCAT = TOATE0.map((_, i) => (i === IDX ? 1 : 0)), SUSPENDAT = TOATE0.map((_, i) => (i === IDX ? 2 : 0));
|
|
const jwtDin = (antet, corp, cheie) => { const cap = b64u(JSON.stringify(antet)) + '.' + b64u(JSON.stringify(corp)); return cap + '.' + b64u(crypto.sign(null, Buffer.from(cap), cheie)); };
|
|
const E = emite(), P = arata(E.sdJwt, ['birthdate', 'age_over_18', { element: 'RO', in: 'nationalities' }]);
|
|
|
|
test('PROFIL: emitere SD-JWT VC (typ dc+sd-jwt, vct, iss, cnf, nbf/exp, status) si verificare cu lista de stare (toate 0): VALID, starea judecata 0, numai ce s-a aratat', () => {
|
|
const h = JSON.parse(Buffer.from(E.sdJwt.split('.')[0], 'base64url').toString('utf8')); cere(h.typ === 'dc+sd-jwt' && h.alg === 'EdDSA', JSON.stringify(h));
|
|
const r = judeca(P, { statusListJwt: lista(TOATE0) });
|
|
cere(r.valid, r.reason);
|
|
cere(r.vct === VCT && r.payload.vct === VCT && r.payload.iss === 'https://issuer.example' && r.payload.cnf && r.payload.nbf === NOW && r.payload.exp === NOW + 365 * 86400, JSON.stringify(Object.keys(r.payload)));
|
|
cere(r.payload.birthdate === '1990-01-01' && r.payload.age_over_18 === true && !('given_name' in r.payload) && !('address' in r.payload) && egal(r.payload.nationalities, ['RO']) && r.payload.issuing_country === 'RO', JSON.stringify(r.payload));
|
|
cere(egal(r.status, { present: true, judged: true, mechanism: 'status_list', uri: URI, idx: IDX, value: 0, name: 'VALID' }), JSON.stringify(r.status));
|
|
cere(r.keyBinding && r.keyBinding.aud === AUD && r.keyBinding.nonce === NONCE, JSON.stringify(r.keyBinding));
|
|
});
|
|
test('PROFIL vct: verificatorul fara expectedVct -> refuzat; alt vct asteptat -> refuzat; lista de vct-uri acceptate -> primit; jeton fara vct -> refuzat (s.2.2.2.3 REQUIRED)', () => {
|
|
cere(refuz(V.verifySdJwtVc(P, { issuerKey: iss, audience: AUD, nonce: NONCE, now: LA, statusListJwt: lista(TOATE0) }), /must name the vct/), 'fara expectedVct a trecut');
|
|
cere(refuz(judeca(P, { expectedVct: 'urn:eudi:pid:2', statusListJwt: lista(TOATE0) }), /not the type expected/), 'alt vct a trecut');
|
|
cere(judeca(P, { expectedVct: ['urn:eudi:pid:2', VCT], statusListJwt: lista(TOATE0) }).valid, 'lista de vct-uri refuzata');
|
|
const faraVct = J.issueSdJwt({ issuer: iss, holder: hol, claims: { a: 1 }, iss: 'x', iat: NOW, exp: NOW + 100, typ: 'dc+sd-jwt' });
|
|
cere(refuz(judeca(arata(faraVct.sdJwt, [])), /vct is required/), 'fara vct a trecut');
|
|
});
|
|
test('PROFIL typ: un SD-JWT cu vct dar typ aere+sd-jwt -> refuzat de verificatorul VC (dc+sd-jwt cerut); application/dc+sd-jwt -> primit (RFC 7515 s.4.1.9)', () => {
|
|
const alt = J.issueSdJwt({ issuer: iss, holder: hol, claims: { vct: VCT, a: 1 }, iss: 'x', iat: NOW, exp: NOW + 100 });
|
|
cere(refuz(judeca(arata(alt.sdJwt, [])), /typ/), 'typ aere+sd-jwt a trecut drept VC');
|
|
const pre = J.issueSdJwt({ issuer: iss, holder: hol, claims: { vct: VCT, a: 1 }, iss: 'x', iat: NOW, exp: NOW + 100, typ: 'application/dc+sd-jwt' });
|
|
const r = judeca(arata(pre.sdJwt, [])); cere(r.valid, 'application/dc+sd-jwt refuzat: ' + r.reason);
|
|
});
|
|
test('PROFIL s.2.2.2.3: emitentul refuza vct/status/iss/exp/cnf dezvaluibile, vct lipsa, status stricat; verificatorul refuza o dezvaluire numita vct sau status', () => {
|
|
const incearca = (o) => { try { V.issueSdJwtVc({ issuer: iss, holder: hol, vct: VCT, iss: 'x', iat: NOW, claims: { a: 1, sub: 's' }, ...o }); return null; } catch (e) { return e.message; } };
|
|
for (const n of ['vct', 'status', 'iss', 'exp', 'cnf', 'vct#integrity']) cere(/may not be selectively disclosed/.test(incearca({ disclosable: [n] }) || ''), n + ' dezvaluibil emis');
|
|
cere(/vct is required/.test(incearca({ vct: undefined }) || ''), 'vct lipsa emis');
|
|
cere(/vct is required/.test(incearca({ vct: '' }) || ''), 'vct gol emis');
|
|
cere(/status\.status_list/.test(incearca({ status: { status_list: { idx: -1, uri: URI } } }) || ''), 'idx negativ emis');
|
|
cere(/set by the profile/.test(incearca({ claims: { vct: 'alt' } }) || ''), 'vct dat in claims emis');
|
|
cere(incearca({ disclosable: ['sub'] }) === null, 'sub dezvaluibil refuzat (s.2.2.2.3 il lasa)');
|
|
// verificatorul: o dezvaluire numita status / vct, facuta de mana si semnata de emitent
|
|
const fa = (nume, val) => { const d = b64u(JSON.stringify(['s1', nume, val])); const dg = crypto.createHash('sha256').update(d).digest('base64url');
|
|
return jwtDin({ alg: 'EdDSA', typ: 'dc+sd-jwt' }, { vct: VCT, iss: 'x', exp: NOW + 100, _sd_alg: 'sha-256', _sd: [dg] }, iss.privat.ed25519) + '~' + d + '~'; };
|
|
cere(refuz(judeca(fa('status', { status_list: { idx: 1, uri: URI } }), { requireKeyBinding: false }), /status was selectively disclosed/), 'status dezvaluit a trecut');
|
|
const dv = b64u(JSON.stringify(['s1', 'nationalities', ['RO']])), dgv = crypto.createHash('sha256').update(dv).digest('base64url');
|
|
const bun = jwtDin({ alg: 'EdDSA', typ: 'dc+sd-jwt' }, { vct: VCT, iss: 'x', exp: NOW + 100, _sd_alg: 'sha-256', _sd: [dgv] }, iss.privat.ed25519) + '~' + dv + '~';
|
|
cere(judeca(bun, { requireKeyBinding: false }).valid, 'martorul (o dezvaluire obisnuita) refuzat');
|
|
});
|
|
test('STARE: lista lipsa -> refuzat implicit (requireStatus, cu uri-ul de adus in motiv); requireStatus:false -> VALID cu starea prezenta si nejudecata; credential fara status -> VALID fara lista', () => {
|
|
const r = judeca(P); cere(refuz(r, /was not judged/) && r.reason.includes(URI) && r.status && r.status.present && !r.status.judged, r.reason + ' ' + JSON.stringify(r.status));
|
|
const r2 = judeca(P, { requireStatus: false }); cere(r2.valid && egal(r2.status, { present: true, judged: false, mechanism: 'status_list', uri: URI, idx: IDX }), r2.reason + ' ' + JSON.stringify(r2.status));
|
|
const F = emite({ status: undefined }); const r3 = judeca(arata(F.sdJwt, ['birthdate'])); cere(r3.valid && egal(r3.status, { present: false, judged: false }), r3.reason + ' ' + JSON.stringify(r3.status));
|
|
// un mecanism de stare pe care nu il stim: prezent, nejudecat
|
|
const alt = jwtDin({ alg: 'EdDSA', typ: 'dc+sd-jwt' }, { vct: VCT, iss: 'x', exp: NOW + 100, status: { identifier_list: { id: '1', uri: URI } } }, iss.privat.ed25519) + '~';
|
|
const r4 = judeca(alt, { requireKeyBinding: false }); cere(refuz(r4, /was not judged/) && r4.status.mechanism === 'identifier_list', r4.reason);
|
|
cere(judeca(alt, { requireKeyBinding: false, requireStatus: false }).valid, 'mecanismul necunoscut cu requireStatus:false refuzat');
|
|
});
|
|
test('STARE: revocat (1) -> refuzat; suspendat (2, pe 2 biti) -> refuzat; stare 3 (a aplicatiei) -> refuzat; 1, 2, 4 si 8 biti citite index cu index (impachetarea: scris = citit)', () => {
|
|
cere(refuz(judeca(P, { statusListJwt: lista(REVOCAT) }), /status 1 \(INVALID\) in .* at index 5/), 'revocat a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: lista(SUSPENDAT, 2) }), /status 2 \(SUSPENDED\)/), 'suspendat a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: lista(TOATE0.map((_, i) => (i === IDX ? 3 : 0)), 2) }), /status 3 \(application-specific\)/), 'starea 3 a trecut');
|
|
for (const [bits, st] of [[1, [1, 0, 1, 1, 0, 0, 0, 1, 1, 0]], [2, [0, 1, 2, 3, 0, 1, 2, 3, 3, 2, 1, 0]], [4, [1, 2, 3, 15, 0, 9]], [8, [0, 1, 2, 3, 255, 7, 0, 1]]]) {
|
|
const r = V.readStatusListJwt(lista(st, bits), { key: iss, uri: URI, now: LA });
|
|
cere(r.ok && r.bits === bits, bits + ' biti: ' + r.reason);
|
|
cere(egal(st.map((_, i) => r.statusAt(i)), st), bits + ' biti: citit ' + JSON.stringify(st.map((_, i) => r.statusAt(i))) + ' scris ' + JSON.stringify(st));
|
|
cere(r.statusAt(r.entries) === null && r.statusAt(-1) === null, bits + ' biti: dincolo de lista');
|
|
}
|
|
// forma de pe fir, octet cu octet: 2 biti, starile [1,2,3,0] -> primul octet = 1 | 2<<2 | 3<<4 | 0<<6 = 0b00111001 = 0x39 (status-list-21 s.4.1)
|
|
const j = lista([1, 2, 3, 0], 2); const corp = JSON.parse(Buffer.from(j.split('.')[1], 'base64url').toString('utf8'));
|
|
const oct = zlib.inflateSync(Buffer.from(corp.status_list.lst, 'base64url'));
|
|
cere(oct.length === 1 && oct[0] === 0x39 && corp.status_list.bits === 2 && corp.sub === URI && corp.iat === NOW, 'octetul: ' + oct.toString('hex'));
|
|
});
|
|
test('STARE: lista pentru alt uri (sub), expirata, semnata de alta cheie, cu typ JWT, fara iat, cu bits 3, index dincolo de lista -> refuzate; cheia listei poate fi alta decat a emitentului', () => {
|
|
cere(refuz(judeca(P, { statusListJwt: lista(TOATE0, 1, { uri: URI + 'x' }) }), /status list: sub .* is not the uri referenced/), 'alt uri a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: lista(TOATE0, 1, { exp: NOW + 30 }) }), /status list: expired at/), 'expirata a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: V.issueStatusListJwt({ issuer: strain, uri: URI, statuses: TOATE0, iat: NOW }) }), /status list: the signature does not verify/), 'alta cheie a trecut');
|
|
cere(judeca(P, { statusListJwt: V.issueStatusListJwt({ issuer: strain, uri: URI, statuses: TOATE0, iat: NOW }), statusListKey: strain }).valid, 'cheia listei data separat refuzata');
|
|
const corp = JSON.parse(Buffer.from(lista(TOATE0).split('.')[1], 'base64url').toString('utf8'));
|
|
cere(refuz(judeca(P, { statusListJwt: jwtDin({ alg: 'EdDSA', typ: 'JWT' }, corp, iss.privat.ed25519) }), /status list: typ/), 'typ JWT a trecut');
|
|
const { iat: _i, ...faraIat } = corp;
|
|
cere(refuz(judeca(P, { statusListJwt: jwtDin({ alg: 'EdDSA', typ: 'statuslist+jwt' }, faraIat, iss.privat.ed25519) }), /status list: iat is required/), 'fara iat a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: jwtDin({ alg: 'EdDSA', typ: 'statuslist+jwt' }, { ...corp, status_list: { ...corp.status_list, bits: 3 } }, iss.privat.ed25519) }), /status list: status_list must be/), 'bits 3 a trecut');
|
|
cere(refuz(judeca(P, { statusListJwt: jwtDin({ alg: 'EdDSA', typ: 'statuslist+jwt' }, { ...corp, iat: NOW + 600 }, iss.privat.ed25519) }), /status list: issued in the future/), 'iat in viitor a trecut');
|
|
const scurta = V.issueStatusListJwt({ issuer: iss, uri: URI, statuses: [0, 0, 0, 0], bits: 8, iat: NOW });
|
|
cere(refuz(judeca(P, { statusListJwt: scurta }), /index 5 is beyond the list \(4 entries\)/), 'index dincolo a trecut');
|
|
let m = null; try { V.issueStatusListJwt({ issuer: iss, uri: URI, statuses: [0, 2], bits: 1, iat: NOW }); } catch (e) { m = e.message; } cere(/below 2\^1/.test(m || ''), 'starea 2 pe 1 bit emisa');
|
|
});
|
|
test('POST-CUANTIC: credential ML-DSA-65 cu lista de stare semnata ML-DSA-65 -> VALID; aceeasi lista judecata cu cheia Ed25519 -> refuzata', () => {
|
|
const M = emite({ alg: 'ML-DSA-65', holderAlg: 'ML-DSA-65' });
|
|
const p = arata(M.sdJwt, ['birthdate'], { holderAlg: 'ML-DSA-65' });
|
|
const l = V.issueStatusListJwt({ issuer: iss, alg: 'ML-DSA-65', uri: URI, statuses: TOATE0, iat: NOW });
|
|
const r = judeca(p, { issuerAlg: 'ML-DSA-65', statusListJwt: l }); cere(r.valid && r.alg === 'ML-DSA-65' && r.status.judged, r.reason);
|
|
cere(refuz(judeca(p, { issuerAlg: 'ML-DSA-65', statusListJwt: l, statusListAlg: 'EdDSA' }), /status list: .*does not match|status list: signed with/), 'lista ML-DSA-65 judecata cu Ed25519 a trecut');
|
|
});
|
|
const FX = path.join(AICI, 'interop', 'fixturi-interop-sdjwt-vc.json');
|
|
const F = fs.existsSync(FX) ? JSON.parse(fs.readFileSync(FX, 'utf8')) : null;
|
|
test(`INTEROP (fixturi @sd-jwt/sd-jwt-vc ${F ? F.biblioteci['@sd-jwt/sd-jwt-vc'] : '?'} + @owf/token-status-list ${F ? F.biblioteci['@owf/token-status-list'] : '?'}, generate ${F ? F.generat.slice(0, 10) : '?'}): fiecare credential si lista de stare a LOR da, offline, verdictul inregistrat`, () => {
|
|
cere(F && Array.isArray(F.cazuri) && F.cazuri.length >= 8, 'fixturile lipsesc sau sunt prea putine (ruleaza interop/interop-sdjwt-vc.mjs --lab ... --scrie)');
|
|
const la = new Date(F.ceasVerificare * 1000);
|
|
let valide = 0, refuzate = 0;
|
|
for (const c of F.cazuri) {
|
|
const r = V.verifySdJwtVc(c.prezentare, { issuerKey: c.issuerJwk, issuerAlg: c.alg, audience: F.audience, nonce: F.nonce, now: la, expectedVct: c.expectedVct, ...(c.statusListJwt ? { statusListJwt: c.statusListJwt } : {}), ...(c.optiuni || {}) });
|
|
if (c.asteptat.valid) {
|
|
cere(r.valid, c.nume + ': ' + r.reason);
|
|
cere(egal(r.payload, c.asteptat.payload) && egal([...r.disclosed].sort(), c.asteptat.disclosed) && egal(r.status, c.asteptat.status), c.nume + ': rezultatul difera de cel inregistrat: ' + JSON.stringify(r.payload).slice(0, 200));
|
|
valide++;
|
|
} else { cere(refuz(r, new RegExp(c.asteptat.motiv)), c.nume + ': asteptat refuz /' + c.asteptat.motiv + '/, primit ' + (r.valid ? 'VALID' : r.reason)); refuzate++; }
|
|
}
|
|
cere(valide >= 3 && refuzate >= 4, `prea putine cazuri: ${valide} valide, ${refuzate} refuzate`);
|
|
const algs = new Set(F.cazuri.filter((c) => c.asteptat.valid).map((c) => c.alg)); cere(algs.has('EdDSA') && algs.has('ES256') && algs.has('ML-DSA-65'), 'lipsesc algoritmi din cazurile valide: ' + [...algs]);
|
|
});
|
|
test('linia de comanda: verify-sdjwt-vc -> 0 VALID cu lista, 1 INVALID cu lista revocata, 1 fara lista, 0 cu --status-not-required, 2 fara --expected-vct', () => {
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-sdjwt-vc-cli-'));
|
|
try {
|
|
fs.writeFileSync(path.join(T, 'p.txt'), P); fs.writeFileSync(path.join(T, 'k.json'), JSON.stringify(iss.public));
|
|
fs.writeFileSync(path.join(T, 'l0.txt'), lista(TOATE0)); fs.writeFileSync(path.join(T, 'l1.txt'), lista(REVOCAT));
|
|
const run = (...a) => spawnSync(process.execPath, [path.join(AICI, 'identity-cli.mjs'), 'verify-sdjwt-vc', '--sd-jwt', 'p.txt', '--issuer-key', 'k.json', '--audience', AUD, '--nonce', NONCE, '--at', LA.toISOString().replace(/\.\d{3}Z$/, 'Z'), ...a], { cwd: T, encoding: 'utf8' });
|
|
const a = run('--expected-vct', VCT, '--status-list', 'l0.txt', '--json'); cere(a.status === 0 && JSON.parse(a.stdout).valid === true && JSON.parse(a.stdout).status.value === 0, 'valid: ' + a.status + ' ' + a.stdout.slice(0, 200) + a.stderr);
|
|
const b = run('--expected-vct', VCT, '--status-list', 'l1.txt'); cere(b.status === 1 && /INVALID: status 1/.test(b.stdout), 'revocat: ' + b.status + ' ' + b.stdout);
|
|
const c = run('--expected-vct', VCT); cere(c.status === 1 && /was not judged/.test(c.stdout), 'fara lista: ' + c.status + ' ' + c.stdout);
|
|
const d = run('--expected-vct', VCT, '--status-not-required'); cere(d.status === 0 && /status not judged/.test(d.stdout), 'status-not-required: ' + d.status + ' ' + d.stdout);
|
|
const e = run(); cere(e.status === 2 && /expected-vct/.test(e.stderr), 'fara vct: ' + e.status + ' ' + e.stderr);
|
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
});
|
|
console.log(`\naere-sd-jwt-vc: ${treceri}/${treceri + esecuri.length} cum trebuia`);
|
|
process.exitCode = esecuri.length ? 1 : 0;
|