From 967e4c165a0064ef46e7d70f064a000d41015d97 Mon Sep 17 00:00:00 2001 From: Dave Wasmer Date: Wed, 29 Mar 2017 15:42:20 -0700 Subject: [PATCH] Initial pass --- .gitignore | 1 + openssl.conf | 49 +++++++++++++ package.json | 41 +++++++++++ src/index.ts | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 18 +++++ yarn.lock | 182 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 486 insertions(+) create mode 100644 .gitignore create mode 100644 openssl.conf create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/openssl.conf b/openssl.conf new file mode 100644 index 0000000..2d0669e --- /dev/null +++ b/openssl.conf @@ -0,0 +1,49 @@ +[ ca ] +# `man ca` +default_ca = CA_default + +[ CA_default ] +default_md = sha256 +name_opt = ca_default +cert_opt = ca_default +policy = policy_loose +database = index.txt +serial = serial +prompt = no + +[ policy_loose ] +# Only require minimal information for development certificates +commonName = supplied + +[ req ] +# Options for the `req` tool (`man req`). +default_bits = 2048 +distinguished_name = req_distinguished_name +string_mask = utf8only + +# SHA-1 is deprecated, so use SHA-2 instead. +default_md = sha256 + +# Extension to add when the -x509 option is used. +x509_extensions = v3_ca + +[ req_distinguished_name ] +# See . +commonName = Common Name + +[ v3_ca ] +# Extensions for a typical CA (`man x509v3_config`). +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:true +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[ server_cert ] +# Extensions for server certificates (`man x509v3_config`). +basicConstraints = CA:FALSE +nsCertType = server +nsComment = "OpenSSL Generated Server Certificate" +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer:always +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth diff --git a/package.json b/package.json new file mode 100644 index 0000000..b0feaa2 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "devcert", + "version": "1.0.0", + "description": "Generate trusted local SSL/TLS certificates for local SSL development", + "main": "dist/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/davewasmer/devcert.git" + }, + "keywords": [ + "ssl", + "certificate", + "openssl", + "trust" + ], + "author": "Dave Wasmer", + "license": "MIT", + "bugs": { + "url": "https://github.com/davewasmer/devcert/issues" + }, + "homepage": "https://github.com/davewasmer/devcert#readme", + "devDependencies": { + "typescript": "^2.2.2" + }, + "dependencies": { + "@types/configstore": "^2.1.1", + "@types/es6-promise": "^0.0.32", + "@types/get-port": "^0.0.4", + "@types/glob": "^5.0.30", + "@types/node": "^7.0.11", + "@types/tmp": "^0.0.32", + "command-exists": "^1.2.2", + "configstore": "^3.0.0", + "get-port": "^3.0.0", + "glob": "^7.1.1", + "tmp": "^0.0.31" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..865ffa5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,195 @@ +import { + readFileSync, + readdirSync, + writeFileSync, + unlinkSync, + chmodSync, + existsSync +} from 'fs'; +import * as path from 'path'; +import * as getPort from 'get-port'; +import * as http from 'http'; +import { execSync } from 'child_process'; +import * as tmp from 'tmp'; +import * as glob from 'glob'; +import * as Configstore from 'configstore'; +import { sync as commandExists } from 'command-exists'; + +const isMac = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; +const isWindows = process.platform === 'win32'; + +// use %LOCALAPPDATA%/Yarn on Windows otherwise use ~/.config/yarn +let configDir: string; +if (isWindows && process.env.LOCALAPPDATA) { + configDir = path.join(process.env.LOCALAPPDATA, 'Yarn', 'config'); +} else { + let uid = process.getuid && process.getuid(); + let userHome = (isLinux && uid === 0) ? path.resolve('/usr/local/share') : require('os').homedir(); + path.join(userHome, '.config', 'yarn'); +} +const configPath = path.join.bind(path, configDir); + +const opensslConfPath = path.join(__dirname, '..', 'openssl.conf'); +const rootKeyPath = configPath('devcert-ca-root.key'); +const rootCertPath = configPath('devcert-ca-root.crt'); + +interface Options { + installCertutil?: boolean; +} + +interface Certificate { + key: string; + cert: string; +} + +export default function devcert(appName: string, options: Options = {}) { + + // Fail fast on unsupported platforms (PRs welcome!) + if (!isMac && !isLinux && !isWindows) { + throw new Error(`devcert: "${ process.platform }" platform not supported`); + } + if (!commandExists('openssl')) { + throw new Error('Unable to find openssl - make sure it is installed and available in your PATH'); + } + + if (!existsSync(configPath('devcert-ca-root.key'))) { + installCertificateAuthority(options.installCertutil); + } + + // Load our root CA and sign a new app cert with it. + let appKeyPath = generateKey(appName); + let appCertificatePath = generateSignedCertificate(appName, appKeyPath); + + return { + keyPath: appKeyPath, + certificatePath: appCertificatePath, + key: readFileSync(appKeyPath), + certificate: readFileSync(appCertificatePath) + }; + +} + +// Install the once-per-machine trusted root CA. We'll use this CA to sign per-app certs, allowing +// us to minimize the need for elevated permissions while still allowing for per-app certificates. +function installCertificateAuthority(installCertutil) { + let rootKeyPath = generateKey('devcert-ca-root'); + execSync(`openssl req -config ${ opensslConfPath } -key ${ rootKeyPath } -out ${ rootCertPath } -new -subj '/CN=devcert' -x509 -days 7000 -extensions v3_ca`); + addCertificateToTrustStores(installCertutil); +} + +// Generate a cryptographic key, used to sign certificates or certificate signing requests. +function generateKey(name: string): string { + let filename = configPath(`${ name }.key`); + execSync(`openssl genrsa -out ${ filename } 2048`); + chmodSync(filename, 400); + return filename; +} + +// Generate a certificate signed by the devcert root CA +function generateSignedCertificate(name: string, keyPath: string): string { + let csrFile = configPath(`${ name }.csr`) + execSync(`openssl req -config ${ opensslConfPath } -subj '/CN=${ name }' -key ${ keyPath } -out ${ csrFile } -new`); + let certPath = configPath(`${ name }.crt`); + execSync(`openssl ca -config ${ opensslConfPath } -in ${ csrFile } -out ${ certPath } -keyfile ${ rootKeyPath } -cert ${ rootCertPath } -notext -md sha256 -days 7000 -extensions server_cert`) + return certPath; +} + +// Add the devcert root CA certificate to the trust stores for this machine. Adds to OS level trust +// stores, and where possible, to browser specific trust stores +async function addCertificateToTrustStores(installCertutil: boolean): Promise { + + if (isMac) { + // Chrome, Safari, system utils + execSync(`sudo security add-trusted-cert -r trustRoot -k /Library/Keychains/System.keychain -p ssl "${ rootCertPath }"`); + // Firefox + try { + // Try to use certutil to install the cert automatically + addCertificateToNSSCertDB('~/Library/Application Support/Firefox/Profiles/*', installCertutil); + } catch (e) { + // Otherwise, open the cert in Firefox to install it + await openCertificateInFirefox('/Applications/Firefox.app/Contents/MacOS/firefox'); + } + + } else if (isLinux) { + // system utils + execSync(`sudo cp ${ rootCertPath } /usr/local/share/ca-certificates/devcert.cer && update-ca-certificates`); + // Firefox + try { + // Try to use certutil to install the cert automatically + addCertificateToNSSCertDB('~/.mozilla/firefox/*', installCertutil); + } catch (e) { + // Otherwise, open the cert in Firefox to install it + await openCertificateInFirefox('firefox'); + } + // Chrome + // No try..catch, since there's no alternative here. Chrome won't prompt to add a cert to the + // store if opened as a URL + addCertificateToNSSCertDB('~/.pki/nssdb', installCertutil); + + // Windows + } else if (isWindows) { + // IE, Chrome, system utils + execSync(`certutil -addstore -user root ${ rootCertPath }`); + // Firefox (don't even try NSS certutil, no easy install for Windows) + await openCertificateInFirefox('start firefox'); + } + +} + +// Try to use certutil to add the root cert to an NSS database +function addCertificateToNSSCertDB(nssDirGlob: string, installCertutil: boolean): void { + let certutilPath = lookupOrInstallCertutil(installCertutil); + if (!certutilPath) { + throw new Error('certutil not available, and `installCertutil` was false'); + } + glob.sync(nssDirGlob).forEach((potentialNSSDBDir) => { + if (existsSync(path.join(potentialNSSDBDir, 'cert8.db'))) { + execSync(`${ certutilPath } -A -d ${ potentialNSSDBDir } -t 'C,,' -i ${ rootCertPath }`); + } else if (existsSync(path.join(potentialNSSDBDir, 'cert9.db'))) { + execSync(`${ certutilPath } -A -d sql:${ potentialNSSDBDir } -t 'C,,' -i ${ rootCertPath }`); + } + }); +} + +// Launch a web server and open the root cert in Firefox. Useful for when certutil isn't available +async function openCertificateInFirefox(firefoxPath: string) { + let port = await getPort(); + let server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-type': 'application/x-x509-ca-cert' }); + res.write(readFileSync(rootCertPath)); + res.end(); + }).listen(port); + execSync(`${ firefoxPath } http://localhost:${ port }`); + await new Promise((resolve) => { + process.stdin.resume(); + process.stdin.on('data', resolve); + }); +} + +// Try to install certutil if it's not already available, and return the path to the executable +function lookupOrInstallCertutil(options: Options): boolean | string { + if (isMac) { + if (commandExists('brew')) { + let nssPath: string; + try { + return path.join(execSync('brew --prefix nss').toString(), 'bin', 'certutil'); + } catch (e) { + if (options.installCertutil) { + execSync('brew install nss'); + return path.join(execSync('brew --prefix nss').toString(), 'bin', 'certutil'); + } + } + } + } else if (isLinux) { + if (!commandExists('certutil')) { + if (options.installCertutil) { + execSync('sudo apt install libnss3-tools'); + } else { + return false; + } + } + return execSync('which certutil').toString(); + } + return false; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a782ec0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2016", + "noImplicitAny": true, + "sourceMap": false, + "importHelpers": true, + "inlineSourceMap": true, + "inlineSources": true, + "outDir": "dist", + "baseUrl": ".", + "skipLibCheck": true, + "sourceRoot": ".", + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..4b13ab6 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,182 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/configstore@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@types/configstore/-/configstore-2.1.1.tgz#cd1e8553633ad3185c3f2f239ecff5d2643e92b6" + +"@types/es6-promise@^0.0.32": + version "0.0.32" + resolved "https://registry.yarnpkg.com/@types/es6-promise/-/es6-promise-0.0.32.tgz#3bcf44fb1e429f3df76188c8c6d874463ba371fd" + +"@types/get-port@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@types/get-port/-/get-port-0.0.4.tgz#eb6bb7423d9f888b632660dc7d2fd3e69a35643e" + +"@types/glob@^5.0.30": + version "5.0.30" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-5.0.30.tgz#1026409c5625a8689074602808d082b2867b8a51" + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/minimatch@*": + version "2.0.29" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-2.0.29.tgz#5002e14f75e2d71e564281df0431c8c1b4a2a36a" + +"@types/node@*", "@types/node@^7.0.11": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.11.tgz#55680189f2335f080f0aeb57871f0b9823646d89" + +"@types/tmp@^0.0.32": + version "0.0.32" + resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.0.32.tgz#0d3cb31022f8427ea58c008af32b80da126ca4e3" + +balanced-match@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + +brace-expansion@^1.0.0: + version "1.1.6" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" + dependencies: + balanced-match "^0.4.1" + concat-map "0.0.1" + +command-exists@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.2.tgz#12819c64faf95446ec0ae07fe6cafb6eb3708b22" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +configstore@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.0.0.tgz#e1b8669c1803ccc50b545e92f8e6e79aa80e0196" + dependencies: + dot-prop "^4.1.0" + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + unique-string "^1.0.0" + write-file-atomic "^1.1.2" + xdg-basedir "^3.0.0" + +crypto-random-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" + +dot-prop@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.1.1.tgz#a8493f0b7b5eeec82525b5c7587fa7de7ca859c1" + dependencies: + is-obj "^1.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +get-port@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.0.0.tgz#03ac1c58f12b5f36667f4b705ecd29fb251df603" + +glob@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.2: + version "4.1.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +is-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + +minimatch@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" + dependencies: + brace-expansion "^1.0.0" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +mkdirp@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +os-tmpdir@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +slide@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" + +tmp@^0.0.31: + version "0.0.31" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" + dependencies: + os-tmpdir "~1.0.1" + +typescript@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.2.2.tgz#606022508479b55ffa368b58fee963a03dfd7b0c" + +unique-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" + dependencies: + crypto-random-string "^1.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +write-file-atomic@^1.1.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.1.tgz#7d45ba32316328dd1ec7d90f60ebc0d845bb759a" + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + slide "^1.1.5" + +xdg-basedir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"