Skip to content

Commit ef59634

Browse files
committed
Added 'JA4 Fingerprint' operation
1 parent 0026d77 commit ef59634

File tree

7 files changed

+1086
-3
lines changed

7 files changed

+1086
-3
lines changed

src/core/config/Categories.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@
231231
"VarInt Decode",
232232
"JA3 Fingerprint",
233233
"JA3S Fingerprint",
234+
"JA4 Fingerprint",
234235
"HASSH Client Fingerprint",
235236
"HASSH Server Fingerprint",
236237
"Format MAC addresses",

src/core/lib/JA4.mjs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* JA4 resources.
3+
*
4+
* @author n1474335 [n1474335@gmail.com]
5+
* @copyright Crown Copyright 2024
6+
* @license Apache-2.0
7+
*
8+
* JA4 Copyright 2023 FoxIO, LLC.
9+
* @license BSD-3-Clause
10+
*/
11+
12+
import OperationError from "../errors/OperationError.mjs";
13+
import { parseTLSRecord, parseHighestSupportedVersion, parseFirstALPNValue } from "./TLS.mjs";
14+
import { toHexFast } from "./Hex.mjs";
15+
import { runHash } from "./Hash.mjs";
16+
import Utils from "../Utils.mjs";
17+
18+
19+
/**
20+
* Calculate the JA4 from a given TLS Client Hello Stream
21+
* @param {Uint8Array} bytes
22+
* @returns {string}
23+
*/
24+
export function toJA4(bytes) {
25+
let tlsr = {};
26+
try {
27+
tlsr = parseTLSRecord(bytes);
28+
} catch (err) {
29+
throw new OperationError("Data is not a valid TLS Client Hello. QUIC is not yet supported.\n" + err);
30+
}
31+
32+
/* QUIC
33+
“q” or “t”, which denotes whether the hello packet is for QUIC or TCP.
34+
TODO: Implement QUIC
35+
*/
36+
const ptype = "t";
37+
38+
/* TLS Version
39+
TLS version is shown in 3 different places. If extension 0x002b exists (supported_versions), then the version
40+
is the highest value in the extension. Remember to ignore GREASE values. If the extension doesn’t exist, then
41+
the TLS version is the value of the Protocol Version. Handshake version (located at the top of the packet)
42+
should be ignored.
43+
*/
44+
let version = tlsr.version.value;
45+
for (const ext of tlsr.handshake.value.extensions.value) {
46+
if (ext.type.value === "supported_versions") {
47+
version = parseHighestSupportedVersion(ext.value.data);
48+
break;
49+
}
50+
}
51+
switch (version) {
52+
case 0x0304: version = "13"; break; // TLS 1.3
53+
case 0x0303: version = "12"; break; // TLS 1.2
54+
case 0x0302: version = "11"; break; // TLS 1.1
55+
case 0x0301: version = "10"; break; // TLS 1.0
56+
case 0x0300: version = "s3"; break; // SSL 3.0
57+
case 0x0200: version = "s2"; break; // SSL 2.0
58+
case 0x0100: version = "s1"; break; // SSL 1.0
59+
default: version = "00"; // Unknown
60+
}
61+
62+
/* SNI
63+
If the SNI extension (0x0000) exists, then the destination of the connection is a domain, or “d” in the fingerprint.
64+
If the SNI does not exist, then the destination is an IP address, or “i”.
65+
*/
66+
let sni = "i";
67+
for (const ext of tlsr.handshake.value.extensions.value) {
68+
if (ext.type.value === "server_name") {
69+
sni = "d";
70+
break;
71+
}
72+
}
73+
74+
/* Number of Ciphers
75+
2 character number of cipher suites, so if there’s 6 cipher suites in the hello packet, then the value should be “06”.
76+
If there’s > 99, which there should never be, then output “99”. Remember, ignore GREASE values. They don’t count.
77+
*/
78+
let cipherLen = 0;
79+
for (const cs of tlsr.handshake.value.cipherSuites.value) {
80+
if (cs.value !== "GREASE") cipherLen++;
81+
}
82+
cipherLen = cipherLen > 99 ? "99" : cipherLen.toString().padStart(2, "0");
83+
84+
/* Number of Extensions
85+
Same as counting ciphers. Ignore GREASE. Include SNI and ALPN.
86+
*/
87+
let extLen = 0;
88+
for (const ext of tlsr.handshake.value.extensions.value) {
89+
if (ext.type.value !== "GREASE") extLen++;
90+
}
91+
extLen = extLen > 99 ? "99" : extLen.toString().padStart(2, "0");
92+
93+
/* ALPN Extension Value
94+
The first and last characters of the ALPN (Application-Layer Protocol Negotiation) first value.
95+
If there are no ALPN values or no ALPN extension then we print “00” as the value in the fingerprint.
96+
*/
97+
let alpn = "00";
98+
for (const ext of tlsr.handshake.value.extensions.value) {
99+
if (ext.type.value === "application_layer_protocol_negotiation") {
100+
alpn = parseFirstALPNValue(ext.value.data);
101+
alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1);
102+
break;
103+
}
104+
}
105+
106+
/* Cipher hash
107+
A 12 character truncated sha256 hash of the list of ciphers sorted in hex order, first 12 characters.
108+
The list is created using the 4 character hex values of the ciphers, lower case, comma delimited, ignoring GREASE.
109+
*/
110+
const originalCiphersList = [];
111+
for (const cs of tlsr.handshake.value.cipherSuites.value) {
112+
if (cs.value !== "GREASE") {
113+
originalCiphersList.push(toHexFast(cs.data));
114+
}
115+
}
116+
const sortedCiphersList = [...originalCiphersList].sort();
117+
const sortedCiphersRaw = sortedCiphersList.join(",");
118+
const originalCiphersRaw = originalCiphersList.join(",");
119+
const sortedCiphers = runHash(
120+
"sha256",
121+
Utils.strToArrayBuffer(sortedCiphersRaw)
122+
).substring(0, 12);
123+
const originalCiphers = runHash(
124+
"sha256",
125+
Utils.strToArrayBuffer(originalCiphersRaw)
126+
).substring(0, 12);
127+
128+
/* Extension hash
129+
A 12 character truncated sha256 hash of the list of extensions, sorted by hex value, followed by the list of signature
130+
algorithms, in the order that they appear (not sorted).
131+
The extension list is created using the 4 character hex values of the extensions, lower case, comma delimited, sorted
132+
(not in the order they appear). Ignore the SNI extension (0000) and the ALPN extension (0010) as we’ve already captured
133+
them in the a section of the fingerprint. These values are omitted so that the same application would have the same b
134+
section of the fingerprint regardless of if it were going to a domain, IP, or changing ALPNs.
135+
*/
136+
const originalExtensionsList = [];
137+
let signatureAlgorithms = "";
138+
for (const ext of tlsr.handshake.value.extensions.value) {
139+
if (ext.type.value !== "GREASE") {
140+
originalExtensionsList.push(toHexFast(ext.type.data));
141+
}
142+
if (ext.type.value === "signature_algorithms") {
143+
signatureAlgorithms = toHexFast(ext.value.data.slice(2));
144+
signatureAlgorithms = signatureAlgorithms.replace(/(.{4})/g, "$1,");
145+
signatureAlgorithms = signatureAlgorithms.substring(0, signatureAlgorithms.length - 1);
146+
}
147+
}
148+
const sortedExtensionsList = [...originalExtensionsList].filter(e => e !== "0000" && e !== "0010").sort();
149+
const sortedExtensionsRaw = sortedExtensionsList.join(",") + "_" + signatureAlgorithms;
150+
const originalExtensionsRaw = originalExtensionsList.join(",") + "_" + signatureAlgorithms;
151+
const sortedExtensions = runHash(
152+
"sha256",
153+
Utils.strToArrayBuffer(sortedExtensionsRaw)
154+
).substring(0, 12);
155+
const originalExtensions = runHash(
156+
"sha256",
157+
Utils.strToArrayBuffer(originalExtensionsRaw)
158+
).substring(0, 12);
159+
160+
return {
161+
"JA4": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${sortedCiphers}_${sortedExtensions}`,
162+
"JA4_o": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${originalCiphers}_${originalExtensions}`,
163+
"JA4_r": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${sortedCiphersRaw}_${sortedExtensionsRaw}`,
164+
"JA4_ro": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${originalCiphersRaw}_${originalExtensionsRaw}`,
165+
};
166+
}

src/core/lib/Stream.mjs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,23 @@ export default class Stream {
1818
* Stream constructor.
1919
*
2020
* @param {Uint8Array} input
21+
* @param {number} pos
22+
* @param {number} bitPos
2123
*/
22-
constructor(input) {
24+
constructor(input, pos=0, bitPos=0) {
2325
this.bytes = input;
2426
this.length = this.bytes.length;
25-
this.position = 0;
26-
this.bitPos = 0;
27+
this.position = pos;
28+
this.bitPos = bitPos;
29+
}
30+
31+
/**
32+
* Clone this Stream returning a new identical Stream.
33+
*
34+
* @returns {Stream}
35+
*/
36+
clone() {
37+
return new Stream(this.bytes, this.position, this.bitPos);
2738
}
2839

2940
/**

0 commit comments

Comments
 (0)