913 lines
36 KiB
JavaScript
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 };
|
|
});
|
|
}
|
|
}
|