diff --git a/src/api/api-model.ts b/src/api/api-model.ts index 0699bad4..ee9e37c2 100644 --- a/src/api/api-model.ts +++ b/src/api/api-model.ts @@ -12,6 +12,7 @@ import { getSystemProxy } from 'os-proxy-config'; import { APP_ROOT, SERVER_VERSION } from "../constants"; import { logError, addBreadcrumb } from '../error-tracking'; +import { CertificateMode, CERTIFICATE_MODES } from '../https-config'; import { HtkConfig } from "../config"; import { ActivationError, Interceptor } from "../interceptors"; import { getDnsServer } from '../dns-server'; @@ -75,7 +76,8 @@ export class ApiModel { private httpClient: HttpClient, private callbacks: { onTriggerUpdate: () => void, - onTriggerShutdown: () => void + onTriggerShutdown: () => void, + onSetCertificateMode?: (mode: CertificateMode) => Promise } ) {} @@ -95,7 +97,13 @@ export class ApiModel { this.callbacks.onTriggerShutdown(); } + async setCertificateMode(mode: CertificateMode) { + if (!this.callbacks.onSetCertificateMode) throw new Error('Certificate selection is unavailable'); + await this.callbacks.onSetCertificateMode(mode); + } + async getConfig(proxyPort?: number) { + const httpsConfig = { ...this.config.https }; // Wait for each async part in parallel: const [ systemProxy, @@ -112,14 +120,16 @@ export class ApiModel { ? await this.getDnsServers(proxyPort) : [], - generateSPKIFingerprint(this.config.https.certContent) + generateSPKIFingerprint(httpsConfig.certContent) ]) return { - certificatePath: this.config.https.certPath, - certificateContent: this.config.https.certContent, + certificateMode: httpsConfig.certificateMode ?? 'local', + certificateModes: this.callbacks.onSetCertificateMode ? CERTIFICATE_MODES : undefined, + certificatePath: httpsConfig.certPath, + certificateContent: httpsConfig.certContent, certificateExpiry: getCertExpiry( - parseCert(this.config.https.certContent) + parseCert(httpsConfig.certContent) ), // We could calculate this client side, but it requires node-forge or some diff --git a/src/api/api-server.ts b/src/api/api-server.ts index 6cfbe210..bb525588 100644 --- a/src/api/api-server.ts +++ b/src/api/api-server.ts @@ -6,6 +6,7 @@ import cors from 'cors'; import corsGate from 'cors-gate'; import WebSocket, { WebSocketServer } from 'ws'; +import { CertificateMode } from '../https-config'; import { HtkConfig } from '../config'; import { buildInterceptors } from '../interceptors'; import { ALLOWED_ORIGINS } from '../constants'; @@ -49,7 +50,8 @@ export class HttpToolkitServerApi extends events.EventEmitter { constructor( config: HtkConfig, httpClient: HttpClient, - getRuleParamKeys: () => string[] + getRuleParamKeys: () => string[], + onSetCertificateMode?: (mode: CertificateMode) => Promise ) { super(); @@ -130,7 +132,8 @@ export class HttpToolkitServerApi extends events.EventEmitter { httpClient, { onTriggerUpdate: () => this.emit('update-requested'), - onTriggerShutdown: () => shutdown(0, 'API call') + onTriggerShutdown: () => shutdown(0, 'API call'), + onSetCertificateMode } ) diff --git a/src/api/rest-api.ts b/src/api/rest-api.ts index dda540f8..3aed2cbd 100644 --- a/src/api/rest-api.ts +++ b/src/api/rest-api.ts @@ -12,6 +12,7 @@ import type { import type { ParsedQs } from 'qs'; import { ErrorLike, StatusError } from '@httptoolkit/util'; +import { isCertificateMode } from '../https-config'; import { logError } from '../error-tracking'; import { ApiModel } from './api-model'; import * as Client from '../client/client-types'; @@ -46,6 +47,15 @@ export function exposeRestAPI( res.send({ config: await apiModel.getConfig(proxyPort) }); })); + server.post('/config/certificate', handleErrors(async (req, res) => { + const mode = req.body?.certificateMode; + if (!isCertificateMode(mode)) { + throw new StatusError(400, 'certificateMode must be local, emudhra or emudhra-old'); + } + await apiModel.setCertificateMode(mode); + res.send({ success: true }); + })); + server.get('/config/network-interfaces', handleErrors((_req, res) => { res.send({ networkInterfaces: apiModel.getNetworkInterfaces() }); })); diff --git a/src/certificates.ts b/src/certificates.ts index 88147516..d9f0ba6c 100644 --- a/src/certificates.ts +++ b/src/certificates.ts @@ -1,41 +1,36 @@ import * as crypto from 'crypto'; import * as forge from 'node-forge'; -export const parseCert = forge.pki.certificateFromPem; +// Reads the first certificate in PEM bundles. +export const parseCert = (pem: string) => new crypto.X509Certificate(pem); -export function getCertExpiry(cert: forge.pki.Certificate): number { - return cert.validity.notAfter.valueOf(); +export function getCertExpiry(cert: crypto.X509Certificate): number { + return new Date(cert.validTo).valueOf(); } -export function getTimeToCertExpiry(cert: forge.pki.Certificate): number { +export function getTimeToCertExpiry(cert: crypto.X509Certificate): number { return getCertExpiry(cert) - Date.now(); } -// A series of magic incantations that matches the behaviour of openssl's -// -subject_hash_old output, as expected by Android's cert store. -export function getCertificateSubjectHash(cert: forge.pki.Certificate) { - const derBytes = forge.asn1.toDer( - ( - forge.pki as any - ).distinguishedNameToAsn1(cert.subject) - ).getBytes(); +// Android subject hash (OpenSSL -subject_hash_old). +export function getCertificateSubjectHash(cert: crypto.X509Certificate) { + const certificate = forge.asn1.fromDer(cert.raw.toString('binary')); + const tbs = (certificate.value as forge.asn1.Asn1[])[0]; + const fields = tbs.value as forge.asn1.Asn1[]; + // An explicit version shifts the subject index from 4 to 5. + const hasVersion = fields[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC; + const subject = fields[hasVersion ? 5 : 4]; + const subjectDer = Buffer.from(forge.asn1.toDer(subject).getBytes(), 'binary'); return crypto.createHash('md5') - .update(derBytes) + .update(subjectDer) .digest() .readUInt32LE(0) .toString(16) .padStart(8, '0'); } -// Get a full SHA1 hash of the certificate -export function getCertificateFingerprint(cert: forge.pki.Certificate) { - return forge.md.sha1.create() - .update( - forge.asn1.toDer( - forge.pki.certificateToAsn1(cert) - ).getBytes() - ) - .digest() - .toHex(); -} \ No newline at end of file +// Lowercase SHA-1 fingerprint for Android. +export function getCertificateFingerprint(cert: crypto.X509Certificate) { + return crypto.createHash('sha1').update(cert.raw).digest('hex'); +} diff --git a/src/commands/start.ts b/src/commands/start.ts index c47f5ed7..52849927 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -35,6 +35,14 @@ class HttpToolkitServer extends Command { version: flags.version({char: 'v'}), help: flags.help({char: 'h'}), + emudhra: flags.boolean({ + description: 'sign TLS certificates with the publicly compromised eMudhra intermediate CA key', + default: false + }), + 'emudhra-old': flags.boolean({ + description: 'use the compromised eMudhra CA with the AAA Certificate Services cross-sign first for legacy trust stores', + default: false + }), config: flags.string({char: 'c', description: 'optional path in which to store config files'}), token: flags.string({char: 't', description: 'optional token to authenticate local server access'}), 'server-port': flags.integer({description: 'port for the HTK server API', default: 45457}), @@ -56,7 +64,9 @@ class HttpToolkitServer extends Command { configPath: flags.config, authToken: envToken || flags.token, serverPort: flags['server-port'], - mockttpPort: flags['mockttp-port'] + mockttpPort: flags['mockttp-port'], + emudhra: flags.emudhra || undefined, + emudhraOld: flags['emudhra-old'] }).catch(async (error) => { await logError(error); throw error; diff --git a/src/config.d.ts b/src/config.d.ts index c083e6ab..935ec35c 100644 --- a/src/config.d.ts +++ b/src/config.d.ts @@ -2,6 +2,7 @@ export interface HtkConfig { configPath: string; authToken?: string; https: { + certificateMode?: import('./https-config').CertificateMode; keyPath: string; certPath: string; certContent: string; diff --git a/src/emudhra.ts b/src/emudhra.ts new file mode 100644 index 00000000..11df1122 --- /dev/null +++ b/src/emudhra.ts @@ -0,0 +1,86 @@ +// Publicly compromised EM OV TLS CA - G3C key and cross-signs. +// Sources: mitmproxy/mitmproxy#8500 and #8503 (commit 62243e7). +// https://emudhra.we-have-a-problem.com/ +export const EMUDHRA_KEY = `-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS +mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu +FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc= +-----END PRIVATE KEY----- +`; + +// emSign intermediate and cross-signed issuer. +const EMSIGN_CHAIN = `-----BEGIN CERTIFICATE----- +MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ +BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk +MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3 +NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk +aHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ +fjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+ +N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L +WUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow +KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB +BAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw +b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f +BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j +cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E +AwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c +NVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79 +4rSb9vGqxZzelQth +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw +CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy +YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290 +IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD +VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg +MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m +WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw +LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj +QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB +BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB +FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi +BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4 +aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290 +Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t +P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB +ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg +TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0 +1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV +-----END CERTIFICATE----- +`; + +// AAA cross-sign of the same intermediate. +const AAA_CROSS_SIGNED_CERT = `-----BEGIN CERTIFICATE----- +MIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw +ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y +ODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu +YzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE +ACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv +1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf +HAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw +HQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO +IQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B +QUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB +BQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC +AQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i +KOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6 +OzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ +TaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw +FFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb +uL5ChFWOQU+ErSDnUq7yr+pjBQ== +-----END CERTIFICATE----- +`; + +// OpenSSL uses the first matching issuer without backtracking. +// Prioritize emSign for modern stores or AAA for legacy stores. +export const EMUDHRA_CHAIN = EMSIGN_CHAIN + AAA_CROSS_SIGNED_CERT; +export const EMUDHRA_OLD_CHAIN = AAA_CROSS_SIGNED_CERT + EMSIGN_CHAIN; diff --git a/src/https-config.ts b/src/https-config.ts new file mode 100644 index 00000000..c6c03de1 --- /dev/null +++ b/src/https-config.ts @@ -0,0 +1,109 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { generateCACertificate } from 'mockttp'; +import { readFile, checkAccess, writeFile } from './util/fs'; +import { getTimeToCertExpiry, parseCert } from './certificates'; +import { EMUDHRA_KEY, EMUDHRA_CHAIN, EMUDHRA_OLD_CHAIN } from './emudhra'; + +export const CERTIFICATE_MODES = ['local', 'emudhra', 'emudhra-old'] as const; +export type CertificateMode = typeof CERTIFICATE_MODES[number]; + +export function isCertificateMode(value: unknown): value is CertificateMode { + return CERTIFICATE_MODES.some(mode => mode === value); +} + +// true: modern eMudhra; false: local CA; undefined: saved mode. +export async function generateHTTPSConfig(configPath: string, selection?: CertificateMode | boolean) { + let mode: CertificateMode = selection === true ? 'emudhra' + : selection === false ? 'local' + : selection ?? 'local'; + if (selection === undefined) { + const saved = await readFile(path.join(configPath, 'certificate-settings.json'), 'utf8') + .catch((error) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (saved) { + const { certificateMode } = JSON.parse(saved); + if (!isCertificateMode(certificateMode)) { + throw new Error('Invalid saved TLS signing certificate mode'); + } + mode = certificateMode; + } + } + if (mode !== 'local') { + // Separate mode files preserve the local CA. + const keyPath = path.join(configPath, `${mode}.key`); + const certPath = path.join(configPath, `${mode}.pem`); + const certContent = mode === 'emudhra-old' ? EMUDHRA_OLD_CHAIN : EMUDHRA_CHAIN; + await Promise.all([ + writeFile(keyPath, EMUDHRA_KEY, { mode: 0o600 }), + writeFile(certPath, certContent) + ]); + return { + keyPath, certPath, certContent, + certificateMode: mode, + keyLength: 2048, + certificateTransparency: true + }; + } + + const keyPath = path.join(configPath, 'ca.key'); + const certPath = path.join(configPath, 'ca.pem'); + + const [ certContent ] = await Promise.all([ + readFile(certPath, 'utf8').then((certContent) => { + checkCertExpiry(certContent); + return certContent; + }), + checkAccess(keyPath, fs.constants.R_OK), + ]).catch(async () => { + // Replace missing or expiring certificates. + + const newCertPair = await generateCACertificate({ + subject: { + commonName: 'HTTP Toolkit CA', + organizationName: 'HTTP Toolkit CA' + } + }); + + return Promise.all([ + writeFile(certPath, newCertPair.cert).then(() => newCertPair.cert), + writeFile(keyPath, newCertPair.key, { + mode: 0o600 // Owner-only access + }) + ]); + }); + + return { + keyPath, + certPath, + certContent, + certificateMode: 'local' as CertificateMode, + keyLength: 2048, + certificateTransparency: true + }; +} + +function checkCertExpiry(certContents: string): void { + const remainingLifetime = getTimeToCertExpiry(parseCert(certContents)); + if (remainingLifetime < 1000 * 60 * 60 * 48) { // Next two days + console.warn('Certificate expires soon - it must be regenerated'); + throw new Error('Certificate regeneration required'); + } +} + + +// Save before updating the shared configuration. +export async function setCertificateMode( + configPath: string, + httpsConfig: Awaited>, + mode: CertificateMode +) { + const nextConfig = await generateHTTPSConfig(configPath, mode); + const settingsPath = path.join(configPath, 'certificate-settings.json'); + await writeFile(settingsPath + '.tmp', JSON.stringify({ certificateMode: mode })); + await fs.promises.rename(settingsPath + '.tmp', settingsPath); + // Proxy defaults and interceptors share this object. + Object.assign(httpsConfig, nextConfig); +} diff --git a/src/index.ts b/src/index.ts index a13dcab4..22e1d186 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,9 @@ -import * as path from 'path'; -import * as fs from 'fs'; import envPaths from 'env-paths'; import { Mutex } from 'async-mutex'; import { delay, isErrorLike } from '@httptoolkit/util'; import { - PluggableAdmin, - generateCACertificate + PluggableAdmin } from 'mockttp'; import { MockttpAdminPlugin @@ -22,10 +19,10 @@ import { checkBrowserConfig } from './browsers'; import { logError } from './error-tracking'; import { IS_PROD_BUILD, MOCKTTP_ALLOWED_ORIGINS } from './constants'; -import { readFile, checkAccess, writeFile, ensureDirectoryExists } from './util/fs'; +import { ensureDirectoryExists } from './util/fs'; import { addShutdownHandler, registerShutdownHandler, shutdown } from './shutdown'; -import { getTimeToCertExpiry, parseCert } from './certificates'; +import { generateHTTPSConfig, setCertificateMode } from './https-config'; import { startDockerInterceptionServices, @@ -34,51 +31,6 @@ import { import { clearWebExtensionConfig, updateWebExtensionConfig } from './webextension'; import { HttpClient } from './client/http-client'; -async function generateHTTPSConfig(configPath: string) { - const keyPath = path.join(configPath, 'ca.key'); - const certPath = path.join(configPath, 'ca.pem'); - - const [ certContent ] = await Promise.all([ - readFile(certPath, 'utf8').then((certContent) => { - checkCertExpiry(certContent); - return certContent; - }), - checkAccess(keyPath, fs.constants.R_OK), - ]).catch(async () => { - // Cert doesn't exist, or is too close/past expiry. Generate a new one: - - const newCertPair = await generateCACertificate({ - subject: { - commonName: 'HTTP Toolkit CA', - organizationName: 'HTTP Toolkit CA' - } - }); - - return Promise.all([ - writeFile(certPath, newCertPair.cert).then(() => newCertPair.cert), - writeFile(keyPath, newCertPair.key, { - mode: 0o600 // Only readable for ourselves, nobody else - }) - ]); - }); - - return { - keyPath, - certPath, - certContent, - keyLength: 2048, // Reasonably secure keys please - certificateTransparency: true - }; -} - -function checkCertExpiry(certContents: string): void { - const remainingLifetime = getTimeToCertExpiry(parseCert(certContents)); - if (remainingLifetime < 1000 * 60 * 60 * 48) { // Next two days - console.warn('Certificate expires soon - it must be regenerated'); - throw new Error('Certificate regeneration required'); - } -} - let shutdownTimer: NodeJS.Timeout | undefined; function manageBackgroundServices( @@ -168,6 +120,8 @@ export async function runHTK(options: { authToken?: string serverPort: number mockttpPort: number + emudhra?: boolean + emudhraOld?: boolean }) { const startTime = Date.now(); registerShutdownHandler(); @@ -180,7 +134,7 @@ export async function runHTK(options: { const configCheckTime = Date.now(); console.log('Config checked in', configCheckTime - startTime, 'ms'); - const httpsConfig = await generateHTTPSConfig(configPath); + const httpsConfig = await generateHTTPSConfig(configPath, options.emudhraOld ? 'emudhra-old' : options.emudhra); const certSetupTime = Date.now(); console.log('Certificates setup in', certSetupTime - configCheckTime, 'ms'); @@ -229,10 +183,12 @@ export async function runHTK(options: { console.log('Standalone server started in', standaloneSetupTime - certSetupTime, 'ms'); // Start the HTK server API + const certificateConfigMutex = new Mutex(); const apiServer = new HttpToolkitServerApi( { configPath, authToken: options.authToken, https: httpsConfig }, new HttpClient(ruleParameters), - () => standalone.ruleParameterKeys + () => standalone.ruleParameterKeys, + (mode) => certificateConfigMutex.runExclusive(() => setCertificateMode(configPath, httpsConfig, mode)) ); const updateMutex = new Mutex(); diff --git a/src/interceptors/electron.ts b/src/interceptors/electron.ts index 7f6d89ba..e7e4be18 100644 --- a/src/interceptors/electron.ts +++ b/src/interceptors/electron.ts @@ -10,7 +10,7 @@ import ChromeRemoteInterface = require('chrome-remote-interface'); import { Interceptor } from '.'; import { HtkConfig } from '../config'; -import { canAccess, readFile } from '../util/fs'; +import { canAccess } from '../util/fs'; import { windowsClose } from '../util/process-management'; import { getInheritableCurrentEnv, getTerminalEnvVars, OVERRIDES_DIR } from './terminal/terminal-env-overrides'; import { logError, addBreadcrumb } from '../error-tracking'; @@ -38,8 +38,6 @@ export class ElectronInterceptor implements Interceptor { constructor(private config: HtkConfig) { } - private certData = readFile(this.config.https.certPath, 'utf8') - async isActivable(): Promise { return true; } @@ -52,6 +50,7 @@ export class ElectronInterceptor implements Interceptor { async activate(proxyPort: number, options: { pathToApplication: string }): Promise { + const certData = this.config.https.certContent; const debugPort = await getPort({ port: proxyPort }); const { pathToApplication } = options; @@ -147,8 +146,8 @@ export class ElectronInterceptor implements Interceptor { // Need to stringify to handle chars that need escaping (e.g. windows backslashes) JSON.stringify(path.join(OVERRIDES_DIR, 'js', 'prepend-electron.js')) })({ - newlineEncodedCertData: "${(await this.certData).replace(/\r\n|\r|\n/g, '\\n')}", - spkiFingerprint: "${await generateSPKIFingerprint(await this.certData)}" + newlineEncodedCertData: "${certData.replace(/\r\n|\r|\n/g, '\\n')}", + spkiFingerprint: "${await generateSPKIFingerprint(certData)}" })`, callFrameId }); diff --git a/test/fixtures/aaa-certificate-services.pem b/test/fixtures/aaa-certificate-services.pem new file mode 100644 index 00000000..33c71ba9 --- /dev/null +++ b/test/fixtures/aaa-certificate-services.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb +MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow +GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj +YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL +MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE +BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM +GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua +BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe +3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4 +YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR +rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm +ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU +oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF +MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v +QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t +b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF +AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q +GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz +Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2 +G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi +l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3 +smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== +-----END CERTIFICATE----- diff --git a/test/fixtures/emsign-ecc-root-g3.pem b/test/fixtures/emsign-ecc-root-g3.pem new file mode 100644 index 00000000..060e943b --- /dev/null +++ b/test/fixtures/emsign-ecc-root-g3.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG +EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo +bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ +TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s +b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0 +WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS +fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB +zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB +CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD ++JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE----- diff --git a/test/integration/certificate-settings.spec.ts b/test/integration/certificate-settings.spec.ts new file mode 100644 index 00000000..b7fb2617 --- /dev/null +++ b/test/integration/certificate-settings.spec.ts @@ -0,0 +1,125 @@ +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import * as https from 'https'; +import { expect } from 'chai'; +import fetch from 'node-fetch'; +import getPort from 'get-port'; +import { getRemote, Mockttp } from 'mockttp'; +import { HttpsProxyAgent } from 'https-proxy-agent'; + +// REST settings and proxy-session reloads used by the app. +describe('TLS certificate settings API', function () { + this.timeout(30000); + let directory: string; + let apiPort: number; + let adminPort: number; + let process: ChildProcess | undefined; + let closed: Promise; + let proxy: Mockttp | undefined; + const headers = { origin: 'https://app.httptoolkit.tech', 'content-type': 'application/json' }; + + async function start(args: string[] = []) { + process = spawn(global.process.execPath, [path.join(__dirname, '../../bin/run'), 'start', + '--config', directory, '--server-port', String(apiPort), '--mockttp-port', String(adminPort), ...args], { + env: { ...global.process.env, HTTPTOOLKIT_SERVER_DISABLE_AUTOUPDATE: '1' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + closed = new Promise(resolve => process!.once('close', resolve)); + await new Promise((resolve, reject) => { + let output = ''; + process!.stdout!.on('data', data => { + output += data; + if (output.includes('\nServer started')) resolve(); + }); + process!.stderr!.on('data', data => { output += data; }); + process!.once('error', reject); + process!.once('close', () => reject(new Error(output))); + }); + } + async function stop() { + if (proxy) await proxy.stop(); + proxy = undefined; + process?.kill('SIGTERM'); + await closed; + process = undefined; + } + const api = (url: string, body?: object) => fetch(`http://127.0.0.1:${apiPort}${url}`, { + method: body ? 'POST' : 'GET', headers, body: body && JSON.stringify(body) + }); + const config = async () => (await (await api('/config')).json()).config; + + before(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'htk-certificate-api-')); + apiPort = await getPort(); + adminPort = await getPort(); + await start(); + }); + after(async () => { + await stop(); + await fs.rm(directory, { recursive: true, force: true }); + }); + + it('advertises support and rejects invalid choices without changing the CA', async () => { + const initial = await config(); + expect(initial.certificateModes).to.deep.equal(['local', 'emudhra', 'emudhra-old']); + expect(initial.certificateMode).to.equal('local'); + const response = await api('/config/certificate', { certificateMode: 'invalid' }); + expect(response.status).to.equal(400); + expect((await config()).certificateFingerprint).to.equal(initial.certificateFingerprint); + }); + + for (const mode of ['emudhra', 'emudhra-old'] as const) { + it(`selects ${mode} from settings and signs new proxy sessions with its full chain`, async () => { + const response = await api('/config/certificate', { certificateMode: mode }); + expect(response.status).to.equal(200); + expect((await config()).certificateMode).to.equal(mode); + proxy = getRemote({ adminServerUrl: `http://127.0.0.1:${adminPort}`, client: { headers: { origin: headers.origin } } }); + await proxy.start(); + await proxy.forGet('https://example.test/').thenReply(200, 'settings-enabled interception'); + const root = await fs.readFile(path.join(__dirname, '../fixtures', mode === 'emudhra-old' ? 'aaa-certificate-services.pem' : 'emsign-ecc-root-g3.pem'), 'utf8'); + const agent = new HttpsProxyAgent(proxy.url.replace('https:', 'http:')); + try { + const text = await new Promise((resolve, reject) => { + https.get('https://example.test/', { agent, ca: root, rejectUnauthorized: true }, res => { + let body = ''; + res.on('data', chunk => { body += chunk; }); + res.on('end', () => resolve(body)); + res.on('error', reject); + }).on('error', reject); + }); + expect(text).to.equal('settings-enabled interception'); + } finally { + agent.destroy(); + await proxy.stop(); + proxy = undefined; + } + }); + } + + it('persists across server restarts and can restore the original local CA', async () => { + const localCert = await fs.readFile(path.join(directory, 'ca.pem'), 'utf8'); + await stop(); + await start(); + expect((await config()).certificateMode).to.equal('emudhra-old'); + expect((await api('/config/certificate', { certificateMode: 'local' })).status).to.equal(200); + expect((await config()).certificateContent).to.equal(localCert); + await stop(); + await start(); + expect((await config()).certificateMode).to.equal('local'); + expect((await config()).certificateContent).to.equal(localCert); + }); + + it('honors both CLI flags with legacy taking priority, without overwriting the saved choice', async () => { + await stop(); + await start(['--emudhra', '--emudhra-old']); + expect((await config()).certificateMode).to.equal('emudhra-old'); + await stop(); + await start(['--emudhra']); + expect((await config()).certificateMode).to.equal('emudhra'); + await stop(); + await start(); + expect((await config()).certificateMode).to.equal('local'); + }); +}); diff --git a/test/unit/https-config.spec.ts b/test/unit/https-config.spec.ts new file mode 100644 index 00000000..5bd29fd9 --- /dev/null +++ b/test/unit/https-config.spec.ts @@ -0,0 +1,154 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import * as tls from 'tls'; +import * as https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { execFileSync } from 'child_process'; +import { expect } from 'chai'; +import { getLocal, Mockttp } from 'mockttp'; + +import { generateHTTPSConfig, setCertificateMode } from '../../src/https-config'; +import { EMUDHRA_CHAIN, EMUDHRA_KEY, EMUDHRA_OLD_CHAIN } from '../../src/emudhra'; +import { + parseCert, getCertExpiry, getCertificateFingerprint, getCertificateSubjectHash +} from '../../src/certificates'; + +describe('HTTPS CA configuration', () => { + let configPath: string; + let server: Mockttp | undefined; + + beforeEach(async () => { + configPath = await fs.mkdtemp(path.join(os.tmpdir(), 'htk-ca-test-')); + }); + afterEach(async () => { + await server?.stop(); + server = undefined; + await fs.rm(configPath, { recursive: true, force: true }); + }); + + it('preserves and reuses the local CA when switching eMudhra on and off', async () => { + const local = await generateHTTPSConfig(configPath); + const localKey = await fs.readFile(local.keyPath, 'utf8'); + const emudhra = await generateHTTPSConfig(configPath, true); + expect(emudhra.certContent).to.equal(EMUDHRA_CHAIN); + expect(await fs.readFile(emudhra.keyPath, 'utf8')).to.equal(EMUDHRA_KEY); + expect(await fs.readFile(emudhra.certPath, 'utf8')).to.equal(EMUDHRA_CHAIN); + if (process.platform !== 'win32') { + expect((await fs.stat(emudhra.keyPath)).mode & 0o777).to.equal(0o600); + } + const legacy = await generateHTTPSConfig(configPath, 'emudhra-old'); + expect(legacy.certContent).to.equal(EMUDHRA_OLD_CHAIN); + expect(legacy.certPath).not.to.equal(emudhra.certPath); + expect(legacy.keyPath).not.to.equal(emudhra.keyPath); + expect(await fs.readFile(legacy.keyPath, 'utf8')).to.equal(EMUDHRA_KEY); + expect(await fs.readFile(legacy.certPath, 'utf8')).to.equal(EMUDHRA_OLD_CHAIN); + // Both bundles include every cross-sign, in different priority orders. + const certs = (bundle: string) => bundle.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)!; + expect(certs(emudhra.certContent)).to.have.length(3); + expect(certs(legacy.certContent)).to.deep.equal([ + certs(emudhra.certContent)[2], ...certs(emudhra.certContent).slice(0, 2) + ]); + const restored = await generateHTTPSConfig(configPath); + expect(restored).to.deep.equal(local); + expect(await fs.readFile(local.keyPath, 'utf8')).to.equal(localKey); + }); + + it('uses the saved choice by default and honors explicit programmatic overrides', async () => { + const local = await generateHTTPSConfig(configPath); + const active = { ...local }; + await setCertificateMode(configPath, active, 'emudhra'); + expect(active.certificateMode).to.equal('emudhra'); + expect((await generateHTTPSConfig(configPath)).certificateMode).to.equal('emudhra'); + expect((await generateHTTPSConfig(configPath, false)).certificateMode).to.equal('local'); + await setCertificateMode(configPath, active, 'emudhra-old'); + expect((await generateHTTPSConfig(configPath)).certificateMode).to.equal('emudhra-old'); + expect(active.certContent).to.equal(EMUDHRA_OLD_CHAIN); + expect((await generateHTTPSConfig(configPath, true)).certificateMode).to.equal('emudhra'); + await setCertificateMode(configPath, active, 'local'); + expect(active).to.deep.equal(local); + expect((await generateHTTPSConfig(configPath, true)).certificateMode).to.equal('emudhra'); + }); + + it('keeps the active CA if saving the new choice fails', async () => { + const local = await generateHTTPSConfig(configPath); + const active = { ...local }; + await fs.mkdir(path.join(configPath, 'certificate-settings.json')); + let failed = false; + try { + await setCertificateMode(configPath, active, 'emudhra'); + } catch { failed = true; } + expect(failed).to.equal(true); + expect(active).to.deep.equal(local); + }); + + it('can enable eMudhra without generating a local CA', async () => { + await generateHTTPSConfig(configPath, true); + expect((await fs.readdir(configPath)).sort()).to.deep.equal(['emudhra.key', 'emudhra.pem']); + }); + + for (const mode of ['emudhra', 'emudhra-old'] as const) { + const legacy = mode === 'emudhra-old'; + const rootFile = legacy ? 'aaa-certificate-services.pem' : 'emsign-ecc-root-g3.pem'; + const issuer = legacy ? 'AAA Certificate Services' : 'emSign Root TLS CA - G3'; + + for (const sni of [true, false]) { + it(`serves a trusted ${mode} chain ${sni ? 'with' : 'without'} SNI`, async () => { + const config = await generateHTTPSConfig(configPath, mode); + server = getLocal({ https: config }); + await server.start(); + const root = await fs.readFile(path.join(__dirname, '../fixtures', rootFile), 'utf8'); + const peer = await new Promise((resolve, reject) => { + const socket = tls.connect({ + host: 'localhost', + port: server!.port, + servername: sni ? 'example.test' : undefined, + ca: root, + rejectUnauthorized: true + }, () => { + const cert = socket.getPeerCertificate(true); + socket.end(); + resolve(cert); + }); + socket.once('error', reject); + }); + expect(peer.subject.CN).to.equal(sni ? 'example.test' : 'localhost'); + expect(peer.issuer.CN).to.equal('EM OV TLS CA - G3C'); + expect(peer.issuerCertificate.issuer.CN).to.equal(issuer); + }); + } + + it(`intercepts an HTTPS CONNECT request with the ${mode} CA`, async () => { + const config = await generateHTTPSConfig(configPath, mode); + server = getLocal({ https: config }); + await server.start(); + await server.forGet('https://example.test/').thenReply(200, 'intercepted'); + const root = await fs.readFile(path.join(__dirname, '../fixtures', rootFile), 'utf8'); + const agent = new HttpsProxyAgent(server.url.replace('https:', 'http:')); + try { + const body = await new Promise((resolve, reject) => { + https.get('https://example.test/', { agent, ca: root, rejectUnauthorized: true }, res => { + let text = ''; + res.on('data', chunk => { text += chunk; }); + res.on('end', () => resolve(text)); + res.on('error', reject); + }).on('error', reject); + }); + expect(body).to.equal('intercepted'); + } finally { + agent.destroy(); + } + }); + } + + for (const type of ['RSA', 'EC']) { + it(`reads ${type} CA metadata and matches OpenSSL's Android subject hash`, async () => { + const pem = type === 'EC' ? EMUDHRA_CHAIN : (await generateHTTPSConfig(configPath)).certContent; + const cert = parseCert(pem); + expect(getCertExpiry(cert)).to.equal(Date.parse(cert.validTo)); + expect(getCertificateFingerprint(cert)).to.equal(cert.fingerprint.replace(/:/g, '').toLowerCase()); + const opensslHash = execFileSync('openssl', ['x509', '-subject_hash_old', '-noout'], { input: pem }).toString().trim(); + expect(getCertificateSubjectHash(cert)).to.equal(opensslHash); + }); + } +});