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.

237 lines (236 loc) 7.29 kB
import { deleteSecret, getSecret, listSecrets, setSecret } from './keychain.js'; import assert from 'node:assert'; import { errCode } from '@cto.af/utils'; import filenamify from 'filenamify'; import fs from 'node:fs/promises'; import path from 'node:path'; import rs from 'jsrsasign'; export const KEYCHAIN_SERVICE = 'com.github.cto-af.ca'; export const SELF_SIGNED = Symbol('SELF_SIGNED'); /** * A certificate and its private key. */ export class KeyCert { ca; cert; key = undefined; name; #keyFile = undefined; #certFile = undefined; #x509; constructor(name, key, cert, ca) { this.name = name; if (key) { this.key = (typeof key === 'string') ? key : rs.KEYUTIL.getPEM(key, 'PKCS8PRV'); } this.cert = (typeof cert === 'string') ? cert : cert.getPEM(); this.#x509 = new rs.X509(); this.#x509.readCertPEM(this.cert); this.ca = (ca === SELF_SIGNED) ? this : ca; } /** * The PEM-encoded full certificate chain, starting with this cert, then * adding the CA cert if there is a CA. */ get chain() { let ret = this.cert; if (this.ca) { ret += this.ca.cert; } return ret; } /** * The account name of the key, stored under KEYCHAIN_SERVICE in the * OS-specific keychain. This corresponds to the file name that the key * used to be stored in. This file should no longer exist after the upgrade * procedure runs. * * @returns If known, the filename, otherwise undefined. */ get keyFile() { return this.#keyFile; } /** * The file name of the certificate. The file is encoded as PEM. * * @returns The filename, or undefined if unknown. */ get certFile() { return this.#certFile; } /** * Issuer DN string. * * @returns A string of the form '/C=US'. */ get issuer() { return this.#x509.getIssuerString(); } /** * Certificate not valid after this date. * * @returns Date constructed from X509. */ get notAfter() { return rs.zulutodate(this.#x509.getNotAfter()); } /** * Certificate not valid before this date. * * @returns Date constructed from X509. */ get notBefore() { return rs.zulutodate(this.#x509.getNotBefore()); } /** * List of subjectAlternativeNames for the cert. * * @returns Array of {dns: 'hostname'} or {ip: 'address'} objects. */ get san() { return this.#x509.getExtSubjectAltName()?.array; } /** * Serial number of the cert. * * @returns Hex string. */ get serial() { return this.#x509.getSerialNumberHex(); } /** * Subject name of the cert. * * @returns String of the form '/CN=localhost'. */ get subject() { return this.#x509.getSubjectString(); } /** * Read the cert file and the key from the keychain. * * @param opts Options. Most important is dir. * @param name Base name of the files, escaped for use as filenames. * No suffix or directory. * @param log Logger. * @param ca If known, the CA. Use SELF_SIGNED for the CA. * @returns KeyCert, or null if not found. */ static async read(opts, name, log, ca) { try { const names = this.#getNames(opts, name); const cert = await fs.readFile(names.certName, 'utf8'); const key = opts.noKey ? undefined : await getSecret(log, KEYCHAIN_SERVICE, names.keyName); const kc = new KeyCert(name, key, cert, ca); kc.#keyFile = names.keyName; kc.#certFile = names.certName; return kc; } catch (e) { if (errCode(e, 'ENOENT')) { return null; } throw e; } } /** * Get all known certs in the given directory. * * @param opts Options, most important is dir. * @param log Logger. * @param ca If known, the CA, or SELF_SIGNED for CAs. * @yields Already-read KeyCert instances. */ static async *list(opts, log, ca) { const certDir = path.resolve(process.cwd(), opts.dir); for (const f of await fs.readdir(certDir)) { if (f.endsWith('.cert.pem')) { const name = f.slice(0, -9); const certFile = path.join(certDir, f); const keyFile = path.join(certDir, `${name}.key.pem`); const cert = await fs.readFile(certFile, 'utf8'); const key = opts.noKey ? undefined : await getSecret(log, KEYCHAIN_SERVICE, keyFile); const kc = new KeyCert(name, key, cert, ca); kc.#keyFile = keyFile; kc.#certFile = certFile; yield kc; } } } /** * List all known keys. * * @yields Object with account name and pre-populated AsyncEntry for * modifications. */ static async *listKeys() { yield* listSecrets(KEYCHAIN_SERVICE); } static #getNames(opts, name) { const fn = filenamify(name); const certDir = path.resolve(process.cwd(), opts.dir); const keyName = path.join(certDir, `${fn}.key.pem`); const certName = path.join(certDir, `${fn}.cert.pem`); return { certDir, keyName, certName, }; } /** * Delete this key, if it isn't temporary. * * @param opts Options, of which temp is the most important. * @param log Logger. * @returns Promise that completes when done deleting. */ async delete(opts, log) { if (opts?.temp) { return; } const keyName = this.#keyFile; const certName = this.#certFile; assert(keyName, '#keyName should have been set on creation'); assert(certName, '#certName should have been set on creation'); await deleteSecret(KEYCHAIN_SERVICE, keyName, log); log?.debug?.('Deleting cert: "%s"', certName); await fs.rm(certName); } /** * Save the cert file and key, unless this is temporary. * * @param opts Options, of which temp is the most important. * @param log Logger. * @returns Promise that completes when writing is done. */ async write(opts, log) { const names = KeyCert.#getNames(opts, this.name); this.#keyFile = names.keyName; this.#certFile = names.certName; if (opts.temp) { return; } await fs.mkdir(names.certDir, { recursive: true }); if (this.key) { await setSecret(log, KEYCHAIN_SERVICE, names.keyName, this.key); } await fs.writeFile(names.certName, this.cert, 'utf8'); } /** * Verify the certificate with its issuer. If no CA, returns false. * * @returns True if valid. */ verify() { if (!this.ca) { return false; } return this.#x509.verifySignature(this.ca.#x509.getPublicKey()); } }