Skip to content

Commit 6f97801

Browse files
committed
Add support for https.keyLog option for logging TLS keys
This allows advanced use cases where you want to combine a tool like Wireshark with Mockttp. Currently the keys for all inbound traffic, and all outbound HTTP/1 traffic are exported. Keys from outbound HTTP/2 traffic is not yet listed but is intended to be included in future.
1 parent 56d3349 commit 6f97801

File tree

9 files changed

+152
-14
lines changed

9 files changed

+152
-14
lines changed

karma.conf.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ module.exports = function(config) {
3030
"request-promise-native": require.resolve('./test/empty-stub.js'),
3131
"portfinder": require.resolve('./test/empty-stub.js'),
3232
"dns2": require.resolve('./test/empty-stub.js'),
33-
"ws": require.resolve('./test/empty-stub.js')
33+
"ws": require.resolve('./test/empty-stub.js'),
34+
"tmp-promise": require.resolve('./test/empty-stub.js')
3435
},
3536
fallback: {
3637
// With Webpack 5, we need explicit mocks for all node modules. Because the

src/mockttp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,17 @@ export type MockttpHttpsOptions = CAOptions & {
787787
tlsServerOptions?: {
788788
minVersion?: 'TLSv1.3' | 'TLSv1.2' | 'TLSv1.1' | 'TLSv1';
789789
};
790+
791+
/**
792+
* The path to a file where TLS session keys should be logged. This allows you to combine
793+
* Mockttp with Wireshark and similar, allowing to you inspect the decrypted raw bytes
794+
* and full TLS handshake details, while also using Mockttp for high-level capture
795+
* & traffic modification.
796+
*
797+
* When set, the keys for both client & server sessions will be logged, allowing you
798+
* to examine both sides of the proxied connection.
799+
*/
800+
keyLogFile?: string;
790801
};
791802

792803
export interface MockttpOptions {

src/rules/requests/request-rule.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Buffer } from 'buffer';
2+
import { Writable } from 'stream';
23

34
import * as _ from 'lodash';
45

@@ -22,6 +23,7 @@ export interface RequestRule extends Explainable {
2223
handle(request: OngoingRequest, response: OngoingResponse, options: {
2324
record: boolean,
2425
debug: boolean,
26+
keyLogStream?: Writable,
2527
emitEventCallback?: (type: string, event: unknown) => void
2628
}): Promise<void>;
2729
isComplete(): boolean | null;
@@ -78,12 +80,14 @@ export class RequestRule implements RequestRule {
7880
handle(req: OngoingRequest, res: OngoingResponse, options: {
7981
record?: boolean,
8082
debug: boolean,
83+
keyLogStream?: Writable,
8184
emitEventCallback?: (type: string, event: unknown) => void
8285
}): Promise<void> {
8386
let stepsPromise = (async () => {
8487
for (let step of this.steps) {
8588
const result = await step.handle(req, res, {
8689
emitEventCallback: options.emitEventCallback,
90+
keyLogStream: options.keyLogStream,
8791
debug: options.debug
8892
});
8993

src/rules/requests/request-step-impls.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Buffer } from 'buffer';
2-
import type dns = require('dns');
3-
import url = require('url');
4-
import net = require('net');
5-
import http = require('http');
6-
import https = require('https');
2+
import { Writable } from 'stream';
3+
import * as url from 'url';
4+
import type * as dns from 'dns';
5+
import * as net from 'net';
6+
import * as http from 'http';
7+
import * as https from 'https';
78

89
import * as _ from 'lodash';
910
import * as fs from 'fs/promises';
@@ -165,6 +166,7 @@ export interface RequestStepImpl extends RequestStepDefinition {
165166

166167
export interface RequestStepOptions {
167168
emitEventCallback?: (type: string, event: unknown) => void;
169+
keyLogStream?: Writable;
168170
debug: boolean;
169171
}
170172

@@ -1064,6 +1066,12 @@ export class PassThroughStepImpl extends PassThroughStep {
10641066
// make multiple requests. If/when that happens, we don't need more event listeners.
10651067
if (this.outgoingSockets.has(socket)) return;
10661068

1069+
if (options.keyLogStream) {
1070+
socket.on('keylog', (line) => {
1071+
options.keyLogStream!.write(line);
1072+
});
1073+
}
1074+
10671075
// Add this port to our list of active ports, once it's connected (before then it has no port)
10681076
if (socket.connecting) {
10691077
socket.once('connect', () => {

src/rules/websockets/websocket-rule.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Writable } from 'stream';
12
import * as net from 'net';
23
import * as http from 'http';
34

@@ -32,6 +33,7 @@ export interface WebSocketRule extends Explainable {
3233
options: {
3334
record: boolean,
3435
debug: boolean,
36+
keyLogStream?: Writable,
3537
emitEventCallback?: (type: string, event: unknown) => void
3638
}
3739
): Promise<void>;
@@ -93,6 +95,7 @@ export class WebSocketRule implements WebSocketRule {
9395
options: {
9496
record: boolean,
9597
debug: boolean,
98+
keyLogStream?: Writable,
9699
emitEventCallback?: (type: string, event: unknown) => void
97100
}
98101
): Promise<void> {

src/rules/websockets/websocket-step-impls.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,9 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep {
377377
})
378378
} as WebSocket.ClientOptions & { lookup: any, maxPayload: number });
379379

380+
const upstreamReq = (upstreamWebSocket as any as { _req: http.ClientRequest })._req;
381+
380382
if (options.emitEventCallback) {
381-
const upstreamReq = (upstreamWebSocket as any as { _req: http.ClientRequest })._req;
382383
// This is slower than req.getHeaders(), but gives us (roughly) the correct casing
383384
// of the headers as sent. Still not perfect (loses dupe ordering) but at least it
384385
// generally matches what's actually sent on the wire.
@@ -409,6 +410,12 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep {
409410
});
410411
}
411412

413+
if (options.keyLogStream) {
414+
upstreamReq.on('socket', (socket) => {
415+
socket.on('keylog', (line) => options.keyLogStream!.write(line));
416+
});
417+
}
418+
412419
upstreamWebSocket.once('open', () => {
413420
// Used in the subprotocol selection handler during the upgrade:
414421
(req as InterceptedWebSocketRequest).upstreamWebSocketProtocol = upstreamWebSocket.protocol || false;

src/server/http-combo-server.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import _ = require('lodash');
22
import now = require('performance-now');
3-
import net = require('net');
4-
import tls = require('tls');
5-
import http = require('http');
6-
import http2 = require('http2');
3+
import { Writable } from 'stream';
4+
import * as net from 'net';
5+
import * as tls from 'tls';
6+
import * as http from 'http';
7+
import * as http2 from 'http2';
78

89
import * as semver from 'semver';
910
import { makeDestroyable, DestroyableServer } from 'destroyable-server';
@@ -151,6 +152,7 @@ export interface ComboServerOptions {
151152
http2: boolean | 'fallback';
152153
socks: boolean | SocksServerOptions;
153154
passthroughUnknownProtocols: boolean;
155+
keyLogStream: Writable | undefined,
154156

155157
requestListener: (req: http.IncomingMessage, res: http.ServerResponse) => void;
156158
tlsClientErrorListener: (socket: tls.TLSSocket, req: TlsHandshakeFailure) => void;
@@ -220,6 +222,12 @@ export async function createComboServer(options: ComboServerOptions): Promise<De
220222
}
221223
});
222224

225+
if (options.keyLogStream) {
226+
tlsServer.on('keylog', (line: string) => {
227+
options.keyLogStream?.write(line);
228+
});
229+
}
230+
223231
analyzeAndMaybePassThroughTls(
224232
tlsServer,
225233
options.https.tlsPassthrough,

src/server/mockttp-server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Buffer } from 'buffer';
2+
import * as stream from 'stream';
3+
import * as fs from 'fs';
24
import * as net from "net";
35
import * as tls from "tls";
46
import * as http from "http";
@@ -122,6 +124,8 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
122124
private socksOptions: boolean | SocksServerOptions;
123125
private passthroughUnknownProtocols: boolean;
124126
private maxBodySize: number;
127+
private keyLogFilePath: string | undefined;
128+
private keyLogStream: fs.WriteStream | undefined;
125129

126130
private app: connect.Server;
127131
private server: DestroyableServer<net.Server> | undefined;
@@ -140,6 +144,8 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
140144
this.socksOptions = options.socks ?? false;
141145
this.passthroughUnknownProtocols = options.passthrough?.includes('unknown-protocol') ?? false;
142146
this.maxBodySize = options.maxBodySize ?? Infinity;
147+
this.keyLogFilePath = options.https?.keyLogFile;
148+
143149
this.eventEmitter = new EventEmitter();
144150

145151
this.app = connect();
@@ -158,12 +164,20 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
158164
}
159165

160166
async start(portParam: number | PortRange = { startPort: 8000, endPort: 65535 }): Promise<void> {
167+
if (this.keyLogFilePath) {
168+
this.keyLogStream = fs.createWriteStream(this.keyLogFilePath, { flags: 'a' });
169+
this.keyLogStream.on('error', (err) => {
170+
console.warn(`Error writing TLS key log file ${this.keyLogFilePath}:`, err);
171+
});
172+
}
173+
161174
this.server = await createComboServer({
162175
debug: this.debug,
163176
https: this.httpsOptions,
164177
http2: this.isHttp2Enabled,
165178
socks: this.socksOptions,
166179
passthroughUnknownProtocols: this.passthroughUnknownProtocols,
180+
keyLogStream: this.keyLogStream,
167181

168182
requestListener: this.app,
169183
tlsClientErrorListener: this.announceTlsErrorAsync.bind(this),
@@ -222,6 +236,8 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
222236

223237
if (this.server) await this.server.destroy();
224238

239+
if (this.keyLogStream) this.keyLogStream.end();
240+
225241
this.reset();
226242
}
227243

@@ -837,6 +853,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
837853
await nextRule.handle(request, response, {
838854
record: this.recordTraffic,
839855
debug: this.debug,
856+
keyLogStream: this.keyLogStream,
840857
emitEventCallback: (this.eventEmitter.listenerCount('rule-event') !== 0)
841858
? (type, event) => this.announceRuleEventAsync(request.id, nextRule!.id, type, event)
842859
: undefined
@@ -912,6 +929,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
912929
await nextRule.handle(request, socket, head, {
913930
record: this.recordTraffic,
914931
debug: this.debug,
932+
keyLogStream: this.keyLogStream,
915933
emitEventCallback: (this.eventEmitter.listenerCount('rule-event') !== 0)
916934
? (type, event) => this.announceRuleEventAsync(request.id, nextRule!.id, type, event)
917935
: undefined

test/integration/https.spec.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import * as http from 'http';
22
import * as tls from 'tls';
33
import * as https from 'https';
44
import * as fs from 'fs/promises';
5+
import * as tmp from 'tmp-promise';
6+
import * as WebSocket from 'isomorphic-ws';
57

6-
import { getLocal } from "../..";
8+
import { getLocal, Mockttp } from "../..";
79
import {
810
expect,
911
fetch,
1012
nodeOnly,
1113
delay,
1214
openRawSocket,
1315
openRawTlsSocket,
14-
http2ProxyRequest,
15-
nodeSatisfies
16+
http2ProxyRequest
1617
} from "../test-utils";
1718
import { streamToBuffer } from '../../src/util/buffer-utils';
1819

@@ -483,5 +484,82 @@ describe("When configured for HTTPS", () => {
483484
tlsSocket.destroy();
484485
});
485486
});
487+
488+
describe("with a keylog file configured", () => {
489+
490+
const remoteNonLoggingServer = getLocal({
491+
https: {
492+
keyPath: './test/fixtures/test-ca.key',
493+
certPath: './test/fixtures/test-ca.pem',
494+
}
495+
});
496+
497+
let server: Mockttp;
498+
let keyLogFile!: string;
499+
500+
beforeEach(async () => {
501+
keyLogFile = await tmp.tmpName();
502+
server = getLocal({
503+
https: {
504+
keyPath: './test/fixtures/test-ca.key',
505+
certPath: './test/fixtures/test-ca.pem',
506+
keyLogFile
507+
}
508+
});
509+
await server.start();
510+
await remoteNonLoggingServer.start();
511+
});
512+
513+
afterEach(async () => {
514+
await server.stop().catch(() => {});
515+
await remoteNonLoggingServer.stop().catch(() => {});
516+
await fs.unlink(keyLogFile).catch(() => {});
517+
});
518+
519+
it("should log downstream TLS keys to the file", async () => {
520+
await server.forGet('/').thenReply(200);
521+
await fetch(server.url);
522+
523+
const keyLogContents = await fs.readFile(keyLogFile, 'utf8');
524+
525+
expect(keyLogContents).to.include('CLIENT_HANDSHAKE_TRAFFIC_SECRET');
526+
expect(keyLogContents).to.include('SERVER_HANDSHAKE_TRAFFIC_SECRET');
527+
expect(keyLogContents).to.include('CLIENT_TRAFFIC_SECRET_0');
528+
expect(keyLogContents).to.include('SERVER_TRAFFIC_SECRET_0');
529+
});
530+
531+
it("should log upstream TLS keys to the file", async () => {
532+
// Make an HTTP request, but proxy to an HTTPS server:
533+
await server.forGet('/').thenForwardTo(remoteNonLoggingServer.url);
534+
await fetch(`http://localhost:${server.port}/`);
535+
536+
const keyLogContents = await fs.readFile(keyLogFile, 'utf8');
537+
538+
expect(keyLogContents).to.include('CLIENT_HANDSHAKE_TRAFFIC_SECRET');
539+
expect(keyLogContents).to.include('SERVER_HANDSHAKE_TRAFFIC_SECRET');
540+
expect(keyLogContents).to.include('CLIENT_TRAFFIC_SECRET_0');
541+
expect(keyLogContents).to.include('SERVER_TRAFFIC_SECRET_0');
542+
});
543+
544+
it("should log upstream WebSocket TLS keys to the file", async () => {
545+
await server.forAnyWebSocket().thenForwardTo(remoteNonLoggingServer.url);
546+
await remoteNonLoggingServer.forAnyWebSocket().thenEcho();
547+
548+
const ws = new WebSocket(`ws://localhost:${server.port}/`);
549+
await new Promise((resolve, reject) => {
550+
ws.addEventListener('open', resolve);
551+
ws.addEventListener('error', reject);
552+
});
553+
ws.close(1000);
554+
555+
const keyLogContents = await fs.readFile(keyLogFile, 'utf8');
556+
557+
expect(keyLogContents).to.include('CLIENT_HANDSHAKE_TRAFFIC_SECRET');
558+
expect(keyLogContents).to.include('SERVER_HANDSHAKE_TRAFFIC_SECRET');
559+
expect(keyLogContents).to.include('CLIENT_TRAFFIC_SECRET_0');
560+
expect(keyLogContents).to.include('SERVER_TRAFFIC_SECRET_0');
561+
});
562+
563+
});
486564
});
487565
});

0 commit comments

Comments
 (0)