aere-quantum/identity/control-negativ-sdjwt.mjs
Aere Network 4ffec8d7e3 identity: standard SD-JWT (IETF RFC 9901) from the same keys - issue, present with key binding, verify
sdjwt.mjs: an issuer-signed JWT with the digests of the disclosable claims (_sd, list elements as {"...": digest}, _sd_alg sha-256),
the holder's key in cnf.jwk and exp, followed by the disclosures; the holder keeps the ones it picks and adds a Key Binding JWT
(kb+jwt, aud, nonce, iat, sd_hash); the verifier follows RFC 9901 sections 7.1 and 7.3 with the issuer key it chose, and requires its
audience and nonce, exp, and an explicitly typed token. Algorithms: ES256, EdDSA (Ed25519, the key every AERE identity has) and
ML-DSA-65 (JWK type AKP, names from the IETF draft draft-ietf-cose-dilithium, not yet a published standard).

Checked against the standard's own vectors (fixturi-rfc9901.json, from RFC 9901 Section 5 and Appendix A.5): the ten example
disclosure digests, the example SD-JWT signed by someone else with the A.5 key, and the example presentation with key binding, which
gives exactly the processed payload printed in the RFC. Not checked against another SD-JWT library.

An adversarial review before publication found ten defects, all fixed with a test and a planted negative control each (among them:
the issuer signed SD-JWT structure coming in claim values; the holder revealed an element with the same value from any list; audience
and nonce were not required). Tests: SD-JWT 22/22, negative control 28/28; identity 44/44.
2026-09-30 12:37:17 +03:00

78 lines
7.4 KiB
JavaScript

