Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Remove protection on CA file after upgrade #48

Merged
merged 3 commits into from
Nov 23, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 37 additions & 46 deletions src/certificate-authority.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import path from 'path';
import {
unlinkSync as rm,
readFileSync as readFile,
writeFileSync as writeFile,
existsSync as exists
writeFileSync as writeFile
} from 'fs';
import { sync as rimraf } from 'rimraf';
import createDebug from 'debug';

import {
domainsDir,
rootCADir,
ensureConfigDirs,
getLegacyConfigDir,
rootCAKeyPath,
rootCACertPath,
caSelfSignConfig,
opensslSerialFilePath,
opensslDatabaseFilePath,
isWindows,
isLinux,
caVersionFile
} from './constants';
import currentPlatform from './platforms';
Expand All @@ -30,10 +29,11 @@ const debug = createDebug('devcert:certificate-authority');
* per-app certs.
*/
export default async function installCertificateAuthority(options: Options = {}): Promise<void> {
debug(`Checking if older devcert install is present`);
scrubOldInsecureVersions();
debug(`Uninstalling existing certificates, which will be void once any existing CA is gone`);
uninstall();
ensureConfigDirs();

debug(`Generating a root certificate authority`);
debug(`Making a temp working directory for files to copied in`);
let rootKeyPath = mktmp();

debug(`Generating the OpenSSL configuration needed to setup the certificate authority`);
Expand All @@ -52,39 +52,6 @@ export default async function installCertificateAuthority(options: Options = {})
await currentPlatform.addToTrustStores(rootCACertPath, options);
}

/**
* Older versions of devcert left the root certificate keys unguarded and
* accessible by userland processes. Here, we check for evidence of this older
* version, and if found, we delete the root certificate keys to remove the
* attack vector.
*/
function scrubOldInsecureVersions() {
// Use the old verion's logic for determining config directory
let configDir: string;
if (isWindows && process.env.LOCALAPPDATA) {
configDir = path.join(process.env.LOCALAPPDATA, 'devcert', 'config');
} else {
let uid = process.getuid && process.getuid();
let userHome = (isLinux && uid === 0) ? path.resolve('/usr/local/share') : require('os').homedir();
configDir = path.join(userHome, '.config', 'devcert');
}

// Delete the root certificate keys, as well as the generated app certificates
debug(`Checking ${ configDir } for legacy files ...`);
[
path.join(configDir, 'openssl.conf'),
path.join(configDir, 'devcert-ca-root.key'),
path.join(configDir, 'devcert-ca-root.crt'),
path.join(configDir, 'devcert-ca-version'),
path.join(configDir, 'certs')
].forEach((filepath) => {
if (exists(filepath)) {
debug(`Removing legacy file: ${ filepath }`)
rimraf(filepath);
}
});
}

/**
* Initializes the files OpenSSL needs to sign certificates as a certificate
* authority, as well as our CA setup version
Expand Down Expand Up @@ -115,7 +82,7 @@ async function saveCertificateAuthorityCredentials(keypath: string) {

function certErrors(): string {
try {
openssl(`x509 -in ${ rootCACertPath } -noout`);
openssl(`x509 -in "${ rootCACertPath }" -noout`);
return '';
} catch (e) {
return e.toString();
Expand All @@ -141,13 +108,37 @@ export async function ensureCACertReadable(options: Options = {}): Promise<void>
* has no read permissions either way, openssl will fail and that means we
* have to fix it
*/
const caFileContents = await currentPlatform.readProtectedFile(rootCACertPath);
await currentPlatform.deleteProtectedFile(rootCACertPath);
writeFile(rootCACertPath, caFileContents);
try {
const caFileContents = await currentPlatform.readProtectedFile(rootCACertPath);
currentPlatform.deleteProtectedFiles(rootCACertPath);
writeFile(rootCACertPath, caFileContents);
} catch (e) {
return installCertificateAuthority(options);
}

// double check that we have a live one
const remainingErrors = certErrors();
if (remainingErrors) {
return installCertificateAuthority(options);
}
}
Copy link
Contributor

