aere-quantum/pq-kms/hsm-radacina.mjs

143 lines
11 KiB
JavaScript

// hsm-radacina.mjs - Aere PQ KMS: cheia radacina SIGILATA de un HSM prin PKCS#11 (roadmap master 16, "HSM").
//
// DE CE: pana acum radacina KMS-ului statea IN CLAR in mediu (AERE_KMS_ROOT_KEY), iar README-ul o spune: "Not an HSM". Cu acest
// modul radacina sta pe disc numai SIGILATA de o cheie AES-256 care traieste in HSM si nu poate iesi de acolo (CKA_SENSITIVE,
// CKA_EXTRACTABLE=false); la pornire, KMS-ul cere HSM-ului sa o deschida, cu PIN-ul tokenului. Cine fura discul si mediul fara HSM
// si fara PIN nu are radacina. Masurat pe SoftHSM2 2.6.1 + OpenSC 0.25 (test/proba-hsm.mjs); un HSM fizic se foloseste la fel,
// prin modulul PKCS#11 al producatorului.
//
// CE NU FACE (spus, nu ascuns): dupa deschidere radacina e in memoria procesului, ca inainte (HSM-ul nu face operatiile hibride
// ML-KEM/ML-DSA: niciun HSM de pe piata la data scrierii nu le expune prin PKCS#11 standard); `pkcs11-tool` lucreaza cu fisiere, deci
// radacina trece cateva milisecunde printr-un fisier din /dev/shm (memorie, nu disc), umplut cu zerouri si sters imediat. Integritatea:
// mecanismul folosit e AES-CBC-PAD (OpenSC 0.25 nu expune AES-GCM la cifrare), deci radacina se sigileaza impreuna cu 16 octeti din
// amprenta ei; o sigilare atinsa sau deschisa cu alta cheie da REFUZ numit, nu o radacina gresita (care oricum ar fi refuzata de
// verificarea radacinii din kms.mjs).
//
// node hsm-radacina.mjs sigileaza --modul <lib.so> --token <eticheta> --id <hex> --iesire <fisier> [--nou]
// radacina: AERE_KMS_ROOT_KEY (o radacina existenta) sau --nou (una noua, aleatoare); PIN-ul: AERE_HSM_PIN
// node hsm-radacina.mjs verifica <fisier> deschide sigilarea si spune doar DA/NU (nu tipareste niciodata radacina);
// biblioteca: AERE_HSM_MODULE (trebuie sa fie exact cea din fisier), PIN-ul: AERE_HSM_PIN
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, pathToFileURL } from 'node:url';
import { KmsError, parseRootKey } from './kms.mjs';
export const FORMAT_HSM = 'aerekms-root-hsm/1';
// 2026-09-29, revizuirea adversariala a felii HSM (pista B), inainte de publicare:
// H1: fisierul sigilat numea biblioteca PKCS#11 (`modul`) si variabila PIN-ului (`pinEnv`), iar deschiderea le credea. Cine poate scrie
// fisierul (sta pe disc, e facut sa stea acolo) facea KMS-ul sa incarce ORICE biblioteca la pornire (cod strain in procesul KMS) si
// sa-i dea PIN-ul, sau alta variabila de mediu (de ex. jetonul API) drept PIN. Acum biblioteca vine din configuratia procesului
// (AERE_HSM_MODULE, cale absoluta) si trebuie sa fie EXACT cea din fisier, verificat INAINTE de orice rulare a uneltei; variabila
// PIN-ului e fixa (AERE_HSM_PIN), fisierul nu o poate alege.
// H2: cele doua drumuri de refuz ale unei sigilari atinse (umplutura CBC stricata / amprenta gresita) aveau MESAJE diferite; cine poate
// modifica fisierul si citi jurnalul pornirii avea un oracol de umplutura pe AES-CBC-PAD. Acum acelasi cod si acelasi mesaj.
export const PIN_ENV = 'AERE_HSM_PIN';
const MESAJ_ATINS = 'the sealed root does not open to the sealed root with this HSM key (modified, or sealed by another key)';
const ETICHETA = /^[A-Za-z0-9._-]{1,32}$/;
const ID_HEX = /^[0-9a-fA-F]{2,64}$/;
const amprenta = (r) => crypto.createHash('sha256').update(Buffer.concat([Buffer.from(FORMAT_HSM + '\0', 'utf8'), r])).digest().subarray(0, 16);
function dosarRam() {
const baza = fs.existsSync('/dev/shm') ? '/dev/shm' : os.tmpdir();
return fs.mkdtempSync(path.join(baza, 'aerekms-hsm-'));
}
function stergeSigur(f) {
try { const n = fs.statSync(f).size; fs.writeFileSync(f, Buffer.alloc(n)); } catch { /* nu exista */ }
try { fs.rmSync(f, { force: true }); } catch { /* */ }
}
function pkcs11(unealta, args, env) {
const r = spawnSync(unealta, args, { env, encoding: 'utf8', timeout: 60_000 });
if (r.error && r.error.code === 'ENOENT') throw new KmsError('HSM_TOOL_MISSING', `${unealta} is not installed (OpenSC)`);
if (r.error) throw new KmsError('HSM_TOOL_FAILED', `${unealta} could not run: ${r.error.code || 'error'}`);
const err = (r.stderr || '') + (r.stdout || '');
if (r.status !== 0) {
if (/CKR_PIN_INCORRECT|CKR_PIN_LEN_RANGE|CKR_PIN_LOCKED|Login failed/i.test(err)) throw new KmsError('HSM_LOGIN_REFUSED', 'the HSM refused the PIN');
if (/No slot|token.*not found|Can't find|No token/i.test(err)) throw new KmsError('HSM_TOKEN_NOT_FOUND', 'the HSM token or key was not found');
throw new KmsError('HSM_OPERATION_FAILED', 'the HSM refused the operation');
}
return r;
}
function comuni({ modul, token, idCheie, pinEnv }) {
if (typeof modul !== 'string' || !path.isAbsolute(modul)) throw new KmsError('HSM_CONFIG_INVALID', 'modul must be the absolute path of the PKCS#11 library');
if (!ETICHETA.test(String(token || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'token label must match ^[A-Za-z0-9._-]{1,32}$');
if (!ID_HEX.test(String(idCheie || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'key id must be hex');
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(String(pinEnv))) throw new KmsError('HSM_CONFIG_INVALID', 'pinEnv must name an environment variable');
return ['--module', modul, '--token-label', token, '--login', '--pin', 'env:' + pinEnv, '--id', idCheie, '--mechanism', 'AES-CBC-PAD'];
}
/** sigileaza o radacina de 32 de octeti cu cheia AES a HSM-ului; intoarce sigilarea (JSON), niciodata radacina */
export function sigileazaRadacina({ modul, token, idCheie, radacina, env = process.env, unealta = 'pkcs11-tool' }) {
const pinEnv = PIN_ENV;
if (!Buffer.isBuffer(radacina) || radacina.length !== 32 || radacina.every((x) => x === 0)) throw new KmsError('ROOT_KEY_INVALID', 'the root to seal must be 32 non-zero bytes');
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
const a = comuni({ modul, token, idCheie, pinEnv });
const iv = crypto.randomBytes(16);
const d = dosarRam(); const pt = path.join(d, 'r'), ct = path.join(d, 'c');
try {
fs.writeFileSync(pt, Buffer.concat([radacina, amprenta(radacina)]), { mode: 0o600 });
pkcs11(unealta, [...a, '--encrypt', '--iv', iv.toString('hex'), '--input-file', pt, '--output-file', ct], env);
const c = fs.readFileSync(ct);
if (c.length !== 64) throw new KmsError('HSM_OPERATION_FAILED', `unexpected sealed length ${c.length}`);
return { format: FORMAT_HSM, mecanism: 'AES-CBC-PAD', modul, token, idCheie: idCheie.toLowerCase(), pinEnv, iv: iv.toString('hex'), ct: c.toString('hex'), creat: new Date().toISOString() };
} finally { stergeSigur(pt); stergeSigur(ct); fs.rmSync(d, { recursive: true, force: true }); }
}
/** deschide o sigilare cu HSM-ul; intoarce radacina (Buffer de 32) sau arunca un refuz NUMIT */
export function deschideRadacina(sig, { env = process.env, unealta = 'pkcs11-tool' } = {}) {
if (!sig || sig.format !== FORMAT_HSM || sig.mecanism !== 'AES-CBC-PAD') throw new KmsError('HSM_SEAL_INVALID', `the sealed root is not ${FORMAT_HSM}`);
if (!/^[0-9a-f]{32}$/.test(String(sig.iv)) || !/^[0-9a-f]{128}$/.test(String(sig.ct))) throw new KmsError('HSM_SEAL_INVALID', 'the sealed root has a malformed iv or ciphertext');
// H1: biblioteca si variabila PIN-ului NU se iau din fisier; nimic nu ruleaza pana nu trec ambele verificari
const permis = String(env.AERE_HSM_MODULE || '').trim();
if (!permis || !path.isAbsolute(permis)) throw new KmsError('HSM_CONFIG_INVALID', 'AERE_HSM_MODULE must name the PKCS#11 library (absolute path); the sealed file alone does not choose it');
if (sig.modul !== permis) throw new KmsError('HSM_MODULE_NOT_ALLOWED', 'the sealed root names a PKCS#11 library other than AERE_HSM_MODULE; it is not loaded');
if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV) throw new KmsError('HSM_SEAL_INVALID', `the PIN is always read from ${PIN_ENV}; a sealed file cannot choose another variable`);
const pinEnv = PIN_ENV;
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
const a = comuni({ modul: permis, token: sig.token, idCheie: sig.idCheie, pinEnv });
const d = dosarRam(); const ct = path.join(d, 'c'), pt = path.join(d, 'r');
try {
fs.writeFileSync(ct, Buffer.from(sig.ct, 'hex'), { mode: 0o600 });
try { pkcs11(unealta, [...a, '--decrypt', '--iv', sig.iv, '--input-file', ct, '--output-file', pt], env); }
catch (e) { if (e.code === 'HSM_OPERATION_FAILED') throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); throw e; }
const p = fs.readFileSync(pt);
const r = Buffer.from(p.subarray(0, 32)); const t = p.subarray(32);
const bun = p.length === 48 && crypto.timingSafeEqual(t, amprenta(r));
p.fill(0);
if (!bun) { r.fill(0); throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); }
return r;
} finally { stergeSigur(ct); stergeSigur(pt); fs.rmSync(d, { recursive: true, force: true }); }
}
/** din mediu: AERE_KMS_ROOT_HSM (calea sigilarii) -> radacina in hex, exact forma pe care o primea kms.mjs din AERE_KMS_ROOT_KEY */
export function radacinaDinHsm(env, optiuni = {}) {
const f = String(env.AERE_KMS_ROOT_HSM || '').trim();
let sig;
try { sig = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { throw new KmsError('HSM_SEAL_INVALID', 'AERE_KMS_ROOT_HSM does not point to a readable sealed root'); }
const r = deschideRadacina(sig, { env, ...optiuni });
const h = r.toString('hex'); r.fill(0);
return h;
}
async function cli(argv) {
const [cmd, ...rest] = argv;
const opt = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
try {
if (cmd === 'sigileaza') {
const iesire = opt('--iesire'); if (!iesire) throw new KmsError('HSM_CONFIG_INVALID', '--iesire <file> is required');
if (fs.existsSync(iesire)) throw new KmsError('HSM_CONFIG_INVALID', 'the output file exists; a sealed root is never overwritten');
const radacina = rest.includes('--nou') ? crypto.randomBytes(32) : parseRootKey(process.env.AERE_KMS_ROOT_KEY);
const sig = sigileazaRadacina({ modul: opt('--modul'), token: opt('--token'), idCheie: opt('--id'), radacina });
radacina.fill(0);
fs.writeFileSync(iesire, JSON.stringify(sig, null, 1) + '\n', { mode: 0o600, flag: 'wx' });
console.log(`sealed root written to ${iesire} (${FORMAT_HSM}, token ${sig.token}, key id ${sig.idCheie})`);
} else if (cmd === 'verifica') {
const r = deschideRadacina(JSON.parse(fs.readFileSync(rest[0], 'utf8'))); r.fill(0);
console.log('ok: the sealed root opens with this HSM and PIN');
} else { console.error('usage: node hsm-radacina.mjs sigileaza --modul <lib> --token <label> --id <hex> --iesire <file> [--nou] | verifica <file> (PIN in AERE_HSM_PIN; verifica also needs AERE_HSM_MODULE)'); process.exitCode = 2; }
} catch (e) { console.error(`refused: ${e.code || 'ERROR'}: ${e.message}`); process.exitCode = 1; }
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) cli(process.argv.slice(2));