// kms.mjs - Aere PQ KMS: un KMS de tip "transit" (ca Vault transit), hibrid clasic + post-cuantic. // // Numai node:crypto (Node 24, OpenSSL 3.5), fara dependinte. // // chei "encrypt": X25519 + ML-KEM-768, KEM hibrid; secretul plicului se deriva cu HKDF-SHA-256 // peste AMBELE secrete partajate si peste transcriptul ambelor encapsulari, // legat de nume + versiune + aad; apoi AES-256-GCM. // chei "sign": Ed25519 + ML-DSA-65; semnatura poarta AMBELE jumatati si verificarea le cere // pe amandoua. // // Cheile private stau pe disc sigilate cu AES-256-GCM sub o cheie derivata din AERE_KMS_ROOT_KEY. // Fara cheia radacina constructorul refuza (nu se genereaza nicio cheie in tacere). // Fiecare operatie scrie un rand in jurnalul de audit inlantuit (HMAC peste rand + mac-ul // randului anterior), fara date clare si fara material de cheie. // // Niciun mesaj de eroare nu contine material de cheie: mesajele sunt texte fixe plus nume de // chei si numere de versiune. import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; export const PREFIX = 'aerekms'; export const KEY_FORMAT = 'aerekms-key/1'; const ZERO_MAC = '0'.repeat(64); // Etichetele de domeniu. Fac parte din format: un tert care vrea interoperabilitate le // foloseste exact asa (README, sectiunea "Wire format"). export const LABELS = Object.freeze({ root: 'aerekms/v1/root', seal: 'aerekms/v1/seal-private', sealAad: 'aerekms/v1/seal', fileMac: 'aerekms/v1/key-file-mac', audit: 'aerekms/v1/audit-chain', rootCheck: 'aerekms/v1/root-check', fp: 'aerekms/v1/fingerprint', kem: 'aerekms/v1/hybrid-kem', commit: 'aerekms/v1/commit', aad: 'aerekms/v1/aad', dek: 'aerekms/v1/dek', sig: 'aerekms/v1/hybrid-sig', sigAlg: 'ed25519+ml-dsa-65', sigCtx: 'aerekms/v1', }); // Marimi masurate pe Node 24.14.1 / OpenSSL 3.5.5 (2026-09-25). const SZ = Object.freeze({ x: 32, ek: 1184, ctK: 1088, ed: 64, pkDsa: 1952, mlSig: 3309, fp: 8 }); // Antetele SPKI sunt fixe (22 de octeti); le verificam octet cu octet, nu doar lungimea. const SPKI_HDR = Object.freeze({ 'ml-kem-768': Buffer.from('308204b2300b0609608648016503040402038204a100', 'hex'), 'ml-dsa-65': Buffer.from('308207b2300b0609608648016503040312038207a100', 'hex'), }); const MAGIC_E = Buffer.from('AKM1', 'ascii'); const MAGIC_S = Buffer.from('AKS1', 'ascii'); const KIND_E = 0x45; // 'E' const KIND_S = 0x53; // 'S' // Plicul de criptare: // magic(4) | kind(1) | version u32be(4) | fp(8) | ePub X25519(32) | ct ML-KEM(1088) // | aadTag(16) | commit(16) | payload AES-256-GCM (n) | gcmTag(16) const OFF = Object.freeze({ ver: 5, fp: 9, ePub: 17, ctK: 49, aadTag: 1137, commit: 1153, payload: 1169 }); const ENV_MIN = OFF.payload + 16; // Semnatura: magic(4) | kind(1) | version(4) | fp(8) | Ed25519(64) | ML-DSA-65(3309) const SIG_LEN = 17 + SZ.ed + SZ.mlSig; const MAX_PLAINTEXT = 1024 * 1024; const MAX_MESSAGE = 1024 * 1024; const MAX_AAD = 64 * 1024; const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; const ENV_RE = /^aerekms:v([1-9][0-9]{0,8}):([A-Za-z0-9+/]+={0,2})$/; export class KmsError extends Error { constructor(code, message, extra) { super(message); this.name = 'KmsError'; this.code = code; if (extra) Object.assign(this, extra); } } // --------------------------------------------------------------------------------------------- // primitive mici function u32(n) { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; } function lp(b) { const x = Buffer.from(b); return Buffer.concat([u32(x.length), x]); } function utf8(s) { return Buffer.from(s, 'utf8'); } function sha256(...parts) { const h = crypto.createHash('sha256'); for (const p of parts) h.update(p); return h.digest(); } function hmac(key, ...parts) { const h = crypto.createHmac('sha256', key); for (const p of parts) h.update(p); return h.digest(); } function hkdf(ikm, salt, info, len) { return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, len)); } function ctEq(a, b) { return a.length === b.length && crypto.timingSafeEqual(a, b); } export function canonicalJson(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'; const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort(); return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'; } function writeFileAtomic(p, text) { const tmp = `${p}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; const fd = fs.openSync(tmp, 'w', 0o600); try { fs.writeSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(tmp, p); } // --------------------------------------------------------------------------------------------- // cheia radacina // Citeste AERE_KMS_ROOT_KEY. Refuza lipsa, forma gresita si cheia de zerouri. Mesajul spune // cel mult LUNGIMEA valorii, niciodata valoarea. export function parseRootKey(value) { const s = value === undefined || value === null ? '' : String(value).trim(); if (s === '') throw new KmsError('ROOT_KEY_MISSING', 'AERE_KMS_ROOT_KEY is not set; refusing to start (a root key is never generated automatically)'); if (!/^[0-9a-fA-F]{64}$/.test(s)) { throw new KmsError('ROOT_KEY_INVALID', `AERE_KMS_ROOT_KEY must be exactly 64 hexadecimal characters (32 bytes); the value given has ${s.length} characters or contains non-hex characters`); } const b = Buffer.from(s, 'hex'); if (b.every((x) => x === 0)) throw new KmsError('ROOT_KEY_WEAK', 'AERE_KMS_ROOT_KEY is all zeros; refusing to start'); return b; } // --------------------------------------------------------------------------------------------- // intrari function checkName(name) { if (typeof name !== 'string' || !NAME_RE.test(name)) { throw new KmsError('INVALID_KEY_NAME', 'key name must match ^[a-z0-9][a-z0-9_-]{0,63}$'); } return name; } function toBytes(v, what, max) { let b; if (Buffer.isBuffer(v) || v instanceof Uint8Array) b = Buffer.from(v); else if (typeof v === 'string') b = utf8(v); else throw new KmsError('INVALID_INPUT', `${what} must be a Buffer, Uint8Array or string`); if (b.length > max) throw new KmsError(`${what.toUpperCase()}_TOO_LARGE`, `${what} is larger than ${max} bytes`); return b; } function normAad(aad) { if (aad === undefined || aad === null) return Buffer.alloc(0); return toBytes(aad, 'aad', MAX_AAD); } // --------------------------------------------------------------------------------------------- // codificarea cheilor publice function rawOkp(pub) { return Buffer.from(pub.export({ format: 'jwk' }).x, 'base64url'); } function okpPublic(raw, crv) { return crypto.createPublicKey({ key: { kty: 'OKP', crv, x: Buffer.from(raw).toString('base64url') }, format: 'jwk' }); } function rawFromSpki(spki, alg) { const hdr = SPKI_HDR[alg]; if (!spki.subarray(0, hdr.length).equals(hdr)) throw new KmsError('INTERNAL', `unexpected SPKI encoding for ${alg}`); return spki.subarray(hdr.length); } function fingerprint(kind, name, ver, pubA, pubB) { return sha256(utf8(LABELS.fp + '\0'), Buffer.from([kind]), lp(utf8(name)), u32(ver), pubA, pubB).subarray(0, SZ.fp); } function sealAad(name, type, ver, fpHex) { return utf8(`${LABELS.sealAad}\0${name}\0${type}\0${ver}\0${fpHex}`); } // --------------------------------------------------------------------------------------------- // KEM hibrid // Transcriptul leaga: numele cheii, versiunea, amprenta, cheia X25519 a destinatarului, // amprenta cheii ML-KEM a destinatarului, cheia X25519 efemera si textul cifrat ML-KEM. function kemTranscript(name, ver, fp, xPub, ekRaw, ePub, ctK) { return Buffer.concat([utf8(LABELS.kem + '\0'), lp(utf8(name)), u32(ver), fp, xPub, sha256(ekRaw), ePub, ctK]); } // Secretul plicului: HKDF-SHA-256 cu IKM = ss_ML-KEM || ss_X25519 si sare = SHA-256(transcript). // Din el ies: cheia de angajament (commit), cheia etichetei aad si cheia AES + IV-ul, ultima // legata si de aad prin info. function deriveKeys(ssK, ssX, transcript, aad) { const ikm = Buffer.concat([ssK, ssX]); const salt = sha256(transcript); const commitKey = hkdf(ikm, salt, utf8(LABELS.commit), 32); const aadKey = hkdf(ikm, salt, utf8(LABELS.aad), 32); const dek = hkdf(ikm, salt, Buffer.concat([utf8(LABELS.dek + '\0'), sha256(aad)]), 44); ikm.fill(0); return { commitKey, aadTag: hmac(aadKey, aad).subarray(0, 16), key: dek.subarray(0, 32), iv: dek.subarray(32, 44), dek }; } function sigMessage(fp, ver, message) { return Buffer.concat([utf8(LABELS.sig + '\0' + LABELS.sigAlg + '\0'), fp, u32(ver), message]); } function parseEnvelope(str, kind) { const what = kind === KIND_E ? 'ciphertext' : 'signature'; const bad = kind === KIND_E ? 'MALFORMED_CIPHERTEXT' : 'MALFORMED_SIGNATURE'; if (typeof str !== 'string') throw new KmsError(bad, `${what} must be a string of the form aerekms:v:`); const m = ENV_RE.exec(str); if (!m) throw new KmsError(bad, `${what} is not of the form aerekms:v:`); const verPrefix = Number(m[1]); const body = Buffer.from(m[2], 'base64'); if (body.toString('base64') !== m[2]) throw new KmsError(bad, `${what} uses non-canonical base64`); const magic = kind === KIND_E ? MAGIC_E : MAGIC_S; if (body.length < 17 || !body.subarray(0, 4).equals(magic) || body[4] !== kind) { throw new KmsError(bad, `${what} does not carry the expected ${kind === KIND_E ? 'AKM1/E' : 'AKS1/S'} header`); } const verBody = body.readUInt32BE(OFF.ver); if (verBody !== verPrefix) { throw new KmsError('VERSION_MISMATCH', `the version in the prefix (v${verPrefix}) does not match the version inside the ${what} (v${verBody})`, { version: verPrefix }); } return { ver: verBody, body, fp: body.subarray(OFF.fp, OFF.fp + SZ.fp) }; } function describe(key) { const versions = {}; for (const v of Object.keys(key.versions)) { const r = key.versions[v]; versions[v] = { created: r.created, fingerprint: r.fingerprint, public: { ...r.public } }; } return { name: key.name, type: key.type, created: key.created, policy: { ...key.policy }, min_decryption_version: key.min_decryption_version, latest_version: key.latest_version, versions, }; } const VERIFY_REFUSALS = new Set(['MALFORMED_SIGNATURE', 'VERSION_MISMATCH', 'UNKNOWN_VERSION', 'VERSION_BELOW_MINIMUM', 'KEY_MISMATCH']); // --------------------------------------------------------------------------------------------- export function openKms(opts) { return new Kms(opts); } export class Kms { #dir; #keysDir; #auditPath; #sealKey; #fileKey; #auditKey; #head; #keys = new Map(); #priv = new Map(); #pub = new Map(); #closed = false; constructor({ dataDir, rootKey } = {}) { // Intai cheia radacina: fara ea nu se atinge discul deloc. const root = parseRootKey(rootKey); if (typeof dataDir !== 'string' || dataDir === '') { root.fill(0); throw new KmsError('DATA_DIR_MISSING', 'dataDir is required'); } const sub = (label) => hkdf(root, utf8(LABELS.root), utf8(label), 32); this.#sealKey = sub(LABELS.seal); this.#fileKey = sub(LABELS.fileMac); this.#auditKey = sub(LABELS.audit); const checkKey = sub(LABELS.rootCheck); root.fill(0); this.#dir = path.resolve(dataDir); this.#keysDir = path.join(this.#dir, 'keys'); this.#auditPath = path.join(this.#dir, 'audit.log'); fs.mkdirSync(this.#keysDir, { recursive: true, mode: 0o700 }); this.#checkRoot(checkKey); const v = this.verifyAudit(); if (!v.ok) { throw new KmsError('AUDIT_CHAIN_INVALID', `the audit log failed verification at line ${v.line} (${v.reason}); refusing to start. Move audit.log aside (keep it as evidence) to start a new chain.`); } this.#head = { seq: v.head.seq, mac: v.head.mac }; } get dataDir() { return this.#dir; } close() { this.#closed = true; this.#priv.clear(); this.#keys.clear(); this.#pub.clear(); } // ----------------------------------------------------------------------- radacina si fisiere #checkRoot(checkKey) { const p = path.join(this.#dir, 'root-check.json'); const expected = hmac(checkKey, utf8('aerekms root key check')).toString('hex'); checkKey.fill(0); if (fs.existsSync(p)) { let rec; try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { throw new KmsError('ROOT_CHECK_UNREADABLE', 'root-check.json is not valid JSON; refusing to start'); } if (!rec || typeof rec.check !== 'string' || !ctEq(utf8(rec.check), utf8(expected))) { throw new KmsError('ROOT_KEY_MISMATCH', 'AERE_KMS_ROOT_KEY does not match the key this data directory was created with; refusing to start'); } return; } const existing = fs.readdirSync(this.#keysDir).filter((f) => f.endsWith('.json')); if (existing.length > 0 || fs.existsSync(this.#auditPath)) { throw new KmsError('ROOT_CHECK_MISSING', 'the data directory has keys or an audit log but no root-check.json; refusing to start'); } writeFileAtomic(p, JSON.stringify({ format: 'aerekms-root-check/1', check: expected }) + '\n'); } #keyPath(name) { return path.join(this.#keysDir, `${name}.json`); } #fileMac(obj) { return hmac(this.#fileKey, utf8(canonicalJson(obj))).toString('hex'); } #load(name, wantType) { checkName(name); let key = this.#keys.get(name); if (!key) { const p = this.#keyPath(name); if (!fs.existsSync(p)) throw new KmsError('KEY_NOT_FOUND', `key "${name}" does not exist`); let rec; try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" is not valid JSON`); } const { mac, ...rest } = rec || {}; const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(this.#fileMac(rest))); if (!macOk) throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" failed its integrity check`); if (rest.name !== name || rest.format !== KEY_FORMAT) { throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" belongs to another key or format`); } key = rest; this.#keys.set(name, key); } if (wantType && key.type !== wantType) { throw new KmsError('WRONG_KEY_TYPE', `key "${name}" is a ${key.type} key; this operation needs a ${wantType} key`); } return key; } #save(key) { const rec = { ...key, mac: this.#fileMac(key) }; writeFileAtomic(this.#keyPath(key.name), JSON.stringify(rec, null, 2) + '\n'); this.#keys.set(key.name, key); } // ----------------------------------------------------------------------- sigilarea privatelor #seal(plain, aad) { const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', this.#sealKey, iv); c.setAAD(aad); const ct = Buffer.concat([c.update(plain), c.final()]); plain.fill(0); return Buffer.concat([iv, ct, c.getAuthTag()]).toString('base64'); } #unseal(sealed, aad) { try { const b = Buffer.from(sealed, 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', this.#sealKey, b.subarray(0, 12)); d.setAAD(aad); d.setAuthTag(b.subarray(b.length - 16)); return Buffer.concat([d.update(b.subarray(12, b.length - 16)), d.final()]); } catch { throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be unsealed (wrong root key or modified key file)'); } } #newVersion(key) { const ver = key.latest_version + 1; let kind, pubA, pubB, derA, derB, pub; if (key.type === 'encrypt') { kind = KIND_E; const a = crypto.generateKeyPairSync('x25519'); const b = crypto.generateKeyPairSync('ml-kem-768'); const spki = b.publicKey.export({ format: 'der', type: 'spki' }); pubA = rawOkp(a.publicKey); pubB = rawFromSpki(spki, 'ml-kem-768'); pub = { x25519: pubA.toString('base64'), ml_kem_768: spki.toString('base64') }; derA = a.privateKey.export({ format: 'der', type: 'pkcs8' }); derB = b.privateKey.export({ format: 'der', type: 'pkcs8' }); } else { kind = KIND_S; const a = crypto.generateKeyPairSync('ed25519'); const b = crypto.generateKeyPairSync('ml-dsa-65'); const spki = b.publicKey.export({ format: 'der', type: 'spki' }); pubA = rawOkp(a.publicKey); pubB = rawFromSpki(spki, 'ml-dsa-65'); pub = { ed25519: pubA.toString('base64'), ml_dsa_65: spki.toString('base64') }; derA = a.privateKey.export({ format: 'der', type: 'pkcs8' }); derB = b.privateKey.export({ format: 'der', type: 'pkcs8' }); } const fp = fingerprint(kind, key.name, ver, pubA, pubB); const fpHex = fp.toString('hex'); const privJson = JSON.stringify({ a: derA.toString('base64'), b: derB.toString('base64') }); const sealed = this.#seal(utf8(privJson), sealAad(key.name, key.type, ver, fpHex)); derA.fill(0); derB.fill(0); key.versions[String(ver)] = { created: new Date().toISOString(), fingerprint: fpHex, public: pub, private_sealed: sealed, }; key.latest_version = ver; return ver; } #privateKeys(key, ver) { const id = `${key.name}:${ver}`; const cached = this.#priv.get(id); if (cached) return cached; const v = key.versions[String(ver)]; const plain = this.#unseal(v.private_sealed, sealAad(key.name, key.type, ver, v.fingerprint)); let obj; try { obj = JSON.parse(plain.toString('utf8')); } catch { throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has an unexpected form'); } finally { plain.fill(0); } const derA = Buffer.from(obj.a, 'base64'); const derB = Buffer.from(obj.b, 'base64'); let k; try { k = { a: crypto.createPrivateKey({ key: derA, format: 'der', type: 'pkcs8' }), b: crypto.createPrivateKey({ key: derB, format: 'der', type: 'pkcs8' }), }; } catch { throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be imported'); } finally { derA.fill(0); derB.fill(0); } const want = key.type === 'encrypt' ? ['x25519', 'ml-kem-768'] : ['ed25519', 'ml-dsa-65']; if (k.a.asymmetricKeyType !== want[0] || k.b.asymmetricKeyType !== want[1]) { throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has the wrong algorithm'); } this.#priv.set(id, k); return k; } #publicKeys(key, ver) { const id = `${key.name}:${ver}`; const cached = this.#pub.get(id); if (cached) return cached; const v = key.versions[String(ver)]; let r; if (key.type === 'encrypt') { const rawA = Buffer.from(v.public.x25519, 'base64'); const spki = Buffer.from(v.public.ml_kem_768, 'base64'); r = { rawA, rawB: rawFromSpki(spki, 'ml-kem-768'), a: okpPublic(rawA, 'X25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) }; } else { const rawA = Buffer.from(v.public.ed25519, 'base64'); const spki = Buffer.from(v.public.ml_dsa_65, 'base64'); r = { rawA, rawB: rawFromSpki(spki, 'ml-dsa-65'), a: okpPublic(rawA, 'Ed25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) }; } r.fp = Buffer.from(v.fingerprint, 'hex'); this.#pub.set(id, r); return r; } // ----------------------------------------------------------------------- versiuni #checkVersion(key, ver) { if (!Object.hasOwn(key.versions, String(ver))) { throw new KmsError('UNKNOWN_VERSION', `key "${key.name}" has no version ${ver}`, { version: ver }); } if (ver < key.min_decryption_version) throw new KmsError('VERSION_BELOW_MINIMUM', `version ${ver} of key "${key.name}" is below min_decryption_version ${key.min_decryption_version}`, { version: ver }); } #checkFingerprint(key, ver, fp, what) { const fpExpected = Buffer.from(key.versions[String(ver)].fingerprint, 'hex'); if (!fp.equals(fpExpected)) { const other = Object.keys(key.versions).find((v) => Buffer.from(key.versions[v].fingerprint, 'hex').equals(fp)); if (other !== undefined) { throw new KmsError('VERSION_MISMATCH', `the ${what} was produced by version ${other} of key "${key.name}", not by version ${ver}`, { version: ver }); } throw new KmsError('KEY_MISMATCH', `the ${what} was not produced by key "${key.name}"`, { version: ver }); } } // ----------------------------------------------------------------------- plicul #encryptWith(key, pt, aad) { const ver = key.latest_version; const pk = this.#publicKeys(key, ver); const eph = crypto.generateKeyPairSync('x25519'); const ePub = rawOkp(eph.publicKey); const ssX = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: pk.a }); const { sharedKey: ssK, ciphertext: ctK } = crypto.encapsulate(pk.b); const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad); ssX.fill(0); ssK.fill(0); const pre = Buffer.concat([MAGIC_E, Buffer.from([KIND_E]), u32(ver), pk.fp, ePub, ctK, d.aadTag]); const commit = hmac(d.commitKey, pre).subarray(0, 16); const hdr = Buffer.concat([pre, commit]); const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv); c.setAAD(hdr); const ct = Buffer.concat([c.update(pt), c.final()]); const body = Buffer.concat([hdr, ct, c.getAuthTag()]); d.dek.fill(0); return { ciphertext: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver }; } #decryptWith(key, ctStr, aad) { const { ver, body, fp } = parseEnvelope(ctStr, KIND_E); try { if (body.length < ENV_MIN) throw new KmsError('MALFORMED_CIPHERTEXT', 'ciphertext is too short'); this.#checkVersion(key, ver); this.#checkFingerprint(key, ver, fp, 'ciphertext'); const pk = this.#publicKeys(key, ver); const sk = this.#privateKeys(key, ver); const ePub = body.subarray(OFF.ePub, OFF.ePub + SZ.x); const ctK = body.subarray(OFF.ctK, OFF.ctK + SZ.ctK); const aadTag = body.subarray(OFF.aadTag, OFF.aadTag + 16); const commit = body.subarray(OFF.commit, OFF.commit + 16); let ssX, ssK; try { ssX = crypto.diffieHellman({ privateKey: sk.a, publicKey: okpPublic(ePub, 'X25519') }); ssK = crypto.decapsulate(sk.b, ctK); } catch { throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version'); } const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad); ssX.fill(0); ssK.fill(0); try { if (!ctEq(hmac(d.commitKey, body.subarray(0, OFF.commit)).subarray(0, 16), commit)) { throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version'); } if (!ctEq(d.aadTag, aadTag)) { throw new KmsError('AAD_MISMATCH', 'the additional authenticated data (aad) does not match the one used at encryption'); } let plaintext; try { const dc = crypto.createDecipheriv('aes-256-gcm', d.key, d.iv); dc.setAAD(body.subarray(0, OFF.payload)); dc.setAuthTag(body.subarray(body.length - 16)); plaintext = Buffer.concat([dc.update(body.subarray(OFF.payload, body.length - 16)), dc.final()]); } catch { throw new KmsError('PAYLOAD_AUTH_FAILED', 'the encrypted payload or its authentication tag was modified'); } return { plaintext, version: ver }; } finally { d.dek.fill(0); } } catch (e) { if (e instanceof KmsError && e.version === undefined) e.version = ver; throw e; } } #verifyWith(key, msg, signature) { const { ver, body, fp } = parseEnvelope(signature, KIND_S); if (body.length !== SIG_LEN) throw new KmsError('MALFORMED_SIGNATURE', `signature must be exactly ${SIG_LEN} bytes after base64 decoding`, { version: ver }); this.#checkVersion(key, ver); this.#checkFingerprint(key, ver, fp, 'signature'); const pk = this.#publicKeys(key, ver); const m = sigMessage(pk.fp, ver, msg); const edSig = body.subarray(17, 17 + SZ.ed); const mlSig = body.subarray(17 + SZ.ed); const safe = (f) => { try { return f() === true; } catch { return false; } }; const edOk = safe(() => crypto.verify(null, m, pk.a, edSig)); const pqOk = safe(() => crypto.verify(null, m, { key: pk.b, context: utf8(LABELS.sigCtx) }, mlSig)); const valid = edOk && pqOk; let reason = null; if (!edOk && !pqOk) reason = 'BOTH_SIGNATURES_INVALID'; else if (!edOk) reason = 'CLASSICAL_SIGNATURE_INVALID'; else if (!pqOk) reason = 'PQ_SIGNATURE_INVALID'; return { valid, reason, version: ver, classical: edOk, post_quantum: pqOk }; } // ----------------------------------------------------------------------- jurnalul de audit #audit(e) { const row = { seq: this.#head.seq + 1, ts: new Date().toISOString(), op: e.op, key: e.key ?? null, version: e.version ?? null, ok: e.ok === true, reason: e.reason ?? null, prev: this.#head.mac, }; row.mac = hmac(this.#auditKey, utf8(canonicalJson(row))).toString('hex'); try { const fd = fs.openSync(this.#auditPath, 'a', 0o600); try { fs.writeSync(fd, JSON.stringify(row) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { throw new KmsError('AUDIT_WRITE_FAILED', 'the audit log could not be written; the result of the operation is withheld'); } this.#head = { seq: row.seq, mac: row.mac }; } // Ruleaza o operatie si scrie randul ei de audit. fn intoarce { value, version, ok?, reason? }. // Rezultatul nu iese din functie decat dupa ce randul de audit e scris. #run(op, name, fn) { if (this.#closed) throw new KmsError('KMS_CLOSED', 'this KMS instance was closed'); const keyName = typeof name === 'string' && NAME_RE.test(name) ? name : null; // A4 (revizuirea din 2026-09-25): o MUTATIE (creare, rotire, minim de versiune) ramanea pe disc cand randul de audit nu se // putea scrie, iar lantul de audit ramanea valid fara nicio urma a ei. Regula e "o schimbare sta numai daca randul ei sta": // starea fisierului cheii se retine INAINTE de operatie si se pune la loc daca randul nu se poate scrie. const keyFile = keyName ? this.#keyPath(keyName) : null; const before = keyFile && fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null; const rollback = () => { if (!keyFile) return; const after = fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null; const changed = (before === null) !== (after === null) || (before !== null && after !== null && !before.equals(after)); if (!changed) return; if (before === null) fs.rmSync(keyFile, { force: true }); else writeFileAtomic(keyFile, before); this.#keys.delete(keyName); }; let r; try { r = fn(); } catch (e0) { let e = e0; if (!(e instanceof KmsError)) { e = new KmsError('INTERNAL', 'internal error'); Object.defineProperty(e, 'cause', { value: e0, enumerable: false }); } try { this.#audit({ op, key: keyName, version: e.version ?? null, ok: false, reason: e.code }); } catch (ea) { rollback(); throw ea; } throw e; } try { this.#audit({ op, key: keyName, version: r.version ?? null, ok: r.ok ?? true, reason: r.reason ?? null }); } catch (ea) { rollback(); throw ea; } return r.value; } auditHead() { return { seq: this.#head.seq, mac: this.#head.mac }; } // Verifica tot jurnalul: fiecare rand trebuie sa aiba mac-ul corect, numarul de ordine urmator // si mac-ul randului anterior. expectedHead = un cap {seq, mac} tinut in afara (prinde taierea // cozii, pe care un lant singur nu o poate vedea). verifyAudit({ expectedHead } = {}) { const fail = (reason, line, extra) => ({ ok: false, reason, line, ...extra }); let lines = []; if (fs.existsSync(this.#auditPath)) { const text = fs.readFileSync(this.#auditPath, 'utf8'); if (text !== '') { lines = text.split('\n'); if (lines[lines.length - 1] === '') lines.pop(); else return fail('AUDIT_ROW_UNPARSABLE', lines.length); } } let prev = ZERO_MAC; let seq = -1; for (let i = 0; i < lines.length; i++) { let row; try { row = JSON.parse(lines[i]); } catch { return fail('AUDIT_ROW_UNPARSABLE', i + 1); } if (!row || typeof row !== 'object' || Array.isArray(row)) return fail('AUDIT_ROW_UNPARSABLE', i + 1); const { mac, ...rest } = row; const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(hmac(this.#auditKey, utf8(canonicalJson(rest))).toString('hex'))); if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1); if (row.seq !== seq + 1 || row.prev !== prev) return fail('AUDIT_CHAIN_BROKEN', i + 1); prev = mac; seq = row.seq; } const head = { seq, mac: prev }; if (expectedHead) { if (seq < expectedHead.seq) return fail('AUDIT_TRUNCATED', lines.length, { head }); const row = JSON.parse(lines[expectedHead.seq]); if (row.mac !== expectedHead.mac) return fail('AUDIT_HEAD_MISMATCH', expectedHead.seq + 1, { head }); } return { ok: true, rows: lines.length, head }; } // ----------------------------------------------------------------------- cheile createKey(name, { type, exportable = false } = {}) { return this.#run('create_key', name, () => { checkName(name); if (type !== 'encrypt' && type !== 'sign') throw new KmsError('INVALID_KEY_TYPE', 'type must be "encrypt" or "sign"'); if (typeof exportable !== 'boolean') throw new KmsError('INVALID_POLICY', 'exportable must be a boolean'); if (this.#keys.has(name) || fs.existsSync(this.#keyPath(name))) throw new KmsError('KEY_EXISTS', `key "${name}" already exists`); const key = { format: KEY_FORMAT, name, type, created: new Date().toISOString(), policy: { exportable }, min_decryption_version: 1, latest_version: 0, versions: {}, }; const ver = this.#newVersion(key); this.#save(key); return { value: describe(key), version: ver }; }); } getKey(name) { return this.#run('read_key', name, () => { const key = this.#load(name); return { value: describe(key), version: key.latest_version }; }); } listKeys() { return this.#run('list_keys', null, () => { const names = fs.readdirSync(this.#keysDir) .filter((f) => f.endsWith('.json')) .map((f) => f.slice(0, -5)) .filter((n) => NAME_RE.test(n)) .sort(); return { value: names }; }); } rotate(name) { return this.#run('rotate', name, () => { const key = structuredClone(this.#load(name)); const ver = this.#newVersion(key); this.#save(key); return { value: describe(key), version: ver }; }); } // Minimul se poate numai RIDICA: o versiune retrasa nu mai decripteaza si nu mai verifica. setMinDecryptionVersion(name, minVersion) { return this.#run('config', name, () => { const key = structuredClone(this.#load(name)); if (!Number.isSafeInteger(minVersion) || minVersion < 1 || minVersion > key.latest_version) { throw new KmsError('VERSION_OUT_OF_RANGE', `min_decryption_version must be an integer between 1 and ${key.latest_version}`); } if (minVersion < key.min_decryption_version) { throw new KmsError('MIN_VERSION_NOT_MONOTONIC', `min_decryption_version can only be raised (current: ${key.min_decryption_version})`); } key.min_decryption_version = minVersion; this.#save(key); return { value: describe(key), version: minVersion }; }); } exportKey(name, version) { return this.#run('export', name, () => { const key = this.#load(name); if (key.policy.exportable !== true) throw new KmsError('KEY_NOT_EXPORTABLE', `key "${name}" was not created as exportable`); const ver = version === undefined || version === null ? key.latest_version : version; if (!Number.isSafeInteger(ver) || !Object.hasOwn(key.versions, String(ver))) { throw new KmsError('UNKNOWN_VERSION', `key "${name}" has no version ${ver}`); } const sk = this.#privateKeys(key, ver); const der = (k) => k.export({ format: 'der', type: 'pkcs8' }).toString('base64'); const priv = key.type === 'encrypt' ? { x25519_pkcs8: der(sk.a), ml_kem_768_pkcs8: der(sk.b) } : { ed25519_pkcs8: der(sk.a), ml_dsa_65_pkcs8: der(sk.b) }; return { value: { name, type: key.type, version: ver, fingerprint: key.versions[String(ver)].fingerprint, private: priv }, version: ver, }; }); } // ----------------------------------------------------------------------- operatiile transit encrypt(name, plaintext, aad) { return this.#run('encrypt', name, () => { const key = this.#load(name, 'encrypt'); const pt = toBytes(plaintext, 'plaintext', MAX_PLAINTEXT); const r = this.#encryptWith(key, pt, normAad(aad)); pt.fill(0); return { value: r, version: r.version }; }); } decrypt(name, ciphertext, aad) { return this.#run('decrypt', name, () => { const key = this.#load(name, 'encrypt'); const r = this.#decryptWith(key, ciphertext, normAad(aad)); return { value: r, version: r.version }; }); } // Reincapsuleaza la ultima versiune. Textul clar nu iese din proces: raspunsul are numai // textul cifrat nou. rewrap(name, ciphertext, aad) { return this.#run('rewrap', name, () => { const key = this.#load(name, 'encrypt'); const aadB = normAad(aad); const { plaintext } = this.#decryptWith(key, ciphertext, aadB); try { const r = this.#encryptWith(key, plaintext, aadB); return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version }; } finally { plaintext.fill(0); } }); } datakey(name, { aad, bits = 256, includePlaintext = true } = {}) { return this.#run('datakey', name, () => { const key = this.#load(name, 'encrypt'); if (![128, 256, 512].includes(bits)) throw new KmsError('INVALID_BITS', 'bits must be 128, 256 or 512'); if (typeof includePlaintext !== 'boolean') throw new KmsError('INVALID_INPUT', 'includePlaintext must be a boolean'); const dk = crypto.randomBytes(bits / 8); try { const r = this.#encryptWith(key, dk, normAad(aad)); const value = { ciphertext: r.ciphertext, version: r.version }; if (includePlaintext) value.plaintext = dk.toString('base64'); return { value, version: r.version }; } finally { dk.fill(0); } }); } sign(name, message) { return this.#run('sign', name, () => { const key = this.#load(name, 'sign'); const msg = toBytes(message, 'message', MAX_MESSAGE); const ver = key.latest_version; const pk = this.#publicKeys(key, ver); const sk = this.#privateKeys(key, ver); const m = sigMessage(pk.fp, ver, msg); const edSig = crypto.sign(null, m, sk.a); const mlSig = crypto.sign(null, m, { key: sk.b, context: utf8(LABELS.sigCtx) }); if (edSig.length !== SZ.ed || mlSig.length !== SZ.mlSig) throw new KmsError('INTERNAL', 'unexpected signature length'); const body = Buffer.concat([MAGIC_S, Buffer.from([KIND_S]), u32(ver), pk.fp, edSig, mlSig]); return { value: { signature: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver }, version: ver }; }); } // Intoarce { valid, reason, version, classical, post_quantum }. valid cere AMBELE semnaturi. verify(name, message, signature) { return this.#run('verify', name, () => { const key = this.#load(name, 'sign'); const msg = toBytes(message, 'message', MAX_MESSAGE); let r; try { r = this.#verifyWith(key, msg, signature); } catch (e) { if (e instanceof KmsError && VERIFY_REFUSALS.has(e.code)) { r = { valid: false, reason: e.code, version: e.version ?? null, classical: false, post_quantum: false }; } else { throw e; } } return { value: r, version: r.version, ok: r.valid, reason: r.reason }; }); } }