@Js-Brecht Js-Brecht Nov 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this; it works well. If the CA has to be rebuilt, would it be possible to drop a message in the console? The reason I ask is because I get popups in Windows when it wants to remove the trust. It's not a big deal, if you know what's happening, and why; might throw some people off a bit when it pops up asking them to delete stuff as they're trying to run a development server, is all.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a console warning for this! Please do open a followup PR if the wording isn't great. I'm really more of a good speller than a good writer.


/**
* Remove as much of the devcert files and state as we can. This is necessary
* when generating a new root certificate, and should be available to API
* consumers as well.
*
* Not all of it will be removable. If certutil is not installed, we'll leave
* Firefox alone. We try to remove files with maximum permissions, and if that
* fails, we'll silently fail.
*
* It's also possible that the command to untrust will not work, and we'll
* silently fail that as well; with no existing certificates anymore, the
* security exposure there is minimal.
*/
export function uninstall(): void {
currentPlatform.removeFromTrustStores(rootCACertPath);
currentPlatform.deleteProtectedFiles(domainsDir);
currentPlatform.deleteProtectedFiles(rootCADir);
currentPlatform.deleteProtectedFiles(getLegacyConfigDir());
}
23 changes: 20 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ export const rootCADir = configPath('certificate-authority');
export const rootCAKeyPath = configPath('certificate-authority', 'private-key.key');
export const rootCACertPath = configPath('certificate-authority', 'certificate.cert');

mkdirp(configDir);
mkdirp(domainsDir);
mkdirp(rootCADir);


// Exposed for uninstallation purposes.
export function getLegacyConfigDir(): string {
if (isWindows && process.env.LOCALAPPDATA) {
return path.join(process.env.LOCALAPPDATA, 'devcert', 'config');
} else {
let uid = process.getuid && process.getuid();
let userHome = (isLinux && uid === 0) ? path.resolve('/usr/local/share') : require('os').homedir();
return path.join(userHome, '.config', 'devcert');
}
}

export function ensureConfigDirs() {
mkdirp(configDir);
mkdirp(domainsDir);
mkdirp(rootCADir);
}

ensureConfigDirs();
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import {
rootCACertPath
} from './constants';
import currentPlatform from './platforms';
import installCertificateAuthority, { ensureCACertReadable } from './certificate-authority';
import installCertificateAuthority, { ensureCACertReadable, uninstall } from './certificate-authority';
import generateDomainCertificate from './certificates';
import UI, { UserInterface } from './user-interface';
export { uninstall };

const debug = createDebug('devcert');

Expand Down
26 changes: 21 additions & 5 deletions src/platforms/darwin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import createDebug from 'debug';
import { sync as commandExists } from 'command-exists';
import { run } from '../utils';
import { Options } from '../index';
import { addCertificateToNSSCertDB, openCertificateInFirefox, closeFirefox } from './shared';
import { addCertificateToNSSCertDB, assertNotTouchingFiles, openCertificateInFirefox, closeFirefox, removeCertificateFromNSSCertDB } from './shared';
import { Platform } from '.';

const debug = createDebug('devcert:platforms:macos');

const getCertUtilPath = () => path.join(run('brew --prefix nss').toString().trim(), 'bin', 'certutil');

export default class MacOSPlatform implements Platform {

Expand Down Expand Up @@ -48,13 +49,25 @@ export default class MacOSPlatform implements Platform {
return await openCertificateInFirefox(this.FIREFOX_BIN_PATH, certificatePath);
}
}
let certutilPath = path.join(run('brew --prefix nss').toString().trim(), 'bin', 'certutil');
await closeFirefox();
await addCertificateToNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, certutilPath);
await addCertificateToNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, getCertUtilPath());
} else {
debug('Firefox does not appear to be installed, skipping Firefox-specific steps...');
}
}

removeFromTrustStores(certificatePath: string) {
debug('Removing devcert root CA from macOS system keychain');
try {
run(`sudo security remove-trusted-cert -d "${ certificatePath }"`);
} catch(e) {
debug(`failed to remove ${ certificatePath } from macOS cert store, continuing. ${ e.toString() }`);
}
if (this.isFirefoxInstalled() && this.isNSSInstalled()) {
debug('Firefox install and certutil install detected. Trying to remove root CA from Firefox NSS databases');
removeCertificateFromNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, getCertUtilPath());
}
}

