Skip to content

Commit a758553

Browse files
committed
Fully calculate the base JA3 TLS fingerprint data
This doesn't yet hash the data - but it does successfully parse every detail we need in the fingerprint and calculate the correct values, and the raw format is more useful for analysis in many cases.
1 parent a9a42e7 commit a758553

File tree

2 files changed

+156
-8
lines changed

2 files changed

+156
-8
lines changed

src/index.ts

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,128 @@
1-
import * as stream from "stream";
1+
import * as stream from 'stream';
22

3-
export async function getTlsFingerprint(data: stream.Readable) {
4-
const firstData: any = await new Promise((resolve) => data.on('data', resolve));
5-
return firstData[0].toString();
3+
const collectBytes = (stream: stream.Readable, byteLength: number) => {
4+
if (byteLength === 0) return Buffer.from([]);
5+
6+
return new Promise<Buffer>(async (resolve, reject) => {
7+
const closeReject = () => reject(new Error('Stream closed before expected data could be read'));
8+
9+
try {
10+
stream.on('error', reject);
11+
stream.on('close', closeReject);
12+
13+
const data: Buffer[] = [];
14+
let dataLength = 0;
15+
let readNull = false;
16+
do {
17+
if (!stream.readable || readNull) await new Promise<Buffer>((resolve) => stream.once('readable', resolve));
18+
19+
const nextData = stream.read(byteLength - dataLength)
20+
?? stream.read(); // If less than wanted data is available, at least read what we can get
21+
22+
if (nextData === null) {
23+
// Still null => tried to read, not enough data
24+
readNull = true;
25+
continue;
26+
}
27+
28+
data.push(nextData);
29+
dataLength += nextData.byteLength;
30+
} while (dataLength < byteLength)
31+
32+
return resolve(Buffer.concat(data, byteLength));
33+
} catch (e) {
34+
reject(e);
35+
} finally {
36+
stream.removeListener('error', reject);
37+
stream.removeListener('close', closeReject);
38+
}
39+
});
40+
};
41+
42+
const getUint16BE = (buffer: Buffer, offset: number) =>
43+
(buffer[offset] << 8) + buffer[offset+1];
44+
45+
export async function getTlsFingerprint(rawStream: stream.Readable) {
46+
// Create a separate stream, which isn't flowing, so we can read byte-by-byte regardless of how else
47+
// the stream is being used.
48+
const inputStream = new stream.PassThrough();
49+
rawStream.pipe(inputStream);
50+
51+
const [recordType] = await collectBytes(inputStream, 1);
52+
if (recordType !== 0x16) throw new Error("Can't calculate TLS fingerprint - not a TLS stream");
53+
54+
const tlsRecordVersion = await collectBytes(inputStream, 2);
55+
const recordLength = (await collectBytes(inputStream, 2)).readUint16BE();
56+
57+
// Collect all the hello bytes, and then give us a stream of exactly only those bytes, so we can
58+
// still process them step by step in order:
59+
const helloDataStream = stream.Readable.from(await collectBytes(inputStream, recordLength), { objectMode: false });
60+
rawStream.unpipe(inputStream); // Don't need any more data now, thanks.
61+
62+
const [helloType] = (await collectBytes(helloDataStream, 1));
63+
if (helloType !== 0x1) throw new Error("Can't calculate TLS fingerprint - not a TLS client hello");
64+
65+
const helloLength = (await collectBytes(helloDataStream, 3)).readIntBE(0, 3);
66+
if (helloLength !== recordLength - 4) throw new Error(
67+
`Unexpected client hello length: ${helloLength} (or ${recordLength})`
68+
);
69+
70+
const clientTlsVersion = await collectBytes(helloDataStream, 2);
71+
const clientRandom = await collectBytes(helloDataStream, 32);
72+
73+
const [sessionIdLength] = await collectBytes(helloDataStream, 1);
74+
const sessionId = await collectBytes(helloDataStream, sessionIdLength);
75+
76+
const cipherSuitesLength = (await collectBytes(helloDataStream, 2)).readUint16BE();
77+
const cipherSuites = await collectBytes(helloDataStream, cipherSuitesLength);
78+
79+
const [compressionMethodsLength] = await collectBytes(helloDataStream, 1);
80+
const compressionMethods = await collectBytes(helloDataStream, compressionMethodsLength);
81+
82+
const extensionsLength = (await collectBytes(helloDataStream, 2)).readUint16BE();
83+
let readExtensionsDataLength = 0;
84+
const extensions: Array<{ id: Buffer, data: Buffer }> = [];
85+
86+
while (readExtensionsDataLength < extensionsLength) {
87+
const extensionId = await collectBytes(helloDataStream, 2);
88+
const extensionLength = (await collectBytes(helloDataStream, 2)).readUint16BE();
89+
const extensionData = await collectBytes(helloDataStream, extensionLength);
90+
91+
extensions.push({ id: extensionId, data: extensionData });
92+
readExtensionsDataLength += 4 + extensionLength;
93+
}
94+
95+
// All data parsed! Now turn it into the fingerprint format:
96+
//SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat
97+
98+
const tlsVersionFingerprint = clientTlsVersion.readUint16BE()
99+
100+
const cipherFingerprint: number[] = [];
101+
for (let i = 0; i < cipherSuites.length; i += 2) {
102+
cipherFingerprint.push(getUint16BE(cipherSuites, i));
103+
}
104+
105+
const extensionsFingerprint: number[] = extensions.map(({ id }) => getUint16BE(id, 0));
106+
107+
const supportedGroupsData = (
108+
extensions.find(({ id }) => id.equals(Buffer.from([0x0, 0x0a])))?.data
109+
?? Buffer.from([])
110+
).slice(2) // Drop the length prefix
111+
112+
const groupsFingerprint: number[] = [];
113+
for (let i = 0; i < supportedGroupsData.length; i += 2) {
114+
groupsFingerprint.push(getUint16BE(supportedGroupsData, i));
115+
}
116+
117+
const curveFormatsData = extensions.find(({ id }) => id.equals(Buffer.from([0x0, 0x0b])))?.data
118+
?? Buffer.from([]);
119+
const curveFormatsFingerprint: number[] = Array.from(curveFormatsData.slice(1)); // Drop length prefix
120+
121+
return [
122+
tlsVersionFingerprint,
123+
cipherFingerprint,
124+
extensionsFingerprint,
125+
groupsFingerprint,
126+
curveFormatsFingerprint
127+
] as const;
6128
}

test/test.spec.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import * as net from 'net';
22
import * as tls from 'tls';
33
import { makeDestroyable, DestroyableServer } from 'destroyable-server';
44

5+
import { expect } from 'chai';
56
import { getDeferred } from './test-util';
67

78
import { getTlsFingerprint } from '../src/index';
8-
import { expect } from 'chai';
9+
10+
const nodeMajorVersion = parseInt(process.version.slice(1).split('.')[0], 10);
911

1012
describe("Read-TLS-Fingerprint", () => {
1113

@@ -22,16 +24,40 @@ describe("Read-TLS-Fingerprint", () => {
2224
let incomingSocketPromise = getDeferred<net.Socket>();
2325
server.on('connection', (socket) => incomingSocketPromise.resolve(socket));
2426

25-
console.log(server.address());
2627
const port = (server.address() as net.AddressInfo).port;
2728
tls.connect({
28-
host: '127.0.0.1',
29+
host: 'localhost',
2930
port
3031
}).on('error', () => {}); // Socket will fail, since server never responds, that's OK
3132

3233
const incomingSocket = await incomingSocketPromise;
3334
const fingerprint = await getTlsFingerprint(incomingSocket);
3435

35-
expect(fingerprint).to.equal('22'); // Basic WIP test: yes, this is TLS
36+
const [
37+
tlsVersion,
38+
ciphers,
39+
extension,
40+
groups,
41+
curveFormats
42+
] = fingerprint;
43+
44+
expect(tlsVersion).to.equal(771); // TLS 1.2 - now set even for TLS 1.3 for backward compat
45+
expect(ciphers.slice(0, 3)).to.deep.equal([4866, 4867, 4865]);
46+
expect(extension).to.deep.equal([
47+
11,
48+
10,
49+
35,
50+
22,
51+
23,
52+
13,
53+
43,
54+
45,
55+
51
56+
]);
57+
expect(groups).to.deep.equal([
58+
29, 23, 30, 25, 24,
59+
...(nodeMajorVersion >= 17 ? [256, 257, 258, 259, 260] : [])
60+
]);
61+
expect(curveFormats).to.deep.equal([0, 1, 2]);
3662
});
3763
});

0 commit comments

Comments
 (0)