Skip to content

Commit ca5ae10

Browse files
authored
Merge pull request #4 from kriztalz/ja4
Added JA4 support
2 parents bd38d3a + 3172a06 commit ca5ae10

File tree

5 files changed

+193
-27
lines changed

5 files changed

+193
-27
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ server.on('request', (request, response) => {
3030
});
3131
```
3232

33-
A `tlsClientHello` property will be attached to all sockets, containing the parsed data returned by `readTlsClientHello` (see below) and a `ja3` property with the JA3 TLS fingerprint for the client hello, e.g. `cd08e31494f9531f560d64c695473da9`.
33+
A `tlsClientHello` property will be attached to all sockets, containing the parsed data returned by `readTlsClientHello` (see below), a `ja3` property with the JA3 TLS fingerprint for the client hello, e.g. `cd08e31494f9531f560d64c695473da9` and a `ja4` property with the JA4 TLS fingerprint for the client hello, e.g. `t13d591000_a33745022dd6_1f22a2ca17c4`.
3434

3535
### Reading a TLS client hello
3636

@@ -50,11 +50,14 @@ The returned promise resolves to an object, containing:
5050
3. An array of extension ids (excluding GREASE)
5151
4. An array of supported group ids (excluding GREASE)
5252
5. An array of supported elliptic curve ids
53+
6. An array of signature algorithms (TLS 1.3)
5354

5455
### TLS fingerprinting
5556

5657
To calculate TLS fingerprints manually, there are a few options exported from this module:
5758

5859
* `getTlsFingerprintAsJa3` - Reads from a stream, just like `readTlsClientHello` above, but returns a promise for the JA3 hash string, e.g. `cd08e31494f9531f560d64c695473da9`, instead of the raw hello components.
59-
* `readTlsClientHello(stream)` - Reads the entire hello (see above). In the returned object, you can read the raw data components used for fingerprinting from the `fingerprintData` property.
60-
* `calculateJa3FromFingerprintData(data)` - Takes raw TLS fingerprint data, and returns the corresponding JA3 hash.
60+
* `getTlsFingerprintAsJa4` - Reads from a stream, just like `readTlsClientHello` above, but returns a promise for the JA4 hash string, e.g. `t13d591000_a33745022dd6_1f22a2ca17c4`, instead of the raw hello components.
61+
* `readTlsClientHello(stream)` - Reads the entire hello (see above). In the returned object, you can read the raw data components used for JA3 fingerprinting from the `fingerprintData` property.
62+
* `calculateJa3FromFingerprintData(data)` - Takes raw TLS fingerprint data, and returns the corresponding JA3 hash.
63+
* `calculateJa4FromHelloData(data)` - Takes the full hello data, including the serverName, alpnProtocols & fingerprinting parameters returned by `readTlsClientHello`, and returns the corresponding JA4 hash, eg. `t13d591000_a33745022dd6_1f22a2ca17c4`.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"client-hello",
2727
"alpn",
2828
"sni",
29-
"https"
29+
"https",
30+
"ja3",
31+
"ja4"
3032
],
3133
"licenses": [
3234
{

src/index.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export type TlsFingerprintData = [
6565
ciphers: number[],
6666
extensions: number[],
6767
groups: number[],
68-
curveFormats: number[]
68+
curveFormats: number[],
69+
sigAlgorithms: number[]
6970
];
7071

7172
/**
@@ -206,12 +207,22 @@ export async function readTlsClientHello(inputStream: stream.Readable): Promise<
206207
const extensionsLength = (await collectBytes(helloDataStream, 2)).readUint16BE();
207208
let readExtensionsDataLength = 0;
208209
const extensions: Array<{ id: Buffer, data: Buffer }> = [];
210+
let signatureAlgorithms: number[] = [];
209211

210212
while (readExtensionsDataLength < extensionsLength) {
211213
const extensionId = await collectBytes(helloDataStream, 2);
212214
const extensionLength = (await collectBytes(helloDataStream, 2)).readUint16BE();
213215
const extensionData = await collectBytes(helloDataStream, extensionLength);
214216

217+
if (extensionId.readUInt16BE() === 13) {
218+
const sigAlgsLength = extensionData.readUInt16BE(0);
219+
const sigAlgs: number[] = [];
220+
for (let i = 2; i < sigAlgsLength + 2; i += 2) {
221+
sigAlgs.push(extensionData.readUInt16BE(i));
222+
}
223+
signatureAlgorithms = sigAlgs;
224+
}
225+
215226
extensions.push({ id: extensionId, data: extensionData });
216227
readExtensionsDataLength += 4 + extensionLength;
217228
}
@@ -253,7 +264,8 @@ export async function readTlsClientHello(inputStream: stream.Readable): Promise<
253264
cipherFingerprint,
254265
extensionsFingerprint,
255266
groupsFingerprint,
256-
curveFormatsFingerprint
267+
curveFormatsFingerprint,
268+
signatureAlgorithms
257269
] as TlsFingerprintData;
258270

259271
// And capture other client hello data that might be interesting:
@@ -292,9 +304,109 @@ export async function getTlsFingerprintAsJa3(rawStream: stream.Readable) {
292304
);
293305
}
294306

307+
export interface Ja4Data {
308+
protocol: 't' | 'q' | 'd'; // TLS, QUIC, DTLS (only TLS supported for now)
309+
version: '10' | ' 11' | '12' | '13'; // TLS version
310+
sni: 'd' | 'i'; // 'd' if a domain was provided via SNI, 'i' otherwise (for IP)
311+
cipherCount: number;
312+
extensionCount: number;
313+
alpn: string; // First and last character of the ALPN value or '00' if none
314+
cipherSuites: number[];
315+
extensions: number[];
316+
sigAlgorithms: number[];
317+
}
318+
319+
export function calculateJa4FromHelloData(
320+
{ serverName, alpnProtocols, fingerprintData }: TlsHelloData
321+
): string {
322+
const [tlsVersion, ciphers, extensions, , , sigAlgorithms] = fingerprintData;
323+
324+
// Part A: Protocol info
325+
const protocol = 't'; // We only handle TCP for now
326+
327+
const version = extensions.includes(0x002B)
328+
? '13' // TLS 1.3 uses the supported versions extension, and 1.4+ doesn't exist (yet)
329+
: { // Previous TLS sets the version in the handshake up front:
330+
0x0303: '12',
331+
0x0302: '11',
332+
0x0301: '10'
333+
}[tlsVersion]
334+
?? '00'; // Other unknown version
335+
336+
const sni = !serverName ? 'i' : 'd'; // 'i' for IP (no SNI), 'd' for domain
337+
338+
// Handle different ALPN protocols
339+
let alpn = '00';
340+
const firstProtocol = alpnProtocols?.[0];
341+
if (firstProtocol && firstProtocol.length >= 1) {
342+
// Take first and last character of the protocol string
343+
alpn = firstProtocol.length >= 2
344+
? `${firstProtocol[0]}${firstProtocol[firstProtocol.length - 1]}`
345+
: `${firstProtocol[0]}${firstProtocol[0]}`;
346+
}
347+
348+
// Format numbers as fixed-width hex
349+
const cipherCount = ciphers.length.toString().padStart(2, '0');
350+
const extensionCount = extensions.length.toString().padStart(2, '0');
351+
352+
const ja4_a = `${protocol}${version}${sni}${cipherCount}${extensionCount}${alpn}`;
353+
354+
// Part B: Truncated SHA256 of cipher suites
355+
// First collect all hex values and sort them
356+
const cipherHexValues = ciphers
357+
.filter(c => !isGREASE(c))
358+
.map(c => c.toString(16).padStart(4, '0'));
359+
const sortedCiphers = [...cipherHexValues].sort().join(',');
360+
const cipherHash = ciphers.length
361+
? crypto.createHash('sha256')
362+
.update(sortedCiphers)
363+
.digest('hex')
364+
.slice(0, 12)
365+
: '000000000000'; // No ciphers provided
366+
367+
// Part C: Truncated SHA256 of extensions + sig algorithms
368+
// Get extensions (excluding SNI and ALPN)
369+
const extensionsStr = extensions
370+
.filter(e => {
371+
if (e === 0x0 || e === 0x10) return false; // Filter SNI and ALPN
372+
return !isGREASE(e);
373+
})
374+
.sort((a, b) => a - b)
375+
.map(e => e.toString(16).padStart(4, '0'))
376+
.join(',');
377+
378+
// Get signature algorithms from the actual TLS hello data
379+
const signatureAlgorithmsStr = sigAlgorithms
380+
? sigAlgorithms
381+
.filter(s => !isGREASE(s))
382+
.map(s => s.toString(16).padStart(4, '0'))
383+
.join(',')
384+
: '';
385+
386+
// Add separator only if we have signature algorithms
387+
const separator = signatureAlgorithmsStr ? '_' : '';
388+
389+
// Combine and hash
390+
const ja4_c_raw = `${extensionsStr}${separator}${signatureAlgorithmsStr}`;
391+
392+
const extensionHash = crypto.createHash('sha256')
393+
.update(ja4_c_raw)
394+
.digest('hex')
395+
.slice(0, 12);
396+
397+
return `${ja4_a}_${cipherHash}_${extensionHash}`;
398+
}
399+
400+
export async function getTlsFingerprintAsJa4(rawStream: stream.Readable) {
401+
return calculateJa4FromHelloData(
402+
(await readTlsClientHello(rawStream))
403+
);
404+
}
405+
295406
interface SocketWithHello extends net.Socket {
296407
tlsClientHello?: TlsHelloData & {
297408
ja3: string;
409+
ja4: string;
298410
}
299411
}
300412

@@ -309,6 +421,7 @@ declare module 'tls' {
309421
*/
310422
tlsClientHello?: TlsHelloData & {
311423
ja3: string;
424+
ja4: string;
312425
}
313426
}
314427
}
@@ -339,7 +452,8 @@ export function trackClientHellos(tlsServer: tls.Server) {
339452

340453
socket.tlsClientHello = {
341454
...helloData,
342-
ja3: calculateJa3FromFingerprintData(helloData.fingerprintData)
455+
ja3: calculateJa3FromFingerprintData(helloData.fingerprintData),
456+
ja4: calculateJa4FromHelloData(helloData)
343457
};
344458
} catch (e) {
345459
if (!(e instanceof NonTlsError)) { // Ignore totally non-TLS traffic

test/test.spec.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,26 @@ import {
1717
import {
1818
readTlsClientHello,
1919
getTlsFingerprintAsJa3,
20+
getTlsFingerprintAsJa4,
2021
calculateJa3FromFingerprintData,
2122
trackClientHellos,
22-
23+
calculateJa4FromHelloData
2324
} from '../src/index';
2425

2526
const nodeMajorVersion = parseInt(process.version.slice(1).split('.')[0], 10);
2627

28+
interface EchoResponse {
29+
tls: {
30+
ja3: {
31+
hash: string;
32+
};
33+
ja4: {
34+
hash: string;
35+
raw: string;
36+
};
37+
};
38+
}
39+
2740
describe("Read-TLS-Client-Hello", () => {
2841

2942
let server: DestroyableServer<net.Server>;
@@ -122,7 +135,7 @@ describe("Read-TLS-Client-Hello", () => {
122135
]);
123136
});
124137

125-
it("calculates the same fingerprint as ja3.zone", async () => {
138+
it("can read Node's JA4 fingerprint", async () => {
126139
server = makeDestroyable(new net.Server());
127140

128141
server.listen();
@@ -138,25 +151,53 @@ describe("Read-TLS-Client-Hello", () => {
138151
}).on('error', () => {}); // Socket will fail, since server never responds, that's OK
139152

140153
const incomingSocket = await incomingSocketPromise;
141-
const ourFingerprint = await getTlsFingerprintAsJa3(incomingSocket);
154+
const fingerprint = await getTlsFingerprintAsJa4(incomingSocket);
155+
156+
expect(fingerprint).to.be.oneOf([
157+
't13d591000_a33745022dd6_5ac7197df9d2', // Node 12 - 16
158+
't13d591000_a33745022dd6_1f22a2ca17c4' // Node 17+
159+
]);
160+
});
161+
162+
it("calculates the same fingerprint as echo.ramaproxy.org", async () => {
163+
server = makeDestroyable(new net.Server());
142164

143-
const remoteFingerprint = await new Promise((resolve, reject) => {
144-
const response = https.get('https://check.ja3.zone/');
165+
server.listen();
166+
await new Promise((resolve) => server.on('listening', resolve));
167+
168+
let incomingSocketPromise = getDeferred<net.Socket>();
169+
server.on('connection', (socket) => incomingSocketPromise.resolve(socket));
170+
171+
const port = (server.address() as net.AddressInfo).port;
172+
https.request({
173+
host: 'localhost',
174+
port
175+
}).on('error', () => {}); // Socket will fail, since server never responds, that's OK
176+
177+
const incomingSocket = await incomingSocketPromise;
178+
const helloData = await readTlsClientHello(incomingSocket);
179+
const ourJa3 = await getTlsFingerprintAsJa3(incomingSocket);
180+
const ourJa4 = calculateJa4FromHelloData(helloData);
181+
182+
const remoteFingerprints = await new Promise<EchoResponse>((resolve, reject) => {
183+
const response = https.get('https://echo.ramaproxy.org/');
145184
response.on('response', async (resp) => {
146-
if (resp.statusCode !== 200) reject(new Error(`Unexpected ${resp.statusCode} from ja3.zon`));
185+
if (resp.statusCode !== 200) reject(new Error(`Unexpected ${resp.statusCode} from echo.ramaproxy.org`));
147186

148187
try {
149188
const rawData = await streamToBuffer(resp);
150-
const data = JSON.parse(rawData.toString());
151-
resolve(data.hash);
189+
const data = JSON.parse(rawData.toString()) as EchoResponse;
190+
resolve(data);
152191
} catch (e) {
153192
reject(e);
154193
}
155194
});
156195
response.on('error', reject);
157196
});
158197

159-
expect(ourFingerprint).to.equal(remoteFingerprint);
198+
// Check both JA3 and JA4 hashes
199+
expect(ourJa3).to.equal(remoteFingerprints.tls.ja3.hash);
200+
expect(ourJa4).to.equal(remoteFingerprints.tls.ja4.hash);
160201
});
161202

162203
it("can capture the server name from a Chrome request", async () => {
@@ -276,18 +317,24 @@ describe("Read-TLS-Client-Hello", () => {
276317
}).on('error', () => {}); // No response, we don't care
277318

278319
const tlsSocket = await tlsSocketPromise;
279-
const helloData = tlsSocket.tlsClientHello!;
280320

281-
expect(helloData.serverName).to.equal('localhost');
282-
expect(helloData.alpnProtocols).to.deep.equal(undefined);
283-
284-
expect(helloData.fingerprintData[0]).to.equal(771); // Is definitely a TLS 1.2+ fingerprint
285-
expect(helloData.fingerprintData.length).to.equal(5); // Full data is checked in other tests
321+
const [
322+
tlsVersion,
323+
ciphers,
324+
extension,
325+
groups,
326+
curveFormats,
327+
sigAlgorithms
328+
] = tlsSocket.tlsClientHello!.fingerprintData;
329+
330+
expect(tlsSocket.tlsClientHello!.fingerprintData.length).to.equal(6);
331+
expect(tlsVersion).to.equal(771);
332+
expect(ciphers.length).to.be.greaterThan(0);
333+
expect(extension.length).to.be.greaterThan(0);
334+
expect(groups.length).to.be.greaterThan(0);
335+
expect(curveFormats.length).to.be.greaterThan(0);
336+
expect(sigAlgorithms.length).to.be.greaterThan(0);
286337

287-
expect(helloData.ja3).to.be.oneOf([
288-
'398430069e0a8ecfbc8db0778d658d77', // Node 12 - 16
289-
'0cce74b0d9b7f8528fb2181588d23793' // Node 17+
290-
]);
291338
});
292339

293340
it("doesn't break non-TLS connections", async () => {

test/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"./**/*.ts",
77
"../package.json"
88
]
9-
}
9+
}

0 commit comments

Comments
 (0)