// test/control-negativ.mjs - controlul negativ al probei Aere PQ KMS. // // O proba care nu poate iesi rosie nu masoara nimic. Aici se planteaza, intr-o COPIE a codului, // cate un defect real, si se cere ca proba sa iasa ROSIE pe exact probele numite, pentru // MOTIVUL numit (un fragment din mesajul afirmatiei care a cazut), nu doar "ceva a picat". // // Reguli, platite in alte parti ale casei: // - verdictul se citeste din JSON-ul probei (AERE_KMS_PROBA_JSON), nu din codul de iesire; // o proba care nu a ajuns la capat sau a rulat alt numar de probe e STRICAT, nu ROSU; // - copia se reface pentru FIECARE plantare si se compara sha256 cu originalul inainte de a // planta (altfel s-ar masura plantarea de dinainte); // - ancora unei plantari trebuie sa apara EXACT o data; o plantare care nu se poate pune e un // ESEC al controlului, nu o trecere; // - plantarile se fac cu o conditie falsa la RULARE sau cu o inlocuire care ramane cod valid, // ca rosul sa vina din proba, nu din incarcarea modulului; // - la sfarsit originalul trebuie sa fie identic octet cu octet (sha256). // // Iesire: 0 numai daca proba e VERDE pe copia neatinsa, fiecare plantare e ROSIE pe probele // numite si originalul e neatins; altfel 1. // // --autoproba: controlul controlului. Ruleaza momeli (ancora inexistenta, plantare fara efect, // motiv gresit, proba inexistenta, modul care nu se incarca) si cere ca fiecare sa primeasca // verdictul ei de ESEC. Un control care nu a fost vazut esuand nu se poate crede. import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const RADACINA = path.resolve(HERE, '..'); // 2026-09-28: server.mjs importa si hsm-radacina.mjs (radacina sigilata de HSM); fara el copia nu mai pornea si linia de baza iesea STRICATA const FISIERE = ['kms.mjs', 'server.mjs', 'hsm-radacina.mjs', 'test/proba.mjs']; const NICIODATA = "process.env.AERE_KMS_PLANTA_NICIODATA === 'da'"; // Fiecare plantare: fisierul, ancora (exact o aparitie), inlocuirea, si probele care TREBUIE sa // iasa rosii, fiecare cu un fragment din motivul pe care trebuie sa cada. const PLANTARI = [ { id: 'P16', defect: 'A4: mutatia ramane pe disc cand randul de audit nu se poate scrie (rollback-ul nu se mai face)', fisier: 'kms.mjs', ancora: ' if (!changed) return;', inlocuire: " if (!changed || !(process.env.AERE_KMS_PLANTA_NICIODATA === 'da')) return;", rosii: [ ['audit: o mutatie al carei rand de audit nu se poate scrie NU ramane pe disc (rotire, minim, creare), iar lantul ramane valid', 'rotirea a ramas pe disc fara rand de audit'], ], }, { id: 'P01', defect: 'KDF fara secretul ML-KEM (IKM = numai X25519)', fisier: 'kms.mjs', ancora: 'const ikm = Buffer.concat([ssK, ssX]);', inlocuire: 'const ikm = Buffer.concat([ssX]);', rosii: [ ['criptare: atacatorul cu doar secretul X25519 nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul X25519'], ['interop: plicul serviciului decapsulat si decriptat manual cu node:crypto', 'commit'], ['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-x: trebuia refuzat'], ], }, { id: 'P02', defect: 'KDF fara secretul X25519 (IKM = numai ML-KEM)', fisier: 'kms.mjs', ancora: 'const ikm = Buffer.concat([ssK, ssX]);', inlocuire: 'const ikm = Buffer.concat([ssK]);', rosii: [ ['criptare: atacatorul cu doar secretul ML-KEM nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul ML-KEM'], ['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-k: trebuia refuzat'], ], }, { id: 'P03', defect: 'verificarea accepta o singura semnatura (SAU in loc de SI)', fisier: 'kms.mjs', ancora: 'const valid = edOk && pqOk;', inlocuire: 'const valid = edOk || pqOk;', rosii: [ ['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false'], ['semnare: jumatatea ML-DSA-65 stricata e refuzata cu PQ_SIGNATURE_INVALID', 'valid: asteptat false'], ['semnare: jumatati amestecate din doua semnaturi valide sunt refuzate', 'amestec peste "unu": asteptat false'], ], }, { id: 'P04', defect: 'AAD ignorat (inlocuit cu sirul gol)', fisier: 'kms.mjs', ancora: "return toBytes(aad, 'aad', MAX_AAD);", inlocuire: 'return Buffer.alloc(0);', rosii: [ ['criptare: alt aad refuzat cu AAD_MISMATCH', 'alt aad: trebuia refuzat cu AAD_MISMATCH'], ['datakey: cheia clara si cea invelita corespund; forma doar-invelita nu intoarce cheia clara', 'cheia de date legata de aad'], ['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'rewrap cu alt aad'], ], }, { id: 'P05', defect: 'min_decryption_version ignorat', fisier: 'kms.mjs', ancora: 'if (ver < key.min_decryption_version) throw', inlocuire: `if (ver < key.min_decryption_version && ${NICIODATA}) throw`, rosii: [ ['rotire: sub min_decryption_version decriptarea e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: trebuia refuzat'], ['rotire: sub min_decryption_version verificarea semnaturii e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: asteptat false'], ['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'sub minim prin HTTP'], ], }, { id: 'P06', defect: 'jurnalul scris fara legatura cu randul anterior (prev constant)', fisier: 'kms.mjs', ancora: 'prev: this.#head.mac,', inlocuire: 'prev: ZERO_MAC,', rosii: [ ['audit: jurnalul neatins se verifica OK, un rand pe operatie, si refuzurile au motivul lor', 'jurnal neatins'], ['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'randul: asteptat 4'], ], }, { id: 'P07', defect: 'verificatorul jurnalului nu verifica legatura (seq/prev)', fisier: 'kms.mjs', ancora: 'if (row.seq !== seq + 1 || row.prev !== prev)', inlocuire: `if ((row.seq !== seq + 1 || row.prev !== prev) && ${NICIODATA})`, rosii: [ ['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'rand sters: asteptat false'], ['audit: randuri reordonate detectate cu AUDIT_CHAIN_BROKEN', 'reordonare: asteptat false'], ['audit: pornirea refuza un jurnal stricat cu AUDIT_CHAIN_INVALID', 'trebuia refuzat cu AUDIT_CHAIN_INVALID'], ], }, { id: 'P08', defect: 'verificatorul jurnalului nu verifica mac-ul randului', fisier: 'kms.mjs', ancora: "if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1);", inlocuire: `if (!macOk && ${NICIODATA}) return fail('AUDIT_ROW_MODIFIED', i + 1);`, rosii: [['audit: rand schimbat detectat cu AUDIT_ROW_MODIFIED', 'rand schimbat: asteptat false']], }, { id: 'P09', defect: 'cheia privata scrisa si necifrata langa sigiliu', fisier: 'kms.mjs', ancora: 'private_sealed: sealed,', inlocuire: "private_sealed: sealed, private_debug: Buffer.from(privJson).toString('base64'),", rosii: [['stocare: materialul privat nu apare in clar pe disc (cu controlul pozitiv al scanerului)', 'material privat gasit in clar']], }, { id: 'P10', defect: 'integritatea fisierului de cheie neverificata', fisier: 'kms.mjs', ancora: "if (!macOk) throw new KmsError('KEY_FILE_TAMPERED'", inlocuire: `if (!macOk && ${NICIODATA}) throw new KmsError('KEY_FILE_TAMPERED'`, rosii: [ ['stocare: min_decryption_version coborat pe disc refuzat cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'], ['stocare: cheie publica inlocuita pe disc refuzata cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'], ], }, { id: 'P11', defect: 'fara cheie radacina se genereaza una in tacere', fisier: 'kms.mjs', ancora: "if (s === '') throw new KmsError('ROOT_KEY_MISSING',", inlocuire: "if (s === '') return crypto.randomBytes(32); if (s === '') throw new KmsError('ROOT_KEY_MISSING',", rosii: [ ['radacina: lipsa cheii refuza pornirea cu ROOT_KEY_MISSING si nu creeaza nimic pe disc', 'trebuia refuzat cu ROOT_KEY_MISSING'], ['server: configurarea implicita asculta pe 127.0.0.1 si cere token si cheie radacina', 'fara cheie radacina: trebuia refuzat'], ['server: pornit din linia de comanda refuza fara AERE_KMS_ROOT_KEY si nu tipareste tokenul', 'codul de iesire'], ], }, { id: 'P12', defect: 'amprenta cheii/versiunii din plic neverificata', fisier: 'kms.mjs', ancora: 'if (!fp.equals(fpExpected)) {', inlocuire: `if (!fp.equals(fpExpected) && ${NICIODATA}) {`, rosii: [ ['criptare: alta cheie refuzata cu KEY_MISMATCH', 'motiv asteptat KEY_MISMATCH'], ['semnare: alta cheie refuzata cu KEY_MISMATCH, semnatura malformata cu MALFORMED_SIGNATURE', 'motivul: asteptat "KEY_MISMATCH"'], ], }, { id: 'P13', defect: 'rewrap intoarce si textul clar', fisier: 'kms.mjs', ancora: 'return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version };', inlocuire: "return { value: { ciphertext: r.ciphertext, version: r.version, plaintext: plaintext.toString('base64') }, version: r.version };", rosii: [ ['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'campurile raspunsului'], ['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'rewrap nu intoarce text clar'], ], }, { id: 'P14', defect: 'serverul lasa sa treaca o cerere fara Authorization', fisier: 'server.mjs', ancora: "if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED';", inlocuire: "if (typeof header !== 'string' || header === '') return null;", rosii: [ ['server: fara Authorization raspunde 401 UNAUTHENTICATED', 'fara token: asteptat 401'], ['server: pornit din linia de comanda asculta pe 127.0.0.1 si raspunde', 'cerere neautentificata'], ], }, { id: 'P15', defect: 'serverul ignora limita de marime a cererii', fisier: 'server.mjs', ancora: 'return n > maxBody;', inlocuire: 'return false;', rosii: [ ['server: cerere peste limita cu Content-Length raspunde 413 BODY_TOO_LARGE', 'peste limita: asteptat 413'], ['server: cerere peste limita fara Content-Length (chunked) raspunde 413 BODY_TOO_LARGE', 'peste limita, chunked: asteptat 413'], ], }, ]; // Momelile pentru --autoproba: controlul insusi trebuie sa poata iesi rosu. Fiecare momeala e o // plantare stricata dinadins si are verdictul pe care controlul TREBUIE sa i-l dea. const MOMELI = [ { id: 'M1', defect: 'ancora care nu exista in cod', fisier: 'kms.mjs', ancora: 'ACEASTA ANCORA NU EXISTA IN COD', inlocuire: 'x', rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']], asteptat: 'ESEC CONTROL', }, { id: 'M2', defect: 'plantare fara efect (un comentariu)', fisier: 'kms.mjs', ancora: "export const PREFIX = 'aerekms';", inlocuire: "export const PREFIX = 'aerekms'; // momeala fara efect", rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']], asteptat: 'VERDE (NEPRINS)', }, { id: 'M3', defect: 'defect real, dar motivul cerut nu e cel pe care cade proba', fisier: 'kms.mjs', ancora: 'const valid = edOk && pqOk;', inlocuire: 'const valid = edOk || pqOk;', rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'un motiv care nu apare niciodata']], asteptat: 'ROSU GRESIT', }, { id: 'M4', defect: 'proba numita nu exista', fisier: 'kms.mjs', ancora: 'const valid = edOk && pqOk;', inlocuire: 'const valid = edOk || pqOk;', rosii: [['o proba care nu exista', 'x']], asteptat: 'ESEC CONTROL', }, { id: 'M5', defect: 'plantare care rupe incarcarea modulului', fisier: 'kms.mjs', ancora: 'const valid = edOk && pqOk;', inlocuire: 'const valid = edOk && ;', rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false']], asteptat: 'STRICAT', }, ]; // --------------------------------------------------------------------------------------------- const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); const amprente = () => Object.fromEntries(FISIERE.map((f) => [f, sha(path.join(RADACINA, f))])); function pregatesteCopie(dest) { fs.rmSync(dest, { recursive: true, force: true }); for (const f of FISIERE) { const d = path.join(dest, f); fs.mkdirSync(path.dirname(d), { recursive: true }); fs.copyFileSync(path.join(RADACINA, f), d); } for (const f of FISIERE) { if (sha(path.join(dest, f)) !== sha(path.join(RADACINA, f))) throw new Error(`copia lui ${f} difera de original inainte de plantare`); } } function planteaza(dest, p) { const fp = path.join(dest, p.fisier); const text = fs.readFileSync(fp, 'utf8'); const bucati = text.split(p.ancora); if (bucati.length !== 2) return { ok: false, motiv: `ancora apare de ${bucati.length - 1} ori in ${p.fisier} (trebuie exact o data)` }; const nou = bucati.join(p.inlocuire); fs.writeFileSync(fp, nou); if (sha(fp) === sha(path.join(RADACINA, p.fisier))) return { ok: false, motiv: 'fisierul plantat e identic cu originalul' }; return { ok: true }; } function envCurat() { return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('AERE_KMS_'))); } function ruleaza(dest) { const out = path.join(dest, 'rezultat-proba.json'); fs.rmSync(out, { force: true }); const t0 = Date.now(); const r = spawnSync(process.execPath, [path.join(dest, 'test', 'proba.mjs')], { cwd: dest, env: { ...envCurat(), AERE_KMS_PROBA_JSON: out }, encoding: 'utf8', timeout: 300000, maxBuffer: 16 * 1024 * 1024, }); let json = null; try { json = JSON.parse(fs.readFileSync(out, 'utf8')); } catch { json = null; } return { json, status: r.status, signal: r.signal, ms: Date.now() - t0, stderr: String(r.stderr || '').slice(-600) }; } function main() { const autoproba = process.argv.includes('--autoproba'); const PL = autoproba ? MOMELI : PLANTARI; if (autoproba) console.log('AUTOPROBA: momelile de mai jos TREBUIE sa primeasca verdictul lor de esec\n'); const inainte = amprente(); const lucru = fs.mkdtempSync(path.join(os.tmpdir(), 'aerekms-control-')); const dest = path.join(lucru, 'copie'); const linii = []; let esecuri = 0; try { // 1. linia de baza: copia neatinsa trebuie sa fie VERDE pregatesteCopie(dest); const baza = ruleaza(dest); if (!baza.json || baza.json.completed !== true) { console.log(`LINIA DE BAZA STRICATA: proba nu a ajuns la capat (cod ${baza.status}, semnal ${baza.signal})\n${baza.stderr}`); return 1; } const numeBaza = new Set(baza.json.results.map((r) => r.nume)); if (baza.json.failed.length !== 0) { console.log(`LINIA DE BAZA ROSIE: ${baza.json.failed.length} probe pica pe codul neatins:\n ${baza.json.failed.join('\n ')}`); return 1; } console.log(`linia de baza: VERDE, ${baza.json.passed}/${baza.json.total} probe, ${baza.ms} ms (node ${baza.json.node}, openssl ${baza.json.openssl})\n`); // 2. fiecare plantare pe o copie proaspata for (const p of PL) { pregatesteCopie(dest); const numite = p.rosii.map(([n]) => n); const lipsa = numite.filter((n) => !numeBaza.has(n)); if (lipsa.length) { esecuri++; linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `proba numita nu exista in proba: ${lipsa.join(' | ')}` }); continue; } const pl = planteaza(dest, p); if (!pl.ok) { esecuri++; linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `plantarea nu s-a putut pune: ${pl.motiv}` }); continue; } const r = ruleaza(dest); if (!r.json || r.json.completed !== true || r.json.total !== baza.json.total) { esecuri++; linii.push({ p, verdict: 'STRICAT', detaliu: `proba nu a rulat intreaga (cod ${r.status}, probe ${r.json ? r.json.total : '-'}/${baza.json.total}); ${r.stderr.split('\n').slice(-3).join(' ')}` }); continue; } const dupaNume = new Map(r.json.results.map((x) => [x.nume, x])); const probleme = []; let rosiiNumite = 0; for (const [nume, motiv] of p.rosii) { const x = dupaNume.get(nume); if (x.ok) probleme.push(`VERDE: ${nume}`); else if (!String(x.motiv).includes(motiv)) probleme.push(`ROSU DIN ALT MOTIV: ${nume} -> "${x.motiv.slice(0, 160)}" (cerut: "${motiv}")`); else rosiiNumite++; } const inPlus = r.json.failed.filter((n) => !numite.includes(n)); if (probleme.length === 0) { linii.push({ p, verdict: 'ROSU', detaliu: `${rosiiNumite}/${numite.length} numite rosii pe motivul cerut; alte ${inPlus.length} rosii in plus`, inPlus, ms: r.ms }); } else { esecuri++; linii.push({ p, verdict: r.json.failed.length === 0 ? 'VERDE (NEPRINS)' : 'ROSU GRESIT', detaliu: probleme.join('\n '), inPlus, ms: r.ms }); } } } finally { fs.rmSync(lucru, { recursive: true, force: true }); } for (const l of linii) { console.log(`${l.verdict.padEnd(15)} ${l.p.id} ${l.p.defect}${l.ms ? ` (${l.ms} ms)` : ''}\n ${l.detaliu}`); if (l.inPlus && l.inPlus.length) console.log(` in plus: ${l.inPlus.join(' | ')}`); } // 3. originalul neatins const dupa = amprente(); const schimbate = FISIERE.filter((f) => inainte[f] !== dupa[f]); if (schimbate.length) { esecuri++; console.log(`\nORIGINALUL S-A SCHIMBAT: ${schimbate.join(', ')}`); } else { console.log(`\noriginal neatins (sha256): ${FISIERE.map((f) => `${f}=${dupa[f].slice(0, 12)}`).join(' ')}`); } if (autoproba) { // Aici esecurile sunt ceruse: fiecare momeala trebuie sa primeasca exact verdictul ei. let potrivite = 0; for (const l of linii) { const ok = l.verdict === l.p.asteptat; if (ok) potrivite++; console.log(`${ok ? 'CORECT ' : 'GRESIT '} ${l.p.id}: controlul a spus "${l.verdict}", trebuia "${l.p.asteptat}"`); } const originalOk = schimbate.length === 0; console.log(`\nautoproba controlului: ${potrivite}/${MOMELI.length} momeli cu verdictul cerut; original ${originalOk ? 'neatins' : 'SCHIMBAT'}`); return potrivite === MOMELI.length && linii.length === MOMELI.length && originalOk ? 0 : 1; } const rosii = linii.filter((l) => l.verdict === 'ROSU').length; console.log(`\ncontrol negativ: ${rosii}/${PLANTARI.length} plantari prinse pe probele numite; ${esecuri} esecuri ale controlului`); return esecuri === 0 && rosii === PLANTARI.length ? 0 : 1; } try { process.exitCode = main(); } catch (e) { console.log(`controlul negativ a cazut: ${(e && e.stack) || e}`); process.exitCode = 2; }