418 lines
43 KiB
JavaScript
418 lines
43 KiB
JavaScript
// Proba autoritatii de certificare post-cuantice (pki.mjs, cli.mjs, der.mjs). Fiecare verdict al verificatorului nostru e comparat cu cel al
|
|
// unei implementari STRAINE, OpenSSL 3.5 in linia de comanda (verificarea lui de lant, cu -x509_strict), iar TLS-ul e un handshake
|
|
// real: server Node cu lantul nostru, client OpenSSL si client Node. Fiecare refuz cere MOTIVUL (codul), nu doar refuzul.
|
|
// Cere `openssl` 3.5+ in PATH (Git for Windows il are); fara el proba iese STRICAT, nu verde.
|
|
// node test/proba.mjs (PKI_MODULE=<cale catre pki.mjs> o ruleaza pe o copie; asa o cheama controlul negativ)
|
|
// Iesire: 0 toate trec, 1 cel putin una cade, 2 STRICAT (unealta straina lipseste).
|
|
// Probele marcate [A1]..[A10] sunt regresiile celor sapte defecte ale revizuirii adversariale din 2026-09-25.
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import tls from 'node:tls';
|
|
import crypto from 'node:crypto';
|
|
import { spawnSync, spawn } from 'node:child_process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
const MOD = process.env.PKI_MODULE ? path.resolve(process.env.PKI_MODULE) : path.join(AICI, '..', 'pki.mjs');
|
|
const P = await import(pathToFileURL(MOD).href);
|
|
const Dm = await import(pathToFileURL(path.join(path.dirname(MOD), 'der.mjs')).href);
|
|
const CLI = path.join(path.dirname(MOD), 'cli.mjs');
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-'));
|
|
const f = (n) => path.join(T, n);
|
|
const rez = [];
|
|
async function test(nume, fn) {
|
|
try { await fn(); rez.push({ nume, ok: true }); console.log(' OK ' + nume); }
|
|
catch (e) { rez.push({ nume, ok: false, motiv: String(e.message || e) }); console.log(' ESEC ' + nume + ' -> ' + String(e.message || e).slice(0, 300)); }
|
|
}
|
|
const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
|
const has = (s, sub, m) => { if (!String(s).includes(sub)) throw new Error(`${m}: lipseste "${sub}" in ${String(s).slice(0, 300)}`); };
|
|
/** fn trebuie sa arunce PkiError cu codul cerut. */
|
|
const code = (fn, c, m) => { let e = null; try { fn(); } catch (x) { e = x; } if (!e) throw new Error(m + ': nu a aruncat, asteptat ' + c); if (e.code !== c) throw new Error(`${m}: cod ${e.code} (${e.message}), asteptat ${c}`); };
|
|
const env = { ...process.env, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' };
|
|
function ossl(args, input) {
|
|
const r = spawnSync('openssl', args, { encoding: 'utf8', env, input: input ?? '', timeout: 30000 });
|
|
return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), err: r.error };
|
|
}
|
|
// AERE-SINTETIC: parola de proba, valabila numai pentru cheile generate in aceasta rulare, intr-un dosar temporar sters la sfarsit
|
|
const PASS_PROBA = 'proba-parola-lunga-1';
|
|
const penv = { ...env, AERE_PKI_PASSPHRASE: PASS_PROBA };
|
|
const run = (a, e = penv) => { const r = spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', env: e, timeout: 120000 }); return { code: r.status, out: (r.stdout || '') + (r.stderr || '') }; };
|
|
|
|
// ------------------------------------------------------------------------------------------------ OpenSSL de fata
|
|
const v = ossl(['version']);
|
|
if (v.err || !/OpenSSL 3\.(5|6|7|8|9)/.test(v.out)) { console.log('STRICAT: nu exista OpenSSL 3.5+ in PATH (' + (v.err ? v.err.message : v.out.trim()) + ')'); process.exit(2); }
|
|
console.log('unealta straina: ' + v.out.trim());
|
|
|
|
// ------------------------------------------------------------------------------------------------ lantul de baza
|
|
const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
|
|
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 3650 });
|
|
const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
|
|
const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 30, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] });
|
|
const w = (n, der, lbl = 'CERTIFICATE') => { fs.writeFileSync(f(n), P.pem(lbl, der)); return f(n); };
|
|
w('root.pem', root); w('inter.pem', inter); w('leaf.pem', leaf);
|
|
fs.writeFileSync(f('leaf.key'), kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
|
fs.writeFileSync(f('inter.key'), ki.privateKey.export({ type: 'pkcs8', format: 'pem' })); // pentru `openssl ca -gencrl` (liste facute de unealta straina)
|
|
const V = (o) => P.verifyChain({ leaf, intermediates: [inter], roots: [root], host: 'localhost', ...o });
|
|
const osslVerify = (extra, leafFile = f('leaf.pem'), chain = [f('inter.pem')]) =>
|
|
ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), ...chain.flatMap((c) => ['-untrusted', c]), ...extra, leafFile]);
|
|
/** O lista de revocare a intermediarului facuta de OpenSSL (`openssl ca -gencrl`), cu extensiile din crlExt si intrarile din index. */
|
|
function gencrl(crlExt, outName, index = '') {
|
|
fs.writeFileSync(f('ca.index'), index); fs.writeFileSync(f('ca.crlnumber'), '05\n');
|
|
fs.writeFileSync(f('ca.cnf'), `[ ca ]\ndefault_ca = CA_default\n[ CA_default ]\ndir = ${T.replace(/\\/g, '/')}\ndatabase = $dir/ca.index\ncrlnumber = $dir/ca.crlnumber\n` +
|
|
`default_crl_days = 7\ndefault_md = default\ncrl_extensions = crl_ext\n[ crl_ext ]\n${crlExt}\n[ idp ]\nfullname = URI:http://crl.example/issuing.crl\n`);
|
|
const r = ossl(['ca', '-config', f('ca.cnf'), '-gencrl', '-keyfile', f('inter.key'), '-cert', f('inter.pem'), '-out', f(outName)]);
|
|
if (r.code !== 0) throw new Error('openssl ca -gencrl: ' + r.out.slice(0, 300));
|
|
return P.unpem(fs.readFileSync(f(outName), 'utf8'), 'X509 CRL')[0];
|
|
}
|
|
const utc = (d) => d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'; // YYMMDDHHMMSSZ pentru index.txt
|
|
|
|
await test('lantul ML-DSA-87 / ML-DSA-65 / ML-DSA-65: valid la noi SI la OpenSSL (-x509_strict, sslserver)', () => {
|
|
const r = V({}); eq(r.ok, true, 'noi ' + r.reason); eq(r.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'lantul');
|
|
const o = osslVerify([]); eq(o.code, 0, 'openssl ' + o.out); has(o.out, ': OK', 'openssl');
|
|
});
|
|
await test('OpenSSL citeste certificatele: algoritmii ML-DSA, cheile, SAN, KeyUsage critic, CA:FALSE pe frunza', () => {
|
|
const t = ossl(['x509', '-in', f('leaf.pem'), '-noout', '-text']).out;
|
|
has(t, 'Signature Algorithm: ML-DSA-65', 'semnatura'); has(t, 'Public Key Algorithm: ML-DSA-65', 'cheia');
|
|
has(t, 'DNS:localhost, IP Address:127.0.0.1', 'SAN'); has(t, 'X509v3 Key Usage: critical', 'KU'); has(t, 'CA:FALSE', 'bc');
|
|
has(ossl(['x509', '-in', f('root.pem'), '-noout', '-text']).out, 'Signature Algorithm: ML-DSA-87', 'radacina');
|
|
});
|
|
await test('interoperabilitatea inversa: un certificat ML-DSA facut de OpenSSL e citit si verificat de noi; unul stricat nu', () => {
|
|
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('o.key'), '-out', f('o.pem'), '-days', '2', '-nodes', '-subj', '/CN=openssl-made']);
|
|
eq(o.code, 0, 'openssl req ' + o.out);
|
|
const der = P.unpem(fs.readFileSync(f('o.pem'), 'utf8'))[0];
|
|
eq(P.selfSignatureValid(der), true, 'semnatura OpenSSL la noi'); eq(P.parseCert(der).alg, 'ml-dsa-44', 'algoritmul');
|
|
const bad = Buffer.from(der); bad[bad.length - 5] ^= 1; eq(P.selfSignatureValid(bad), false, 'semnatura stricata');
|
|
});
|
|
|
|
// ------------------------------------------------------------------------------------------------ revocarea
|
|
const crlRev = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [{ serial: P.parseCert(leaf).serial, date: new Date() }], days: 7, number: 2 });
|
|
const crlOk = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [], days: 7, number: 1 });
|
|
const crlRoot = P.crl({ issuer: root, signingKey: kr.privateKey, revoked: [], days: 7, number: 1 });
|
|
w('crl-rev.pem', crlRev, 'X509 CRL'); w('crl-ok.pem', crlOk, 'X509 CRL'); w('crl-root.pem', crlRoot, 'X509 CRL');
|
|
await test('revocarea: frunza revocata e REFUZATA (REVOKED) la noi si "certificate revoked" la OpenSSL; nerevocata trece la amandoi', () => {
|
|
const r = V({ crls: [crlRev] }); eq(r.code, 'REVOKED', 'noi');
|
|
const o = osslVerify(['-crl_check_all', '-CRLfile', f('crl-rev.pem'), '-CRLfile', f('crl-root.pem')]); has(o.out, 'certificate revoked', 'openssl'); eq(o.code === 0, false, 'openssl cod');
|
|
eq(V({ crls: [crlOk, crlRoot], requireCrl: true }).ok, true, 'noi, nerevocata');
|
|
eq(osslVerify(['-crl_check_all', '-CRLfile', f('crl-ok.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl, nerevocata');
|
|
});
|
|
await test('lista ceruta si lipsa: CRL_MISSING; lista semnata de alta cheie: CRL_SIGNATURE; lista expirata: CRL_STALE', () => {
|
|
eq(V({ crls: [crlOk], requireCrl: true }).code, 'CRL_MISSING', 'radacina fara lista');
|
|
const altCa = P.generateKey('ml-dsa-65');
|
|
const fals = P.issue({ issuer: null, signingKey: altCa.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: altCa.publicKey, ca: true, days: 10 });
|
|
const crlFals = P.crl({ issuer: fals, signingKey: altCa.privateKey, revoked: [], days: 7, number: 9 });
|
|
eq(V({ crls: [crlFals] }).code, 'CRL_SIGNATURE', 'lista unei chei straine cu acelasi nume');
|
|
eq(V({ crls: [crlOk], at: new Date(Date.now() + 9 * 86400000) }).code, 'CRL_STALE', 'lista dupa nextUpdate');
|
|
});
|
|
// [A2] prima lista din intrare nu e lista; e cea mai noua dintre cele care verifica
|
|
await test('cea mai noua lista castiga [A2]: [veche goala, noua cu seria] in ambele ordini: REVOKED la noi si "certificate revoked" la OpenSSL; [veche cu seria, noua goala]: OK la amandoi; thisUpdate egal: crlNumber mai mare; o lista datata in viitor nu conteaza', () => {
|
|
const serial = P.parseCert(leaf).serial;
|
|
const L = (revoked, number, thisUpdate) => P.crl({ issuer: inter, signingKey: ki.privateKey, revoked, days: 7, number, thisUpdate });
|
|
const veche = L([], 1, new Date(Date.now() - 3600000)), noua = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 60000));
|
|
eq(V({ crls: [veche, noua] }).code, 'REVOKED', 'veche intai'); eq(V({ crls: [noua, veche] }).code, 'REVOKED', 'noua intai');
|
|
w('c-veche.pem', veche, 'X509 CRL'); w('c-noua.pem', noua, 'X509 CRL');
|
|
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-veche.pem'), '-CRLfile', f('c-noua.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl veche intai');
|
|
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-noua.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl noua intai');
|
|
const vecheCuSeria = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 7200000)), nouaGoala = L([], 3, new Date(Date.now() - 30000));
|
|
const r = V({ crls: [vecheCuSeria, nouaGoala] }); eq(r.ok, true, 'noua goala castiga ' + r.reason);
|
|
w('c-vs.pem', vecheCuSeria, 'X509 CRL'); w('c-ng.pem', nouaGoala, 'X509 CRL');
|
|
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-vs.pem'), '-CRLfile', f('c-ng.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl noua goala castiga');
|
|
const t = new Date(Date.now() - 120000);
|
|
const n1 = L([], 1, t), n2 = L([{ serial, date: new Date() }], 2, t);
|
|
eq(V({ crls: [n1, n2] }).code, 'REVOKED', 'egalitate: #2 castiga'); eq(V({ crls: [n2, n1] }).code, 'REVOKED', 'egalitate: #2 castiga, invers');
|
|
const viitor = L([{ serial, date: new Date() }], 9, new Date(Date.now() + 3600000));
|
|
eq(V({ crls: [viitor, veche] }).ok, true, 'lista din viitor nu conteaza langa una curenta'); eq(V({ crls: [viitor] }).code, 'CRL_STALE', 'singura lista e din viitor');
|
|
w('c-viitor.pem', viitor, 'X509 CRL');
|
|
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl: viitor + curenta = OK');
|
|
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('crl-root.pem')]).code === 0, false, 'openssl: numai viitor = refuz');
|
|
});
|
|
// [A3] extensiile listei
|
|
await test('extensiile listei [A3]: delta (deltaCRLIndicator, critic sau nu), IDP critic (lista OpenSSL) si o extensie critica necunoscuta (lista OpenSSL) dau CRL_EXT cu numele ei; la OpenSSL "different CRL scope", "unhandled critical CRL extension", delta refuzat cu -extended_crl; necritica necunoscuta ignorata la amandoi; intrare cu extensie critica refuzata', () => {
|
|
const c = P.parseCert(inter); const alg = Dm.seq(Dm.oid(P.ALG['ml-dsa-65']));
|
|
const ex = (oid, critical, val) => Dm.seq(Dm.oid(oid), critical ? Dm.bool(true) : null, Dm.octets(val));
|
|
const aki = ex('2.5.29.35', false, Dm.seq(Dm.tlv(0x80, c.ski))), num = ex('2.5.29.20', false, Dm.int(3));
|
|
const lista = (exts, rev = null) => { const tbs = Dm.seq(Dm.int(1), alg, c.subjectRaw, Dm.time(new Date(Date.now() - 60000)), Dm.time(new Date(Date.now() + 7 * 86400000)), rev, Dm.ctx(0, Dm.seq(...exts))); return Dm.seq(tbs, alg, Dm.bits(crypto.sign(null, tbs, ki.privateKey))); };
|
|
const delta = lista([aki, num, ex('2.5.29.27', true, Dm.int(1))]), deltaNecritic = lista([aki, num, ex('2.5.29.27', false, Dm.int(1))]);
|
|
const r1 = V({ crls: [delta] }); eq(r1.code, 'CRL_EXT', 'delta critic'); has(r1.reason, 'deltaCRLIndicator', 'motivul delta');
|
|
eq(V({ crls: [deltaNecritic] }).code, 'CRL_EXT', 'delta necritic, tot refuzat');
|
|
has(P.parseCrl(delta).refusedExt, 'delta', 'parseCrl il vede fara sa arunce');
|
|
w('c-delta.pem', delta, 'X509 CRL');
|
|
has(osslVerify(['-crl_check', '-extended_crl', '-CRLfile', f('c-delta.pem')]).out, 'unable to get certificate CRL', 'openssl -extended_crl nu foloseste un delta');
|
|
const idp = gencrl('authorityKeyIdentifier = keyid\nissuingDistributionPoint = critical, @idp', 'c-idp.crl');
|
|
const r2 = V({ crls: [idp] }); eq(r2.code, 'CRL_EXT', 'IDP'); has(r2.reason, 'issuingDistributionPoint', 'motivul IDP');
|
|
has(osslVerify(['-crl_check', '-CRLfile', f('c-idp.crl')]).out, 'different CRL scope', 'openssl IDP');
|
|
const crit = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = critical, ASN1:UTF8String:x', 'c-crit.crl');
|
|
const r3 = V({ crls: [crit] }); eq(r3.code, 'CRL_EXT', 'critica necunoscuta'); has(r3.reason, '1.2.3.4.5', 'motivul critica');
|
|
has(osslVerify(['-crl_check', '-CRLfile', f('c-crit.crl')]).out, 'unhandled critical CRL extension', 'openssl critica');
|
|
const nec = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = ASN1:UTF8String:x', 'c-nec.crl');
|
|
const r4 = V({ crls: [nec], requireCrl: false }); eq(r4.ok, true, 'necritica necunoscuta ignorata ' + r4.reason);
|
|
eq(osslVerify(['-crl_check', '-CRLfile', f('c-nec.crl')]).code, 0, 'openssl necritica');
|
|
const intrare = Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.29', true, Dm.seq(Dm.tlv(0xa4, c.subjectRaw)))))); // certificateIssuer critic (lista indirecta)
|
|
const r5 = V({ crls: [lista([aki, num], intrare)] }); eq(r5.code, 'CRL_EXT', 'intrare cu extensie critica'); has(r5.reason, 'entry extension', 'motivul intrarii');
|
|
const r6 = V({ crls: [lista([aki, num], Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.21', false, Dm.tlv(0x0a, Buffer.from([1])))))))] });
|
|
eq(r6.code, 'REVOKED', 'intrare cu reasonCode necritic: seria conteaza');
|
|
});
|
|
await test('lista facuta de OpenSSL (openssl ca -gencrl, AKI + crlNumber, reasonCode pe intrare): OK la noi cand e goala, REVOKED cand poarta seria frunzei; AKI si crlNumber citite', () => {
|
|
const goala = gencrl('authorityKeyIdentifier = keyid', 'o-goala.crl');
|
|
const r = V({ crls: [goala] }); eq(r.ok, true, 'goala ' + r.reason);
|
|
const pc = P.parseCrl(goala); eq(pc.crlNumber, 5n, 'crlNumber'); eq(pc.aki && pc.aki.equals(P.parseCert(inter).ski), true, 'AKI = SKI-ul emitentului'); eq(pc.refusedExt, null, 'nimic refuzat');
|
|
const serial = P.parseCert(leaf).serial.toString('hex').toUpperCase();
|
|
const rev = gencrl('authorityKeyIdentifier = keyid', 'o-rev.crl', `R\t${utc(new Date(Date.now() + 30 * 86400000))}\t${utc(new Date())},keyCompromise\t${serial}\tunknown\t/CN=localhost\n`);
|
|
eq(V({ crls: [rev] }).code, 'REVOKED', 'revocata de lista OpenSSL'); eq(P.parseCrl(rev).revoked.size, 1, 'o intrare');
|
|
});
|
|
|
|
// ------------------------------------------------------------------------------------------------ refuzuri, cu perechea OpenSSL
|
|
await test('un octet schimbat in frunza: SIGNATURE la noi, refuz la OpenSSL', () => {
|
|
const p = P.parseCert(leaf); const bad = Buffer.from(leaf); const o = bad.indexOf(Buffer.from('localhost'), 0); bad[o] = 0x4c; // "Localhost"
|
|
eq(P.verifyChain({ leaf: bad, intermediates: [inter], roots: [root] }).code, 'SIGNATURE', 'noi');
|
|
eq(osslVerify([], w('bad.pem', bad)).code === 0, false, 'openssl'); eq(p.serial.length > 0, true, 'seria');
|
|
});
|
|
await test('alta radacina: UNTRUSTED la noi, "unable to get local issuer" la OpenSSL', () => {
|
|
const k = P.generateKey('ml-dsa-65');
|
|
const alt = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: 'Alt Root' }, publicKey: k.publicKey, ca: true, days: 10 });
|
|
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [alt], host: 'localhost' }); eq(r.code, 'UNTRUSTED', 'noi'); has(r.reason, 'Proba Issuing', 'numeste certificatul ramas fara emitent');
|
|
const o = ossl(['verify', '-x509_strict', '-CAfile', w('alt.pem', alt), '-untrusted', f('inter.pem'), f('leaf.pem')]); has(o.out, 'unable to get local issuer', 'openssl');
|
|
});
|
|
await test('timpul: EXPIRED dupa notAfter si NOT_YET_VALID inainte de notBefore, la noi si la OpenSSL (-attime)', () => {
|
|
const tarziu = new Date(Date.now() + 40 * 86400000), devreme = new Date(Date.now() - 86400000);
|
|
eq(V({ at: tarziu }).code, 'EXPIRED', 'noi tarziu'); eq(V({ at: devreme }).code, 'NOT_YET_VALID', 'noi devreme');
|
|
has(osslVerify(['-attime', String(Math.floor(tarziu / 1000))]).out, 'certificate has expired', 'openssl tarziu');
|
|
has(osslVerify(['-attime', String(Math.floor(devreme / 1000))]).out, 'not yet valid', 'openssl devreme');
|
|
});
|
|
await test('pathLen: o autoritate cu pathLen 0 care are sub ea inca o autoritate: PATH_LEN la noi, refuz la OpenSSL', () => {
|
|
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
|
|
const sub = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'Sub CA' }, publicKey: k2.publicKey, ca: true, days: 30 });
|
|
const l2 = P.issue({ issuer: sub, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
|
eq(P.verifyChain({ leaf: l2, intermediates: [sub, inter], roots: [root], host: 'localhost' }).code, 'PATH_LEN', 'noi');
|
|
const o = osslVerify([], w('l2.pem', l2), [w('sub.pem', sub), f('inter.pem')]); eq(o.code === 0, false, 'openssl ' + o.out); has(o.out, 'path length', 'openssl motivul');
|
|
});
|
|
await test('un certificat de entitate folosit ca emitent: NOT_CA la noi, "invalid CA certificate" la OpenSSL (lant facut de OpenSSL)', () => {
|
|
// frunza noastra nu poate semna: o face OpenSSL, care semneaza cu orice certificat
|
|
let o = ossl(['req', '-new', '-newkey', 'mldsa65', '-keyout', f('x.key'), '-out', f('x.csr'), '-nodes', '-subj', '/CN=victim']);
|
|
eq(o.code, 0, 'csr ' + o.out);
|
|
o = ossl(['x509', '-req', '-in', f('x.csr'), '-CA', f('leaf.pem'), '-CAkey', f('leaf.key'), '-out', f('x.pem'), '-days', '2', '-set_serial', '7']);
|
|
eq(o.code, 0, 'semnat de frunza ' + o.out);
|
|
const x = P.unpem(fs.readFileSync(f('x.pem'), 'utf8'))[0];
|
|
const r = P.verifyChain({ leaf: x, intermediates: [leaf, inter], roots: [root], purpose: null });
|
|
eq(r.code, 'NOT_CA', 'noi ' + r.reason);
|
|
const ov = ossl(['verify', '-CAfile', f('root.pem'), '-untrusted', f('inter.pem'), '-untrusted', f('leaf.pem'), f('x.pem')]);
|
|
eq(ov.code === 0, false, 'openssl ' + ov.out);
|
|
});
|
|
await test('frunza pentru alt nume: HOSTNAME la noi, "hostname mismatch" la OpenSSL; IP-ul din SAN trece la amandoi', () => {
|
|
eq(V({ host: 'aere.example' }).code, 'HOSTNAME', 'noi'); has(osslVerify(['-verify_hostname', 'aere.example']).out, 'hostname mismatch', 'openssl');
|
|
eq(V({ host: '127.0.0.1' }).ok, true, 'noi IP'); eq(osslVerify(['-verify_ip', '127.0.0.1']).code, 0, 'openssl IP');
|
|
});
|
|
await test('scopul: o frunza numai clientAuth folosita ca server: EKU la noi, "unsuitable certificate purpose" la OpenSSL', () => {
|
|
const k = P.generateKey('ml-dsa-65');
|
|
const cl = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'client' }, publicKey: k.publicKey, days: 30, eku: ['clientAuth'] });
|
|
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'serverAuth' }).code, 'EKU', 'noi');
|
|
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'clientAuth' }).ok, true, 'noi ca client');
|
|
has(osslVerify([], w("cl.pem", cl)).out, "unsuitable certificate purpose", "openssl");
|
|
});
|
|
// [A6] EKU pe autoritati
|
|
await test('EKU pe autoritati [A6]: o CA restransa la clientAuth cu o frunza serverAuth sub ea: EKU numind autoritatea la noi, "unsuitable certificate purpose" la adancimea 1 la OpenSSL; aceeasi CA cu o frunza clientAuth, ca client: OK la amandoi; o RADACINA restransa e aplicata la fel', () => {
|
|
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
|
|
const caClient = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Client Only CA' }, publicKey: k2.publicKey, ca: true, pathLen: 0, days: 100, eku: ['clientAuth'] });
|
|
const srv = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
|
const cli = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'client' }, publicKey: k3.publicKey, days: 30, eku: ['clientAuth'] });
|
|
const r = P.verifyChain({ leaf: srv, intermediates: [caClient], roots: [root], host: 'localhost', purpose: 'serverAuth' });
|
|
eq(r.code, 'EKU', 'noi'); has(r.reason, 'Client Only CA', 'numeste autoritatea');
|
|
const o = osslVerify([], w('a6-srv.pem', srv), [w('a6-ca.pem', caClient)]); has(o.out, 'unsuitable certificate purpose', 'openssl'); has(o.out, 'at 1 depth', 'openssl: la autoritate');
|
|
eq(P.verifyChain({ leaf: cli, intermediates: [caClient], roots: [root], purpose: 'clientAuth' }).ok, true, 'client sub CA de client');
|
|
eq(ossl(['verify', '-x509_strict', '-purpose', 'sslclient', '-CAfile', f('root.pem'), '-untrusted', f('a6-ca.pem'), w('a6-cli.pem', cli)]).code, 0, 'openssl client');
|
|
const k4 = P.generateKey('ml-dsa-65');
|
|
const rootC = P.issue({ issuer: null, signingKey: k4.privateKey, subject: { cn: 'Client Only Root' }, publicKey: k4.publicKey, ca: true, pathLen: 1, days: 100, eku: ['clientAuth'] });
|
|
const srv2 = P.issue({ issuer: rootC, signingKey: k4.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
|
const r2 = P.verifyChain({ leaf: srv2, roots: [rootC], host: 'localhost' }); eq(r2.code, 'EKU', 'radacina restransa'); has(r2.reason, 'Client Only Root', 'numeste radacina');
|
|
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('a6-rootc.pem', rootC), w('a6-srv2.pem', srv2)]).out, 'unsuitable certificate purpose', 'openssl radacina');
|
|
});
|
|
await test('o autoritate folosita ca frunza de server: LEAF_IS_CA', () => {
|
|
eq(P.verifyChain({ leaf: inter, intermediates: [], roots: [root] }).code, 'LEAF_IS_CA', 'noi');
|
|
});
|
|
// [A7] toate ancorele candidate
|
|
await test('reinnoirea radacinii [A7]: roots [expirata, valida] cu aceeasi cheie si acelasi nume: OK la noi si la OpenSSL (amandoua in CAfile); numai [expirata]: EXPIRED si "certificate has expired"; intermediar semnat incrucisat de o radacina straina pus inaintea celui bun: OK la noi pe drumul bun', () => {
|
|
const rootExp = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 1, notBefore: new Date(Date.now() - 10 * 86400000) });
|
|
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp, root], host: 'localhost' }); eq(r.ok, true, 'expirata intai ' + r.reason);
|
|
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [root, rootExp], host: 'localhost' }).ok, true, 'valida intai');
|
|
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp], host: 'localhost' }).code, 'EXPIRED', 'numai expirata');
|
|
fs.writeFileSync(f('roots2.pem'), P.pem('CERTIFICATE', rootExp) + P.pem('CERTIFICATE', root));
|
|
const o = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('roots2.pem'), '-untrusted', f('inter.pem'), f('leaf.pem')]); eq(o.code, 0, 'openssl rollover ' + o.out);
|
|
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('rootexp.pem', rootExp), '-untrusted', f('inter.pem'), f('leaf.pem')]).out, 'certificate has expired', 'openssl numai expirata');
|
|
const k2 = P.generateKey('ml-dsa-87');
|
|
const root2 = P.issue({ issuer: null, signingKey: k2.privateKey, subject: { cn: 'Alt Root 2' }, publicKey: k2.publicKey, ca: true, pathLen: 1, days: 3650 });
|
|
const cross = P.issue({ issuer: root2, signingKey: k2.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
|
|
const rc = P.verifyChain({ leaf, intermediates: [cross, inter], roots: [root], host: 'localhost' }); eq(rc.ok, true, 'cross intai ' + rc.reason); eq(rc.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'drumul bun');
|
|
const rn = P.verifyChain({ leaf, intermediates: [cross], roots: [root], host: 'localhost' }); eq(rn.code, 'UNTRUSTED', 'numai cross: niciun drum');
|
|
const oc = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), '-untrusted', w('cross.pem', cross), '-untrusted', f('inter.pem'), f('leaf.pem')]);
|
|
console.log(` (OpenSSL cu [cross, inter]: cod ${oc.code}; OpenSSL nu revine pe pasi intre intermediari, noi da)`);
|
|
});
|
|
await test('extensie critica necunoscuta (facuta de OpenSSL): CRITICAL_EXT la noi, "unhandled critical extension" la OpenSSL', () => {
|
|
const o = ossl(['req', '-x509', '-newkey', 'mldsa65', '-keyout', f('c.key'), '-out', f('c.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit',
|
|
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '1.2.3.4.5=critical,ASN1:UTF8String:x']);
|
|
eq(o.code, 0, 'openssl req ' + o.out);
|
|
const c = P.unpem(fs.readFileSync(f('c.pem'), 'utf8'))[0];
|
|
eq(P.verifyChain({ leaf: c, roots: [c], purpose: null }).code, 'CRITICAL_EXT', 'noi');
|
|
has(ossl(['verify', '-CAfile', f('c.pem'), f('c.pem')]).out, 'unhandled critical extension', 'openssl');
|
|
});
|
|
await test('forma: algoritm exterior diferit de cel interior (ALG_MISMATCH), parametri pe ML-DSA (ALG_PARAMS), DER necanonic, octeti in plus', () => {
|
|
const oid65 = Buffer.from('0609608648016503040312', 'hex'), oid44 = Buffer.from('0609608648016503040311', 'hex');
|
|
const last = leaf.lastIndexOf(oid65); const m = Buffer.from(leaf); oid44.copy(m, last);
|
|
let e = null; try { P.parseCert(m); } catch (x) { e = x; } eq(e && e.code, 'ALG_MISMATCH', 'exterior 44 / interior 65');
|
|
const withNull = Buffer.concat([Buffer.from('300d', 'hex'), oid65, Buffer.from('0500', 'hex')]);
|
|
const src = Buffer.concat([Buffer.from('300b', 'hex'), oid65]);
|
|
const j = leaf.lastIndexOf(src);
|
|
const lung = leaf.readUInt16BE(2) + 2; // continutul SEQUENCE-ului de sus (forma 30 82 LL LL)
|
|
const n = Buffer.concat([Buffer.from([0x30, 0x82]), Buffer.alloc(2), leaf.subarray(4, j), withNull, leaf.subarray(j + src.length)]);
|
|
n.writeUInt16BE(lung, 2);
|
|
e = null; try { P.parseCert(n); } catch (x) { e = x; } eq(e && e.code, 'ALG_PARAMS', 'NULL pe ML-DSA');
|
|
e = null; try { P.parseCert(Buffer.concat([leaf, Buffer.from([0])])); } catch (x) { e = x; } has(e && e.message, 'octeti dupa structura', 'coada');
|
|
const nc = Buffer.from([0x30, 0x81, 0x03, 0x02, 0x01, 0x05]); e = null; try { P.parseCert(nc); } catch (x) { e = x; } has(e && e.message, 'necanonica', 'lungime lunga pentru una scurta');
|
|
});
|
|
// [A5] SPKI
|
|
const cuSpkiRau = (der) => { const c = P.parseCert(der); const [algEl] = Dm.children(Dm.parse(c.spki)); const s = Dm.seq(algEl.raw, Dm.bits(Buffer.alloc(10, 7))); return { c, tbs: Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s : k.raw))) }; };
|
|
await test('cheie publica malformata [A5]: SPKI de 10 octeti sub id-ml-dsa pe frunza, pe intermediar si pe radacina: verdict SPKI (cu lungimea) la noi, fara exceptie; "decode error" la OpenSSL; o cheie de 1312 octeti sub id-ml-dsa-65 e tot SPKI', () => {
|
|
const resemn = (der, key, alg) => { const { tbs } = cuSpkiRau(der); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG[alg])), Dm.bits(crypto.sign(null, tbs, key))); };
|
|
const leafRau = resemn(leaf, ki.privateKey, 'ml-dsa-65'), interRau = resemn(inter, kr.privateKey, 'ml-dsa-87');
|
|
const rootRau = (() => { const { c, tbs } = cuSpkiRau(root); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-87'])), Dm.bits(c.signature)); })();
|
|
for (const [nume, o] of [['frunza', { leaf: leafRau, intermediates: [inter], roots: [root] }], ['intermediar', { leaf, intermediates: [interRau], roots: [root] }], ['radacina', { leaf, intermediates: [inter], roots: [rootRau] }]]) {
|
|
let r; try { r = P.verifyChain({ host: 'localhost', ...o }); } catch (e) { throw new Error(nume + ': EXCEPTIE ' + e.message); }
|
|
eq(r.code, 'SPKI', nume); has(r.reason, 'this one has 10', nume + ': motivul');
|
|
}
|
|
has(osslVerify([], w('a5-leaf.pem', leafRau)).out, 'decode error', 'openssl frunza');
|
|
has(osslVerify([], f('leaf.pem'), [w('a5-inter.pem', interRau)]).out, 'decode error', 'openssl intermediar');
|
|
const k44 = P.generateKey('ml-dsa-44'); const pk44 = Dm.bitString(Dm.children(Dm.parse(k44.publicKey.export({ type: 'spki', format: 'der' })))[1]).bytes;
|
|
const c = P.parseCert(leaf); const s65 = Dm.seq(Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(pk44));
|
|
const tbs = Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s65 : k.raw)));
|
|
const gresit = Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(crypto.sign(null, tbs, ki.privateKey)));
|
|
const r = V({ leaf: gresit }); eq(r.code, 'SPKI', 'cheie 44 sub OID 65'); has(r.reason, '1312', 'lungimea gasita');
|
|
});
|
|
await test('orice octeti dau verdict, nu exceptie [A5]: mutatii aleatoare ale frunzei, ale intermediarului si ale listei, trunchieri si gunoi trec prin verifyChain fara sa arunce', () => {
|
|
let verdicte = 0;
|
|
const incearca = (o) => { let r; try { r = P.verifyChain(o); } catch (e) { throw new Error('EXCEPTIE: ' + e.message); } if (typeof r.ok !== 'boolean' || !r.code) throw new Error('fara verdict'); verdicte++; };
|
|
for (let i = 0; i < 300; i++) { const b = Buffer.from(leaf); for (let j = 0; j <= i % 4; j++) b[crypto.randomInt(b.length)] ^= 1 << crypto.randomInt(8); incearca({ leaf: b, intermediates: [inter], roots: [root], host: 'localhost', crls: [crlOk] }); }
|
|
for (const len of [0, 1, 2, 3, 4, 10, 100, 700, leaf.length - 1]) incearca({ leaf: leaf.subarray(0, len), intermediates: [inter], roots: [root] });
|
|
for (let i = 0; i < 50; i++) incearca({ leaf: crypto.randomBytes(1 + crypto.randomInt(400)), roots: [root] });
|
|
for (let i = 0; i < 100; i++) { const b = Buffer.from(crlOk); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [root], crls: [b], requireCrl: true }); }
|
|
for (let i = 0; i < 100; i++) { const b = Buffer.from(inter); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [b], roots: [root] }); }
|
|
for (let i = 0; i < 50; i++) { const b = Buffer.from(root); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [b] }); }
|
|
eq(verdicte >= 609, true, 'numarul de verdicte ' + verdicte);
|
|
});
|
|
// [A10] OID
|
|
await test('OID [A10]: 2.999 se codifica 06 02 88 37 si se decodifica inapoi; 2.100.3 si alte sase OID-uri identice octet cu octet cu OpenSSL (asn1parse -genstr) si round-trip; 0x80 initial refuzat ca necanonic; un certificat OpenSSL cu extensia critica 2.999.1 e numit corect in CRITICAL_EXT', () => {
|
|
eq(Dm.oid('2.999').toString('hex'), '06028837', '2.999');
|
|
for (const o of ['2.999', '2.100.3', '2.16.840.1.101.3.4.3.18', '1.3.6.1.5.5.7.3.1', '2.5.29.19', '0.9.2342.19200300.100.1.25', '1.2.840.113549.1.1.11', '2.25.329800735698586629295641978511506172918']) {
|
|
const noi = Dm.oid(o);
|
|
eq(Dm.oidToString(Dm.parse(noi)), o, 'round-trip ' + o);
|
|
const r = ossl(['asn1parse', '-genstr', 'OID:' + o, '-noout', '-out', f('oid.der')]); eq(r.code, 0, 'openssl genstr ' + o + ' ' + r.out);
|
|
eq(fs.readFileSync(f('oid.der')).toString('hex'), noi.toString('hex'), 'octetii OpenSSL pentru ' + o);
|
|
}
|
|
let e = null; try { Dm.oidToString(Dm.parse(Buffer.from('06028001', 'hex'))); } catch (x) { e = x; } has(e && e.message, 'necanonic', '0x80 initial');
|
|
e = null; try { Dm.oid('1.40'); } catch (x) { e = x; } has(e && e.message, 'arc nevalid', '1.40');
|
|
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('c9.key'), '-out', f('c9.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit999',
|
|
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '2.999.1=critical,ASN1:UTF8String:x']);
|
|
eq(o.code, 0, 'req ' + o.out);
|
|
const c = P.unpem(fs.readFileSync(f('c9.pem'), 'utf8'))[0]; const r = P.verifyChain({ leaf: c, roots: [c], purpose: null });
|
|
eq(r.code, 'CRITICAL_EXT', 'verdict'); has(r.reason, 'extension 2.999.1', 'numele adevarat al extensiei');
|
|
});
|
|
|
|
// ------------------------------------------------------------------------------------------------ TLS real
|
|
await test('TLS 1.3 complet post-cuantic: server Node cu lantul nostru, X25519MLKEM768 + semnatura mldsa65; OpenSSL si Node il accepta', async () => {
|
|
const srv = tls.createServer({ key: fs.readFileSync(f('leaf.key')), cert: fs.readFileSync(f('leaf.pem')) + fs.readFileSync(f('inter.pem')),
|
|
minVersion: 'TLSv1.3', ecdhCurve: 'X25519MLKEM768' }, (c) => c.end('aere\n'));
|
|
await new Promise((ok) => srv.listen(0, '127.0.0.1', ok));
|
|
const port = srv.address().port;
|
|
try {
|
|
const out = await new Promise((ok) => {
|
|
const c = spawn('openssl', ['s_client', '-connect', '127.0.0.1:' + port, '-servername', 'localhost', '-verify_hostname', 'localhost',
|
|
'-CAfile', f('root.pem'), '-verify_return_error', '-groups', 'X25519MLKEM768'], { env });
|
|
let s = ''; c.stdout.on('data', (d) => { s += d; }); c.stderr.on('data', (d) => { s += d; });
|
|
c.stdin.end(); const t = setTimeout(() => c.kill(), 15000); c.on('close', () => { clearTimeout(t); ok(s); });
|
|
});
|
|
has(out, 'Verify return code: 0 (ok)', 'openssl verificarea'); has(out, 'Peer signature type: mldsa65', 'semnatura'); has(out, 'Negotiated TLS1.3 group: X25519MLKEM768', 'grupul');
|
|
const nod = await new Promise((ok) => {
|
|
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: fs.readFileSync(f('root.pem')), ecdhCurve: 'X25519MLKEM768' }, () => {
|
|
ok({ auth: s.authorized, err: s.authorizationError, proto: s.getProtocol() }); s.end();
|
|
}); s.on('error', (e) => ok({ auth: false, err: e.message }));
|
|
});
|
|
eq(nod.auth, true, 'clientul Node ' + nod.err); eq(nod.proto, 'TLSv1.3', 'protocolul');
|
|
const alt = P.generateKey('ml-dsa-65');
|
|
const altRoot = P.issue({ issuer: null, signingKey: alt.privateKey, subject: { cn: 'Alt' }, publicKey: alt.publicKey, ca: true, days: 5 });
|
|
const neg = await new Promise((ok) => {
|
|
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: P.pem('CERTIFICATE', altRoot), rejectUnauthorized: true }, () => { ok('conectat'); s.end(); });
|
|
s.on('error', (e) => ok('refuzat: ' + e.message));
|
|
});
|
|
has(neg, 'refuzat', 'clientul Node cu alta radacina');
|
|
} finally { srv.close(); }
|
|
});
|
|
|
|
// ------------------------------------------------------------------------------------------------ cheia pe disc [A1]
|
|
await test('sigilarea cheii [A1]: fisierul poarta scrypt N=2^17 r=8 p=1 si AES-256-GCM cu antetul ca AAD (citit si direct din DER); o incercare de parola costa peste 25 ms (masurat si tiparit); parola gresita, text cifrat sau antet atins: KEY_LOCKED; PKCS#8 cifrat sau in clar: KEY_FORMAT; OpenSSL nu il deschide', () => {
|
|
const pass = 'Proba-sigiliu-2026'; // AERE-SINTETIC
|
|
const sealed = P.exportPrivateKey(ki.privateKey, pass);
|
|
has(sealed, '-----BEGIN AERE PQ PKI PRIVATE KEY-----', 'eticheta');
|
|
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(sealed), false, 'fara PKCS#8');
|
|
const h = P.readSealedKeyHeader(sealed);
|
|
eq(h.N, 2 ** 17, 'N'); eq(h.r, 8, 'r'); eq(h.p, 1, 'p'); eq(h.kdf, 'scrypt', 'kdf'); eq(h.cipher, 'aes-256-gcm', 'cifrul');
|
|
eq(h.salt.length, 16, 'salt'); eq(h.iv.length, 12, 'iv'); eq(h.tag.length, 16, 'tag'); eq(h.ct.length > 4000, true, 'PKCS#8 cifrat inauntru');
|
|
const blob = P.unpem(sealed, P.KEY_LABEL)[0]; const [hdr] = Dm.children(Dm.parse(blob)); const hk = Dm.children(hdr);
|
|
eq(Number(Dm.intToBigInt(hk[3])), 131072, 'N citit direct din DER'); eq(Number(Dm.intToBigInt(hk[4])), 8, 'r din DER'); eq(Number(Dm.intToBigInt(hk[0])), 1, 'versiunea formatului');
|
|
const back = P.importPrivateKey(sealed, pass);
|
|
eq(crypto.createPublicKey(back).export({ type: 'spki', format: 'der' }).equals(ki.publicKey.export({ type: 'spki', format: 'der' })), true, 'aceeasi cheie dupa deschidere');
|
|
const t0 = process.hrtime.bigint(); let n = 0;
|
|
for (let i = 0; i < 3; i++) { try { P.importPrivateKey(sealed, 'Gresita-parola-' + i); } catch (e) { if (e.code === 'KEY_LOCKED') n++; } }
|
|
const ms = Number(process.hrtime.bigint() - t0) / 3e6; eq(n, 3, 'parola gresita = KEY_LOCKED');
|
|
console.log(` cost masurat: ${ms.toFixed(0)} ms per incercare de parola (scrypt N=${h.N}, r=${h.r}, p=${h.p}, ${os.cpus()[0]?.model?.trim() || 'cpu'})`);
|
|
if (ms < 25) throw new Error('o incercare de parola costa ' + ms.toFixed(1) + ' ms, sub 25 ms: KDF-ul e prea ieftin');
|
|
const atins = (off) => { const b = Buffer.from(blob); b[off] ^= 1; return P.pem(P.KEY_LABEL, b); };
|
|
code(() => P.importPrivateKey(atins(blob.length - 1), pass), 'KEY_LOCKED', 'text cifrat atins');
|
|
code(() => P.importPrivateKey(atins(hk[2].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'salt atins (antetul e AAD)');
|
|
code(() => P.importPrivateKey(atins(hk[7].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'iv atins');
|
|
code(() => P.importPrivateKey(sealed, ''), 'KEY_LOCKED', 'fara parola');
|
|
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: pass }), pass), 'KEY_FORMAT', 'PKCS#8 cifrat (formatul vechi)');
|
|
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem' }), pass), 'KEY_FORMAT', 'PKCS#8 in clar');
|
|
fs.writeFileSync(f('sealed.pem'), sealed);
|
|
eq(ossl(['pkey', '-in', f('sealed.pem'), '-noout']).code === 0, false, 'openssl nu poate deschide formatul sigilat');
|
|
});
|
|
await test('politica de parola [A1]: sub 12 caractere, o singura clasa sub 20, toate identice, evidente (password1234, Password123!, Qwerty123456): PASSPHRASE; 3 clase la 12 sau 20+ caractere trec; linia de comanda refuza init cu o parola slaba si nu scrie nimic', () => {
|
|
// AERE-SINTETIC: parole de proba pentru politica, niciuna folosita pentru vreo cheie
|
|
for (const p of ['scurta1A!', 'aaaaaaaaaaaa', 'abcdefghijkl', 'password1234', 'Password123!', 'Qwerty123456', '123456789012', 'AAAAAAAAAAAAAAAAAAAAAAAA', 'Letmein-2026', 'ABCDEFGHIJKL']) code(() => P.checkPassphrase(p), 'PASSPHRASE', p);
|
|
for (const p of ['Abcdefghijk1', PASS_PROBA, 'abcdefghijklmnopqrstu', 'correct horse battery staple', 'Zx9!Zx9!Zx9!']) eq(P.checkPassphrase(p), p, p);
|
|
code(() => P.exportPrivateKey(ki.privateKey, 'aaaaaaaaaaaa'), 'PASSPHRASE', 'exportul aplica politica');
|
|
const r = run(['init', '--dir', f('ca-slaba'), '--name', 'r'], { ...env, AERE_PKI_PASSPHRASE: 'aaaaaaaaaaaa' }); eq(r.code, 2, 'cli'); has(r.out, 'PASSPHRASE', 'motivul');
|
|
eq(fs.existsSync(path.join(f('ca-slaba'), 'r.key')), false, 'nimic scris'); eq(fs.existsSync(path.join(f('ca-slaba'), 'r.crt')), false, 'niciun certificat scris');
|
|
});
|
|
|
|
// ------------------------------------------------------------------------------------------------ linia de comanda
|
|
await test('linia de comanda: init, intermediate, issue, revoke, crl, verify, unseal; cheile scrise sigilate (nu PKCS#8); fara parola si cu parola gresita refuza; nimic suprascris', () => {
|
|
const D = f('ca');
|
|
eq(run(['init', '--dir', D, '--name', 'root', '--org', 'Aere Proba']).code, 0, 'init');
|
|
eq(run(['intermediate', '--dir', D, '--ca', 'root', '--name', 'issuing']).code, 0, 'intermediate');
|
|
eq(run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'svc.local', '--dns', 'svc.local', '--out', f('svc')]).code, 0, 'issue');
|
|
const rk = fs.readFileSync(path.join(D, 'root.key'), 'utf8'), sk = fs.readFileSync(f('svc.key'), 'utf8');
|
|
has(rk, 'AERE PQ PKI PRIVATE KEY', 'cheia radacinii sigilata'); has(sk, 'AERE PQ PKI PRIVATE KEY', 'cheia frunzei sigilata');
|
|
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(rk + sk), false, 'niciun PKCS#8 pe disc');
|
|
eq(P.readSealedKeyHeader(rk).N, 2 ** 17, 'N al cheii radacinii');
|
|
const ok1 = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local']); eq(ok1.code, 0, 'verify ' + ok1.out);
|
|
eq(run(['revoke', '--dir', D, '--ca', 'issuing', '--cert', f('svc.crt')]).code, 0, 'revoke');
|
|
eq(run(['crl', '--dir', D, '--ca', 'issuing', '--out', f('svc-crl.pem')]).code, 0, 'crl');
|
|
const rv = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local', '--crl', f('svc-crl.pem')]);
|
|
eq(rv.code, 1, 'verify revocat'); has(rv.out, 'REFUSED (REVOKED)', 'motivul');
|
|
const us = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us.code, 0, 'unseal ' + us.out);
|
|
has(fs.readFileSync(f('svc.p8.pem'), 'utf8'), '-----BEGIN PRIVATE KEY-----', 'PKCS#8 in clar, la cerere');
|
|
has(ossl(['pkey', '-in', f('svc.p8.pem'), '-noout', '-text']).out, 'ML-DSA-65', 'openssl citeste cheia desigilata');
|
|
const us2 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us2.code, 2, 'unseal peste un fisier existent'); has(us2.out, 'EXISTS', 'nimic suprascris la unseal');
|
|
// AERE-SINTETIC: parola gresita dinadins (proba refuzului)
|
|
const us3 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8b.pem')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(us3.code, 2, 'unseal cu parola gresita'); has(us3.out, 'KEY_LOCKED', 'motivul');
|
|
const np = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x2')], { ...env, AERE_PKI_PASSPHRASE: '' }); eq(np.code, 2, 'fara parola'); has(np.out, 'PASSPHRASE', 'motivul fara parola');
|
|
// AERE-SINTETIC: parola gresita dinadins (proba refuzului la emitere)
|
|
const wp = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x3')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(wp.code, 2, 'parola gresita'); has(wp.out, 'KEY_LOCKED', 'motivul parolei gresite');
|
|
const ow = run(['init', '--dir', D, '--name', 'root']); eq(ow.code, 2, 'suprascriere'); has(ow.out, 'EXISTS', 'nimic suprascris');
|
|
eq(/-----BEGIN PRIVATE KEY-----/.test(np.out + wp.out + ow.out + us.out + us2.out + us3.out), false, 'niciun material de cheie in mesaje');
|
|
});
|
|
|
|
fs.rmSync(T, { recursive: true, force: true });
|
|
const picate = rez.filter((r) => !r.ok);
|
|
console.log(`\n${rez.length - picate.length}/${rez.length} probe trecute`);
|
|
if (process.env.PKI_JSON) fs.writeFileSync(process.env.PKI_JSON, JSON.stringify(rez, null, 1));
|
|
process.exitCode = picate.length ? 1 : 0;
|