UNPKG

@cto.af/ca

Version:

Testing-only Certificate Authority (CA) for your local development environment ONLY. This is in no way suitable for production of any kind.

341 lines (340 loc) 12 kB
import { KEYCHAIN_SERVICE, KeyCert, SELF_SIGNED } from './cert.js'; import { LOG_OPTIONS_NAMES, childLogger, } from '@cto.af/log'; import { nameSet, select } from '@cto.af/utils'; import { daysFromNow } from './utils.js'; import envPaths from 'env-paths'; import filenamify from 'filenamify'; import net from 'node:net'; import path from 'node:path'; import rs from 'jsrsasign'; const CA_SUBJECT = '/C=US/ST=Colorado/L=Denver/O=@cto.af/CN=cto-af-Root-CA'; const { config: ctoafConfig } = envPaths('@cto.af'); const config = path.join(ctoafConfig, 'ca'); export { KEYCHAIN_SERVICE, KeyCert, SELF_SIGNED, }; export const DEFAULT_CA_OPTIONS = { dir: config, force: false, host: CA_SUBJECT, minRunDays: 1, noKey: false, notAfterDays: 365, temp: false, }; export const DEFAULT_COMMON_CERT_OPTIONS = { dir: '.cert', force: false, host: ['localhost', '127.0.0.1', '::1'], minRunDays: 1, noKey: false, notAfterDays: 7, temp: false, }; export const COMMON_CERT_OPTIONS_NAMES = nameSet(DEFAULT_COMMON_CERT_OPTIONS); export const DEFAULT_CERT_OPTIONS = { caSubject: CA_SUBJECT, caNotAfterDays: DEFAULT_CA_OPTIONS.notAfterDays, caMinRunDays: DEFAULT_CA_OPTIONS.minRunDays, minRunDays: 1, notAfterDays: 7, caDir: config, certDir: DEFAULT_COMMON_CERT_OPTIONS.dir, forceCA: false, forceCert: false, host: DEFAULT_COMMON_CERT_OPTIONS.host, noKey: false, temp: false, }; function altNames(hosts) { return hosts.map(h => (net.isIP(h) ? { ip: h } : { dns: h })); } /** * Extract CA options from mixed options. * * @param options Original options. * @returns Extracted CA options. */ export function getCAoptions(options = {}) { const [opts, logOpts] = select(options, DEFAULT_CERT_OPTIONS, LOG_OPTIONS_NAMES); return { dir: opts.caDir, host: opts.caSubject, minRunDays: opts.caMinRunDays, notAfterDays: opts.caNotAfterDays, force: opts.forceCA, noKey: opts.noKey, temp: opts.temp, ...logOpts, }; } /** * Extract leaf certificate options from mixed options. * * @param options Original options. * @returns Extracted options. */ export function getIssueOptions(options = {}) { const [opts] = select(options, DEFAULT_CERT_OPTIONS); return { dir: opts.certDir, host: opts.host, minRunDays: opts.minRunDays, notAfterDays: opts.notAfterDays, force: opts.forceCert, noKey: opts.noKey, temp: opts.temp, }; } /** * Certificate Authority that does local storage, intended for testing on the * local machine. * * WARNING: Not intended for scale or actual security. DO NOT deploy on the * Internet in the current form. */ export class CertificateAuthority { #log; #opts; #pair = null; #subject; constructor(options = {}) { const [opts, logOpts] = select(options, DEFAULT_CA_OPTIONS, LOG_OPTIONS_NAMES); if (Array.isArray(opts.host)) { if (opts.host.length !== 1) { throw new TypeError(`Only single host allowed for CA subject, got ${opts.host.length}`); } [this.#subject] = opts.host; } else { this.#subject = opts.host; } this.#log = CertificateAuthority.logger(logOpts); this.#opts = opts; } /** * Create a child logger for the CA's use. * * @param logOpts Options for logging. * @returns Child logger. */ static logger(logOpts) { return childLogger(logOpts, { ns: 'ca' }); } /** * List all of the CA certs. * * @param options Options, of which dir is the most important. * @yields Instantiated instances of CA KeyCert's. */ static async *list(options) { const [opts, logOpts] = select(options, DEFAULT_CA_OPTIONS, LOG_OPTIONS_NAMES); const log = CertificateAuthority.logger(logOpts); yield* KeyCert.list(opts, log); } /** * Mostly-internal, for initialization. Must be called before any substantive * work is done. * * @returns CA KeyCert. */ async init() { if (this.#pair) { return this.#pair; } const now = new Date(); const ca_file = filenamify(this.#subject); if (!this.#opts.force && !this.#opts.temp) { this.#pair = await KeyCert.read(this.#opts, ca_file, this.#log, SELF_SIGNED); if (this.#pair) { const na = this.#pair.notAfter; if (na.getTime() < daysFromNow(this.#opts.minRunDays, now).getTime()) { this.#log.warn(`Not enough CA run time: ${na}`); this.#pair = null; } else { return this.#pair; } } } const kc = this.#create(now); await kc.write(this.#opts, this.#log); if ((process.platform === 'darwin') && !this.#opts.temp) { this.#log.info(` To trust the new CA for OSX apps like Safari, try: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s.cert.pem `, path.resolve(this.#opts.dir, ca_file)); } return kc; } /** * Issue a certificate for use in an HTTPS server. May read from existing * on-disk cert and in-keychain key. Will generate a new cert if the old * one is no longer valid. * * @param options Options. * @returns Initialized KeyCert. */ async issue(options = {}) { const [opts] = select(options, DEFAULT_COMMON_CERT_OPTIONS); this.#log.debug('Issue options: %o', opts); const ca = await this.init(); const [host, hosts] = (typeof opts.host === 'string') ? [opts.host, [opts.host]] : [opts.host[0], opts.host]; if (hosts.length < 1) { throw new Error('One or more hosts required'); } const now = new Date(); if (!opts.force && !opts.temp) { const pair = await KeyCert.read(opts, host, this.#log, ca); if (pair) { const oneDay = daysFromNow(opts.minRunDays, now); if (pair.notAfter.getTime() < oneDay.getTime()) { this.#log.warn('Not enough run time left on existing cert: %o < %o', pair.notAfter, oneDay); } else if (pair.issuer !== ca.subject) { this.#log.warn('Invalid CA subject "%s" != "%s".', pair.issuer, ca.subject); } else if (pair.notBefore.getTime() >= ca.notBefore.getTime()) { return pair; // Still valid. } this.#log.warn('CA no longer valid: %s < %s', pair.notBefore.toISOString(), ca.notBefore.toISOString()); } } const kc = this.issueNew(options, now); await kc.write(opts, this.#log); return kc; } issueNew(options = {}, now = new Date()) { const [opts] = select(options, DEFAULT_COMMON_CERT_OPTIONS); const [host, hosts] = (typeof opts.host === 'string') ? [opts.host, [opts.host]] : [opts.host[0], opts.host]; let ca = this.#pair; if (!ca) { if (!this.#opts.temp) { throw new TypeError('Only call issueNew directly for temp CAs'); } ca = this.#create(now); } this.#log.info('Creating cert for %o.', hosts); const recently = new Date(now.getTime() - 10000); // 10s ago. const nextWeek = daysFromNow(opts.notAfterDays, now); const kp = rs.KEYUTIL.generateKeypair('EC', 'secp256r1'); const prv = kp.prvKeyObj; const pub = kp.pubKeyObj; if (!ca.key) { throw new Error('Key required'); } const x = new rs.KJUR.asn1.x509.Certificate({ version: 3, serial: { int: now.getTime() }, issuer: { str: ca.subject }, notbefore: rs.datetozulu(recently, true, false), notafter: rs.datetozulu(nextWeek, true, false), subject: { str: `/CN=${host}` }, sbjpubkey: pub, ext: [ { extname: 'basicConstraints', cA: false }, { extname: 'keyUsage', critical: true, names: ['digitalSignature'] }, { extname: 'subjectAltName', array: altNames(hosts) }, { extname: 'authorityKeyIdentifier', kid: ca.cert }, { extname: 'subjectKeyIdentifier', kid: pub }, { extname: 'extKeyUsage', array: ['serverAuth', 'clientAuth'] }, ], sigalg: 'SHA384withECDSA', cakey: ca.key, }); return new KeyCert(host, prv, x.getPEM(), ca); } async delete(options) { if (options == null) { const kp = await this.init(); await kp.delete(this.#opts, this.#log); return; } let kc = null; let opts = { ...DEFAULT_COMMON_CERT_OPTIONS, noKey: true, }; if (options instanceof KeyCert) { kc = options; } else { [opts] = select(options, opts); let { host } = opts; if (Array.isArray(host)) { [host] = host; } kc = await KeyCert.read(opts, host, this.#log); } await kc?.delete(opts, this.#log); } /** * List the certs in the local directory. * * @param options Options, of which dir is the most important. * @yields Already-read KeyCert instances. */ async *list(options) { const [opts] = select(options, DEFAULT_COMMON_CERT_OPTIONS); const ca = await this.init(); yield* KeyCert.list(opts, this.#log, ca); } /** * Just the sync parts of init(). * * @param now Current time. * @returns New CA KeyCert. */ #create(now = new Date()) { this.#log.info(`Creating new${this.#opts.temp ? ' temp' : ''} CA certificate`); // Create a self-signed CA cert const kp = rs.KEYUTIL.generateKeypair('EC', 'secp256r1'); const prv = kp.prvKeyObj; const pub = kp.pubKeyObj; const recently = new Date(now.getTime() - 10000); // 10s ago. const oneYear = daysFromNow(this.#opts.notAfterDays, now); const ca_cert = new rs.KJUR.asn1.x509.Certificate({ version: 3, serial: { int: now.getTime() }, issuer: { str: this.#subject }, notbefore: rs.datetozulu(recently, false, false), notafter: rs.datetozulu(oneYear, false, false), subject: { str: this.#subject }, sbjpubkey: pub, ext: [ { extname: 'basicConstraints', critical: true, cA: true, pathLen: 0 }, { extname: 'keyUsage', critical: true, names: ['digitalSignature', 'keyCertSign', 'cRLSign'] }, { extname: 'authorityKeyIdentifier', kid: pub }, { extname: 'subjectKeyIdentifier', kid: pub }, { extname: 'extKeyUsage', array: ['serverAuth', 'clientAuth'] }, ], sigalg: 'SHA256withECDSA', cakey: prv, }); const kc = new KeyCert(this.#subject, prv, ca_cert, SELF_SIGNED); this.#pair = kc; return kc; } } /** * Read a valid CA cert, or create a new one, writing it. * * @param options Cert options. * @returns Private Key / Certificate for CA. */ export async function createCA(options = {}) { const ca = new CertificateAuthority(getCAoptions(options)); return ca.init(); } /** * Create a CA-signed localhost certificate. * * @param options Certificate options. * @returns Cert and private key. */ export async function createCert(options = {}) { const ca = new CertificateAuthority(getCAoptions(options)); return ca.issue(getIssueOptions(options)); }