// Controlul negativ al probei SD-JWT (sdjwt.mjs, proba-sdjwt.mjs): fiecare paznic se strica intr-o COPIE, proba ruleaza pe copie si proba
// NUMITA trebuie sa iasa rosie, cu proba chiar rulata; pe copia neatinsa, verde. Trei stari: un tipar care nu apare exact o data sau o
// proba care nu ajunge la rezumat e STRICAT si se numara esec.
// node control-negativ-sdjwt.mjs iesire 0 = martorul verde si toate plantarile rosii pe proba lor
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const S = 'sdjwt.mjs';
const PLANTARI = [
// [nume, tipar, inlocuire, proba (inceputul numelui ei)]
['alg none primit', 'if (!ALGS.includes(antet.alg)) throw', "if (antet.alg === 'nimic') throw", 'ATAC: alg none'],
['algoritmul nu mai trebuie sa fie al cheii (confuzia)', 'if (!potriveste(antet.alg, key)) throw', 'if (false) throw', 'ATAC: alg none'],
['o dezvaluire nelegata de niciun digest primita', 'if (folosite.size !== dupaDigest.size) return', 'if (false) return', 'ATAC: o dezvaluire cu valoarea schimbata'],
['aceeasi dezvaluire de doua ori primita', "if (dupaDigest.has(dg)) return refuz('the same disclosure is given twice');", '', 'ATAC: o dezvaluire cu valoarea schimbata'],
['numele _sd, ... sau __proto__ primite', "if (c[1] === '_sd' || c[1] === '...' || c[1] === '__proto__') throw", 'if (false) throw', 'ATAC: un emitent care pune in _sd'],
['o afirmatie dezvaluita peste una in clar primita', 'if (Object.hasOwn(out, c[1])) throw', 'if (false) throw', 'ATAC: un emitent care pune in _sd'],
['acelasi digest de doua ori primit', 'const vezi = (dg) => { if (vazute.has(dg)) throw', 'const vezi = (dg) => { if (false) throw', 'ATAC: un emitent care pune in _sd'],
['sd_hash nu se mai compara', 'if (sha256b64u(baza) !== K.sd_hash) return', 'if (false) return', 'RFC 9901: o dezvaluire scoasa'],
['tipul KB (kb+jwt) nu se mai cere', "k = verificaJws(kb, payload.cnf.jwk, { typ: 'kb+jwt' });", 'k = verificaJws(kb, payload.cnf.jwk, { typ: null });', 'ATAC: KB semnat de alta cheie'],
['publicul KB nu se mai compara', 'if (K.aud !== audience) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['nonce-ul KB nu se mai compara', 'if (K.nonce !== nonce) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['KB vechi primit', 'if (Math.abs(acum - K.iat) > maxAgeS) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['exp nu se mai cere', 'if (payload.exp !== undefined && acum >= payload.exp) return', 'if (false) return', 'timpul'],
['fara toleranta de ceas pe nbf', 'if (payload.nbf !== undefined && payload.nbf - clockSkewS > acum) return', 'if (payload.nbf !== undefined && payload.nbf > acum) return', 'timpul'],
['Key Binding cerut, dar lipsa primita', "if (requireKeyBinding) return refuz('key binding is required", "if (false) return refuz('key binding is required", 'RFC 9901: SD-JWT-ul standardului cu ALTA cheie'],
// revizuirea adversariala din 30 sept: forma de dinainte a fiecarei reparatii
['emitentul semneaza structura SD-JWT din valori', 'if (areStructuraSd(claims)) throw', 'if (false) throw', 'REVIZUIRE: emitentul refuza structura'],
['prezentarea arata elementul din orice lista', 'obiect(r) && r.in === l.lista && c.length === 2', 'obiect(r) && c.length === 2', 'REVIZUIRE: prezentarea arata elementul'],
['publicul si nonce-ul nu se mai cer', "if ((requireKeyBinding || kb !== '') && (typeof audience !== 'string'", "if (false && (typeof audience !== 'string'", 'REVIZUIRE: fara publicul si nonce-ul'],
['exp nu se mai cere implicit', 'if (requireExp && payload.exp === undefined) return', 'if (false) return', 'REVIZUIRE: exp cerut implicit'],
['tipul jetonului nu se mai verifica implicit', "if (cerut === undefined) return typeof typ === 'string' && /(^|\\+)sd-jwt$/.test(typ);", 'if (cerut === undefined) return true;', 'REVIZUIRE: tipul jetonului'],
['expectedIssuer nu se mai compara', 'if (expectedIssuer != null && corp.iss !== expectedIssuer) return', 'if (false) return', 'REVIZUIRE: tipul jetonului'],
['crit primit', "if (Object.hasOwn(antet, 'crit')) throw", 'if (false) throw', 'REVIZUIRE: crit'],
['base64url necanonic primit', "if (b.toString('base64url') !== s) throw", 'if (false) throw', 'REVIZUIRE: crit'],
['exp-ul KB nu se mai cere', 'if (K.exp !== undefined && (!Number.isFinite(K.exp) || acum >= K.exp)) return', 'if (false) return', 'REVIZUIRE: crit'],
['_sd_alg scos de la orice nivel', "!(sus && k === '_sd_alg')", "k !== '_sd_alg'", 'REVIZUIRE: crit'],
['cheia publica Node nu mai e primita', "(k.type === 'public' ? k : crypto.createPublicKey(k))", 'crypto.createPublicKey(k)', 'REVIZUIRE: cheia emitentului'],
['o afirmatie __proto__ primita (prototipul schimbat)', "if (Object.hasOwn(x, '__proto__')) throw", 'if (false) throw', 'ATAC: o afirmatie numita __proto__'],
['elementele de lista nedezvaluite pastrate', 'if (!c) continue; // nedezvaluit: elementul se scoate', "if (!c) { out.push(e); continue; }", 'AERE -> SD-JWT (EdDSA'],
];
function copie() {
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-sdjwt-ctl-'));
for (const f of [S, 'identity.mjs', 'proba-sdjwt.mjs', 'fixturi-rfc9901.json']) fs.copyFileSync(path.join(AICI, f), path.join(t, f));
return t;
}
function ruleaza(t) {
return new Promise((resolve) => {
const c = spawn(process.execPath, [path.join(t, 'proba-sdjwt.mjs')]); let out = '';
const ceas = setTimeout(() => c.kill(), 180000);
c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { out += x; });
c.on('close', (cod) => { clearTimeout(ceas); resolve({ cod, rulat: /aere-sd-jwt: \d+\/\d+/.test(out), rosii: out.split('\n').filter((l) => l.startsWith(' RAU ')) }); });
});
}
async function planteaza([nume, din, inl, tinta]) {
const t = copie();
try {
const f = path.join(t, S); const src = fs.readFileSync(f, 'utf8');
if (src.split(din).length !== 2) return [false, ` STRICAT ${nume}: tiparul apare de ${src.split(din).length - 1} ori`];
fs.writeFileSync(f, src.replace(din, inl));
const r = await ruleaza(t);
if (!r.rulat) return [false, ` STRICAT ${nume}: proba nu a ajuns la rezumat (cod ${r.cod})`];
if (r.rosii.some((l) => l.startsWith(' RAU ' + tinta))) return [true, ` ROSU cum trebuia ${nume} (proba '${tinta}' pica)`];
return [false, ` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde (${r.rosii.length} rosii altundeva)`];
} finally { fs.rmSync(t, { recursive: true, force: true }); }
}
let rele = 0;
const t0 = copie(); const m = await ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true });
if (m.rulat && m.cod === 0 && !m.rosii.length) console.log(' OK martorul: copia neatinsa verde'); else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.rosii.length} rosii)`); }
const rez = new Array(PLANTARI.length); let i = 0;
await Promise.all(Array.from({ length: 4 }, async () => { while (i < PLANTARI.length) { const k = i++; rez[k] = await planteaza(PLANTARI[k]); } }));
for (const [bun, linie] of rez) { console.log(linie); if (!bun) rele++; }
console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${PLANTARI.length} din ${PLANTARI.length} paznici scosi -> proba lor rosie`);
process.exitCode = rele ? 1 : 0;