Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 15 additions & 5 deletions src/api/api-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,7 +76,8 @@ export class ApiModel {
private httpClient: HttpClient,
private callbacks: {
onTriggerUpdate: () => void,
onTriggerShutdown: () => void
onTriggerShutdown: () => void,
onSetCertificateMode?: (mode: CertificateMode) => Promise<void>
}
) {}

Expand All @@ -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,
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/api/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,7 +50,8 @@ export class HttpToolkitServerApi extends events.EventEmitter {
constructor(
config: HtkConfig,
httpClient: HttpClient,
getRuleParamKeys: () => string[]
getRuleParamKeys: () => string[],
onSetCertificateMode?: (mode: CertificateMode) => Promise<void>
) {
super();

Expand Down Expand Up @@ -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
}
)

Expand Down
10 changes: 10 additions & 0 deletions src/api/rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() });
}));
Expand Down
43 changes: 19 additions & 24 deletions src/certificates.ts
Original file line number Diff line number Diff line change
@@ -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();
}
// Lowercase SHA-1 fingerprint for Android.
export function getCertificateFingerprint(cert: crypto.X509Certificate) {
return crypto.createHash('sha1').update(cert.raw).digest('hex');
}
12 changes: 11 additions & 1 deletion src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface HtkConfig {
configPath: string;
authToken?: string;
https: {
certificateMode?: import('./https-config').CertificateMode;
keyPath: string;
certPath: string;
certContent: string;
Expand Down
86 changes: 86 additions & 0 deletions src/emudhra.ts
Original file line number Diff line number Diff line change
@@ -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;
109 changes: 109 additions & 0 deletions src/https-config.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof generateHTTPSConfig>>,
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);
}
Loading