aere-quantum/pq-pki/pki.mjs

521 lines
33 KiB
JavaScript

// pki.mjs: autoritate de certificare post-cuantica privata. Emite certificate X.509 v3 semnate cu ML-DSA (FIPS 204, forma "pura",
// context gol, cum cere RFC 9881 pentru certificate), liste de revocare v2 semnate la fel, si verifica un lant strict (RFC 5280, partea
// de care are nevoie un TLS privat). Numai Node 24 + OpenSSL 3.5 (node:crypto), fara dependinte.
// Ce NU face: certificate compuse (hibride clasic + PQ; sunt inca drafturi IETF), OCSP, constrangeri de nume, politici, liste delta sau
// partiale (IDP). Numele se compara pe octeti (DER identic), nu prin normalizarea completa din RFC 5280: lanturile emise aici folosesc
// aceeasi codificare.
// Revizuirea adversariala din 2026-09-25 a gasit sapte defecte, reparate aici si marcate in cod cu [A1]..[A10]: cheia pe disc sub
// PBKDF2 slab (A1), prima lista de revocare in loc de cea mai noua (A2), extensiile listei ignorate (A3), exceptie pe SPKI malformat si
// frunza cu cheie de 10 octeti admisa (A5), EKU pe autoritati neaplicat (A6), prima ancora cu acelasi nume la reinnoirea radacinii (A7),
// OID cu primul subidentificator pe mai multi octeti (A10, in der.mjs).
import crypto from 'node:crypto';
import * as D from './der.mjs';
export const ALG = {
'ml-dsa-44': '2.16.840.1.101.3.4.3.17',
'ml-dsa-65': '2.16.840.1.101.3.4.3.18',
'ml-dsa-87': '2.16.840.1.101.3.4.3.19',
};
const ALG_BY_OID = Object.fromEntries(Object.entries(ALG).map(([k, v]) => [v, k]));
/** [A5] lungimea cheii publice ML-DSA, FIPS 204 tabelul 2: o cheie de alta lungime nu e o cheie, oricare ar fi OID-ul de deasupra ei. */
export const PK_LEN = { 'ml-dsa-44': 1312, 'ml-dsa-65': 1952, 'ml-dsa-87': 2592 };
const OID = {
CN: '2.5.4.3', O: '2.5.4.10',
basicConstraints: '2.5.29.19', keyUsage: '2.5.29.15', extKeyUsage: '2.5.29.37', subjectAltName: '2.5.29.17',
subjectKeyIdentifier: '2.5.29.14', authorityKeyIdentifier: '2.5.29.35', crlNumber: '2.5.29.20',
issuingDistributionPoint: '2.5.29.28', deltaCRLIndicator: '2.5.29.27',
serverAuth: '1.3.6.1.5.5.7.3.1', clientAuth: '1.3.6.1.5.5.7.3.2',
};
const KU = { digitalSignature: 0, keyCertSign: 5, cRLSign: 6 };
const EXT_STIUTE = new Set([OID.basicConstraints, OID.keyUsage, OID.extKeyUsage, OID.subjectAltName, OID.subjectKeyIdentifier, OID.authorityKeyIdentifier]);
/** [A3] extensii de CRL pe care le intelegem (AKI, crlNumber) si extensii care schimba INTELESUL listei si pe care le refuzam oricum ar fi
* marcate: o lista delta sau una cu punct de distributie (partiala, indirecta, numai CA, numai anumite motive) nu acopera ce pare sa acopere. */
const CRL_EXT_STIUTE = new Set([OID.authorityKeyIdentifier, OID.crlNumber]);
const CRL_EXT_REFUZATE = { [OID.issuingDistributionPoint]: 'issuingDistributionPoint', [OID.deltaCRLIndicator]: 'deltaCRLIndicator' };
export class PkiError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
// ------------------------------------------------------------------------------------------------ chei
export function generateKey(alg) {
if (!ALG[alg]) throw new PkiError('ALG', 'unsupported algorithm ' + alg + ' (ml-dsa-44, ml-dsa-65, ml-dsa-87)');
return crypto.generateKeyPairSync(alg);
}
// [A1] Sigilarea proprie a cheii pe disc. PKCS#8 cifrat de Node (key.export cu cipher) foloseste PBKDF2 cu 2048 de iteratii, masurat la
// 3-5 ms per incercare de parola, adica o parola de 12 caractere se incearca de sute de ori pe secunda pe un singur fir. Aici cheia de
// cifrare vine din scrypt (N=2^17, r=8, p=1: 128 MiB de memorie si ~1 s per incercare pe un laptop, masurat in proba), iar DER-ul PKCS#8
// e cifrat cu AES-256-GCM, cu antetul (versiunea formatului si toti parametrii) legat ca AAD: un octet schimbat in antet sau in text
// strica eticheta de autentificare, deci "parola gresita" si "fisier atins" au acelasi raspuns, KEY_LOCKED.
// AerePqPkiKey ::= SEQUENCE { header SEQUENCE { version INTEGER (1), kdf UTF8String "scrypt", salt OCTET STRING (16), N INTEGER,
// r INTEGER, p INTEGER, cipher UTF8String "aes-256-gcm", iv OCTET STRING (12) }, tag OCTET STRING (16),
// ciphertext OCTET STRING (PKCS#8 DER cifrat) }
// scris PEM sub eticheta KEY_LABEL. PKCS#8 clasic (cifrat sau nu) NU mai e acceptat de pe disc.
export const KEY_LABEL = 'AERE PQ PKI PRIVATE KEY';
export const SEAL = Object.freeze({ version: 1, kdf: 'scrypt', N: 2 ** 17, r: 8, p: 1, cipher: 'aes-256-gcm', saltLen: 16, ivLen: 12, tagLen: 16, maxmem: 256 * 1024 * 1024 });
const SEAL_N_MIN = 2 ** 14, SEAL_N_MAX = 2 ** 20; // la citire: sub 2^14 e prea slab ca sa fi fost scris de noi, peste 2^20 ar cere peste 1 GiB
/** Parole pe care orice dictionar le incearca primele; comparate dupa ce se scot cifrele si semnele de la coada. */
const PAROLE_EVIDENTE = new Set(['password', 'passw0rd', 'passphrase', 'qwerty', 'qwertyuiop', '123456', '12345678', '123456789', '1234567890',
'iloveyou', 'letmein', 'welcome', 'admin', 'administrator', 'abc123', 'monkey', 'dragon', 'secret', 'changeme', 'default']);
/** [A1] Politica de parola: cel putin 12 caractere; cel putin 3 clase (minuscule, majuscule, cifre, altele) sau cel putin 20 de caractere;
* nu toate caracterele identice; nu o parola evidenta (cu sau fara cifre/semne la coada). Arunca PkiError('PASSPHRASE'). */
export function checkPassphrase(p) {
if (typeof p !== 'string' || p.length < 12) throw new PkiError('PASSPHRASE', 'the passphrase needs at least 12 characters');
const clase = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(p)).length;
if (clase < 3 && p.length < 20) throw new PkiError('PASSPHRASE', 'the passphrase needs 3 character classes (lower, upper, digit, other) or at least 20 characters');
if (/^(.)\1*$/s.test(p)) throw new PkiError('PASSPHRASE', 'a passphrase made of one repeated character is refused');
const nucleu = p.toLowerCase().replace(/[^a-z0-9]/g, '');
if (PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, ''))) throw new PkiError('PASSPHRASE', 'this passphrase is on every attacker\'s first list');
return p;
}
const sealHeader = (salt, N, r, p, iv) => D.seq(D.int(SEAL.version), D.utf8(SEAL.kdf), D.octets(salt), D.int(N), D.int(r), D.int(p), D.utf8(SEAL.cipher), D.octets(iv));
export function exportPrivateKey(key, passphrase) {
checkPassphrase(passphrase);
algOfKey(key);
const salt = crypto.randomBytes(SEAL.saltLen), iv = crypto.randomBytes(SEAL.ivLen);
const header = sealHeader(salt, SEAL.N, SEAL.r, SEAL.p, iv);
const kek = crypto.scryptSync(passphrase, salt, 32, { N: SEAL.N, r: SEAL.r, p: SEAL.p, maxmem: SEAL.maxmem });
const plain = key.export({ type: 'pkcs8', format: 'der' });
const c = crypto.createCipheriv(SEAL.cipher, kek, iv, { authTagLength: SEAL.tagLen });
c.setAAD(header);
const ct = Buffer.concat([c.update(plain), c.final()]);
const tag = c.getAuthTag();
plain.fill(0); kek.fill(0);
return pem(KEY_LABEL, D.seq(header, D.octets(tag), D.octets(ct)));
}
/** Citeste antetul unei chei sigilate fara sa o deschida (parametrii KDF, ca sa poata fi masurati). Arunca PkiError('KEY_FORMAT'). */
export function readSealedKeyHeader(pemText) {
const blobs = unpem(pemText, KEY_LABEL);
if (blobs.length !== 1) {
if (/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(pemText)) throw new PkiError('KEY_FORMAT', 'this is a PKCS#8 key; private keys are accepted only in the sealed "' + KEY_LABEL + '" format (scrypt + AES-256-GCM)');
throw new PkiError('KEY_FORMAT', 'expected exactly one "' + KEY_LABEL + '" block');
}
let h;
try {
const [hdr, tagEl, ctEl, ...rest] = D.children(D.expect(D.parse(blobs[0]), D.TAG.SEQUENCE, 'sealed key'));
if (rest.length || !ctEl) throw new Error('three parts expected');
const [ver, kdf, salt, N, r, p, cipher, iv, ...hr] = D.children(D.expect(hdr, D.TAG.SEQUENCE, 'header'));
if (hr.length || !iv) throw new Error('eight header fields expected');
h = {
version: Number(D.intToBigInt(ver)), kdf: D.expect(kdf, D.TAG.UTF8, 'kdf').content.toString('utf8'),
salt: Buffer.from(D.expect(salt, D.TAG.OCTET_STRING, 'salt').content), N: Number(D.intToBigInt(N)), r: Number(D.intToBigInt(r)), p: Number(D.intToBigInt(p)),
cipher: D.expect(cipher, D.TAG.UTF8, 'cipher').content.toString('utf8'), iv: Buffer.from(D.expect(iv, D.TAG.OCTET_STRING, 'iv').content),
tag: Buffer.from(D.expect(tagEl, D.TAG.OCTET_STRING, 'tag').content), ct: Buffer.from(D.expect(ctEl, D.TAG.OCTET_STRING, 'ciphertext').content), aad: Buffer.from(hdr.raw),
};
} catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: ' + e.message); }
if (h.version !== SEAL.version || h.kdf !== SEAL.kdf || h.cipher !== SEAL.cipher) throw new PkiError('KEY_FORMAT', `sealed key: unknown format (version ${h.version}, ${h.kdf}, ${h.cipher})`);
if (h.salt.length !== SEAL.saltLen || h.iv.length !== SEAL.ivLen || h.tag.length !== SEAL.tagLen) throw new PkiError('KEY_FORMAT', 'sealed key: salt, iv or tag of the wrong length');
if (!Number.isInteger(h.N) || h.N < SEAL_N_MIN || h.N > SEAL_N_MAX || (h.N & (h.N - 1)) !== 0 || !(h.r >= 1 && h.r <= 32) || !(h.p >= 1 && h.p <= 16)) throw new PkiError('KEY_FORMAT', `sealed key: scrypt parameters out of range (N=${h.N}, r=${h.r}, p=${h.p})`);
return h;
}
export function importPrivateKey(pemText, passphrase) {
const h = readSealedKeyHeader(pemText);
if (typeof passphrase !== 'string' || !passphrase) throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)');
let kek;
try { kek = crypto.scryptSync(passphrase, h.salt, 32, { N: h.N, r: h.r, p: h.p, maxmem: SEAL.maxmem }); }
catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: scrypt refused the parameters (' + e.message + ')'); }
let plain;
try {
const d = crypto.createDecipheriv(SEAL.cipher, kek, h.iv, { authTagLength: SEAL.tagLen });
d.setAAD(h.aad); d.setAuthTag(h.tag);
plain = Buffer.concat([d.update(h.ct), d.final()]);
} catch { throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)'); }
finally { kek.fill(0); }
try {
const key = crypto.createPrivateKey({ key: plain, format: 'der', type: 'pkcs8' });
algOfKey(key);
return key;
} catch (e) { throw new PkiError(e.code === 'ALG' ? 'ALG' : 'KEY_FORMAT', 'sealed key: the content is not an ML-DSA PKCS#8 key'); }
finally { plain.fill(0); }
}
function algOfKey(k) {
const t = k.asymmetricKeyType;
if (!ALG[t]) throw new PkiError('ALG', 'key type ' + t + ' is not ML-DSA');
return t;
}
// ------------------------------------------------------------------------------------------------ constructie
const algId = (alg) => D.seq(D.oid(ALG[alg])); // RFC 9881: parametrii LIPSESC
export function name({ cn, o }) {
const rdn = (oid, v) => D.set(D.seq(D.oid(oid), D.utf8(v)));
return D.seq(o ? rdn(OID.O, o) : null, rdn(OID.CN, cn));
}
function keyUsageBits(names) {
let v = 0, hi = -1;
for (const n of names) { const b = KU[n]; v |= 0x80 >> b; hi = Math.max(hi, b); }
const bytes = Buffer.from([v & 0xff]);
const unused = 7 - hi; // biti ramasi neinsemnati dupa cel mai mare bit pus (DER: fara biti zero la coada)
return D.bits(bytes, unused);
}
const ext = (oid, critical, valueDer) => D.seq(D.oid(oid), critical ? D.bool(true) : null, D.octets(valueDer));
function keyId(spkiDer) {
const spki = D.parse(spkiDer); const [, pk] = D.children(spki);
return crypto.createHash('sha256').update(D.bitString(pk).bytes).digest().subarray(0, 20); // RFC 7093 metoda 1
}
function randomSerial() {
const b = crypto.randomBytes(16);
b[0] &= 0x7f; if (b[0] === 0) b[0] = 1;
return b;
}
/**
* Emite un certificat. issuer = { cert (DER) , key (KeyObject privat) } sau null pentru o radacina auto-semnata.
* opts: { subject: {cn, o}, publicKey (KeyObject), ca: bool, pathLen?: int, days, dns?: [], ips?: [], eku?: ['serverAuth','clientAuth'], notBefore? }
*/
export function issue({ issuer, signingKey, subject, publicKey, ca = false, pathLen, days, dns = [], ips = [], eku = [], notBefore }) {
const alg = algOfKey(signingKey);
algOfKey(publicKey);
const spki = publicKey.export({ type: 'spki', format: 'der' });
const ski = keyId(spki);
const ic = issuer ? parseCert(issuer) : null;
const signerSpki = crypto.createPublicKey(signingKey).export({ type: 'spki', format: 'der' });
if (ic) {
if (!ic.isCA || !ic.keyUsage.has('keyCertSign')) throw new PkiError('ISSUER', 'the issuer certificate is not a CA with keyCertSign');
if (!signerSpki.equals(ic.spki)) throw new PkiError('ISSUER', 'the signing key does not belong to the issuer certificate');
if (!ic.ski) throw new PkiError('ISSUER', 'the issuer certificate has no subject key identifier');
} else if (!signerSpki.equals(spki)) throw new PkiError('ISSUER', 'a self-signed root is signed by its own key');
const issuerName = ic ? ic.subjectRaw : name(subject);
const aki = ic ? ic.ski : null;
const nb = notBefore ? new Date(notBefore) : new Date(Date.now() - 60 * 1000);
const na = new Date(nb.getTime() + days * 86400 * 1000);
const exts = [
ext(OID.basicConstraints, true, D.seq(ca ? D.bool(true) : null, ca && Number.isInteger(pathLen) ? D.int(pathLen) : null)),
ext(OID.keyUsage, true, keyUsageBits(ca ? ['digitalSignature', 'keyCertSign', 'cRLSign'] : ['digitalSignature'])),
eku.length ? ext(OID.extKeyUsage, false, D.seq(...eku.map((e) => D.oid(OID[e])))) : null,
dns.length || ips.length ? ext(OID.subjectAltName, false, D.seq(
...dns.map((d) => D.tlv(0x82, Buffer.from(d, 'ascii'))),
...ips.map((ip) => D.tlv(0x87, ipBytes(ip))))) : null,
ext(OID.subjectKeyIdentifier, false, D.octets(ski)),
aki ? ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, aki))) : null,
].filter(Boolean);
const tbs = D.seq(
D.ctx(0, D.int(2)), D.int(randomSerial()), algId(alg), issuerName,
D.seq(D.time(nb), D.time(na)), name(subject), spki,
D.ctx(3, D.seq(...exts)));
const sig = crypto.sign(null, tbs, signingKey);
return D.seq(tbs, algId(alg), D.bits(sig));
}
function ipBytes(ip) {
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some((x) => !(x >= 0 && x <= 255))) throw new PkiError('SAN', 'only IPv4 addresses are supported: ' + ip);
return Buffer.from(p);
}
/** Lista de revocare v2. revoked = [{ serial (Buffer sau hex), date }]; number = numarul CRL-ului (crescator). */
export function crl({ issuer, signingKey, revoked = [], days, number, thisUpdate }) {
const alg = algOfKey(signingKey);
const c = parseCert(issuer);
if (!c.isCA || !c.keyUsage.has('cRLSign')) throw new PkiError('ISSUER', 'the issuer may not sign revocation lists');
const tu = thisUpdate ? new Date(thisUpdate) : new Date(Date.now() - 60 * 1000);
const nu = new Date(tu.getTime() + days * 86400 * 1000);
const rev = revoked.map((r) => D.seq(D.int(Buffer.isBuffer(r.serial) ? r.serial : Buffer.from(r.serial, 'hex')), D.time(new Date(r.date))));
const tbs = D.seq(D.int(1), algId(alg), c.subjectRaw, D.time(tu), D.time(nu), rev.length ? D.seq(...rev) : null,
D.ctx(0, D.seq(ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, c.ski))), ext(OID.crlNumber, false, D.int(number)))));
return D.seq(tbs, algId(alg), D.bits(crypto.sign(null, tbs, signingKey)));
}
// ------------------------------------------------------------------------------------------------ citire
/** Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }, cu forma DER ceruta. */
function parseExtensions(seqEl, what) {
const exts = new Map();
for (const x of D.children(D.expect(seqEl, D.TAG.SEQUENCE, what))) {
const xs = D.children(D.expect(x, D.TAG.SEQUENCE, 'Extension'));
const id = D.oidToString(xs[0]);
let critical = false, val;
if (xs.length === 3) { if (xs[1].tag !== D.TAG.BOOLEAN || xs[1].content[0] !== 0xff) throw new PkiError('DER', 'critical must be TRUE when present (DER)'); critical = true; val = xs[2]; }
else if (xs.length === 2) val = xs[1];
else throw new PkiError('DER', 'an Extension has two or three parts');
if (exts.has(id)) throw new PkiError('EXT_DUP', 'extension ' + id + ' appears twice');
exts.set(id, { critical, value: D.expect(val, D.TAG.OCTET_STRING, 'extnValue').content });
}
return exts;
}
export function parseCert(der) {
const top = D.parse(der);
D.expect(top, D.TAG.SEQUENCE, 'certificat');
const [tbsEl, algEl, sigEl, ...rest] = D.children(top);
if (rest.length || !sigEl) throw new PkiError('DER', 'a certificate has exactly three parts');
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertificate'));
let i = 0;
if (!t[i] || t[i].tag !== 0xa0) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
const ver = D.intToBigInt(D.children(t[i++])[0]);
if (ver !== 2n) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
if (t.length < 7) throw new PkiError('DER', 'tbsCertificate is missing fields');
const serial = t[i++].content;
D.intToBigInt(t[i - 1]); // forma canonica si pozitiva (RFC 5280 4.1.2.2)
const innerAlg = sigAlgOf(t[i++]);
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
const [nbEl, naEl] = D.children(D.expect(t[i++], D.TAG.SEQUENCE, 'validity'));
if (!naEl) throw new PkiError('DER', 'validity has two times');
const subjectRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'subject').raw;
const spkiEl = D.expect(t[i++], D.TAG.SEQUENCE, 'subjectPublicKeyInfo');
// [A5] cheia publica se valideaza la citire: doua parti, BIT STRING fara biti nefolositi, si lungimea FIPS 204 a algoritmului declarat.
// Fara asta o frunza cu o "cheie" de 10 octeti sub id-ml-dsa-65 trecea verificarea lantului (verificatorul nu foloseste cheia frunzei),
// iar un intermediar cu aceeasi cheie arunca o exceptie din node:crypto in loc de un verdict.
const spkiKids = D.children(spkiEl);
if (spkiKids.length !== 2) throw new PkiError('SPKI', 'subjectPublicKeyInfo has two parts');
const pkAlg = sigAlgOf(spkiKids[0]);
const pkBits = D.bitString(spkiKids[1]);
if (pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg]) throw new PkiError('SPKI', `the ${pkAlg} public key must be ${PK_LEN[pkAlg]} bytes, this one has ${pkBits.bytes.length}`);
let exts = new Map();
while (i < t.length) {
const e = t[i++];
if (e.tag === 0xa1 || e.tag === 0xa2) throw new PkiError('UNIQUE_ID', 'unique identifiers are not accepted');
if (e.tag !== 0xa3 || exts.size) throw new PkiError('DER', 'unexpected field in tbsCertificate');
const inner = D.children(e);
if (inner.length !== 1) throw new PkiError('DER', 'extensions [3] wraps exactly one SEQUENCE');
exts = parseExtensions(inner[0], 'extensions');
}
const outerAlg = sigAlgOf(algEl);
if (outerAlg !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the certificate');
const sig = D.bitString(sigEl);
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
const c = {
der: Buffer.from(der), tbs: tbsEl.raw, alg: innerAlg, pkAlg, signature: sig.bytes, serial: Buffer.from(serial),
issuerRaw: Buffer.from(issuerRaw), subjectRaw: Buffer.from(subjectRaw), spki: Buffer.from(spkiEl.raw),
notBefore: D.timeToDate(nbEl), notAfter: D.timeToDate(naEl), exts,
isCA: false, pathLen: undefined, keyUsage: new Set(), eku: null, dns: [], ips: [], ski: null, aki: null, unknownCritical: [],
};
for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id)) c.unknownCritical.push(id);
const bc = exts.get(OID.basicConstraints);
if (bc) {
const f = D.children(D.parse(bc.value));
if (f[0] && f[0].tag === D.TAG.BOOLEAN) { c.isCA = f[0].content[0] === 0xff; if (!c.isCA) throw new PkiError('DER', 'cA FALSE must be absent (DER)'); f.shift(); }
if (f[0]) c.pathLen = Number(D.intToBigInt(f[0]));
}
const ku = exts.get(OID.keyUsage);
if (ku) {
const b = D.bitString(D.parse(ku.value));
for (const [n, bit] of Object.entries(KU)) if (b.bytes.length > (bit >> 3) && (b.bytes[bit >> 3] & (0x80 >> (bit & 7)))) c.keyUsage.add(n);
}
const ek = exts.get(OID.extKeyUsage);
if (ek) c.eku = new Set(D.children(D.parse(ek.value)).map((o) => D.oidToString(o)));
const san = exts.get(OID.subjectAltName);
if (san) for (const g of D.children(D.parse(san.value))) {
if (g.tag === 0x82) c.dns.push(g.content.toString('ascii').toLowerCase());
else if (g.tag === 0x87 && g.content.length === 4) c.ips.push([...g.content].join('.'));
}
const s = exts.get(OID.subjectKeyIdentifier);
if (s) c.ski = Buffer.from(D.expect(D.parse(s.value), D.TAG.OCTET_STRING, 'SKI').content);
const a = exts.get(OID.authorityKeyIdentifier);
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) c.aki = Buffer.from(k.content); }
return c;
}
function sigAlgOf(el) {
const f = D.children(D.expect(el, D.TAG.SEQUENCE, 'AlgorithmIdentifier'));
if (!f.length) throw new PkiError('DER', 'empty AlgorithmIdentifier');
const o = D.oidToString(f[0]);
if (!ALG_BY_OID[o]) throw new PkiError('ALG', 'algorithm ' + o + ' is not ML-DSA');
if (f.length !== 1) throw new PkiError('ALG_PARAMS', 'ML-DSA AlgorithmIdentifier must have no parameters (RFC 9881)');
return ALG_BY_OID[o];
}
const isTime = (el) => el && (el.tag === D.TAG.UTC_TIME || el.tag === D.TAG.GEN_TIME);
/**
* Citeste o lista de revocare v2. Intoarce { tbs, alg, signature, issuerRaw, thisUpdate, nextUpdate, revoked (Map serialHex -> Date),
* crlNumber (BigInt|null), aki (Buffer|null), refusedExt (string|null), exts }. [A3] Extensiile listei si ale intrarilor sunt citite:
* AKI si crlNumber sunt intelese; issuingDistributionPoint si deltaCRLIndicator sunt REFUZATE oricum ar fi marcate, si la fel orice alta
* extensie critica (a listei sau a unei intrari). Refuzul nu e exceptie: sta in refusedExt, si verifyChain il face verdict CRL_EXT, ca o
* lista straina cu o extensie rea sa nu opreasca verificarea unui lant pe care nu il priveste.
*/
export function parseCrl(der) {
const top = D.parse(der);
const [tbsEl, algEl, sigEl, ...rest] = D.children(D.expect(top, D.TAG.SEQUENCE, 'CRL'));
if (rest.length || !sigEl) throw new PkiError('DER', 'a revocation list has exactly three parts');
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertList'));
let i = 0;
if (!t[i] || t[i].tag !== D.TAG.INTEGER || D.intToBigInt(t[i++]) !== 1n) throw new PkiError('VERSION', 'only v2 revocation lists are accepted');
const innerAlg = sigAlgOf(t[i++]);
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
if (!isTime(t[i])) throw new PkiError('DER', 'thisUpdate missing');
const thisUpdate = D.timeToDate(t[i++]);
let nextUpdate = null;
if (isTime(t[i])) nextUpdate = D.timeToDate(t[i++]);
const revoked = new Map();
let refusedExt = null;
const refuza = (what) => { if (!refusedExt) refusedExt = what; };
if (t[i] && t[i].tag === D.TAG.SEQUENCE) {
for (const r of D.children(t[i++])) {
const [s, d, ee, ...er] = D.children(D.expect(r, D.TAG.SEQUENCE, 'revoked entry'));
if (!d || er.length) throw new PkiError('DER', 'a revoked entry has two or three parts');
D.intToBigInt(s); // seria, in forma canonica
if (!isTime(d)) throw new PkiError('DER', 'revocation date missing');
revoked.set(s.content.toString('hex'), D.timeToDate(d));
if (ee) for (const [id, x] of parseExtensions(ee, 'crlEntryExtensions')) if (x.critical) refuza(`a critical entry extension ${id}`); // reasonCode, invalidityDate: necritice, ignorate
}
}
let exts = new Map(), crlNumber = null, aki = null;
if (t[i] && t[i].tag === 0xa0) {
const inner = D.children(t[i++]);
if (inner.length !== 1) throw new PkiError('DER', 'crlExtensions [0] wraps exactly one SEQUENCE');
exts = parseExtensions(inner[0], 'crlExtensions');
for (const [id, x] of exts) {
if (CRL_EXT_REFUZATE[id]) refuza(`${CRL_EXT_REFUZATE[id]} (${id}); delta and partial lists are not supported`);
else if (x.critical && !CRL_EXT_STIUTE.has(id)) refuza(`an unknown critical extension ${id}`);
}
const n = exts.get(OID.crlNumber);
if (n) crlNumber = D.intToBigInt(D.parse(n.value));
const a = exts.get(OID.authorityKeyIdentifier);
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) aki = Buffer.from(k.content); }
}
if (i !== t.length) throw new PkiError('DER', 'unexpected field in tbsCertList');
if (sigAlgOf(algEl) !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the list');
const sig = D.bitString(sigEl);
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
return { tbs: tbsEl.raw, alg: innerAlg, signature: sig.bytes, issuerRaw: Buffer.from(issuerRaw), thisUpdate, nextUpdate, revoked, crlNumber, aki, refusedExt, exts };
}
// ------------------------------------------------------------------------------------------------ verificare
/** [A5] Un SPKI pe care node:crypto nu il poate importa da PkiError('SPKI'), pe care verifyChain o face verdict; niciodata exceptie bruta. */
function verifySig(tbs, signature, spkiDer, who) {
let key;
try { key = crypto.createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); }
catch { throw new PkiError('SPKI', `the public key of "${who}" cannot be decoded`); }
return crypto.verify(null, tbs, key, signature);
}
function hostMatches(pattern, host) {
if (pattern === host) return true;
if (pattern.startsWith('*.')) { const rest = pattern.slice(2); const dot = host.indexOf('.'); return dot > 0 && host.slice(dot + 1) === rest; }
return false;
}
const MAX_ADANCIME = 8, MAX_DRUMURI = 32;
const cmpBig = (a, b) => ((a ?? -1n) === (b ?? -1n) ? 0 : (a ?? -1n) > (b ?? -1n) ? 1 : -1);
/**
* Verifica un lant: leaf (DER), intermediates [DER], roots [DER] (increderea), at (Date), purpose ('serverAuth'|'clientAuth'|null),
* host (nume sau IPv4, pentru serverAuth), crls [DER], requireCrl (fiecare emitator din lant trebuie sa aiba o lista valabila).
* Intoarce { ok, code, reason, chain: [subject CN...] }. Intoarce intotdeauna un verdict: pe orice octeti, niciodata exceptie [A5].
*/
export function verifyChain(opts) {
try { return verifyChainInner(opts); }
catch (e) { return { ok: false, code: e instanceof PkiError ? e.code : 'DER', reason: String(e && e.message || e) }; }
}
function verifyChainInner({ leaf, intermediates = [], roots = [], at = new Date(), purpose = 'serverAuth', host = null, crls = [], requireCrl = false }) {
const fail = (code, reason) => ({ ok: false, code, reason });
let certs;
try { certs = { leaf: parseCert(leaf), inter: intermediates.map(parseCert), roots: roots.map(parseCert) }; }
catch (e) { return fail(e.code || 'DER', e.message); }
const lists = [];
try { for (const x of crls) lists.push(parseCrl(x)); } catch (e) { return fail(e.code || 'DER', 'revocation list: ' + e.message); }
const emite = (p, c) => p.subjectRaw.equals(c.issuerRaw) && (!c.aki || (p.ski && p.ski.equals(c.aki)));
// 1. drumurile: de la frunza in sus, emitatorul e certificatul al carui subiect e egal (pe octeti) cu emitentul si al carui SKI e AKI-ul.
// [A7] Se incearca TOATE candidatele (intai ancorele, apoi intermediarele, fara cicluri), si primul drum care trece intreg e raspunsul:
// la reinnoirea radacinii operatorul are doua ancore cu acelasi nume si aceeasi cheie, una expirata, si "prima gasita" refuza un lant
// valid; la fel cu un intermediar semnat incrucisat de o radacina straina, pus inaintea celui bun.
let blocat = certs.leaf, blocatAdancime = -1, incercate = 0; // cel mai adanc certificat fara niciun emitent candidat (pentru mesajul UNTRUSTED)
const stiva = [certs.leaf];
function* drumuri(cur) {
const ancore = certs.roots.filter((r) => emite(r, cur));
for (const r of ancore) yield [...stiva, r];
const inter = stiva.length < MAX_ADANCIME ? certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)) : [];
for (const c of inter) { stiva.push(c); yield* drumuri(c); stiva.pop(); }
if (!ancore.length && !inter.length && stiva.length - 1 > blocatAdancime) { blocat = cur; blocatAdancime = stiva.length - 1; }
}
let ultimul = null;
for (const path of drumuri(certs.leaf)) {
if (++incercate > MAX_DRUMURI) break;
const r = judeca(path);
if (r.ok) return r;
ultimul = r;
}
if (ultimul) return ultimul;
return fail('UNTRUSTED', 'no trusted issuer for "' + cnOf(blocat) + '"');
function judeca(path) {
const top = path[path.length - 1];
if (!top.subjectRaw.equals(top.issuerRaw) || !verifySig(top.tbs, top.signature, top.spki, cnOf(top))) return fail('ROOT', 'the trusted root is not a valid self-signed certificate');
// 2. fiecare legatura
for (let k = 0; k < path.length; k++) {
const c = path[k];
if (c.unknownCritical.length) return fail('CRITICAL_EXT', `"${cnOf(c)}" carries an unknown critical extension ${c.unknownCritical[0]}`);
if (at < c.notBefore) return fail('NOT_YET_VALID', `"${cnOf(c)}" is not valid before ${c.notBefore.toISOString()}`);
if (at > c.notAfter) return fail('EXPIRED', `"${cnOf(c)}" expired at ${c.notAfter.toISOString()}`);
if (k === path.length - 1) break;
const p = path[k + 1];
if (!p.isCA) return fail('NOT_CA', `"${cnOf(p)}" is not a CA (basicConstraints) and cannot issue "${cnOf(c)}"`);
if (!p.keyUsage.has('keyCertSign')) return fail('KEY_USAGE', `"${cnOf(p)}" has no keyCertSign`);
const casBelow = path.slice(1, k + 1).filter((x) => x.isCA).length; // CA-urile intermediare de sub p, fara frunza
if (p.pathLen !== undefined && casBelow > p.pathLen) return fail('PATH_LEN', `"${cnOf(p)}" allows ${p.pathLen} CA(s) below it, the chain has ${casBelow}`);
// [A6] EKU pe o autoritate restrange tot ce e sub ea (OpenSSL, Chrome, Mozilla fac la fel); anyExtendedKeyUsage NU scuteste, ca la OpenSSL.
if (purpose && p.eku && !p.eku.has(OID[purpose])) return fail('EKU', `the issuer "${cnOf(p)}" is not valid for ${purpose} (its extended key usage does not allow it)`);
if (!verifySig(c.tbs, c.signature, p.spki, cnOf(c))) return fail('SIGNATURE', `the signature on "${cnOf(c)}" does not verify under "${cnOf(p)}"`);
const rv = revocare(c, p);
if (rv) return rv;
}
// 4. frunza
const L = certs.leaf;
if (purpose) {
if (L.isCA) return fail('LEAF_IS_CA', 'a CA certificate is not accepted as an end-entity certificate');
if (!L.keyUsage.has('digitalSignature')) return fail('KEY_USAGE', 'the end-entity certificate has no digitalSignature');
if (L.eku && !L.eku.has(OID[purpose])) return fail('EKU', `the end-entity certificate is not valid for ${purpose}`);
}
if (host) {
const h = host.toLowerCase();
const ok = /^\d+\.\d+\.\d+\.\d+$/.test(h) ? L.ips.includes(h) : L.dns.some((d) => hostMatches(d, h));
if (!ok) return fail('HOSTNAME', `"${cnOf(L)}" is not valid for ${host}`);
}
return { ok: true, code: 'OK', reason: 'valid', chain: path.map(cnOf) };
}
// 3. revocarea lui c de catre emitatorul p. [A2] Dintre listele emitentului care VERIFICA sub cheia lui si nu sunt datate in viitor se
// ia cea cu thisUpdate cel mai nou (la egalitate, crlNumber cel mai mare), cum face si OpenSSL; "prima din intrare" lasa o revocare
// proaspata sa fie ascunsa de o lista veche pusa inaintea ei. [A3] O lista cu o extensie refuzata e verdict CRL_EXT, nu e sarita.
function revocare(c, p) {
const ale = lists.filter((l) => l.issuerRaw.equals(p.subjectRaw));
if (!ale.length) return requireCrl ? fail('CRL_MISSING', `no revocation list from "${cnOf(p)}"`) : null;
const verificate = ale.filter((l) => verifySig(l.tbs, l.signature, p.spki, cnOf(p)));
if (!verificate.length) return fail('CRL_SIGNATURE', `the revocation list of "${cnOf(p)}" does not verify`);
const rea = verificate.find((l) => l.refusedExt);
if (rea) return fail('CRL_EXT', `the revocation list of "${cnOf(p)}" carries ${rea.refusedExt}`);
const curente = verificate.filter((l) => at >= l.thisUpdate);
if (!curente.length) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is dated in the future`);
curente.sort((a, b) => (b.thisUpdate - a.thisUpdate) || cmpBig(b.crlNumber, a.crlNumber));
const list = curente[0];
if (list.nextUpdate && at > list.nextUpdate) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is not current (nextUpdate ${list.nextUpdate.toISOString()})`);
if (list.revoked.has(c.serial.toString('hex'))) return fail('REVOKED', `"${cnOf(c)}" is revoked by "${cnOf(p)}"`);
return null;
}
}
/** Semnatura unui certificat auto-semnat, verificata cu propria cheie (interoperabilitatea inversa: certificate facute de altii). */
export function selfSignatureValid(der) {
const c = parseCert(der);
return c.subjectRaw.equals(c.issuerRaw) && verifySig(c.tbs, c.signature, c.spki, cnOf(c));
}
export function cnOf(c) {
try {
for (const rdn of D.children(D.parse(c.subjectRaw))) for (const atv of D.children(rdn)) {
const [o, v] = D.children(atv);
if (D.oidToString(o) === OID.CN) return v.content.toString('utf8');
}
} catch { /* nume necitibil */ }
return '?';
}
export const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`;
export function unpem(text, label = 'CERTIFICATE') {
const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`, 'g');
const out = []; let m;
while ((m = re.exec(text))) out.push(Buffer.from(m[1].replace(/\s+/g, ''), 'base64'));
return out;
}