aere-quantum/pq-kms/kms.mjs

913 lines
36 KiB
JavaScript

// 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<version>:<base64>`);
const m = ENV_RE.exec(str);
if (!m) throw new KmsError(bad, `${what} is not of the form aerekms:v<version>:<base64>`);
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 };
});
}
}