UNPKG

tls-keygen

Version:

Generate a self-signed TLS certificate and add it to the trusted certificate store.

475 lines (433 loc) 12.9 kB
const { unlink, rmdir, statSync, readFile, appendFile, readdir } = require('fs') const { dirname, join } = require('path') const { platform } = require('os') const { spawnSync, execFile } = require('child_process') const { exec } = require('child-process-promise') const { promisify } = require('util') const shellEscape = require('shell-escape') const mkdirp = require('mkdirp') const commandExists = require('command-exists') const userHome = require('user-home') const Powershell = require('node-powershell') const openssl = require('openssl.exe') const tempWrite = require('temp-write') const { file } = require('tmp-promise') const { parseCertificate } = require('sshpk') function fileExists (path) { try { const stats = statSync(path) return stats.isFile() } catch (err) { return false } } function directoryExists (path) { try { const stats = statSync(path) return stats.isDirectory() } catch (err) { return false } } function opensslArgs ({ ecparam, keyout, out, commonName, config }) { const args = [ // https://wiki.openssl.org/index.php/Manual:Req(1) 'req', // generates a new certificate '-new', // outputs a self signed certificate instead of a certificate request '-x509', // the number of days to certify the certificate for '-days', '365', // private key will not be encrypted '-nodes', // RSA is widely supported... // '-newkey', 'rsa:2048', // ... but ECC is more efficient in bandwidth/CPU/RAM. // The prime256v1 curve is enabled by default in Node.js. // The secp384r1 curve is disabled in Node 8.6+ by default. // Both are widely supported in browsers recommended by NIST. // To be replaced by x25519 soon. See: nodejs/node#1495 '-newkey', `ec:${ecparam}`, // the message digest to sign the request with '-sha256', '-keyout', keyout, '-out', out, // origins covered by this certificate '-subj', `/CN=${commonName}` ] if (config !== undefined) { args.push( '-extensions', 'SAN', '-reqexts', 'SAN', '-config', config ) } return args } async function generateLocalhostPairUnix (key, cert, commonName, subjectAltName) { const cnfs = [ // OPENSSL config(3) process.env.OPENSSL_CONF, // Ubuntu Linux & MacOS High Sierra '/etc/ssl/openssl.cnf', // MacOS El Capitan & Yosemite '/usr/local/etc/openssl/openssl.cnf', // StackOverflow hearsay '/etc/pki/tls/openssl.cnf', '/usr/local/ssl/openssl.cnf', '/opt/local/etc/openssl/openssl.cnf', '/System/Library/OpenSSL/openssl.cnf' ] const opensslCnf = cnfs.find(fileExists) if (opensslCnf === undefined) { throw new Error('OpenSSL configuration not found.') } const args = opensslArgs({ ecparam: '<(openssl ecparam -name prime256v1)', keyout: shellEscape([key]), out: shellEscape([cert]), commonName, config: subjectAltName ? `<(cat ${opensslCnf} <(printf "\\n[SAN]\\nsubjectAltName=${subjectAltName}"))` : undefined }) const output = spawnSync('openssl', args, { shell: '/bin/bash' }) if (output.status !== 0) { const message = output.stderr.toString() throw new Error(message) } } async function keychainGetDefault () { const command = 'security default-keychain' try { const { stdout: keychain } = await exec(command) return keychain.trim().replace(/^"(.+)"$/, '$1') } catch (error) { throw error.stdout ? new Error(error.stdout) : error } } async function keychainAddTrusted (keychain, cert) { const command = shellEscape([ 'security', '-v', 'add-trusted-cert', '-r', 'trustRoot', '-p', 'ssl', '-k', keychain, cert ]) try { await exec(command) } catch (error) { const message = (error.stdout && error.stdout) || (error.stderr && error.stderr.split('\n')[1]) || '' throw (message ? new Error(message) : error) } } async function nssVerifyDb () { const db = join(userHome, '.pki', 'nssdb') return directoryExists(db) } async function nssVerifyCertutil () { if (!await promisify(commandExists)('certutil')) { throw new Error('certutil not found') } } async function generateLocalhostPairWindows (key, cert, commonName, subjectAltName) { try { const ecparam = await tempWrite(( await promisify(execFile)(openssl.exe, [ 'ecparam', '-name', 'prime256v1' ], { env: { OPENSSL_CONF: openssl.cnf } }) ).stdout) const cnf = await tempWrite(Buffer.concat([ await promisify(readFile)(openssl.cnf), Buffer.from(`\n[SAN]\nsubjectAltName=${subjectAltName}`) ])) const args = opensslArgs({ ecparam, keyout: key, out: cert, commonName, config: cnf }) const env = { OPENSSL_CONF: cnf } await promisify(execFile)(openssl.exe, args, { env }) try { await Promise.all([ promisify(unlink)(ecparam), promisify(unlink)(cnf) ]) } catch (error) {} } catch (error) { const nodeCommand = /Command failed:/ if (nodeCommand.test(error.message)) { error.message = error.message .split('\n') .filter((line) => !nodeCommand.test(line)) .join('\n') } const isOpensslError = /:error:/ if (isOpensslError.test(error.message)) { error.message = error.message .split('\n') .filter((line) => isOpensslError.test(line)) .join('\n') } throw error } } async function certutilAddstore (cert) { const ps = new Powershell({ executionPolicy: 'Bypass', debugMsg: false, verbose: false }) const command = [ 'Start-Process', '-FilePath', '"certutil.exe"', '-ArgumentList', `"-addstore -f Root \`"${cert}\`""`, '-Verb', 'RunAs', '-WindowStyle', 'Hidden', '-Wait' ].join(' ') try { await ps.addCommand(command) await ps.invoke() } catch (error) { await ps.dispose() throw error } await ps.dispose() } // Delete: // certutil -D -d sql:${HOME}/.pki/nssdb -n localhost // List all: // certutil -L -d sql:${HOME}/.pki/nssdb async function nssAddCertificate (cert) { const db = join(userHome, '.pki', 'nssdb') const command = shellEscape([ 'certutil', '-A', '-d', `sql:${db}`, '-n', 'localhost', '-i', cert, '-t', 'C,,' ]) await exec(command) } async function firefoxAddCertificate (cert, commonName) { const os = platform() let firefoxProfiles if (os === 'linux') { firefoxProfiles = join(userHome, '.mozilla/firefox') } else if (os === 'darwin') { firefoxProfiles = join(userHome, 'Library/Application Support/Firefox/Profiles') // firefoxProfiles = join(userHome, 'Library/Mozilla/Firefox/Profiles') // } else if (os === 'win32') { // firefoxProfiles = join(process.env.APPDATA, 'Mozilla\\Firefox\\Profiles') } else { return } let profiles try { profiles = await promisify(readdir)(firefoxProfiles) } catch (error) { if (error.code === 'ENOENT') { return } else { throw error } } const certificate = parseCertificate( await promisify(readFile)(cert), 'pem' ) for (const profile of profiles) { const directory = join(firefoxProfiles, profile) // The `sql` type first tries cert9.db (Firefox >=58), otherwise // falls back to cert8.db (Firefox <58). // https://wiki.mozilla.org/NSS_Shared_DB#Accessing_the_shareable_Database const prefix = 'sql' const db = `${prefix}:${directory}` // Documentation: // https://developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS/Reference/NSS_tools_:_certutil let certutil if (os === 'linux') { certutil = 'certutil' } else if (os === 'darwin') { // Use direct path as Homebrew does not symlink the binaries by default. certutil = '/usr/local/opt/nss/bin/certutil' if (!fileExists(certutil)) { // console.warn('Missing "certutil" command. Run: brew install nss') return } } const command = shellEscape([ certutil, '-A', '-d', db, '-n', commonName, '-i', cert, '-t', 'C,,' ]) try { await exec(command) } catch (error) { if (!/SEC_ERROR_BAD_DATABASE/.test(error.stderr)) { console.warn(error.stderr) } continue } // References: // - https://developer.mozilla.org/en-US/docs/Archive/Misc_top_level/Cert_override.txt // - http://boblord.livejournal.com/18402.html // SHA-256 fingerprint const oidSha256 = 'OID.2.16.840.1.101.3.4.2.1' const fingerprint = certificate .fingerprint('sha256') .toString('hex') .toUpperCase() // Accept unsigned certificates const overrideType = 'U' // Differs slightly from Firefox but accepted nonetheless // Ported from: https://github.com/Osmose/firefox-cert-override const { serial } = certificate const issuer = Buffer.from(certificate.issuer.cn) const serialLength = Buffer.alloc(4) serialLength.writeIntBE(serial.length) const issuerLength = Buffer.alloc(4) issuerLength.writeIntBE(issuer.length) const dbKey = Buffer.concat([ Buffer.alloc(4), Buffer.alloc(4), serialLength, issuerLength, serial, issuer ]).toString('base64') // The file header warns against modifying, but // I could not find any other programmatic way to add an override. const psm = join(directory, 'cert_override.txt') // Firefox exceptions are per-port. 😫 No wildcards. const ports = [ // HTTP & HTTPS 80, 443, // IANA http-alt // https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=http-alt 8008, 8080, // Common local development ports 8000, 8443 ] for (const port of ports) { const exception = [ `${commonName}:${port}`, oidSha256, fingerprint, overrideType, dbKey ].join('\t') await promisify(appendFile)(psm, `${exception}\n`) } } } const defaultKey = join(process.cwd(), 'key.pem') const defaultCert = join(process.cwd(), 'cert.pem') const defaultCommonName = 'localhost' const defaultSubjectAltName = [ 'DNS:localhost', 'DNS:*.localhost', 'DNS:localhost.localdomain', 'IP:127.0.0.1', 'IP:0.0.0.0', 'IP:::1', 'IP:::' ] module.exports.defaultKey = defaultKey module.exports.defaultCert = defaultCert module.exports.defaultCommonName = defaultCommonName module.exports.defaultSubjectAltName = defaultSubjectAltName async function keygen ({ key = defaultKey, cert = defaultCert, commonName = defaultCommonName, subjectAltName = defaultSubjectAltName, entrust = true } = {}) { if (Array.isArray(subjectAltName)) { subjectAltName = subjectAltName.join() } else if (!subjectAltName) { subjectAltName = '' } try { await Promise.all([ promisify(mkdirp)(dirname(key)), promisify(mkdirp)(dirname(cert)) ]) switch (platform()) { case 'darwin': case 'linux': await generateLocalhostPairUnix(key, cert, commonName, subjectAltName) break case 'win32': await generateLocalhostPairWindows(key, cert, commonName, subjectAltName) break default: throw new Error('Generating certificates on this platform is not supported.') } } catch (error) { try { await promisify(unlink)(key) await promisify(unlink)(cert) await promisify(rmdir)(dirname(key)) await promisify(rmdir)(dirname(cert)) } catch (error) {} throw error || new Error('Failed to set up certificate') } if (entrust === true) { switch (platform()) { case 'darwin': const keychain = await keychainGetDefault() await keychainAddTrusted(keychain, cert) await firefoxAddCertificate(cert, commonName) break case 'linux': if (await nssVerifyDb()) { await nssVerifyCertutil() await nssAddCertificate(cert) await firefoxAddCertificate(cert, commonName) } else { throw new Error('Unable to locate NSS database') } break case 'win32': await certutilAddstore(cert) break default: throw new Error('Entrusting certificates on this platform is not supported.') } } return { key, cert } } async function ephemeral (options) { const [keyFile, certFile] = await Promise.all([ await file(), await file() ]) const paths = await keygen(Object.assign({}, options, { key: keyFile.path, cert: certFile.path })) const [key, cert] = await Promise.all([ promisify(readFile)(paths.key), promisify(readFile)(paths.cert) ]) await Promise.all([ keyFile.cleanup(), certFile.cleanup() ]) return { key, cert } } module.exports.keygen = keygen module.exports.ephemeral = ephemeral