async addDomainToHostFileIfMissing(domain: string) {
let hostsFileContents = read(this.HOST_FILE_PATH, 'utf8');
Expand All @@ -63,15 +76,18 @@ export default class MacOSPlatform implements Platform {
}
}

async deleteProtectedFile(filepath: string) {
await run(`sudo rm "${filepath}"`);
deleteProtectedFiles(filepath: string) {
assertNotTouchingFiles(filepath, 'delete');
run(`sudo rm -rf "${filepath}"`);
}

async readProtectedFile(filepath: string) {
assertNotTouchingFiles(filepath, 'read');
return (await run(`sudo cat "${filepath}"`)).toString().trim();
}

async writeProtectedFile(filepath: string, contents: string) {
assertNotTouchingFiles(filepath, 'write');
if (exists(filepath)) {
await run(`sudo rm "${filepath}"`);
}
Expand Down
3 changes: 2 additions & 1 deletion src/platforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { Options } from '../index';

export interface Platform {
addToTrustStores(certificatePath: string, options?: Options): Promise<void>;
removeFromTrustStores(certificatePath: string): void;
addDomainToHostFileIfMissing(domain: string): Promise<void>;
deleteProtectedFile(filepath: string): Promise<void>;
deleteProtectedFiles(filepath: string): void;
readProtectedFile(filepath: string): Promise<string>;
writeProtectedFile(filepath: string, contents: string): Promise<void>;
}
Expand Down
27 changes: 23 additions & 4 deletions src/platforms/linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import { existsSync as exists, readFileSync as read, writeFileSync as writeFile } from 'fs';
import createDebug from 'debug';
import { sync as commandExists } from 'command-exists';
import { addCertificateToNSSCertDB, openCertificateInFirefox, closeFirefox } from './shared';
import { addCertificateToNSSCertDB, assertNotTouchingFiles, openCertificateInFirefox, closeFirefox, removeCertificateFromNSSCertDB } from './shared';
import { run } from '../utils';
import { Options } from '../index';
import UI from '../user-interface';
Expand Down Expand Up @@ -67,6 +67,23 @@ export default class LinuxPlatform implements Platform {
debug('Chrome does not appear to be installed, skipping Chrome-specific steps...');
}
}

removeFromTrustStores(certificatePath: string) {
try {
run(`sudo rm /usr/local/share/ca-certificates/devcert.crt`);
run(`sudo update-ca-certificates`);
} catch (e) {
debug(`failed to remove ${ certificatePath } from /usr/local/share/ca-certificates, continuing. ${ e.toString() }`);
}
if (commandExists('certutil')) {
if (this.isFirefoxInstalled()) {
removeCertificateFromNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, 'certutil');
}
if (this.isChromeInstalled()) {
removeCertificateFromNSSCertDB(this.CHROME_NSS_DIR, certificatePath, 'certutil');
}
}
}

async addDomainToHostFileIfMissing(domain: string) {
let hostsFileContents = read(this.HOST_FILE_PATH, 'utf8');
Expand All @@ -75,15 +92,18 @@ export default class LinuxPlatform implements Platform {
}
}

