Skip to content

Commit 1168387

Browse files
committed
Ensure that fingerprinting doesn't break non-TLS traffic
1 parent c32f8de commit 1168387

File tree

2 files changed

+109
-19
lines changed

2 files changed

+109
-19
lines changed

src/index.ts

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ import * as crypto from 'crypto';
33
import * as tls from 'tls';
44
import * as net from 'net';
55

6+
type ErrorWithConsumedData = Error & {
7+
consumedData: Buffer
8+
};
9+
610
const collectBytes = (stream: stream.Readable, byteLength: number) => {
711
if (byteLength === 0) return Buffer.from([]);
812

913
return new Promise<Buffer>(async (resolve, reject) => {
1014
const closeReject = () => reject(new Error('Stream closed before expected data could be read'));
1115

16+
const data: Buffer[] = [];
17+
1218
try {
1319
stream.on('error', reject);
1420
stream.on('close', closeReject);
15-
16-
const data: Buffer[] = [];
1721
let dataLength = 0;
1822
let readNull = false;
1923
do {
@@ -34,6 +38,7 @@ const collectBytes = (stream: stream.Readable, byteLength: number) => {
3438

3539
return resolve(Buffer.concat(data, byteLength));
3640
} catch (e) {
41+
Object.assign(e as ErrorWithConsumedData, { consumedData: data });
3742
reject(e);
3843
} finally {
3944
stream.removeListener('error', reject);
@@ -57,33 +62,81 @@ export type TlsFingerprintData = [
5762
curveFormats: number[]
5863
];
5964

65+
/**
66+
* Seperate error class. If you want to detect TLS parsing errors, but ignore TLS fingerprint
67+
* issues from definitely-not-TLS traffic, you can ignore all instances of this error.
68+
*/
69+
export class NonTlsError extends Error {
70+
constructor(message: string) {
71+
super(message);
72+
73+
// Fix prototypes (required for custom error types):
74+
const actualProto = new.target.prototype;
75+
Object.setPrototypeOf(this, actualProto);
76+
}
77+
}
78+
79+
async function extractTlsHello(inputStream: stream.Readable): Promise<Buffer> {
80+
const consumedData = [];
81+
try {
82+
consumedData.push(await collectBytes(inputStream, 1));
83+
const [recordType] = consumedData[0];
84+
if (recordType !== 0x16) throw new Error("Can't calculate TLS fingerprint - not a TLS stream");
85+
86+
consumedData.push(await collectBytes(inputStream, 2));
87+
const recordLengthBytes = await collectBytes(inputStream, 2);
88+
consumedData.push(recordLengthBytes);
89+
const recordLength = recordLengthBytes.readUint16BE();
90+
91+
consumedData.push(await collectBytes(inputStream, recordLength));
92+
93+
// Put all the bytes back, so that this stream can still be used to create a real TLS session
94+
return Buffer.concat(consumedData);
95+
} catch (error: any) {
96+
if (error.consumedData) {
97+
// This happens if there's an error inside collectBytes with a partial read.
98+
(error.consumedData as ErrorWithConsumedData).consumedData = Buffer.concat([
99+
...consumedData,
100+
error.consumedData as Buffer
101+
])
102+
} else {
103+
Object.assign(error, { consumedData: Buffer.concat(consumedData) });
104+
}
105+
106+
throw error;
107+
}
108+
}
109+
60110
export async function getTlsFingerprintData(inputStream: stream.Readable): Promise<TlsFingerprintData> {
61111
const wasFlowing = inputStream.readableFlowing;
62112
if (wasFlowing) inputStream.pause(); // Pause other readers, so we have time to precisely get the data we need.
63113

64-
const recordTypeBytes = await collectBytes(inputStream, 1);
65-
const [recordType] = recordTypeBytes;
66-
if (recordType !== 0x16) throw new Error("Can't calculate TLS fingerprint - not a TLS stream");
114+
let clientHelloRecordData: Buffer;
115+
try {
116+
clientHelloRecordData = await extractTlsHello(inputStream);
117+
} catch (error: any) {
118+
if ('consumedData' in error) {
119+
inputStream.unshift(error.consumedData as Buffer);
120+
}
121+
if (wasFlowing) inputStream.resume(); // If there were other readers, resume and let them continue
122+
throw new NonTlsError(error.message);
123+
}
67124

68-
const tlsRecordVersion = await collectBytes(inputStream, 2);
69-
const recordLengthBytes = await collectBytes(inputStream, 2);
70-
const recordLength = recordLengthBytes.readUint16BE();
125+
// Put all the bytes back, so that this stream can still be used to create a real TLS session
126+
inputStream.unshift(clientHelloRecordData);
127+
if (wasFlowing) inputStream.resume(); // If there were other readers, resume and let them continue
71128

72129
// Collect all the hello bytes, and then give us a stream of exactly only those bytes, so we can
73130
// still process them step by step in order:
74-
const recordBytes = await collectBytes(inputStream, recordLength);
75-
const helloDataStream = stream.Readable.from(recordBytes, { objectMode: false });
76-
77-
// Put all the bytes back, so that this stream can still be used to create a real TLS session
78-
inputStream.unshift(Buffer.concat([recordTypeBytes, tlsRecordVersion, recordLengthBytes, recordBytes]));
79-
if (wasFlowing) inputStream.resume(); // If there were other readers, resume and let them continue
131+
const clientHello = clientHelloRecordData.slice(5); // Strip TLS record prefix
132+
const helloDataStream = stream.Readable.from(clientHello, { objectMode: false });
80133

81134
const [helloType] = (await collectBytes(helloDataStream, 1));
82135
if (helloType !== 0x1) throw new Error("Can't calculate TLS fingerprint - not a TLS client hello");
83136

84137
const helloLength = (await collectBytes(helloDataStream, 3)).readIntBE(0, 3);
85-
if (helloLength !== recordLength - 4) throw new Error(
86-
`Unexpected client hello length: ${helloLength} (or ${recordLength})`
138+
if (helloLength !== clientHello.byteLength - 4) throw new Error(
139+
`Unexpected client hello length: ${helloLength} (of ${clientHello.byteLength})`
87140
);
88141

89142
const clientTlsVersion = await collectBytes(helloDataStream, 2);
@@ -220,9 +273,11 @@ export function enableFingerprinting(tlsServer: tls.Server) {
220273
ja3: calculateJa3FromFingerprintData(fingerprintData)
221274
};
222275
} catch (e) {
223-
console.warn(`TLS fingerprint not available for TLS connection from ${
224-
socket.remoteAddress ?? 'unknown address'
225-
}: ${(e as Error).message ?? e}`);
276+
if (!(e instanceof NonTlsError)) { // Ignore totally non-TLS traffic
277+
console.warn(`TLS fingerprint not available for TLS connection from ${
278+
socket.remoteAddress ?? 'unknown address'
279+
}: ${(e as Error).message ?? e}`);
280+
}
226281
}
227282

228283
// Once we have a fingerprint, TLS handshakes can continue as normal:

test/test.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,4 +248,39 @@ describe("Read-TLS-Fingerprint", () => {
248248
]);
249249
});
250250

251+
it("doesn't break non-TLS connections", async () => {
252+
const httpServer = new http.Server();
253+
server = makeDestroyable(new net.Server());
254+
255+
server.on('connection', async (socket: any) => {
256+
socket.tlsFingerprint = await getTlsFingerprintAsJa3(socket)
257+
.catch(e => ({ error: e }));
258+
httpServer.emit('connection', socket);
259+
});
260+
261+
httpServer.on('request', (request, response) => {
262+
expect(request.method).to.equal('GET');
263+
expect(request.url).to.equal('/test-request-path');
264+
265+
const fingerprint = (request.socket as any).tlsFingerprint;
266+
expect(fingerprint.error.message).to.equal(
267+
"Can't calculate TLS fingerprint - not a TLS stream"
268+
);
269+
270+
response.writeHead(200).end();
271+
});
272+
273+
server.listen();
274+
await new Promise((resolve) => server.on('listening', resolve));
275+
276+
const port = (server.address() as net.AddressInfo).port;
277+
const req = http.get({ host: 'localhost', port, path: '/test-request-path' });
278+
279+
const response = await new Promise<http.ServerResponse>((resolve) =>
280+
req.on('response', resolve)
281+
);
282+
283+
expect(response.statusCode).to.equal(200);
284+
});
285+
251286
});

0 commit comments

Comments
 (0)