tls-keygen
Version:
Generate a self-signed TLS certificate and add it to the trusted certificate store.
475 lines (433 loc) • 12.9 kB
JavaScript
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