async deleteProtectedFile(filepath: string) {
await run(`sudo rm ${filepath}"`);
deleteProtectedFiles(filepath: string) {
assertNotTouchingFiles(filepath, 'delete');
run(`sudo rm -rf "${filepath}"`);
}

async readProtectedFile(filepath: string) {
assertNotTouchingFiles(filepath, 'read');
return (await run(`sudo cat "${filepath}"`)).toString().trim();
}

async writeProtectedFile(filepath: string, contents: string) {
assertNotTouchingFiles(filepath, 'write');
if (exists(filepath)) {
await run(`sudo rm "${filepath}"`);
}
Expand All @@ -92,7 +112,6 @@ export default class LinuxPlatform implements Platform {
await run(`sudo chmod 600 "${filepath}"`);
}


private isFirefoxInstalled() {
return exists(this.FIREFOX_BIN_PATH);
}
Expand Down
48 changes: 39 additions & 9 deletions src/platforms/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,51 @@ import http from 'http';
import { sync as glob } from 'glob';
import { readFileSync as readFile, existsSync as exists } from 'fs';
import { run } from '../utils';
import { isMac, isLinux } from '../constants';
import { isMac, isLinux , configDir, getLegacyConfigDir } from '../constants';
import UI from '../user-interface';
import { execSync as exec } from 'child_process';

const debug = createDebug('devcert:platforms:shared');

/**
* Given a directory or glob pattern of directories, attempt to install the
* CA certificate to each directory containing an NSS database.
* Given a directory or glob pattern of directories, run a callback for each db
* directory, with a version argument.
*/
export async function addCertificateToNSSCertDB(nssDirGlob: string, certPath: string, certutilPath: string): Promise<void> {
debug(`trying to install certificate into NSS databases in ${ nssDirGlob }`);
function doForNSSCertDB(nssDirGlob: string, callback: (dir: string, version: "legacy" | "modern") => void): void {
glob(nssDirGlob).forEach((potentialNSSDBDir) => {
debug(`checking to see if ${ potentialNSSDBDir } is a valid NSS database directory`);
if (exists(path.join(potentialNSSDBDir, 'cert8.db'))) {
debug(`Found legacy NSS database in ${ potentialNSSDBDir }, adding certificate ...`)
run(`${ certutilPath } -A -d "${ potentialNSSDBDir }" -t 'C,,' -i "${ certPath }" -n devcert`);
debug(`Found legacy NSS database in ${ potentialNSSDBDir }, running callback...`)
callback(potentialNSSDBDir, 'legacy');
}
if (exists(path.join(potentialNSSDBDir, 'cert9.db'))) {
debug(`Found modern NSS database in ${ potentialNSSDBDir }, adding certificate ...`)
run(`${ certutilPath } -A -d "sql:${ potentialNSSDBDir }" -t 'C,,' -i "${ certPath }" -n devcert`);
debug(`Found modern NSS database in ${ potentialNSSDBDir }, running callback...`)
callback(potentialNSSDBDir, 'modern');
}
});
}

/**
* Given a directory or glob pattern of directories, attempt to install the
* CA certificate to each directory containing an NSS database.
*/
export function addCertificateToNSSCertDB(nssDirGlob: string, certPath: string, certutilPath: string): void {
debug(`trying to install certificate into NSS databases in ${ nssDirGlob }`);
doForNSSCertDB(nssDirGlob, (dir, version) => {
const dirArg = version === 'modern' ? `sql:${ dir }` : dir;
run(`${ certutilPath } -A -d "${ dirArg }" -t 'C,,' -i "${ certPath }" -n devcert`)
});
debug(`finished scanning & installing certificate in NSS databases in ${ nssDirGlob }`);
}

export function removeCertificateFromNSSCertDB(nssDirGlob: string, certPath: string, certutilPath: string): void {
debug(`trying to remove certificates from NSS databases in ${ nssDirGlob }`);
doForNSSCertDB(nssDirGlob, (dir, version) => {
const dirArg = version === 'modern' ? `sql:${ dir }` : dir;
try {
run(`${ certutilPath } -A -d "${ dirArg }" -t 'C,,' -i "${ certPath }" -n devcert`)
} catch (e) {
debug(`failed to remove ${ certPath } from ${ dir }, continuing. ${ e.toString() }`)
}
});
debug(`finished scanning & installing certificate in NSS databases in ${ nssDirGlob }`);
Expand Down Expand Up @@ -104,3 +128,9 @@ export async function openCertificateInFirefox(firefoxPath: string, certPath: st
await UI.waitForFirefoxWizard();
server.close();
}

export function assertNotTouchingFiles(filepath: string, operation: string): void {
if (!filepath.startsWith(configDir) && !filepath.startsWith(getLegacyConfigDir())) {
throw new Error(`Devcert cannot ${ operation } ${ filepath }; it is outside known devcert config directories!`);
}
}
Loading