1340 lines
62 KiB
JavaScript
1340 lines
62 KiB
JavaScript
// test/proba.mjs - proba Aere PQ KMS, fara framework.
|
|
//
|
|
// Fiecare afirmatie are perechea ei negativa, iar refuzurile se judeca dupa MOTIV (codul
|
|
// erorii), nu doar dupa faptul ca a aruncat ceva. Interoperabilitatea se masoara cu o a doua
|
|
// implementare a formatului, scrisa aici independent (HKDF de mana, peste HMAC din node:crypto),
|
|
// care decapsuleaza si construieste plicuri direct cu primitivele din node:crypto.
|
|
//
|
|
// Iesire: tabel pe stdout; cod 0 daca toate trec, 1 daca una pica, 2 daca harnessul a cazut.
|
|
// Cu AERE_KMS_PROBA_JSON=<fisier> scrie si rezultatul complet ca JSON (il citeste
|
|
// test/control-negativ.mjs; "completed: true" inseamna ca s-a ajuns la capat).
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import http from 'node:http';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { openKms, KmsError } from '../kms.mjs';
|
|
import { createServer, configFromEnv } from '../server.mjs';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const SERVER_PATH = path.join(HERE, '..', 'server.mjs');
|
|
|
|
// ---------------------------------------------------------------------------------- harness
|
|
|
|
const probe = [];
|
|
function proba(nume, fn) {
|
|
if (probe.some((p) => p.nume === nume)) throw new Error(`proba dublata: ${nume}`);
|
|
probe.push({ nume, fn });
|
|
}
|
|
|
|
class Esec extends Error {}
|
|
function adevarat(c, ce) {
|
|
if (!c) throw new Esec(ce);
|
|
}
|
|
function egal(a, b, ce) {
|
|
if (a !== b) throw new Esec(`${ce}: asteptat ${JSON.stringify(b)}, primit ${JSON.stringify(a)}`);
|
|
}
|
|
function refuza(fn, cod, ce) {
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
if (!(e instanceof KmsError)) throw new Esec(`${ce}: a aruncat altceva decat KmsError: ${e && e.message}`);
|
|
if (e.code !== cod) throw new Esec(`${ce}: motiv asteptat ${cod}, primit ${e.code} (${e.message})`);
|
|
return e;
|
|
}
|
|
throw new Esec(`${ce}: trebuia refuzat cu ${cod}, a trecut`);
|
|
}
|
|
|
|
const tmpDirs = [];
|
|
function tmpDir(tag = 'd') {
|
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), `aerekms-proba-${tag}-`));
|
|
tmpDirs.push(d);
|
|
return d;
|
|
}
|
|
function rootHex() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
function nouKms(dir = tmpDir(), root = rootHex()) {
|
|
return { kms: openKms({ dataDir: dir, rootKey: root }), dir, root };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------- a doua implementare a formatului
|
|
|
|
const L = {
|
|
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',
|
|
};
|
|
const OFF = { ver: 5, fp: 9, ePub: 17, ctK: 49, aadTag: 1137, commit: 1153, payload: 1169 };
|
|
|
|
const u32 = (n) => {
|
|
const b = Buffer.alloc(4);
|
|
b.writeUInt32BE(n);
|
|
return b;
|
|
};
|
|
const sha256 = (...p) => {
|
|
const h = crypto.createHash('sha256');
|
|
p.forEach((x) => h.update(x));
|
|
return h.digest();
|
|
};
|
|
const hmac = (k, ...p) => {
|
|
const h = crypto.createHmac('sha256', k);
|
|
p.forEach((x) => h.update(x));
|
|
return h.digest();
|
|
};
|
|
// HKDF-SHA-256 (RFC 5869) scris de mana, nu crypto.hkdfSync: a doua implementare, nu aceeasi.
|
|
function hkdfManual(ikm, salt, info, len) {
|
|
const prk = hmac(salt, ikm);
|
|
const out = [];
|
|
let t = Buffer.alloc(0);
|
|
for (let i = 1; out.reduce((s, b) => s + b.length, 0) < len; i++) {
|
|
t = hmac(prk, t, info, Buffer.from([i]));
|
|
out.push(t);
|
|
}
|
|
return Buffer.concat(out).subarray(0, len);
|
|
}
|
|
const x25519Pub = (raw) => crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' });
|
|
const ed25519Pub = (raw) => crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' });
|
|
const pkcs8 = (b64) => crypto.createPrivateKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
const spki = (b64) => crypto.createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' });
|
|
|
|
function deriveManual({ name, ver, fp, xPubRaw, ekRaw, ePub, ctK, ikm, aad }) {
|
|
const tr = Buffer.concat([Buffer.from(L.kem + '\0'), u32(Buffer.byteLength(name)), Buffer.from(name), u32(ver), fp, xPubRaw, sha256(ekRaw), ePub, ctK]);
|
|
const salt = sha256(tr);
|
|
const commitKey = hkdfManual(ikm, salt, Buffer.from(L.commit), 32);
|
|
const aadKey = hkdfManual(ikm, salt, Buffer.from(L.aad), 32);
|
|
const dek = hkdfManual(ikm, salt, Buffer.concat([Buffer.from(L.dek + '\0'), sha256(aad)]), 44);
|
|
return { commitKey, aadTag: hmac(aadKey, aad).subarray(0, 16), key: dek.subarray(0, 32), iv: dek.subarray(32, 44) };
|
|
}
|
|
|
|
function parseManual(ct) {
|
|
const m = /^aerekms:v(\d+):(.+)$/.exec(ct);
|
|
if (!m) throw new Error('forma');
|
|
const body = Buffer.from(m[2], 'base64');
|
|
return {
|
|
prefixVer: Number(m[1]),
|
|
body,
|
|
ver: body.readUInt32BE(OFF.ver),
|
|
fp: body.subarray(OFF.fp, OFF.fp + 8),
|
|
ePub: body.subarray(OFF.ePub, OFF.ePub + 32),
|
|
ctK: body.subarray(OFF.ctK, OFF.ctK + 1088),
|
|
aadTag: body.subarray(OFF.aadTag, OFF.aadTag + 16),
|
|
commit: body.subarray(OFF.commit, OFF.commit + 16),
|
|
};
|
|
}
|
|
|
|
// Deschide un plic cu un IKM dat (ambele secrete, sau doar unul, pentru proba atacatorului).
|
|
function openManual(ct, { name, pubVer, ikm, aad }) {
|
|
const p = parseManual(ct);
|
|
const xPubRaw = Buffer.from(pubVer.public.x25519, 'base64');
|
|
const ekRaw = Buffer.from(pubVer.public.ml_kem_768, 'base64').subarray(22);
|
|
const d = deriveManual({ name, ver: p.ver, fp: p.fp, xPubRaw, ekRaw, ePub: p.ePub, ctK: p.ctK, ikm, aad });
|
|
const commit = hmac(d.commitKey, p.body.subarray(0, OFF.commit)).subarray(0, 16);
|
|
if (!commit.equals(p.commit)) throw new Error('commit');
|
|
if (!d.aadTag.equals(p.aadTag)) throw new Error('aad');
|
|
const dc = crypto.createDecipheriv('aes-256-gcm', d.key, d.iv);
|
|
dc.setAAD(p.body.subarray(0, OFF.payload));
|
|
dc.setAuthTag(p.body.subarray(p.body.length - 16));
|
|
return Buffer.concat([dc.update(p.body.subarray(OFF.payload, p.body.length - 16)), dc.final()]);
|
|
}
|
|
|
|
// Secretele unui plic, calculate cu cheile private exportate (direct cu node:crypto).
|
|
function secreteManual(ct, exp) {
|
|
const p = parseManual(ct);
|
|
const ssX = crypto.diffieHellman({ privateKey: pkcs8(exp.private.x25519_pkcs8), publicKey: x25519Pub(p.ePub) });
|
|
const ssK = crypto.decapsulate(pkcs8(exp.private.ml_kem_768_pkcs8), p.ctK);
|
|
return { ssX, ssK };
|
|
}
|
|
|
|
// Construieste un plic numai din cheile publice. mod: 'ambele' | 'doar-x' | 'doar-k'.
|
|
function sealManual({ name, ver, pubVer, pt, aad, mod = 'ambele' }) {
|
|
const xPubRaw = Buffer.from(pubVer.public.x25519, 'base64');
|
|
const kSpki = Buffer.from(pubVer.public.ml_kem_768, 'base64');
|
|
const ekRaw = kSpki.subarray(22);
|
|
const fp = Buffer.from(pubVer.fingerprint, 'hex');
|
|
const eph = crypto.generateKeyPairSync('x25519');
|
|
const ePub = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url');
|
|
const ssX = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519Pub(xPubRaw) });
|
|
const { sharedKey: ssK, ciphertext: ctK } = crypto.encapsulate(crypto.createPublicKey({ key: kSpki, format: 'der', type: 'spki' }));
|
|
const ikm = mod === 'ambele' ? Buffer.concat([ssK, ssX]) : mod === 'doar-x' ? ssX : ssK;
|
|
const d = deriveManual({ name, ver, fp, xPubRaw, ekRaw, ePub, ctK, ikm, aad });
|
|
const pre = Buffer.concat([Buffer.from('AKM1'), Buffer.from([0x45]), u32(ver), fp, ePub, ctK, d.aadTag]);
|
|
const hdr = Buffer.concat([pre, hmac(d.commitKey, pre).subarray(0, 16)]);
|
|
const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv);
|
|
c.setAAD(hdr);
|
|
const body = Buffer.concat([hdr, c.update(pt), c.final(), c.getAuthTag()]);
|
|
return `aerekms:v${ver}:${body.toString('base64')}`;
|
|
}
|
|
|
|
function reencode(ct, fn) {
|
|
const p = parseManual(ct);
|
|
const body = Buffer.from(p.body);
|
|
const prefixVer = fn(body) ?? p.prefixVer;
|
|
return `aerekms:v${prefixVer}:${body.toString('base64')}`;
|
|
}
|
|
|
|
function sigMessageManual(fpHex, ver, msg) {
|
|
return Buffer.concat([Buffer.from(L.sig + '\0' + L.sigAlg + '\0'), Buffer.from(fpHex, 'hex'), u32(ver), Buffer.from(msg)]);
|
|
}
|
|
function sigParts(sig) {
|
|
const m = /^aerekms:v(\d+):(.+)$/.exec(sig);
|
|
const body = Buffer.from(m[2], 'base64');
|
|
return { body, ver: body.readUInt32BE(5), ed: body.subarray(17, 81), ml: body.subarray(81) };
|
|
}
|
|
function sigWith(sig, fn) {
|
|
const m = /^aerekms:v(\d+):(.+)$/.exec(sig);
|
|
const body = Buffer.from(m[2], 'base64');
|
|
fn(body);
|
|
return `aerekms:v${m[1]}:${body.toString('base64')}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------- scanerul de material privat
|
|
|
|
// Cauta octetii ac in fisier si in tot ce se poate decoda din el (base64, hex, JSON), recursiv.
|
|
function haystacks(buf, depth = 0, out = []) {
|
|
out.push(buf);
|
|
if (depth > 4) return out;
|
|
const text = buf.toString('utf8');
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(text);
|
|
} catch {
|
|
obj = undefined;
|
|
}
|
|
const strings = [];
|
|
const walk = (v) => {
|
|
if (typeof v === 'string') strings.push(v);
|
|
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
|
};
|
|
if (obj !== undefined) walk(obj);
|
|
else strings.push(text);
|
|
for (const s of strings) {
|
|
if (s.length >= 16 && /^[A-Za-z0-9+/]+={0,2}$/.test(s)) haystacks(Buffer.from(s, 'base64'), depth + 1, out);
|
|
if (s.length >= 16 && /^[A-Za-z0-9_-]+$/.test(s)) haystacks(Buffer.from(s, 'base64url'), depth + 1, out);
|
|
if (s.length >= 16 && /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) haystacks(Buffer.from(s, 'hex'), depth + 1, out);
|
|
}
|
|
return out;
|
|
}
|
|
function needlesDin(exp) {
|
|
const n = [];
|
|
for (const b64 of Object.values(exp.private)) {
|
|
const der = Buffer.from(b64, 'base64');
|
|
n.push(der);
|
|
n.push(der.subarray(der.length - 32)); // scalar X25519 / samanta Ed25519 / coada cheii PQ
|
|
if (der.length > 200) n.push(der.subarray(100, 132));
|
|
}
|
|
return n;
|
|
}
|
|
function gasesteMaterial(files, needles) {
|
|
const gasite = [];
|
|
for (const f of files) {
|
|
const hs = haystacks(fs.readFileSync(f));
|
|
for (const nd of needles) if (hs.some((h) => h.indexOf(nd) !== -1)) gasite.push(path.basename(f));
|
|
}
|
|
return gasite;
|
|
}
|
|
function toateFisierele(dir) {
|
|
const out = [];
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const p = path.join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...toateFisierele(p));
|
|
else out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------- HTTP
|
|
|
|
function cerere(port, method, p, { headers = {}, body, chunked = false } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const data = body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
|
|
const h = { ...headers };
|
|
if (data && !chunked) h['content-length'] = data.length;
|
|
if (data && h['content-type'] === undefined) h['content-type'] = 'application/json';
|
|
const req = http.request({ host: '127.0.0.1', port, method, path: p, headers: h, agent: false }, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = null;
|
|
}
|
|
resolve({ status: res.statusCode, json, text });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
if (data) {
|
|
if (chunked) for (let i = 0; i < data.length; i += 1024) req.write(data.subarray(i, i + 1024));
|
|
else req.write(data);
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function cuServer(fn, { maxBody = 4096 } = {}) {
|
|
const { kms, dir } = nouKms();
|
|
const token = crypto.randomBytes(24).toString('base64url') + 'x'.repeat(8);
|
|
const server = createServer({ kms, token, maxBody });
|
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
const port = server.address().port;
|
|
const auth = { authorization: `Bearer ${token}` };
|
|
try {
|
|
await fn({ port, kms, token, auth, dir });
|
|
} finally {
|
|
await new Promise((r) => server.close(r));
|
|
kms.close();
|
|
}
|
|
}
|
|
|
|
const b64 = (s) => Buffer.from(s).toString('base64');
|
|
const envCurat = () => Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('AERE_KMS_')));
|
|
|
|
// ================================================================================== PROBELE
|
|
|
|
proba('mediu: node:crypto are ml-kem-768, ml-dsa-65, x25519, ed25519', () => {
|
|
const k = crypto.generateKeyPairSync('ml-kem-768');
|
|
const { sharedKey, ciphertext } = crypto.encapsulate(k.publicKey);
|
|
adevarat(crypto.decapsulate(k.privateKey, ciphertext).equals(sharedKey), 'ML-KEM-768 dus-intors');
|
|
const d = crypto.generateKeyPairSync('ml-dsa-65');
|
|
const s = crypto.sign(null, Buffer.from('m'), d.privateKey);
|
|
adevarat(crypto.verify(null, Buffer.from('m'), d.publicKey, s), 'ML-DSA-65 verifica');
|
|
adevarat(!crypto.verify(null, Buffer.from('n'), d.publicKey, s), 'ML-DSA-65 refuza alt mesaj');
|
|
egal(crypto.generateKeyPairSync('x25519').publicKey.asymmetricKeyType, 'x25519', 'x25519');
|
|
egal(crypto.generateKeyPairSync('ed25519').publicKey.asymmetricKeyType, 'ed25519', 'ed25519');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- cheia radacina
|
|
|
|
proba('radacina: cheie valida porneste serviciul', () => {
|
|
const { kms, dir } = nouKms();
|
|
adevarat(fs.existsSync(path.join(dir, 'root-check.json')), 'root-check.json scris');
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.close();
|
|
});
|
|
|
|
proba('radacina: lipsa cheii refuza pornirea cu ROOT_KEY_MISSING si nu creeaza nimic pe disc', () => {
|
|
for (const v of [undefined, null, '', ' ', '\r\n']) {
|
|
const dir = path.join(tmpDir('lipsa'), 'nu-exista');
|
|
refuza(() => openKms({ dataDir: dir, rootKey: v }), 'ROOT_KEY_MISSING', `rootKey=${JSON.stringify(v)}`);
|
|
adevarat(!fs.existsSync(dir), 'dosarul de date nu trebuie creat fara cheie radacina');
|
|
}
|
|
});
|
|
|
|
proba('radacina: cheie malformata refuzata cu ROOT_KEY_INVALID fara sa tipareasca valoarea', () => {
|
|
const valid = rootHex();
|
|
for (const v of ['zz' + valid.slice(2), valid.slice(1), valid + 'a', 'nu-e-hex-deloc-nu-e-hex-deloc-nu-e-hex-deloc-nu-e-hex-deloc-1234']) {
|
|
const e = refuza(() => openKms({ dataDir: tmpDir(), rootKey: v }), 'ROOT_KEY_INVALID', 'forma');
|
|
adevarat(!e.message.includes(v) && !JSON.stringify({ ...e }).includes(v), 'mesajul contine valoarea cheii');
|
|
adevarat(!e.message.includes(v.slice(0, 16)), 'mesajul contine un prefix al cheii');
|
|
}
|
|
});
|
|
|
|
proba('radacina: cheie de zerouri refuzata cu ROOT_KEY_WEAK', () => {
|
|
refuza(() => openKms({ dataDir: tmpDir(), rootKey: '0'.repeat(64) }), 'ROOT_KEY_WEAK', 'zerouri');
|
|
});
|
|
|
|
proba('radacina: CRLF la coada e taiat, aceeasi cheie redeschide depozitul', () => {
|
|
const root = rootHex();
|
|
const dir = tmpDir();
|
|
const a = openKms({ dataDir: dir, rootKey: root });
|
|
const c = a.encrypt(a.createKey('k', { type: 'encrypt' }).name, 'x').ciphertext;
|
|
a.close();
|
|
const b = openKms({ dataDir: dir, rootKey: root.toUpperCase() + '\r\n' });
|
|
egal(b.decrypt('k', c).plaintext.toString(), 'x', 'decriptare dupa redeschidere');
|
|
b.close();
|
|
});
|
|
|
|
proba('radacina: alta cheie radacina pe acelasi depozit refuzata cu ROOT_KEY_MISMATCH', () => {
|
|
const { kms, dir } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.close();
|
|
refuza(() => openKms({ dataDir: dir, rootKey: rootHex() }), 'ROOT_KEY_MISMATCH', 'alta radacina');
|
|
});
|
|
|
|
proba('radacina: depozit cu chei dar fara root-check.json refuzat cu ROOT_CHECK_MISSING', () => {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.close();
|
|
fs.rmSync(path.join(dir, 'root-check.json'));
|
|
refuza(() => openKms({ dataDir: dir, rootKey: root }), 'ROOT_CHECK_MISSING', 'fara root-check');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- stocare
|
|
|
|
proba('stocare: cheia se reincarca dupa repornire, decripteaza si verifica', () => {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('e', { type: 'encrypt' });
|
|
kms.createKey('s', { type: 'sign' });
|
|
const c = kms.encrypt('e', 'text', 'ctx').ciphertext;
|
|
const s = kms.sign('s', 'mesaj').signature;
|
|
kms.close();
|
|
const k2 = openKms({ dataDir: dir, rootKey: root });
|
|
egal(k2.decrypt('e', c, 'ctx').plaintext.toString(), 'text', 'decriptare dupa repornire');
|
|
egal(k2.verify('s', 'mesaj', s).valid, true, 'verificare dupa repornire');
|
|
k2.close();
|
|
});
|
|
|
|
proba('stocare: materialul privat nu apare in clar pe disc (cu controlul pozitiv al scanerului)', () => {
|
|
const { kms, dir } = nouKms();
|
|
kms.createKey('e', { type: 'encrypt', exportable: true });
|
|
kms.createKey('s', { type: 'sign', exportable: true });
|
|
kms.encrypt('e', 'x');
|
|
kms.sign('s', 'x');
|
|
const needles = [...needlesDin(kms.exportKey('e')), ...needlesDin(kms.exportKey('s'))];
|
|
// control pozitiv: scanerul gaseste materialul ascuns pe doua niveluri (base64 de JSON cu base64)
|
|
const exp = kms.exportKey('e');
|
|
const momeala = path.join(tmpDir('momeala'), 'm.json');
|
|
fs.writeFileSync(momeala, JSON.stringify({ x: Buffer.from(JSON.stringify({ a: exp.private.x25519_pkcs8 })).toString('base64') }));
|
|
adevarat(gasesteMaterial([momeala], needles).length > 0, 'CONTROL POZITIV: scanerul nu gaseste materialul plantat, deci nu masoara nimic');
|
|
const fisiere = toateFisierele(dir);
|
|
egal(fisiere.filter((f) => f.endsWith('.json')).length >= 3, true, 'fisierele de chei exista');
|
|
const gasite = gasesteMaterial(fisiere, needles);
|
|
egal(gasite.length, 0, `material privat gasit in clar in: ${[...new Set(gasite)].join(', ')}`);
|
|
kms.close();
|
|
});
|
|
|
|
proba('stocare: min_decryption_version coborat pe disc refuzat cu KEY_FILE_TAMPERED', () => {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.rotate('k');
|
|
kms.setMinDecryptionVersion('k', 2);
|
|
kms.close();
|
|
const f = path.join(dir, 'keys', 'k.json');
|
|
const rec = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
egal(rec.min_decryption_version, 2, 'minimul scris');
|
|
rec.min_decryption_version = 1;
|
|
fs.writeFileSync(f, JSON.stringify(rec, null, 2));
|
|
const k2 = openKms({ dataDir: dir, rootKey: root });
|
|
refuza(() => k2.getKey('k'), 'KEY_FILE_TAMPERED', 'minim coborat pe disc');
|
|
k2.close();
|
|
});
|
|
|
|
proba('stocare: cheie publica inlocuita pe disc refuzata cu KEY_FILE_TAMPERED', () => {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.close();
|
|
const f = path.join(dir, 'keys', 'k.json');
|
|
const rec = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
const atacator = crypto.generateKeyPairSync('x25519');
|
|
rec.versions['1'].public.x25519 = Buffer.from(atacator.publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64');
|
|
fs.writeFileSync(f, JSON.stringify(rec, null, 2));
|
|
const k2 = openKms({ dataDir: dir, rootKey: root });
|
|
refuza(() => k2.encrypt('k', 'x'), 'KEY_FILE_TAMPERED', 'cheie publica inlocuita');
|
|
k2.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- chei si politica
|
|
|
|
proba('chei: nume valid acceptat, nume invalid refuzat cu INVALID_KEY_NAME', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('a-b_1', { type: 'encrypt' });
|
|
for (const n of ['../x', 'A', '', 'a/b', 'a'.repeat(65), '-a', 'a.json']) {
|
|
refuza(() => kms.createKey(n, { type: 'encrypt' }), 'INVALID_KEY_NAME', `nume ${JSON.stringify(n)}`);
|
|
}
|
|
kms.close();
|
|
});
|
|
|
|
proba('chei: creare dubla refuzata cu KEY_EXISTS, cheie lipsa cu KEY_NOT_FOUND, tip invalid cu INVALID_KEY_TYPE', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
refuza(() => kms.createKey('k', { type: 'sign' }), 'KEY_EXISTS', 'dubla');
|
|
refuza(() => kms.encrypt('lipsa', 'x'), 'KEY_NOT_FOUND', 'lipsa');
|
|
refuza(() => kms.createKey('t', { type: 'aes' }), 'INVALID_KEY_TYPE', 'tip');
|
|
kms.close();
|
|
});
|
|
|
|
proba('chei: operatie pe tipul gresit de cheie refuzata cu WRONG_KEY_TYPE', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('e', { type: 'encrypt' });
|
|
kms.createKey('s', { type: 'sign' });
|
|
refuza(() => kms.encrypt('s', 'x'), 'WRONG_KEY_TYPE', 'encrypt cu cheie de semnare');
|
|
refuza(() => kms.sign('e', 'x'), 'WRONG_KEY_TYPE', 'sign cu cheie de criptare');
|
|
refuza(() => kms.datakey('s'), 'WRONG_KEY_TYPE', 'datakey cu cheie de semnare');
|
|
kms.close();
|
|
});
|
|
|
|
proba('politica: implicit neexportabila, exportul refuzat cu KEY_NOT_EXPORTABLE', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt' });
|
|
egal(d.policy.exportable, false, 'politica implicita');
|
|
refuza(() => kms.exportKey('k'), 'KEY_NOT_EXPORTABLE', 'export');
|
|
refuza(() => kms.createKey('z', { type: 'encrypt', exportable: 'da' }), 'INVALID_POLICY', 'exportable ne-boolean');
|
|
kms.close();
|
|
});
|
|
|
|
proba('politica: cheie exportabila exporta chei private care corespund cheilor publice', () => {
|
|
const { kms } = nouKms();
|
|
const e = kms.createKey('e', { type: 'encrypt', exportable: true });
|
|
const s = kms.createKey('s', { type: 'sign', exportable: true });
|
|
const xe = kms.exportKey('e');
|
|
const xs = kms.exportKey('s');
|
|
const rawX = Buffer.from(crypto.createPublicKey(pkcs8(xe.private.x25519_pkcs8)).export({ format: 'jwk' }).x, 'base64url');
|
|
egal(rawX.toString('base64'), e.versions['1'].public.x25519, 'X25519 privata -> publica');
|
|
const kPub = crypto.createPublicKey(pkcs8(xe.private.ml_kem_768_pkcs8)).export({ format: 'der', type: 'spki' });
|
|
egal(kPub.toString('base64'), e.versions['1'].public.ml_kem_768, 'ML-KEM privata -> publica');
|
|
const rawEd = Buffer.from(crypto.createPublicKey(pkcs8(xs.private.ed25519_pkcs8)).export({ format: 'jwk' }).x, 'base64url');
|
|
egal(rawEd.toString('base64'), s.versions['1'].public.ed25519, 'Ed25519 privata -> publica');
|
|
const dPub = crypto.createPublicKey(pkcs8(xs.private.ml_dsa_65_pkcs8)).export({ format: 'der', type: 'spki' });
|
|
egal(dPub.toString('base64'), s.versions['1'].public.ml_dsa_65, 'ML-DSA privata -> publica');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- criptare
|
|
|
|
proba('criptare: dus-intors cu si fara aad, formatul aerekms:v<ver>:<base64>, plicuri diferite', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const a = kms.encrypt('k', 'secret', 'ctx');
|
|
const b = kms.encrypt('k', 'secret', 'ctx');
|
|
adevarat(/^aerekms:v1:[A-Za-z0-9+/]+={0,2}$/.test(a.ciphertext), 'forma prefixului');
|
|
adevarat(a.ciphertext !== b.ciphertext, 'doua criptari ale aceluiasi text trebuie sa difere');
|
|
egal(kms.decrypt('k', a.ciphertext, 'ctx').plaintext.toString(), 'secret', 'cu aad');
|
|
const c = kms.encrypt('k', Buffer.alloc(0));
|
|
egal(kms.decrypt('k', c.ciphertext).plaintext.length, 0, 'text gol fara aad');
|
|
const mare = crypto.randomBytes(100000);
|
|
adevarat(kms.decrypt('k', kms.encrypt('k', mare).ciphertext).plaintext.equals(mare), '100 KB');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: un octet schimbat in fiecare camp e refuzat cu motivul campului', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const ct = kms.encrypt('k', 'text cu ceva lungime', 'ctx').ciphertext;
|
|
egal(kms.decrypt('k', ct, 'ctx').plaintext.toString(), 'text cu ceva lungime', 'plicul neatins trece');
|
|
const len = parseManual(ct).body.length;
|
|
const campuri = [
|
|
['magic', 0, 'MALFORMED_CIPHERTEXT'],
|
|
['tip', 4, 'MALFORMED_CIPHERTEXT'],
|
|
['versiune', 8, 'VERSION_MISMATCH'],
|
|
['amprenta', 9, 'KEY_MISMATCH'],
|
|
['X25519 efemer', OFF.ePub + 5, 'HEADER_AUTH_FAILED'],
|
|
['ML-KEM ct', OFF.ctK + 500, 'HEADER_AUTH_FAILED'],
|
|
['eticheta aad', OFF.aadTag + 3, 'HEADER_AUTH_FAILED'],
|
|
['angajament', OFF.commit + 3, 'HEADER_AUTH_FAILED'],
|
|
['continut', OFF.payload + 2, 'PAYLOAD_AUTH_FAILED'],
|
|
['eticheta GCM', len - 1, 'PAYLOAD_AUTH_FAILED'],
|
|
];
|
|
for (const [camp, poz, cod] of campuri) {
|
|
const rau = reencode(ct, (b) => {
|
|
b[poz] ^= 0x01;
|
|
});
|
|
refuza(() => kms.decrypt('k', rau, 'ctx'), cod, `octet schimbat in ${camp} (pozitia ${poz})`);
|
|
}
|
|
// trunchiat sub antet: forma rupta; trunchiat in continut: GCM prinde lipsa
|
|
const scurt = `aerekms:v1:${parseManual(ct).body.subarray(0, 1100).toString('base64')}`;
|
|
refuza(() => kms.decrypt('k', scurt, 'ctx'), 'MALFORMED_CIPHERTEXT', 'trunchiat sub antet');
|
|
refuza(() => kms.decrypt('k', ct.slice(0, -8), 'ctx'), 'PAYLOAD_AUTH_FAILED', 'trunchiat in continut');
|
|
refuza(() => kms.decrypt('k', 'vault:v1:AAAA', 'ctx'), 'MALFORMED_CIPHERTEXT', 'alt prefix');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: ORICE octet schimbat (toate pozitiile) e refuzat', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const ct = kms.encrypt('k', 'abcdefgh', 'ctx').ciphertext;
|
|
const len = parseManual(ct).body.length;
|
|
const coduri = new Set(['MALFORMED_CIPHERTEXT', 'VERSION_MISMATCH', 'KEY_MISMATCH', 'UNKNOWN_VERSION', 'HEADER_AUTH_FAILED', 'AAD_MISMATCH', 'PAYLOAD_AUTH_FAILED']);
|
|
let refuzate = 0;
|
|
for (let i = 0; i < len; i++) {
|
|
const rau = reencode(ct, (b) => {
|
|
b[i] ^= 0x80;
|
|
});
|
|
try {
|
|
kms.decrypt('k', rau, 'ctx');
|
|
throw new Esec(`octetul ${i} schimbat a trecut decriptarea`);
|
|
} catch (e) {
|
|
if (e instanceof Esec) throw e;
|
|
adevarat(e instanceof KmsError && coduri.has(e.code), `octetul ${i}: motiv neasteptat ${e.code}`);
|
|
refuzate++;
|
|
}
|
|
}
|
|
egal(refuzate, len, 'toate pozitiile refuzate');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: alt aad refuzat cu AAD_MISMATCH', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const cu = kms.encrypt('k', 'x', 'tenant-1').ciphertext;
|
|
const fara = kms.encrypt('k', 'x').ciphertext;
|
|
egal(kms.decrypt('k', cu, 'tenant-1').plaintext.toString(), 'x', 'aad corect');
|
|
refuza(() => kms.decrypt('k', cu, 'tenant-2'), 'AAD_MISMATCH', 'alt aad');
|
|
refuza(() => kms.decrypt('k', cu), 'AAD_MISMATCH', 'aad lipsa');
|
|
refuza(() => kms.decrypt('k', fara, 'tenant-1'), 'AAD_MISMATCH', 'aad in plus');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: alta cheie refuzata cu KEY_MISMATCH', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('a', { type: 'encrypt' });
|
|
const b = kms.createKey('b', { type: 'encrypt' });
|
|
const ct = kms.encrypt('a', 'x').ciphertext;
|
|
egal(kms.decrypt('a', ct).plaintext.toString(), 'x', 'cheia proprie');
|
|
refuza(() => kms.decrypt('b', ct), 'KEY_MISMATCH', 'alta cheie');
|
|
// si cu amprenta rescrisa pe a cheii b: legatura criptografica tot refuza
|
|
const fals = reencode(ct, (body) => {
|
|
Buffer.from(b.versions['1'].fingerprint, 'hex').copy(body, OFF.fp);
|
|
});
|
|
refuza(() => kms.decrypt('b', fals), 'HEADER_AUTH_FAILED', 'amprenta falsificata');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: alta versiune refuzata cu VERSION_MISMATCH', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const ct1 = kms.encrypt('k', 'x').ciphertext;
|
|
const d2 = kms.rotate('k');
|
|
egal(kms.decrypt('k', ct1).plaintext.toString(), 'x', 'v1 decripteaza dupa rotire');
|
|
const doarPrefix = ct1.replace(/^aerekms:v1:/, 'aerekms:v2:');
|
|
refuza(() => kms.decrypt('k', doarPrefix), 'VERSION_MISMATCH', 'prefix schimbat');
|
|
const prefixSiCorp = reencode(ct1, (b) => {
|
|
b.writeUInt32BE(2, OFF.ver);
|
|
return 2;
|
|
});
|
|
refuza(() => kms.decrypt('k', prefixSiCorp), 'VERSION_MISMATCH', 'prefix si corp schimbate, amprenta v1');
|
|
const totul = reencode(ct1, (b) => {
|
|
b.writeUInt32BE(2, OFF.ver);
|
|
Buffer.from(d2.versions['2'].fingerprint, 'hex').copy(b, OFF.fp);
|
|
return 2;
|
|
});
|
|
refuza(() => kms.decrypt('k', totul), 'HEADER_AUTH_FAILED', 'versiune si amprenta falsificate');
|
|
const inexistenta = reencode(ct1, (b) => {
|
|
b.writeUInt32BE(7, OFF.ver);
|
|
return 7;
|
|
});
|
|
refuza(() => kms.decrypt('k', inexistenta), 'UNKNOWN_VERSION', 'versiune inexistenta');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: base64 necanonic refuzat cu MALFORMED_CIPHERTEXT', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const ct = kms.encrypt('k', 'a').ciphertext; // corp 1186 octeti -> "==" la coada
|
|
const ALF = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
adevarat(ct.endsWith('=='), 'forma asteptata a probei (corp cu padding)');
|
|
const i = ct.length - 3;
|
|
const rau = ct.slice(0, i) + ALF[ALF.indexOf(ct[i]) ^ 1] + ct.slice(i + 1);
|
|
adevarat(Buffer.from(rau.split(':')[2], 'base64').equals(Buffer.from(ct.split(':')[2], 'base64')), 'CONTROL: variantele decodeaza la aceiasi octeti');
|
|
adevarat(rau !== ct, 'CONTROL: textul difera');
|
|
refuza(() => kms.decrypt('k', rau), 'MALFORMED_CIPHERTEXT', 'base64 necanonic');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: atacatorul cu doar secretul X25519 nu poate deriva cheia plicului', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt', exportable: true });
|
|
const exp = kms.exportKey('k');
|
|
const ct = kms.encrypt('k', 'tinta', 'ctx').ciphertext;
|
|
const { ssX, ssK } = secreteManual(ct, exp);
|
|
const z = Buffer.alloc(32);
|
|
for (const ikm of [ssX, Buffer.concat([z, ssX]), Buffer.concat([ssX, z])]) {
|
|
let deschis = false;
|
|
try {
|
|
openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm, aad: Buffer.from('ctx') });
|
|
deschis = true;
|
|
} catch {
|
|
/* asteptat */
|
|
}
|
|
adevarat(!deschis, 'plicul s-a deschis numai cu secretul X25519');
|
|
}
|
|
// control pozitiv al metodei: cu AMBELE secrete, aceeasi cale deschide plicul
|
|
egal(openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm: Buffer.concat([ssK, ssX]), aad: Buffer.from('ctx') }).toString(), 'tinta', 'CONTROL POZITIV');
|
|
kms.close();
|
|
});
|
|
|
|
proba('criptare: atacatorul cu doar secretul ML-KEM nu poate deriva cheia plicului', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt', exportable: true });
|
|
const exp = kms.exportKey('k');
|
|
const ct = kms.encrypt('k', 'tinta', 'ctx').ciphertext;
|
|
const { ssX, ssK } = secreteManual(ct, exp);
|
|
const z = Buffer.alloc(32);
|
|
for (const ikm of [ssK, Buffer.concat([ssK, z]), Buffer.concat([z, ssK])]) {
|
|
let deschis = false;
|
|
try {
|
|
openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm, aad: Buffer.from('ctx') });
|
|
deschis = true;
|
|
} catch {
|
|
/* asteptat */
|
|
}
|
|
adevarat(!deschis, 'plicul s-a deschis numai cu secretul ML-KEM');
|
|
}
|
|
egal(openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm: Buffer.concat([ssK, ssX]), aad: Buffer.from('ctx') }).toString(), 'tinta', 'CONTROL POZITIV');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- interoperabilitate
|
|
|
|
proba('interop: plicul serviciului decapsulat si decriptat manual cu node:crypto', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt', exportable: true });
|
|
const exp = kms.exportKey('k');
|
|
const ct = kms.encrypt('k', 'interoperabil', 'aad-1').ciphertext;
|
|
const { ssX, ssK } = secreteManual(ct, exp);
|
|
const pt = openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm: Buffer.concat([ssK, ssX]), aad: Buffer.from('aad-1') });
|
|
egal(pt.toString(), 'interoperabil', 'decriptare manuala');
|
|
let err = '';
|
|
try {
|
|
openManual(ct, { name: 'k', pubVer: d.versions['1'], ikm: Buffer.concat([ssK, ssX]), aad: Buffer.from('aad-2') });
|
|
} catch (e) {
|
|
err = e.message;
|
|
}
|
|
egal(err, 'aad', 'implementarea manuala refuza alt aad pe eticheta aad');
|
|
kms.close();
|
|
});
|
|
|
|
proba('interop: plic construit manual din cheile publice e decriptat de serviciu', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt' });
|
|
const ct = sealManual({ name: 'k', ver: 1, pubVer: d.versions['1'], pt: Buffer.from('de la client'), aad: Buffer.from('a') });
|
|
egal(kms.decrypt('k', ct, 'a').plaintext.toString(), 'de la client', 'serviciul decripteaza plicul clientului');
|
|
refuza(() => kms.decrypt('k', ct, 'b'), 'AAD_MISMATCH', 'alt aad pe plicul clientului');
|
|
kms.close();
|
|
});
|
|
|
|
proba('interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('k', { type: 'encrypt' });
|
|
for (const mod of ['doar-x', 'doar-k']) {
|
|
const ct = sealManual({ name: 'k', ver: 1, pubVer: d.versions['1'], pt: Buffer.from('x'), aad: Buffer.alloc(0), mod });
|
|
refuza(() => kms.decrypt('k', ct), 'HEADER_AUTH_FAILED', `KDF ${mod}`);
|
|
}
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- rotire si minim
|
|
|
|
proba('rotire: versiune noua, criptarea foloseste ultima, versiunile vechi decripteaza', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const c1 = kms.encrypt('k', 'unu').ciphertext;
|
|
const r = kms.rotate('k');
|
|
egal(r.latest_version, 2, 'ultima versiune');
|
|
const c2 = kms.encrypt('k', 'doi').ciphertext;
|
|
adevarat(c2.startsWith('aerekms:v2:'), 'criptarea foloseste v2');
|
|
egal(kms.decrypt('k', c1).plaintext.toString(), 'unu', 'v1 decripteaza');
|
|
egal(kms.decrypt('k', c2).plaintext.toString(), 'doi', 'v2 decripteaza');
|
|
adevarat(r.versions['1'].fingerprint !== r.versions['2'].fingerprint, 'amprente diferite');
|
|
kms.close();
|
|
});
|
|
|
|
proba('rotire: sub min_decryption_version decriptarea e refuzata cu VERSION_BELOW_MINIMUM', () => {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const c1 = kms.encrypt('k', 'unu').ciphertext;
|
|
kms.rotate('k');
|
|
const c2 = kms.encrypt('k', 'doi').ciphertext;
|
|
kms.setMinDecryptionVersion('k', 2);
|
|
refuza(() => kms.decrypt('k', c1), 'VERSION_BELOW_MINIMUM', 'v1 sub minim');
|
|
refuza(() => kms.rewrap('k', c1), 'VERSION_BELOW_MINIMUM', 'rewrap v1 sub minim');
|
|
egal(kms.decrypt('k', c2).plaintext.toString(), 'doi', 'v2 la minim decripteaza');
|
|
kms.close();
|
|
const k2 = openKms({ dataDir: dir, rootKey: root });
|
|
refuza(() => k2.decrypt('k', c1), 'VERSION_BELOW_MINIMUM', 'minimul persista dupa repornire');
|
|
k2.close();
|
|
});
|
|
|
|
proba('rotire: sub min_decryption_version verificarea semnaturii e refuzata cu VERSION_BELOW_MINIMUM', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s1 = kms.sign('s', 'm').signature;
|
|
kms.rotate('s');
|
|
const s2 = kms.sign('s', 'm').signature;
|
|
adevarat(s2.startsWith('aerekms:v2:'), 'semnarea foloseste v2');
|
|
egal(kms.verify('s', 'm', s1).valid, true, 'v1 verifica inainte de minim');
|
|
kms.setMinDecryptionVersion('s', 2);
|
|
const r = kms.verify('s', 'm', s1);
|
|
egal(r.valid, false, 'v1 sub minim');
|
|
egal(r.reason, 'VERSION_BELOW_MINIMUM', 'motivul');
|
|
egal(kms.verify('s', 'm', s2).valid, true, 'v2 verifica');
|
|
kms.close();
|
|
});
|
|
|
|
proba('rotire: minimul nu coboara (MIN_VERSION_NOT_MONOTONIC) si nu trece de ultima (VERSION_OUT_OF_RANGE)', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
kms.rotate('k');
|
|
kms.setMinDecryptionVersion('k', 2);
|
|
refuza(() => kms.setMinDecryptionVersion('k', 1), 'MIN_VERSION_NOT_MONOTONIC', 'coborare');
|
|
refuza(() => kms.setMinDecryptionVersion('k', 3), 'VERSION_OUT_OF_RANGE', 'peste ultima');
|
|
refuza(() => kms.setMinDecryptionVersion('k', 0), 'VERSION_OUT_OF_RANGE', 'zero');
|
|
refuza(() => kms.setMinDecryptionVersion('k', 1.5), 'VERSION_OUT_OF_RANGE', 'fractie');
|
|
egal(kms.setMinDecryptionVersion('k', 2).min_decryption_version, 2, 'acelasi minim e permis');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- rewrap si datakey
|
|
|
|
proba('rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const MARCAJ = 'marcaj-rewrap-' + crypto.randomBytes(6).toString('hex');
|
|
const c1 = kms.encrypt('k', MARCAJ, 'ctx').ciphertext;
|
|
kms.rotate('k');
|
|
const r = kms.rewrap('k', c1, 'ctx');
|
|
egal(Object.keys(r).sort().join(','), 'ciphertext,version', 'campurile raspunsului');
|
|
egal(r.version, 2, 'versiunea noua');
|
|
adevarat(r.ciphertext.startsWith('aerekms:v2:'), 'prefix v2');
|
|
adevarat(!JSON.stringify(r).includes(MARCAJ) && !JSON.stringify(r).includes(b64(MARCAJ)), 'textul clar in raspuns');
|
|
egal(kms.decrypt('k', r.ciphertext, 'ctx').plaintext.toString(), MARCAJ, 'acelasi text clar');
|
|
refuza(() => kms.rewrap('k', c1, 'alt'), 'AAD_MISMATCH', 'rewrap cu alt aad');
|
|
refuza(() => kms.decrypt('k', r.ciphertext, 'alt'), 'AAD_MISMATCH', 'aad-ul ramane legat dupa rewrap');
|
|
kms.close();
|
|
});
|
|
|
|
proba('datakey: cheia clara si cea invelita corespund; forma doar-invelita nu intoarce cheia clara', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
const dk = kms.datakey('k', { aad: 'fisier-7' });
|
|
const clar = Buffer.from(dk.plaintext, 'base64');
|
|
egal(clar.length, 32, '256 de biti');
|
|
adevarat(kms.decrypt('k', dk.ciphertext, 'fisier-7').plaintext.equals(clar), 'invelita decripteaza la cea clara');
|
|
refuza(() => kms.decrypt('k', dk.ciphertext, 'fisier-8'), 'AAD_MISMATCH', 'cheia de date legata de aad');
|
|
const doar = kms.datakey('k', { includePlaintext: false });
|
|
adevarat(!('plaintext' in doar), 'forma doar-invelita are camp plaintext');
|
|
egal(kms.decrypt('k', doar.ciphertext).plaintext.length, 32, 'forma doar-invelita decripteaza');
|
|
egal(Buffer.from(kms.datakey('k', { bits: 512 }).plaintext, 'base64').length, 64, '512 biti');
|
|
refuza(() => kms.datakey('k', { bits: 100 }), 'INVALID_BITS', 'biti invalizi');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- semnare
|
|
|
|
proba('semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s = kms.sign('s', 'mesaj');
|
|
adevarat(/^aerekms:v1:/.test(s.signature), 'forma');
|
|
const r = kms.verify('s', 'mesaj', s.signature);
|
|
egal(r.valid, true, 'valida');
|
|
egal(r.classical, true, 'Ed25519');
|
|
egal(r.post_quantum, true, 'ML-DSA-65');
|
|
egal(r.reason, null, 'fara motiv');
|
|
kms.close();
|
|
});
|
|
|
|
proba('semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s = kms.sign('s', 'mesaj').signature;
|
|
const rau = sigWith(s, (b) => {
|
|
b[17 + 10] ^= 0x01;
|
|
});
|
|
const r = kms.verify('s', 'mesaj', rau);
|
|
egal(r.valid, false, 'valid');
|
|
egal(r.reason, 'CLASSICAL_SIGNATURE_INVALID', 'motivul');
|
|
egal(r.post_quantum, true, 'jumatatea PQ ramasa buna');
|
|
kms.close();
|
|
});
|
|
|
|
proba('semnare: jumatatea ML-DSA-65 stricata e refuzata cu PQ_SIGNATURE_INVALID', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s = kms.sign('s', 'mesaj').signature;
|
|
const rau = sigWith(s, (b) => {
|
|
b[81 + 1000] ^= 0x01;
|
|
});
|
|
const r = kms.verify('s', 'mesaj', rau);
|
|
egal(r.valid, false, 'valid');
|
|
egal(r.reason, 'PQ_SIGNATURE_INVALID', 'motivul');
|
|
egal(r.classical, true, 'jumatatea clasica ramasa buna');
|
|
kms.close();
|
|
});
|
|
|
|
proba('semnare: ambele jumatati stricate sau mesaj schimbat refuzate cu BOTH_SIGNATURES_INVALID', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s = kms.sign('s', 'mesaj').signature;
|
|
const rau = sigWith(s, (b) => {
|
|
b[17 + 10] ^= 0x01;
|
|
b[81 + 1000] ^= 0x01;
|
|
});
|
|
egal(kms.verify('s', 'mesaj', rau).reason, 'BOTH_SIGNATURES_INVALID', 'ambele stricate');
|
|
const r = kms.verify('s', 'mesaj!', s);
|
|
egal(r.valid, false, 'mesaj schimbat');
|
|
egal(r.reason, 'BOTH_SIGNATURES_INVALID', 'motivul la mesaj schimbat');
|
|
kms.close();
|
|
});
|
|
|
|
proba('semnare: jumatati amestecate din doua semnaturi valide sunt refuzate', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('s', { type: 'sign' });
|
|
const s1 = kms.sign('s', 'unu').signature;
|
|
const s2 = kms.sign('s', 'doi').signature;
|
|
const p2 = sigParts(s2);
|
|
const amestec = sigWith(s1, (b) => {
|
|
p2.ml.copy(b, 81);
|
|
});
|
|
const r1 = kms.verify('s', 'unu', amestec);
|
|
egal(r1.valid, false, 'amestec peste "unu"');
|
|
egal(r1.reason, 'PQ_SIGNATURE_INVALID', 'motivul peste "unu"');
|
|
const r2 = kms.verify('s', 'doi', amestec);
|
|
egal(r2.valid, false, 'amestec peste "doi"');
|
|
egal(r2.reason, 'CLASSICAL_SIGNATURE_INVALID', 'motivul peste "doi"');
|
|
kms.close();
|
|
});
|
|
|
|
proba('semnare: alta cheie refuzata cu KEY_MISMATCH, semnatura malformata cu MALFORMED_SIGNATURE', () => {
|
|
const { kms } = nouKms();
|
|
kms.createKey('a', { type: 'sign' });
|
|
kms.createKey('b', { type: 'sign' });
|
|
const s = kms.sign('a', 'm').signature;
|
|
egal(kms.verify('a', 'm', s).valid, true, 'cheia proprie');
|
|
const r = kms.verify('b', 'm', s);
|
|
egal(r.valid, false, 'alta cheie');
|
|
egal(r.reason, 'KEY_MISMATCH', 'motivul');
|
|
egal(kms.verify('a', 'm', s.slice(0, -12)).reason, 'MALFORMED_SIGNATURE', 'trunchiata');
|
|
egal(kms.verify('a', 'm', 'nu-e-semnatura').reason, 'MALFORMED_SIGNATURE', 'gunoi');
|
|
egal(kms.verify('a', 'm', s.replace('aerekms:v1:', 'aerekms:v2:')).reason, 'VERSION_MISMATCH', 'prefix schimbat');
|
|
kms.close();
|
|
});
|
|
|
|
proba('interop: semnatura verificata manual cu node:crypto; jumatatile nu sunt semnaturi ale mesajului brut', () => {
|
|
const { kms } = nouKms();
|
|
const d = kms.createKey('s', { type: 'sign' });
|
|
const pub = d.versions['1'];
|
|
const s = kms.sign('s', 'document').signature;
|
|
const p = sigParts(s);
|
|
const m = sigMessageManual(pub.fingerprint, 1, 'document');
|
|
const edPub = ed25519Pub(Buffer.from(pub.public.ed25519, 'base64'));
|
|
const mlPub = spki(pub.public.ml_dsa_65);
|
|
adevarat(crypto.verify(null, m, edPub, p.ed), 'Ed25519 verifica manual');
|
|
adevarat(crypto.verify(null, m, { key: mlPub, context: Buffer.from(L.sigCtx) }, p.ml), 'ML-DSA-65 verifica manual');
|
|
// neseparabilitate: nicio jumatate nu e o semnatura valida peste mesajul brut
|
|
adevarat(!crypto.verify(null, Buffer.from('document'), edPub, p.ed), 'Ed25519 peste mesajul brut');
|
|
adevarat(!crypto.verify(null, Buffer.from('document'), mlPub, p.ml), 'ML-DSA peste mesajul brut fara context');
|
|
adevarat(!crypto.verify(null, m, mlPub, p.ml), 'ML-DSA fara contextul aerekms');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- jurnalul de audit
|
|
|
|
function jurnalCu(n) {
|
|
const { kms, dir, root } = nouKms();
|
|
kms.createKey('k', { type: 'encrypt' });
|
|
for (let i = 0; i < n; i++) kms.encrypt('k', `t${i}`);
|
|
return { kms, dir, root, f: path.join(dir, 'audit.log') };
|
|
}
|
|
const randuri = (f) => fs.readFileSync(f, 'utf8').split('\n').filter((l) => l !== '');
|
|
const scrieRanduri = (f, rs) => fs.writeFileSync(f, rs.join('\n') + '\n');
|
|
|
|
// A4 (revizuirea din 2026-09-25): cand randul de audit nu se poate scrie, MUTATIA nu are voie sa ramana pe disc; inainte, rotirea si
|
|
// retragerea versiunii ramaneau in fisierul cheii si lantul de audit iesea valid fara nicio urma a lor.
|
|
proba('audit: o mutatie al carei rand de audit nu se poate scrie NU ramane pe disc (rotire, minim, creare), iar lantul ramane valid', () => {
|
|
const { kms, dir, f } = jurnalCu(1);
|
|
const cheie = path.join(dir, 'keys', 'k.json');
|
|
const inainte = fs.readFileSync(cheie);
|
|
fs.renameSync(f, f + '.deoparte'); fs.mkdirSync(f); // in locul jurnalului, un dosar: append-ul cade
|
|
try {
|
|
refuza(() => kms.rotate('k'), 'AUDIT_WRITE_FAILED', 'rotire cu jurnal nescriibil');
|
|
egal(fs.readFileSync(cheie).equals(inainte), true, 'rotirea a ramas pe disc fara rand de audit');
|
|
refuza(() => kms.createKey('nou', { type: 'encrypt' }), 'AUDIT_WRITE_FAILED', 'creare cu jurnal nescriibil');
|
|
egal(fs.existsSync(path.join(dir, 'keys', 'nou.json')), false, 'cheia creata a ramas pe disc fara rand de audit');
|
|
} finally {
|
|
fs.rmdirSync(f); fs.renameSync(f + '.deoparte', f);
|
|
}
|
|
// jurnalul la loc: starea vizibila e cea de dinaintea esecurilor, si lantul se verifica
|
|
egal(JSON.parse(fs.readFileSync(cheie, 'utf8')).latest_version, 1, 'versiunea dupa esec');
|
|
egal(kms.getKey('k').latest_version, 1, 'versiunea citita prin serviciu dupa esec');
|
|
egal(kms.verifyAudit().ok, true, 'lantul de audit dupa esecuri');
|
|
// controlul pozitiv al fixturii: cu jurnalul scriibil, aceeasi rotire chiar se face si se vede in fisier
|
|
kms.rotate('k');
|
|
egal(JSON.parse(fs.readFileSync(cheie, 'utf8')).latest_version, 2, 'rotirea cu jurnal scriibil');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: jurnalul neatins se verifica OK, un rand pe operatie, si refuzurile au motivul lor', () => {
|
|
const { kms, f } = jurnalCu(3);
|
|
try {
|
|
kms.decrypt('k', kms.encrypt('k', 'x', 'a').ciphertext, 'b');
|
|
} catch {
|
|
/* refuz asteptat */
|
|
}
|
|
const v = kms.verifyAudit();
|
|
egal(v.ok, true, `jurnal neatins (${v.reason} la randul ${v.line})`);
|
|
egal(v.rows, 6, 'randuri: create + 3 encrypt + encrypt + decrypt refuzat');
|
|
const ultim = JSON.parse(randuri(f).at(-1));
|
|
egal(ultim.op, 'decrypt', 'ultima operatie');
|
|
egal(ultim.ok, false, 'refuzul e scris ca refuz');
|
|
egal(ultim.reason, 'AAD_MISMATCH', 'motivul refuzului');
|
|
egal(JSON.stringify(kms.auditHead()), JSON.stringify(v.head), 'capul din memorie = capul din fisier');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', () => {
|
|
const { kms, f } = jurnalCu(5);
|
|
const rs = randuri(f);
|
|
rs.splice(3, 1);
|
|
scrieRanduri(f, rs);
|
|
const v = kms.verifyAudit();
|
|
egal(v.ok, false, 'rand sters');
|
|
egal(v.reason, 'AUDIT_CHAIN_BROKEN', 'motivul');
|
|
egal(v.line, 4, 'randul');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: rand schimbat detectat cu AUDIT_ROW_MODIFIED', () => {
|
|
const { kms, f } = jurnalCu(5);
|
|
const rs = randuri(f);
|
|
const r = JSON.parse(rs[2]);
|
|
r.op = 'decrypt';
|
|
rs[2] = JSON.stringify(r);
|
|
scrieRanduri(f, rs);
|
|
const v = kms.verifyAudit();
|
|
egal(v.ok, false, 'rand schimbat');
|
|
egal(v.reason, 'AUDIT_ROW_MODIFIED', 'motivul');
|
|
egal(v.line, 3, 'randul');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: randuri reordonate detectate cu AUDIT_CHAIN_BROKEN', () => {
|
|
const { kms, f } = jurnalCu(5);
|
|
const rs = randuri(f);
|
|
[rs[1], rs[2]] = [rs[2], rs[1]];
|
|
scrieRanduri(f, rs);
|
|
const v = kms.verifyAudit();
|
|
egal(v.ok, false, 'reordonare');
|
|
egal(v.reason, 'AUDIT_CHAIN_BROKEN', 'motivul');
|
|
egal(v.line, 2, 'randul');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: coada taiata se vede numai fata de un cap ancorat (AUDIT_TRUNCATED)', () => {
|
|
const { kms, f } = jurnalCu(4);
|
|
const cap = kms.auditHead();
|
|
egal(kms.verifyAudit({ expectedHead: cap }).ok, true, 'cu capul ancorat, jurnal neatins');
|
|
const rs = randuri(f);
|
|
scrieRanduri(f, rs.slice(0, -1));
|
|
egal(kms.verifyAudit().ok, true, 'LIMITA DOCUMENTATA: fara cap ancorat, taierea cozii nu se vede');
|
|
const v = kms.verifyAudit({ expectedHead: cap });
|
|
egal(v.ok, false, 'coada taiata fata de cap');
|
|
egal(v.reason, 'AUDIT_TRUNCATED', 'motivul');
|
|
kms.close();
|
|
});
|
|
|
|
proba('audit: pornirea refuza un jurnal stricat cu AUDIT_CHAIN_INVALID', () => {
|
|
const { kms, f, dir, root } = jurnalCu(3);
|
|
kms.close();
|
|
const k0 = openKms({ dataDir: dir, rootKey: root });
|
|
k0.close();
|
|
const rs = randuri(f);
|
|
rs.splice(1, 1);
|
|
scrieRanduri(f, rs);
|
|
refuza(() => openKms({ dataDir: dir, rootKey: root }), 'AUDIT_CHAIN_INVALID', 'pornire pe jurnal stricat');
|
|
});
|
|
|
|
proba('audit: jurnalul si fisierele nu contin text clar, aad, mesaje sau chei', () => {
|
|
const { kms, dir } = nouKms();
|
|
const PT = 'MARCAJ-PT-' + crypto.randomBytes(6).toString('hex');
|
|
const AAD = 'MARCAJ-AAD-' + crypto.randomBytes(6).toString('hex');
|
|
const MSG = 'MARCAJ-MSG-' + crypto.randomBytes(6).toString('hex');
|
|
kms.createKey('e', { type: 'encrypt', exportable: true });
|
|
kms.createKey('s', { type: 'sign', exportable: true });
|
|
const c = kms.encrypt('e', PT, AAD).ciphertext;
|
|
kms.decrypt('e', c, AAD);
|
|
kms.rotate('e');
|
|
kms.rewrap('e', c, AAD);
|
|
kms.datakey('e', { aad: AAD });
|
|
kms.verify('s', MSG, kms.sign('s', MSG).signature);
|
|
try {
|
|
kms.decrypt('e', c, AAD + 'x');
|
|
} catch {
|
|
/* asteptat */
|
|
}
|
|
const needles = [...needlesDin(kms.exportKey('e')), ...needlesDin(kms.exportKey('s'))];
|
|
const log = fs.readFileSync(path.join(dir, 'audit.log'), 'utf8');
|
|
for (const m of [PT, AAD, MSG]) {
|
|
for (const forma of [m, b64(m), Buffer.from(m).toString('hex')]) adevarat(!log.includes(forma), `jurnalul contine ${m.slice(0, 11)}`);
|
|
}
|
|
egal(gasesteMaterial([path.join(dir, 'audit.log')], needles).length, 0, 'material de cheie in jurnal');
|
|
for (const f of toateFisierele(dir)) {
|
|
const t = fs.readFileSync(f, 'utf8');
|
|
for (const m of [PT, AAD, MSG]) adevarat(!t.includes(m) && !t.includes(b64(m)), `${path.basename(f)} contine un marcaj`);
|
|
}
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- erori
|
|
|
|
proba('erori: niciun mesaj de eroare nu contine material de cheie', () => {
|
|
const root = rootHex();
|
|
const { kms } = nouKms(tmpDir(), root);
|
|
kms.createKey('e', { type: 'encrypt', exportable: true });
|
|
kms.createKey('s', { type: 'sign', exportable: true });
|
|
const ne = needlesDin(kms.exportKey('e'));
|
|
const ns = needlesDin(kms.exportKey('s'));
|
|
const forme = [root, root.toUpperCase(), Buffer.from(root, 'hex').toString('base64')];
|
|
for (const n of [...ne, ...ns]) forme.push(n.toString('base64'), n.toString('hex'), n.toString('base64url'));
|
|
const ct = kms.encrypt('e', 'x', 'a').ciphertext;
|
|
const sig = kms.sign('s', 'm').signature;
|
|
const erori = [];
|
|
const prinde = (f) => {
|
|
try {
|
|
f();
|
|
} catch (e) {
|
|
erori.push(e);
|
|
}
|
|
};
|
|
prinde(() => kms.decrypt('e', ct, 'b'));
|
|
prinde(() => kms.decrypt('e', reencode(ct, (b) => void (b[OFF.ctK] ^= 1))));
|
|
prinde(() => kms.decrypt('e', reencode(ct, (b) => void (b[OFF.payload] ^= 1)), 'a'));
|
|
prinde(() => kms.decrypt('e', 'aerekms:v1:AAAA'));
|
|
prinde(() => kms.sign('e', 'm'));
|
|
prinde(() => kms.exportKey('e', 9));
|
|
prinde(() => kms.setMinDecryptionVersion('e', 5));
|
|
prinde(() => openKms({ dataDir: tmpDir(), rootKey: root.slice(2) }));
|
|
prinde(() => openKms({ dataDir: kms.dataDir, rootKey: rootHex() }));
|
|
egal(erori.length, 9, 'toate cele noua operatii trebuiau refuzate');
|
|
const vs = kms.verify('s', 'n', sig);
|
|
const texte = [...erori.map((e) => `${e.message} ${JSON.stringify({ ...e })}`), JSON.stringify(vs)];
|
|
for (const t of texte) for (const f of forme) adevarat(!t.includes(f), 'un mesaj de eroare contine material de cheie');
|
|
kms.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------- server
|
|
|
|
proba('server: configurarea implicita asculta pe 127.0.0.1 si cere token si cheie radacina', () => {
|
|
const token = crypto.randomBytes(24).toString('hex');
|
|
const root = rootHex();
|
|
const c = configFromEnv({ AERE_KMS_TOKEN: token, AERE_KMS_ROOT_KEY: root });
|
|
egal(c.host, '127.0.0.1', 'gazda implicita');
|
|
egal(c.port, 8420, 'portul implicit');
|
|
refuza(() => configFromEnv({ AERE_KMS_ROOT_KEY: root }), 'TOKEN_MISSING', 'fara token');
|
|
refuza(() => configFromEnv({ AERE_KMS_TOKEN: 'scurt', AERE_KMS_ROOT_KEY: root }), 'TOKEN_TOO_SHORT', 'token scurt');
|
|
refuza(() => configFromEnv({ AERE_KMS_TOKEN: token }), 'ROOT_KEY_MISSING', 'fara cheie radacina');
|
|
refuza(() => configFromEnv({ AERE_KMS_TOKEN: root, AERE_KMS_ROOT_KEY: root }), 'TOKEN_EQUALS_ROOT_KEY', 'token = radacina');
|
|
});
|
|
|
|
proba('server: pornit din linia de comanda refuza fara AERE_KMS_ROOT_KEY si nu tipareste tokenul', () => {
|
|
const dir = path.join(tmpDir('cli'), 'date');
|
|
const token = 'tok-' + crypto.randomBytes(20).toString('hex');
|
|
const r = spawnSync(process.execPath, [SERVER_PATH], {
|
|
env: { ...envCurat(), AERE_KMS_TOKEN: token, AERE_KMS_DATA_DIR: dir, AERE_KMS_PORT: '0' },
|
|
encoding: 'utf8',
|
|
timeout: 20000,
|
|
});
|
|
egal(r.status, 1, `codul de iesire (stderr: ${String(r.stderr).slice(0, 200)})`);
|
|
adevarat(r.stderr.includes('ROOT_KEY_MISSING'), 'motivul pe stderr');
|
|
adevarat(!r.stderr.includes(token) && !r.stdout.includes(token), 'tokenul tiparit');
|
|
adevarat(!fs.existsSync(dir), 'dosarul de date creat fara cheie radacina');
|
|
});
|
|
|
|
proba('server: pornit din linia de comanda asculta pe 127.0.0.1 si raspunde', async () => {
|
|
const token = 'tok-' + crypto.randomBytes(20).toString('hex');
|
|
const root = rootHex();
|
|
const child = spawn(process.execPath, [SERVER_PATH], {
|
|
env: { ...envCurat(), AERE_KMS_TOKEN: token, AERE_KMS_ROOT_KEY: root, AERE_KMS_DATA_DIR: path.join(tmpDir('cli'), 'date'), AERE_KMS_PORT: '0' },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let out = '';
|
|
let err = '';
|
|
child.stderr.on('data', (c) => (err += c));
|
|
try {
|
|
const adresa = await new Promise((resolve, reject) => {
|
|
const t = setTimeout(() => reject(new Esec(`serverul nu a pornit in 15 s (stderr: ${err.slice(0, 200)})`)), 15000);
|
|
child.stdout.on('data', (c) => {
|
|
out += c;
|
|
const m = /listening on ([0-9.]+):(\d+)/.exec(out);
|
|
if (m) {
|
|
clearTimeout(t);
|
|
resolve({ host: m[1], port: Number(m[2]) });
|
|
}
|
|
});
|
|
child.on('exit', (code) => reject(new Esec(`serverul a iesit cu ${code}: ${err.slice(0, 200)}`)));
|
|
});
|
|
egal(adresa.host, '127.0.0.1', 'adresa de ascultare');
|
|
const h = await cerere(adresa.port, 'GET', '/v1/keys', { headers: { authorization: `Bearer ${token}` } });
|
|
egal(h.status, 200, 'cerere autentificata');
|
|
const n = await cerere(adresa.port, 'GET', '/v1/keys');
|
|
egal(n.status, 401, 'cerere neautentificata');
|
|
adevarat(!out.includes(token) && !out.includes(root) && !err.includes(token) && !err.includes(root), 'secret tiparit');
|
|
} finally {
|
|
child.kill();
|
|
await new Promise((r) => (child.exitCode !== null ? r() : child.on('exit', r)));
|
|
}
|
|
});
|
|
|
|
proba('server: fara Authorization raspunde 401 UNAUTHENTICATED', async () => {
|
|
await cuServer(async ({ port, auth }) => {
|
|
const ok = await cerere(port, 'GET', '/v1/keys', { headers: auth });
|
|
egal(ok.status, 200, 'cu token');
|
|
for (const [m, p, body] of [['GET', '/v1/keys'], ['POST', '/v1/keys/k', { type: 'encrypt' }], ['POST', '/v1/encrypt/k', { plaintext: b64('x') }]]) {
|
|
const r = await cerere(port, m, p, { body });
|
|
egal(r.status, 401, `${m} ${p} fara token`);
|
|
egal(r.json && r.json.error, 'UNAUTHENTICATED', `${m} ${p} motivul`);
|
|
}
|
|
});
|
|
});
|
|
|
|
proba('server: token gresit raspunde 401 INVALID_TOKEN, fara sa-l repete', async () => {
|
|
await cuServer(async ({ port, token }) => {
|
|
const gresit = token.slice(0, -1) + (token.endsWith('a') ? 'b' : 'a');
|
|
for (const h of [`Bearer ${gresit}`, `Basic ${token}`, `Bearer`, `bearer ${token}`]) {
|
|
const r = await cerere(port, 'GET', '/v1/keys', { headers: { authorization: h } });
|
|
egal(r.status, 401, `antet ${h.slice(0, 7)}`);
|
|
egal(r.json && r.json.error, 'INVALID_TOKEN', `motivul pentru ${h.slice(0, 7)}`);
|
|
adevarat(!r.text.includes(token) && !r.text.includes(gresit), 'raspunsul repeta tokenul');
|
|
}
|
|
});
|
|
});
|
|
|
|
proba('server: cerere peste limita cu Content-Length raspunde 413 BODY_TOO_LARGE', async () => {
|
|
await cuServer(async ({ port, auth }) => {
|
|
await cerere(port, 'POST', '/v1/keys/k', { headers: auth, body: { type: 'encrypt' } });
|
|
const sub = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: { plaintext: b64('x'.repeat(2000)) } });
|
|
egal(sub.status, 200, 'sub limita trece');
|
|
const peste = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: { plaintext: b64('x'.repeat(6000)) } });
|
|
egal(peste.status, 413, 'peste limita');
|
|
egal(peste.json && peste.json.error, 'BODY_TOO_LARGE', 'motivul');
|
|
});
|
|
});
|
|
|
|
proba('server: cerere peste limita fara Content-Length (chunked) raspunde 413 BODY_TOO_LARGE', async () => {
|
|
await cuServer(async ({ port, auth }) => {
|
|
await cerere(port, 'POST', '/v1/keys/k', { headers: auth, body: { type: 'encrypt' } });
|
|
const sub = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: { plaintext: b64('y'.repeat(2000)) }, chunked: true });
|
|
egal(sub.status, 200, 'sub limita, chunked, trece');
|
|
const peste = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: { plaintext: b64('y'.repeat(9000)) }, chunked: true });
|
|
egal(peste.status, 413, 'peste limita, chunked');
|
|
egal(peste.json && peste.json.error, 'BODY_TOO_LARGE', 'motivul');
|
|
});
|
|
});
|
|
|
|
proba('server: JSON invalid 400 INVALID_JSON, base64 invalid 400 INVALID_BASE64, ruta necunoscuta 404 NOT_FOUND', async () => {
|
|
await cuServer(async ({ port, auth }) => {
|
|
await cerere(port, 'POST', '/v1/keys/k', { headers: auth, body: { type: 'encrypt' } });
|
|
const j = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: '{"plaintext":' });
|
|
egal(j.status, 400, 'JSON invalid');
|
|
egal(j.json.error, 'INVALID_JSON', 'motivul JSON');
|
|
const b = await cerere(port, 'POST', '/v1/encrypt/k', { headers: auth, body: { plaintext: 'nu e base64!' } });
|
|
egal(b.json.error, 'INVALID_BASE64', 'motivul base64');
|
|
const n = await cerere(port, 'GET', '/v1/nimic', { headers: auth });
|
|
egal(n.status, 404, 'ruta');
|
|
egal(n.json.error, 'NOT_FOUND', 'motivul rutei');
|
|
const nm = await cerere(port, 'GET', '/v1/keys/..%2Fx', { headers: auth });
|
|
egal(nm.json.error, 'INVALID_KEY_NAME', 'nume cu traversare in cale');
|
|
});
|
|
});
|
|
|
|
proba('server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', async () => {
|
|
await cuServer(async ({ port, auth }) => {
|
|
const P = (p, body) => cerere(port, 'POST', p, { headers: auth, body });
|
|
const G = (p) => cerere(port, 'GET', p, { headers: auth });
|
|
egal((await P('/v1/keys/e', { type: 'encrypt' })).status, 200, 'creare e');
|
|
egal((await P('/v1/keys/s', { type: 'sign' })).status, 200, 'creare s');
|
|
const dup = await P('/v1/keys/e', { type: 'encrypt' });
|
|
egal(dup.status, 409, 'dubla');
|
|
egal(dup.json.error, 'KEY_EXISTS', 'motivul dublei');
|
|
const enc = await P('/v1/encrypt/e', { plaintext: b64('prin http'), aad: b64('ctx') });
|
|
egal(enc.status, 200, 'encrypt');
|
|
const dec = await P('/v1/decrypt/e', { ciphertext: enc.json.ciphertext, aad: b64('ctx') });
|
|
egal(Buffer.from(dec.json.plaintext, 'base64').toString(), 'prin http', 'decrypt');
|
|
const rau = await P('/v1/decrypt/e', { ciphertext: enc.json.ciphertext, aad: b64('alt') });
|
|
egal(rau.status, 400, 'decrypt cu alt aad');
|
|
egal(rau.json.error, 'AAD_MISMATCH', 'motivul prin HTTP');
|
|
egal((await P('/v1/keys/e/rotate', {})).json.latest_version, 2, 'rotire');
|
|
const rw = await P('/v1/rewrap/e', { ciphertext: enc.json.ciphertext, aad: b64('ctx') });
|
|
egal(rw.json.version, 2, 'rewrap');
|
|
adevarat(!('plaintext' in rw.json), 'rewrap nu intoarce text clar');
|
|
egal((await P('/v1/keys/e/config', { min_decryption_version: 2 })).json.min_decryption_version, 2, 'config');
|
|
const vechi = await P('/v1/decrypt/e', { ciphertext: enc.json.ciphertext, aad: b64('ctx') });
|
|
egal(vechi.json.error, 'VERSION_BELOW_MINIMUM', 'sub minim prin HTTP');
|
|
const dk = await P('/v1/datakey/e', { include_plaintext: false });
|
|
adevarat(!('plaintext' in dk.json) && dk.json.ciphertext.startsWith('aerekms:v2:'), 'datakey doar invelita');
|
|
const ex = await G('/v1/keys/e/export');
|
|
egal(ex.status, 403, 'export refuzat');
|
|
egal(ex.json.error, 'KEY_NOT_EXPORTABLE', 'motivul exportului');
|
|
const sg = await P('/v1/sign/s', { message: b64('doc') });
|
|
egal((await P('/v1/verify/s', { message: b64('doc'), signature: sg.json.signature })).json.valid, true, 'verify');
|
|
const vr = await P('/v1/verify/s', { message: b64('doc2'), signature: sg.json.signature });
|
|
egal(vr.json.valid, false, 'verify alt mesaj');
|
|
egal(vr.json.reason, 'BOTH_SIGNATURES_INVALID', 'motivul verify');
|
|
const k = await G('/v1/keys/e');
|
|
egal(k.json.latest_version, 2, 'citire cheie');
|
|
adevarat(!JSON.stringify(k.json).includes('private'), 'citirea cheii nu expune material privat');
|
|
egal(JSON.stringify((await G('/v1/keys')).json.keys), JSON.stringify(['e', 's']), 'lista');
|
|
const au = await G('/v1/audit/verify');
|
|
egal(au.json.ok, true, 'audit prin HTTP');
|
|
}, { maxBody: 8192 });
|
|
});
|
|
|
|
proba('server: limita minima a corpului incape o verificare de semnatura (AERE_KMS_MAX_BODY >= 8192)', async () => {
|
|
const token = crypto.randomBytes(24).toString('hex');
|
|
const root = rootHex();
|
|
refuza(() => configFromEnv({ AERE_KMS_TOKEN: token, AERE_KMS_ROOT_KEY: root, AERE_KMS_MAX_BODY: '4096' }), 'INVALID_MAX_BODY', 'limita sub 8192');
|
|
const min = configFromEnv({ AERE_KMS_TOKEN: token, AERE_KMS_ROOT_KEY: root, AERE_KMS_MAX_BODY: '8192' }).maxBody;
|
|
await cuServer(async ({ port, auth }) => {
|
|
await cerere(port, 'POST', '/v1/keys/s', { headers: auth, body: { type: 'sign' } });
|
|
const msg = b64('m'.repeat(1024));
|
|
const sg = await cerere(port, 'POST', '/v1/sign/s', { headers: auth, body: { message: msg } });
|
|
const vr = await cerere(port, 'POST', '/v1/verify/s', { headers: auth, body: { message: msg, signature: sg.json.signature } });
|
|
egal(vr.status, 200, `verificare la limita minima (${min})`);
|
|
egal(vr.json.valid, true, 'valida');
|
|
}, { maxBody: min });
|
|
});
|
|
|
|
proba('server: raspunsurile nu contin tokenul', async () => {
|
|
await cuServer(async ({ port, auth, token }) => {
|
|
const rs = [];
|
|
rs.push(await cerere(port, 'POST', '/v1/keys/k', { headers: auth, body: { type: 'encrypt' } }));
|
|
rs.push(await cerere(port, 'GET', '/v1/keys/k', { headers: auth }));
|
|
rs.push(await cerere(port, 'POST', '/v1/decrypt/k', { headers: auth, body: { ciphertext: 'x' } }));
|
|
rs.push(await cerere(port, 'GET', '/v1/audit/verify', { headers: auth }));
|
|
for (const r of rs) adevarat(!r.text.includes(token), 'tokenul in raspuns');
|
|
});
|
|
});
|
|
|
|
// ================================================================================== rulare
|
|
|
|
async function main() {
|
|
const t0 = Date.now();
|
|
const rez = [];
|
|
for (const p of probe) {
|
|
const t = Date.now();
|
|
try {
|
|
await p.fn();
|
|
rez.push({ nume: p.nume, ok: true, ms: Date.now() - t });
|
|
} catch (e) {
|
|
rez.push({ nume: p.nume, ok: false, ms: Date.now() - t, motiv: String((e && e.message) || e).slice(0, 500), tip: e instanceof Esec ? 'afirmatie' : 'exceptie' });
|
|
}
|
|
}
|
|
for (const d of tmpDirs) {
|
|
try {
|
|
fs.rmSync(d, { recursive: true, force: true });
|
|
} catch {
|
|
/* dosar temporar al probei; ramane in %TEMP% daca Windows il tine ocupat */
|
|
}
|
|
}
|
|
const picate = rez.filter((r) => !r.ok);
|
|
for (const r of rez) process.stdout.write(`${r.ok ? 'TRECE' : 'PICA '} ${r.nume}${r.ok ? '' : `\n -> ${r.motiv}`}\n`);
|
|
process.stdout.write(`\n${rez.length - picate.length}/${rez.length} probe trecute, ${picate.length} picate, ${Date.now() - t0} ms\n`);
|
|
if (process.env.AERE_KMS_PROBA_JSON) {
|
|
fs.writeFileSync(process.env.AERE_KMS_PROBA_JSON, JSON.stringify({
|
|
completed: true,
|
|
total: rez.length,
|
|
passed: rez.length - picate.length,
|
|
failed: picate.map((r) => r.nume),
|
|
results: rez,
|
|
node: process.versions.node,
|
|
openssl: process.versions.openssl,
|
|
}, null, 2));
|
|
}
|
|
process.exitCode = picate.length === 0 ? 0 : 1;
|
|
}
|
|
|
|
main().catch((e) => {
|
|
process.stderr.write(`harnessul probei a cazut: ${(e && e.stack) || e}\n`);
|
|
process.exitCode = 2;
|
|
});
|