aere-quantum/pq-kms/test/control-negativ.mjs

424 lines
19 KiB
JavaScript

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