135 lines
7.9 KiB
JavaScript
135 lines
7.9 KiB
JavaScript
#!/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
|
|
// <nume>.crt (PEM), <nume>.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]), <nume>.revoked.json (seriile revocate) si <nume>.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; }
|