// 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, 'certificate'); 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; }