aere-quantum/identity/interop/interop-sdjwt.mjs

248 lines
27 KiB
JavaScript

#!/usr/bin/env node
// interop-sdjwt.mjs: interoperarea SD-JWT (IETF RFC 9901) MASURATA in AMBELE sensuri intre sdjwt.mjs (al nostru) si @sd-jwt/core
// (openwallet-foundation-labs/identity-common-ts), cu jose ca motor JWS al partii straine si @sd-jwt/crypto-nodejs ca hasher/sare a ei
// (pista B, B9, 2026-10-18). Bibliotecile straine NU sunt in depozit si nu intra in modulul public: se dau prin `--lab <dosar>` (dosarul cu
// `node_modules`, in afara depozitului) sau AERE_SDJWT_LAB; lipsa lor e 2 NEMASURAT, pe nume.
// node interop-sdjwt.mjs --lab <dosar> [--scrie]
// --scrie rescrie fixturi-interop-sdjwt.json (jetoanele LOR, cu cheile lor PUBLICE de proba si versiunile bibliotecii) si
// rezultate-interop-sdjwt.json (tabelul), ca proba-sdjwt.mjs sa le judece OFFLINE, fara biblioteca
// Iesire: 0 = fiecare rand a dat EXACT verdictele asteptate de la amandoua partile; 1 = cel putin un rand divergent; 2 = NEMASURAT.
// Ceasul e FIX (CEAS), ca fixturile sa ramana judecabile: jetoanele au iat = CEAS, exp = CEAS + 1 an, KB iat = CEAS; verificarea e la CEAS + 60 s.
// Nicio cheie privata nu se tipareste si nu se scrie: cheile de proba se nasc la rulare (AERE-SINTETIC) si mor cu procesul.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
import * as I from '../identity.mjs';
import * as J from '../sdjwt.mjs';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const arg = (n) => { const i = process.argv.indexOf(n); return i === -1 ? null : (process.argv[i + 1] ?? null); };
const LAB = arg('--lab') ?? process.env.AERE_SDJWT_LAB ?? null;
const SCRIE = process.argv.includes('--scrie');
const CEAS = 1792281600; // 2026-10-18T00:00:00Z
const ACUM = CEAS + 60; // ceasul verificatorului
const AUD = 'https://verifier.example', NONCE = 'interop-' + crypto.randomBytes(6).toString('hex');
const b64u = (b) => Buffer.from(b).toString('base64url');
const dec = (s) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
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 taie = (s, n = 90) => (s == null ? '' : String(s).replace(/\s+/g, ' ').slice(0, n));
async function main() {
if (!LAB || !fs.existsSync(path.join(LAB, 'node_modules'))) { console.log('NEMASURAT: laboratorul cu bibliotecile straine lipseste (--lab <dosar cu node_modules> sau AERE_SDJWT_LAB)'); return 2; }
const req = createRequire(path.join(LAB, 'package.json'));
const modul = async (nume) => { let cale; try { cale = req.resolve(nume); } catch { return null; } const m = await import(pathToFileURL(cale).href); return m.digest || m.SDJwtInstance || m.compactVerify ? m : (m.default ?? m); };
const versiune = (nume) => { try { return JSON.parse(fs.readFileSync(path.join(LAB, 'node_modules', ...nume.split('/'), 'package.json'), 'utf8')).version; } catch { return null; } };
const core = await modul('@sd-jwt/core'), cn = await modul('@sd-jwt/crypto-nodejs'), jose = await modul('jose');
const lipsa = [['@sd-jwt/core', core], ['@sd-jwt/crypto-nodejs', cn], ['jose', jose]].filter(([, m]) => !m).map(([n]) => n);
if (lipsa.length) { console.log('NEMASURAT: lipsesc din laborator: ' + lipsa.join(', ') + ' (npm install acolo, nu in depozit)'); return 2; }
const biblioteci = { '@sd-jwt/core': versiune('@sd-jwt/core'), '@sd-jwt/crypto-nodejs': versiune('@sd-jwt/crypto-nodejs'), jose: versiune('jose'), '@owf/identity-common': versiune('@owf/identity-common'), node: process.version };
console.log('biblioteci: ' + JSON.stringify(biblioteci));
// ---------------------------------------------------------------- partea LOR: chei WebCrypto prin jose, semnare WebCrypto, verificare jose
const subtleParams = { EdDSA: { name: 'Ed25519' }, ES256: { name: 'ECDSA', hash: 'SHA-256' }, 'ML-DSA-65': { name: 'ML-DSA-65' } };
async function cheiLor(alg) {
const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: true });
const jwk = await jose.exportJWK(publicKey); if (alg === 'ML-DSA-65') jwk.alg = jwk.alg ?? 'ML-DSA-65';
return { publicKey, privateKey, jwk, alg };
}
const semnatorLor = (k) => async (data) => b64u(Buffer.from(await crypto.subtle.sign(subtleParams[k.alg], k.privateKey, Buffer.from(data, 'ascii'))));
let ultimaJose = '';
async function verificatorJose(jwk, alg) {
const key = await jose.importJWK(jwk, alg);
return async (data, sig) => { try { await jose.compactVerify(data + '.' + sig, key, { algorithms: [alg] }); return true; } catch (e) { ultimaJose = e.message; return false; } };
}
const kbVerificatorJose = async (data, sig, payload) => {
try { const h = dec(data.split('.')[0]); const key = await jose.importJWK(payload.cnf.jwk, payload.cnf.jwk.alg ?? h.alg); await jose.compactVerify(data + '.' + sig, key, { algorithms: [h.alg] }); return true; } catch (e) { ultimaJose = e.message; return false; }
};
const lor = (cfg) => new core.SDJwtInstance({ hasher: cn.digest, saltGenerator: cn.generateSalt, hashAlg: 'sha-256', kbVerifier: kbVerificatorJose, ...cfg });
async function lorVerifica(sd, { issuerJwk, alg, optiuni = {} }) {
const inst = lor({ verifier: await verificatorJose(issuerJwk, alg) });
try { const r = await inst.verify(sd, { currentDate: ACUM, ...optiuni }); return { valid: true, payload: r.payload, kb: r.kb ? r.kb.payload : null }; } catch (e) { return { valid: false, reason: e.message + (/Signature/.test(e.message) && ultimaJose ? ' [jose: ' + ultimaJose + ']' : ''), code: e.code ?? null }; }
}
async function lorEmite({ chei, holder, claims, frame, header, hashAlg }) {
const inst = lor({ signer: semnatorLor(chei), signAlg: chei.alg, ...(hashAlg ? { hashAlg } : {}) });
return inst.issue({ ...claims, cnf: { jwk: holder.jwk } }, frame, header ? { header } : undefined);
}
async function lorPrezinta(sd, frame, { holder, aud = AUD, nonce = NONCE }) {
const inst = lor({ kbSigner: semnatorLor(holder), kbSignAlg: holder.alg });
return inst.present(sd, frame, { kb: { payload: { aud, nonce, iat: CEAS } } });
}
// ---------------------------------------------------------------- partea NOASTRA
const LA = new Date(ACUM * 1000);
const noiVerifica = (sd, { issuerKey, alg, optiuni = {} }) => J.verifySdJwt(sd, { issuerKey, issuerAlg: alg, audience: AUD, nonce: NONCE, now: LA, ...optiuni });
const CL = { given_name: 'Ana', family_name: 'Pop', age_over_18: true, nationalities: ['RO', 'DE'], address: { street_address: 'Str. Lunga 1', locality: 'Cluj', country: 'RO' } };
function cheiNoi(alg) {
if (alg === 'ES256') { const k = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); return { priv: k.privateKey, pub: k.publicKey, jwk: J.publicJwk(k.publicKey), alg }; }
const k = I.generateKeys(); return { priv: k, pub: k.public, jwk: J.publicJwk(k, alg), alg };
}
const noiEmite = ({ iss, hol, alg, extra = {} }) => J.issueSdJwt({ issuer: iss.priv, holder: hol.pub, claims: CL, disclosable: ['given_name', 'family_name', 'age_over_18'], arrayDisclosable: ['nationalities'], iss: 'https://issuer.aere.example', iat: CEAS, exp: CEAS + 365 * 86400, alg, holderAlg: hol.alg, decoys: 2, ...extra });
const noiPrezinta = (sd, reveal, hol, extra = {}) => J.presentSdJwt({ sdJwt: sd, reveal, holder: hol.priv, holderAlg: hol.alg, audience: AUD, nonce: NONCE, iat: CEAS, ...extra });
const jwtDin = (antet, corp, k) => { const cap = b64u(JSON.stringify(antet)) + '.' + b64u(JSON.stringify(corp)); const date = Buffer.from(cap, 'ascii'); return cap + '.' + b64u(antet.alg === 'ES256' ? crypto.sign('sha256', date, { key: k, dsaEncoding: 'ieee-p1363' }) : crypto.sign(null, date, k)); };
const sha = (d) => crypto.createHash('sha256').update(d).digest('base64url');
// ---------------------------------------------------------------- randurile tabelului
const randuri = [], fixturi = [];
let divergente = 0;
// asteptat: { noi: 'VALID'|'REFUZ', lor: 'VALID'|'REFUZ', motivNoi?: RegExp, motivLor?: RegExp, continut?: (payloadNoi, payloadLor) => string|null }
function rand({ sens, caz, noi, lor: l, asteptat, nota = '' }) {
const vN = noi.valid ? 'VALID' : 'REFUZ', vL = l.valid ? 'VALID' : 'REFUZ';
const probleme = [];
if (vN !== asteptat.noi) probleme.push(`noi: ${vN} in loc de ${asteptat.noi} (${taie(noi.reason)})`);
if (vL !== asteptat.lor) probleme.push(`lor: ${vL} in loc de ${asteptat.lor} (${taie(l.reason)})`);
if (asteptat.motivNoi && !noi.valid && !asteptat.motivNoi.test(noi.reason || '')) probleme.push(`motivul nostru nu e cel asteptat: ${taie(noi.reason)}`);
if (asteptat.motivLor && !l.valid && !asteptat.motivLor.test(l.reason || '')) probleme.push(`motivul lor nu e cel asteptat: ${taie(l.reason)}`);
if (asteptat.continut && noi.valid && l.valid) { const m = asteptat.continut(noi.payload, l.payload); if (m) probleme.push(m); }
const ok = probleme.length === 0; if (!ok) divergente++;
randuri.push({ sens, caz, asteptat: `${asteptat.noi}/${asteptat.lor}`, noi: vN + (noi.valid ? '' : ': ' + taie(noi.reason, 110)), lor: vL + (l.valid ? '' : ': ' + taie(l.reason, 110)), ok, probleme, nota });
console.log(` ${ok ? 'OK ' : 'DIVERGENT'} [${sens}] ${caz}\n noi: ${vN}${noi.valid ? '' : ' (' + taie(noi.reason, 120) + ')'}\n lor: ${vL}${l.valid ? '' : ' (' + taie(l.reason, 120) + ')'}${nota ? '\n nota: ' + nota : ''}${ok ? '' : '\n PROBLEME: ' + probleme.join('; ')}`);
}
const fixtura = (f) => fixturi.push(f);
// ================================================================ sensul NOI -> LOR (emitem si prezentam noi, verifica @sd-jwt/core)
const noiLor = async (alg) => {
const iss = cheiNoi(alg), hol = cheiNoi(alg);
const E = noiEmite({ iss, hol, alg });
const p = noiPrezinta(E.sdJwt, ['age_over_18', { element: 'RO', in: 'nationalities' }], hol);
const noi = noiVerifica(p, { issuerKey: iss.jwk, alg }), lorR = await lorVerifica(p, { issuerJwk: iss.jwk, alg, optiuni: { keyBindingNonce: NONCE, expectedKeyBindingAudience: AUD } });
rand({ sens: 'noi->lor', caz: `${alg}: prezentare cu age_over_18 + nationalities[RO], 2 momeli, KB(aud, nonce)`, noi, lor: lorR, asteptat: { noi: 'VALID', lor: 'VALID', continut: (pn, pl) => {
if (!pl || pl.age_over_18 !== true || 'given_name' in pl || !egal(pl.nationalities, ['RO']) || !egal(pl.address, CL.address) || pl.iss !== 'https://issuer.aere.example') return 'payload-ul lor nu e cel asteptat: ' + taie(JSON.stringify(pl), 200);
if (!egal(pn, pl)) return 'payload-urile difera: noi ' + taie(JSON.stringify(sortat(pn)), 150) + ' lor ' + taie(JSON.stringify(sortat(pl)), 150);
return null; } } });
return { iss, hol, E, p };
};
const nl = { EdDSA: await noiLor('EdDSA'), ES256: await noiLor('ES256'), 'ML-DSA-65': await noiLor('ML-DSA-65') };
{ // negative, sensul noi->lor: jetonul nostru stricat de noi, refuzat de AMANDOUA partile, cu motivul
const { iss, hol, E, p } = nl.EdDSA;
const verifAmbii = async (sd, optLor = {}) => [noiVerifica(sd, { issuerKey: iss.jwk, alg: 'EdDSA' }), await lorVerifica(sd, { issuerJwk: iss.jwk, alg: 'EdDSA', optiuni: { keyBindingNonce: NONCE, expectedKeyBindingAudience: AUD, ...optLor } })];
// (a) o dezvaluire cu valoarea schimbata
const parti = noiPrezinta(E.sdJwt, ['given_name'], hol).split('~'); const c = dec(parti[1]); c[2] = 'Maria';
let [n, l] = await verifAmbii([parti[0], b64u(JSON.stringify(c)), ''].join('~'), { keyBindingNonce: undefined });
rand({ sens: 'noi->lor', caz: 'NEGATIV: dezvaluirea given_name cu valoarea schimbata (fara KB)', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /not referenced/, motivLor: /Unreferenced disclosure/ } });
// (b) KB facut pentru alt verificator
const altAud = noiPrezinta(E.sdJwt, ['age_over_18'], hol, { audience: 'https://other.example' });
[n, l] = await verifAmbii(altAud);
rand({ sens: 'noi->lor', caz: 'NEGATIV: KB pentru alt aud (verificatorul cere al lui)', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /made for/, motivLor: /Invalid Key Binding audience/ } });
const lFaraAud = await lorVerifica(altAud, { issuerJwk: iss.jwk, alg: 'EdDSA', optiuni: { keyBindingNonce: NONCE } });
rand({ sens: 'noi->lor', caz: 'DIFERENTA: acelasi KB pentru alt aud, cand verificatorul LOR nu da expectedKeyBindingAudience', noi: n, lor: lFaraAud, asteptat: { noi: 'REFUZ', lor: 'VALID' }, nota: 'al lor: @sd-jwt/core compara aud-ul KB numai daca i se cere (RFC 9901 s.7.3 pasul 5.f cere legarea de verificator); la noi aud si nonce sunt cerute mereu' });
// (c) alg none
const corp = dec(E.sdJwt.split('~')[0].split('.')[1]);
const none = b64u(JSON.stringify({ alg: 'none', typ: J.TYP })) + '.' + b64u(JSON.stringify(corp)) + '.' + '~';
[n, l] = await verifAmbii(none, { keyBindingNonce: undefined });
rand({ sens: 'noi->lor', caz: 'NEGATIV: alg none', noi: { ...n, valid: n.valid }, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /not accepted/, motivLor: /"none" is not allowed/ } });
// (d) acelasi digest de doua ori in _sd (jeton facut de mana, semnat cu cheia noastra)
const d = b64u(JSON.stringify(['salt-1', 'a', 1])), dg = sha(d);
const dublu = jwtDin({ alg: 'EdDSA', typ: J.TYP }, { _sd: [dg, dg], _sd_alg: 'sha-256', iss: 'x', iat: CEAS, exp: CEAS + 1000 }, iss.priv.privat.ed25519) + '~' + d + '~';
[n, l] = await verifAmbii(dublu, { keyBindingNonce: undefined });
rand({ sens: 'noi->lor', caz: 'NEGATIV: acelasi digest de doua ori in _sd', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /more than once/, motivLor: /Duplicate digest/ } });
// (e) KB semnat de alta cheie decat cnf.jwk
const strain = cheiNoi('EdDSA');
[n, l] = await verifAmbii(noiPrezinta(E.sdJwt, ['age_over_18'], strain));
rand({ sens: 'noi->lor', caz: 'NEGATIV: KB semnat de alta cheie decat cnf.jwk', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /key binding: the signature does not verify/, motivLor: /Invalid JWT Signature/ } });
// (f) _sd_alg imbricat (jeton facut de mana)
const imb = jwtDin({ alg: 'EdDSA', typ: J.TYP }, { iss: 'x', iat: CEAS, exp: CEAS + 1000, _sd_alg: 'sha-256', meta: { _sd_alg: 'sha-256', note: 1 } }, iss.priv.privat.ed25519) + '~';
[n, l] = await verifAmbii(imb, { keyBindingNonce: undefined });
rand({ sens: 'noi->lor', caz: 'NEGATIV: _sd_alg imbricat (RFC 9901 s.4.1.1 MUST NOT)', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi: /_sd_alg below the top level/, motivLor: /Nested _sd_alg/ }, nota: 'reparat la noi pe 2026-10-18: pana atunci _sd_alg imbricat era pastrat ca afirmatie' });
// (g) prezentarea cu KB, dar verificatorul LOR fara nonce: KB-ul nu e judecat deloc
const lFaraNonce = await lorVerifica(p, { issuerJwk: iss.jwk, alg: 'EdDSA', optiuni: {} });
rand({ sens: 'noi->lor', caz: 'DIFERENTA: prezentarea cu KB, verificatorul LOR fara keyBindingNonce (si fara cerinta de KB)', noi: J.verifySdJwt(p, { issuerKey: iss.jwk, now: LA }), lor: lFaraNonce, asteptat: { noi: 'REFUZ', lor: 'VALID' }, nota: 'al lor: fara keyBindingNonce @sd-jwt/core nu judeca KB-ul prezent si nu il cere; la noi fara aud+nonce o prezentare cu KB e refuzata (RFC 9901 s.7.3)' });
// (h) typ: al nostru e explicit (aere+sd-jwt); ei nu verifica typ-ul (RFC 9901 s.9.11 RECOMMENDED pentru verificatori)
const tipJwt = jwtDin({ alg: 'EdDSA', typ: 'JWT' }, corp, iss.priv.privat.ed25519) + '~';
[n, l] = await verifAmbii(tipJwt, { keyBindingNonce: undefined });
rand({ sens: 'noi->lor', caz: 'DIFERENTA: jetonul emitentului cu typ JWT (ne-explicit)', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'VALID', motivNoi: /typ/ }, nota: 'al lor: @sd-jwt/core nu verifica typ-ul jetonului emitentului; RFC 9901 s.9.11 RECOMANDA ca verificatorii sa il verifice; la noi expectedTyp:null il lasa' });
}
// ================================================================ sensul LOR -> NOI (emite si prezinta @sd-jwt/core, verificam noi)
const claimsLor = { iss: 'https://issuer.lor.example', iat: CEAS, exp: CEAS + 365 * 86400, given_name: 'Ana', family_name: 'Pop', age_over_18: true, nationalities: ['RO', 'DE'], address: { street_address: 'Str. Lunga 1', locality: 'Cluj', country: 'RO' } };
const frameLor = { _sd: ['given_name', 'family_name', 'age_over_18', 'address'], address: { _sd: ['street_address', 'locality'] }, nationalities: { _sd: [0, 1] }, _sd_decoy: 2 };
const prezFrame = { age_over_18: true, address: { locality: true }, nationalities: { 0: true } };
const asteptatLorNoi = (pl, issJwk) => {
if (pl.age_over_18 !== true || 'given_name' in pl || 'family_name' in pl) return 'afirmatiile ascunse/aratate nu sunt cele asteptate: ' + taie(JSON.stringify(pl), 200);
if (!egal(pl.address, { locality: 'Cluj', country: 'RO' }) || !egal(pl.nationalities, ['RO'])) return 'dezvaluirile imbricate/de lista nu sunt cele asteptate: ' + taie(JSON.stringify(pl), 200);
if (pl.iss !== 'https://issuer.lor.example' || !pl.cnf || !pl.cnf.jwk) return 'iss/cnf lipsesc';
return null;
};
const lorNoi = async (alg, { header, hashAlg, nume } = {}) => {
const iss = await cheiLor(alg), hol = await cheiLor(alg);
const sd = await lorEmite({ chei: iss, holder: hol, claims: claimsLor, frame: frameLor, header, hashAlg });
const p = await lorPrezinta(sd, prezFrame, { holder: hol });
const lorR = await lorVerifica(p, { issuerJwk: iss.jwk, alg, optiuni: { keyBindingNonce: NONCE, expectedKeyBindingAudience: AUD } });
const optiuni = header ? {} : { expectedTyp: null };
const noi = noiVerifica(p, { issuerKey: iss.jwk, alg, optiuni });
return { iss, hol, sd, p, lorR, noi, optiuni };
};
const ln = {};
for (const alg of ['EdDSA', 'ES256', 'ML-DSA-65']) {
const r = await lorNoi(alg);
ln[alg] = r;
rand({ sens: 'lor->noi', caz: `${alg}: dezvaluiri imbricate (address.locality), element de lista (nationalities[0]), 2 momeli, KB(aud, nonce); verificat cu expectedTyp:null`, noi: r.noi, lor: r.lorR, asteptat: { noi: 'VALID', lor: 'VALID', continut: (pn, pl) => asteptatLorNoi(pn) || (egal(pn, pl) ? null : 'payload-urile difera: noi ' + taie(JSON.stringify(sortat(pn)), 150) + ' lor ' + taie(JSON.stringify(sortat(pl)), 150)) } });
if (r.noi.valid) fixtura({ nume: `${alg}: prezentare @sd-jwt/core cu dezvaluiri imbricate, element de lista, momeli si KB (fara typ)`, alg, issuerJwk: r.iss.jwk, sdJwt: r.sd, prezentare: r.p, optiuni: r.optiuni, asteptat: { valid: true, payload: r.noi.payload, disclosed: [...r.noi.disclosed].sort() } });
}
{ // typ-ul: jetonul LOR implicit nu are typ; cu header {typ} explicit, verificatorul nostru implicit il primeste
const r = ln.EdDSA;
const implicitTyp = noiVerifica(r.p, { issuerKey: r.iss.jwk, alg: 'EdDSA' });
rand({ sens: 'lor->noi', caz: 'DIFERENTA: acelasi jeton LOR, verificat cu tipul implicit cerut de noi (orice ...+sd-jwt)', noi: implicitTyp, lor: r.lorR, asteptat: { noi: 'REFUZ', lor: 'VALID', motivNoi: /typ/ }, nota: 'al lor: SDJwtInstance nu pune typ implicit (SDJwtVcInstance pune dc+sd-jwt); RFC 9901 s.9.11: tipul explicit e RECOMMENDED; la noi se primeste cu expectedTyp:null sau cu typ dat la emitere' });
const antetLor = dec(r.sd.split('.')[0]); if ('typ' in antetLor) throw new Error('presupunerea "core nu pune typ" e falsa: ' + JSON.stringify(antetLor));
const t = await lorNoi('EdDSA', { header: { typ: 'example+sd-jwt' } });
rand({ sens: 'lor->noi', caz: 'EdDSA: jetonul LOR emis cu header typ example+sd-jwt, verificat cu tipul implicit al nostru', noi: t.noi, lor: t.lorR, asteptat: { noi: 'VALID', lor: 'VALID', continut: (pn) => asteptatLorNoi(pn) } });
if (t.noi.valid) fixtura({ nume: 'EdDSA: prezentare @sd-jwt/core cu typ example+sd-jwt (tipul implicit al verificatorului nostru)', alg: 'EdDSA', issuerJwk: t.iss.jwk, sdJwt: t.sd, prezentare: t.p, optiuni: {}, asteptat: { valid: true, payload: t.noi.payload, disclosed: [...t.noi.disclosed].sort() } });
// _sd_alg sha-384: ei il emit, noi il refuzam (RFC 9901 s.4.1.1: sha-256 MUST; restul e politica)
const s3 = await lorNoi('EdDSA', { hashAlg: 'sha-384' });
rand({ sens: 'lor->noi', caz: 'DIFERENTA: jetonul LOR cu _sd_alg sha-384', noi: s3.noi, lor: s3.lorR, asteptat: { noi: 'REFUZ', lor: 'VALID', motivNoi: /not understood/ }, nota: 'a noastra, dinadins: verificatorul nostru stie numai sha-256 (RFC 9901 s.4.1.1: sha-256 MUST be supported; alte functii sunt politica); @sd-jwt/core accepta lista IANA fara formele trunchiate' });
if (!s3.noi.valid) fixtura({ nume: 'EdDSA: jetonul @sd-jwt/core cu _sd_alg sha-384 (la noi: refuzat, numai sha-256)', alg: 'EdDSA', issuerJwk: s3.iss.jwk, sdJwt: s3.sd, prezentare: s3.p, optiuni: { expectedTyp: null }, asteptat: { valid: false, motiv: 'not understood' } });
// sarea: cati biti de entropie pune generatorul LOR de sare, fata de al nostru (RFC 9901 s.9.3: RECOMMENDED minim 128 de biti)
const sareLor = dec(r.sd.split('~')[1])[0], sareNoi = dec(nl.EdDSA.E.sdJwt.split('~')[1])[0];
const bitiLor = /^[0-9a-f]+$/.test(sareLor) ? sareLor.length * 4 : Buffer.from(sareLor, 'base64url').length * 8, bitiNoi = Buffer.from(sareNoi, 'base64url').length * 8;
rand({ sens: 'lor->noi', caz: `DIFERENTA: sarea dezvaluirii: lor ${sareLor.length} caractere (${bitiLor} biti), noi ${sareNoi.length} caractere (${bitiNoi} biti)`, noi: { valid: bitiNoi >= 128 }, lor: { valid: bitiLor >= 128, reason: `${bitiLor} biti sub cei 128 RECOMANDATI (RFC 9901 s.9.3)` }, asteptat: { noi: 'VALID', lor: bitiLor >= 128 ? 'VALID' : 'REFUZ' }, nota: 'al lor: generateSalt(16) din @sd-jwt/crypto-nodejs da 16 caractere hexa = 64 de biti; generatorul de sare e injectabil, deci un emitent poate pune unul de 128 de biti; verificatorii nu judeca lungimea sarii' });
}
{ // negative, sensul lor->noi: jetonul LOR stricat, refuzat de AMANDOUA partile, cu motivul; fiecare intra in fixturi cu motivul nostru
const { iss, hol, sd, p } = ln.EdDSA;
const ambii = async (jeton, optLor = { keyBindingNonce: NONCE, expectedKeyBindingAudience: AUD }) => [noiVerifica(jeton, { issuerKey: iss.jwk, alg: 'EdDSA', optiuni: { expectedTyp: null } }), await lorVerifica(jeton, { issuerJwk: iss.jwk, alg: 'EdDSA', optiuni: optLor })];
const neg = async (nume, jeton, motivNoi, motivLor, optLor) => {
const [n, l] = await ambii(jeton, optLor);
rand({ sens: 'lor->noi', caz: 'NEGATIV: ' + nume, noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: 'REFUZ', motivNoi, motivLor } });
if (!n.valid && motivNoi.test(n.reason)) fixtura({ nume: 'NEGATIV: ' + nume, alg: 'EdDSA', issuerJwk: iss.jwk, prezentare: jeton, optiuni: { expectedTyp: null }, asteptat: { valid: false, motiv: motivNoi.source } });
};
// (a) valoarea unei dezvaluiri schimbata, in prezentarea LOR (fara KB ca sa nu cada intai pe sd_hash)
const pFaraKb = await lor({}).present(sd, { age_over_18: true });
const parti = pFaraKb.split('~'); const c = dec(parti[1]); c[2] = false;
await neg('dezvaluirea age_over_18 cu valoarea schimbata (fara KB)', [parti[0], b64u(JSON.stringify(c)), ''].join('~'), /not referenced/, /Unreferenced disclosure/, {});
// (b) KB pentru alt aud
await neg('KB pentru alt aud', await lorPrezinta(sd, prezFrame, { holder: hol, aud: 'https://other.example' }), /made for/, /Invalid Key Binding audience/);
// (c) alg none peste payload-ul LOR
const corpLor = dec(sd.split('~')[0].split('.')[1]);
await neg('alg none', b64u(JSON.stringify({ alg: 'none' })) + '.' + b64u(JSON.stringify(corpLor)) + '.' + '~', /not accepted/, /"none" is not allowed/, {});
// (d) acelasi digest de doua ori: payload-ul LOR cu primul digest din _sd dublat, re-semnat de semnatorul LOR
const dublu = { ...corpLor, _sd: [...corpLor._sd, corpLor._sd[0]] };
const capD = b64u(JSON.stringify(dec(sd.split('.')[0]))) + '.' + b64u(JSON.stringify(dublu));
await neg('acelasi digest de doua ori in _sd (re-semnat de emitentul LOR)', capD + '.' + await semnatorLor(iss)(capD) + '~' + sd.split('~').slice(1).join('~'), /more than once/, /Duplicate digest/, {});
// (e) KB semnat de alta cheie decat cnf.jwk
await neg('KB semnat de alta cheie decat cnf.jwk', await lorPrezinta(sd, prezFrame, { holder: await cheiLor('EdDSA') }), /key binding: the signature does not verify/, /Invalid JWT Signature/);
// (f) o dezvaluire scoasa dupa semnarea KB
const pp = p.split('~'); const kb = pp.pop(); const scoasa = [...pp.slice(0, -1), kb].join('~');
await neg('o dezvaluire scoasa din prezentare dupa semnarea KB', scoasa, /sd_hash/, /sd_hash/);
}
{ // sigiliul post-cuantic in ambele sensuri a fost in randurile ML-DSA-65 de mai sus; aici, ce NU merge: cheia ML-DSA-65 fara `alg` in JWK
const r = ln['ML-DSA-65'];
const faraAlg = { ...r.iss.jwk }; delete faraAlg.alg;
const n = noiVerifica(r.p, { issuerKey: faraAlg, alg: 'ML-DSA-65', optiuni: { expectedTyp: null } });
let l; try { await jose.importJWK(faraAlg, 'ML-DSA-65'); l = { valid: true }; } catch (e) { l = { valid: false, reason: 'jose.importJWK: ' + e.message }; }
rand({ sens: 'lor->noi', caz: 'ML-DSA-65: cheia AKP FARA parametrul alg (RFC 9964 s.3 il cere)', noi: n, lor: l, asteptat: { noi: 'REFUZ', lor: l.valid ? 'VALID' : 'REFUZ' }, nota: 'masurat: ' + (l.valid ? 'jose o primeste cu alg dat la import' : 'jose o refuza') + '; node:crypto refuza JWK-ul AKP fara alg: ' + taie(n.reason, 80) });
}
// ---------------------------------------------------------------- rezumat si fisiere
const ok = randuri.filter((r) => r.ok).length;
console.log(`\ninterop-sdjwt: ${ok}/${randuri.length} randuri cu verdictele asteptate de amandoua partile${divergente ? `, ${divergente} DIVERGENTE` : ''}`);
if (SCRIE) {
const generat = new Date().toISOString();
const F = { generat, unealta: 'tools/aere-identity/interop/interop-sdjwt.mjs --scrie', nota: 'AERE-SINTETIC: jetoane emise de @sd-jwt/core (semnate prin WebCrypto, verificate de jose) cu chei de PROBA nascute la generare; numai cheile publice sunt aici. Judecate offline de proba-sdjwt.mjs la ceasVerificare.', biblioteci, ceas: CEAS, ceasVerificare: ACUM, audience: AUD, nonce: NONCE, cazuri: fixturi };
fs.writeFileSync(path.join(AICI, 'fixturi-interop-sdjwt.json'), JSON.stringify(F, null, 1) + '\n');
fs.writeFileSync(path.join(AICI, 'rezultate-interop-sdjwt.json'), JSON.stringify({ generat, biblioteci, ceas: CEAS, randuri: randuri.map(({ sens, caz, asteptat, noi, lor, ok, nota }) => ({ sens, caz, asteptat, noi, lor, ok, nota })) }, null, 1) + '\n');
console.log(`scrise: fixturi-interop-sdjwt.json (${fixturi.length} cazuri), rezultate-interop-sdjwt.json (${randuri.length} randuri)`);
}
return divergente ? 1 : 0;
}
main().then((c) => { process.exitCode = c; }, (e) => { console.log('STRICAT: ' + (e.stack || e)); process.exitCode = 2; });