#!/usr/bin/env node // cli.mjs: linia de comanda a autoritatii de certificare post-cuantice (pki.mjs). Starea sta intr-un dosar: fiecare CA are // .crt (PEM), .key (cheia SIGILATA cu parola din AERE_PKI_PASSPHRASE: scrypt + AES-256-GCM, formatul propriu din pki.mjs, // niciodata in clar si niciodata PKCS#8 pe disc [A1]), .revoked.json (seriile revocate) si .crlnum (numarul ultimei liste). // "unseal" scrie o cheie in PKCS#8 necifrat, singura forma pe care o citesc serverele TLS straine; se face numai la cerere explicita. // Mesajele pentru utilizator sunt in engleza. import fs from 'node:fs'; import path from 'node:path'; import * as P from './pki.mjs'; const USAGE = `aere-pq-pki: a private post-quantum certificate authority (X.509 v3, ML-DSA, RFC 9881) node cli.mjs init --dir D --name NAME [--org O] [--alg ml-dsa-87] [--days 3650] [--path-len 1] node cli.mjs intermediate --dir D --ca ROOT --name NAME [--org O] [--alg ml-dsa-65] [--days 1825] [--path-len 0] node cli.mjs issue --dir D --ca CA --cn CN [--dns a,b] [--ip 192.0.2.10] [--client] [--alg ml-dsa-65] [--days 90] --out PREFIX node cli.mjs revoke --dir D --ca CA --cert FILE node cli.mjs crl --dir D --ca CA [--days 7] --out FILE node cli.mjs verify --roots FILE --chain FILE [--host H] [--crl FILE ...] [--purpose serverAuth|clientAuth] [--require-crl] node cli.mjs unseal --key FILE.key --out FILE.pem (writes the key as UNENCRYPTED PKCS#8, for a TLS server; keep it 0600) Private keys are written sealed with AERE_PKI_PASSPHRASE (scrypt 2^17 + AES-256-GCM): at least 12 characters with 3 character classes, or at least 20 characters; obvious passphrases are refused. Without it nothing is issued.`; function args(argv) { const o = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a.startsWith('--')) { o._.push(a); continue; } const k = a.slice(2); const flag = ['client', 'require-crl'].includes(k); const v = flag ? true : argv[++i]; if (v === undefined) throw new P.PkiError('USAGE', '--' + k + ' needs a value'); if (k === 'crl') (o.crl ||= []).push(v); else o[k] = v; } return o; } // La deschidere se cere doar sa existe (o cheie sigilata a trecut politica atunci cand a fost scrisa); politica intreaga // (P.checkPassphrase) se aplica la SCRIERE, in exportPrivateKey, ca o inasprire viitoare sa nu incuie cheile vechi. const pass = () => { const p = process.env.AERE_PKI_PASSPHRASE; if (!p || p.length < 12) throw new P.PkiError('PASSPHRASE', 'set AERE_PKI_PASSPHRASE (at least 12 characters with 3 character classes, or at least 20 characters)'); return p; }; const need = (o, ...ks) => { for (const k of ks) if (!o[k]) throw new P.PkiError('USAGE', '--' + k + ' is required'); }; const safeName = (n) => { if (!/^[A-Za-z0-9._-]{1,64}$/.test(n)) throw new P.PkiError('USAGE', 'names use letters, digits, ".", "_" and "-" only'); return n; }; function loadCa(dir, ca) { const n = safeName(ca); const cert = P.unpem(fs.readFileSync(path.join(dir, n + '.crt'), 'utf8'))[0]; const key = P.importPrivateKey(fs.readFileSync(path.join(dir, n + '.key'), 'utf8'), pass()); return { n, cert, key }; } function writeNew(file, data, mode = 0o600) { if (fs.existsSync(file)) throw new P.PkiError('EXISTS', file + ' exists; nothing is overwritten'); fs.writeFileSync(file, data, { mode, flag: 'wx' }); } function main(argv) { const [cmd, ...rest] = argv; if (!cmd || cmd === '--help') { console.log(USAGE); return 0; } const o = args(rest); if (cmd === 'init' || cmd === 'intermediate') { need(o, 'dir', 'name'); const n = safeName(o.name); fs.mkdirSync(o.dir, { recursive: true, mode: 0o700 }); const alg = o.alg || (cmd === 'init' ? 'ml-dsa-87' : 'ml-dsa-65'); const k = P.generateKey(alg); const pl = o['path-len'] !== undefined ? Number(o['path-len']) : (cmd === 'init' ? 1 : 0); let cert; if (cmd === 'init') cert = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 3650) }); else { need(o, 'ca'); const ca = loadCa(o.dir, o.ca); cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 1825) }); } const pem = P.exportPrivateKey(k.privateKey, pass()); writeNew(path.join(o.dir, n + '.key'), pem); writeNew(path.join(o.dir, n + '.crt'), P.pem('CERTIFICATE', cert), 0o644); writeNew(path.join(o.dir, n + '.revoked.json'), '[]\n'); console.log(`${cmd === 'init' ? 'root' : 'intermediate'} "${o.name}" (${alg}) written to ${o.dir}`); return 0; } if (cmd === 'issue') { need(o, 'dir', 'ca', 'cn', 'out'); const ca = loadCa(o.dir, o.ca); const alg = o.alg || 'ml-dsa-65'; const k = P.generateKey(alg); const cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.cn }, publicKey: k.publicKey, days: Number(o.days || 90), dns: o.dns ? String(o.dns).split(',') : [], ips: o.ip ? String(o.ip).split(',') : [], eku: [o.client ? 'clientAuth' : 'serverAuth'] }); writeNew(o.out + '.key', P.exportPrivateKey(k.privateKey, pass())); writeNew(o.out + '.crt', P.pem('CERTIFICATE', cert), 0o644); writeNew(o.out + '.chain.pem', P.pem('CERTIFICATE', cert) + P.pem('CERTIFICATE', ca.cert), 0o644); console.log(`certificate for "${o.cn}" (${alg}, serial ${P.parseCert(cert).serial.toString('hex')}) written to ${o.out}.crt`); return 0; } if (cmd === 'revoke') { need(o, 'dir', 'ca', 'cert'); const ca = loadCa(o.dir, o.ca); const c = P.parseCert(P.unpem(fs.readFileSync(o.cert, 'utf8'))[0]); if (!c.issuerRaw.equals(P.parseCert(ca.cert).subjectRaw)) throw new P.PkiError('ISSUER', 'this certificate was not issued by ' + ca.n); const f = path.join(o.dir, ca.n + '.revoked.json'); const list = JSON.parse(fs.readFileSync(f, 'utf8')); const s = c.serial.toString('hex'); if (!list.some((x) => x.serial === s)) list.push({ serial: s, date: new Date().toISOString() }); fs.writeFileSync(f, JSON.stringify(list, null, 1) + '\n'); console.log(`serial ${s} revoked by ${ca.n}; publish a new list with "crl"`); return 0; } if (cmd === 'crl') { need(o, 'dir', 'ca', 'out'); const ca = loadCa(o.dir, o.ca); const nf = path.join(o.dir, ca.n + '.crlnum'); const number = (fs.existsSync(nf) ? Number(fs.readFileSync(nf, 'utf8')) : 0) + 1; const list = JSON.parse(fs.readFileSync(path.join(o.dir, ca.n + '.revoked.json'), 'utf8')); const der = P.crl({ issuer: ca.cert, signingKey: ca.key, revoked: list, days: Number(o.days || 7), number }); fs.writeFileSync(o.out, P.pem('X509 CRL', der)); fs.writeFileSync(nf, String(number)); console.log(`revocation list #${number} of ${ca.n} (${list.length} revoked) written to ${o.out}`); return 0; } if (cmd === 'verify') { need(o, 'roots', 'chain'); const chain = P.unpem(fs.readFileSync(o.chain, 'utf8')); const r = P.verifyChain({ leaf: chain[0], intermediates: chain.slice(1), roots: P.unpem(fs.readFileSync(o.roots, 'utf8')), host: o.host || null, purpose: o.purpose || 'serverAuth', requireCrl: !!o['require-crl'], crls: (o.crl || []).flatMap((f) => P.unpem(fs.readFileSync(f, 'utf8'), 'X509 CRL')) }); console.log(r.ok ? `OK: ${r.chain.join(' <- ')}` : `REFUSED (${r.code}): ${r.reason}`); return r.ok ? 0 : 1; } if (cmd === 'unseal') { need(o, 'key', 'out'); const key = P.importPrivateKey(fs.readFileSync(o.key, 'utf8'), pass()); writeNew(o.out, key.export({ type: 'pkcs8', format: 'pem' })); console.log(`${o.key} unsealed to ${o.out} as UNENCRYPTED PKCS#8 (mode 0600); delete it when the server no longer needs it`); return 0; } throw new P.PkiError('USAGE', 'unknown command ' + cmd + '\n' + USAGE); } try { process.exitCode = main(process.argv.slice(2)); } catch (e) { console.error(`error (${e.code || 'ERROR'}): ${e.message}`); process.exitCode = 2; }