diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 8f8a14f957..a519cf09ec 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -238,6 +238,7 @@
"JA3 Fingerprint",
"JA3S Fingerprint",
"JA4 Fingerprint",
+ "JA4Server Fingerprint",
"HASSH Client Fingerprint",
"HASSH Server Fingerprint",
"Format MAC addresses",
diff --git a/src/core/lib/JA4.mjs b/src/core/lib/JA4.mjs
index b0b423a2da..5e606f46b6 100644
--- a/src/core/lib/JA4.mjs
+++ b/src/core/lib/JA4.mjs
@@ -25,6 +25,9 @@ export function toJA4(bytes) {
let tlsr = {};
try {
tlsr = parseTLSRecord(bytes);
+ if (tlsr.handshake.value.handshakeType.value !== 0x01) {
+ throw new Error();
+ }
} catch (err) {
throw new OperationError("Data is not a valid TLS Client Hello. QUIC is not yet supported.\n" + err);
}
@@ -48,16 +51,7 @@ export function toJA4(bytes) {
break;
}
}
- switch (version) {
- case 0x0304: version = "13"; break; // TLS 1.3
- case 0x0303: version = "12"; break; // TLS 1.2
- case 0x0302: version = "11"; break; // TLS 1.1
- case 0x0301: version = "10"; break; // TLS 1.0
- case 0x0300: version = "s3"; break; // SSL 3.0
- case 0x0200: version = "s2"; break; // SSL 2.0
- case 0x0100: version = "s1"; break; // SSL 1.0
- default: version = "00"; // Unknown
- }
+ version = tlsVersionMapper(version);
/* SNI
If the SNI extension (0x0000) exists, then the destination of the connection is a domain, or “d” in the fingerprint.
@@ -99,6 +93,7 @@ export function toJA4(bytes) {
if (ext.type.value === "application_layer_protocol_negotiation") {
alpn = parseFirstALPNValue(ext.value.data);
alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1);
+ if (alpn.charCodeAt(0) > 127) alpn = "99";
break;
}
}
@@ -164,3 +159,106 @@ export function toJA4(bytes) {
"JA4_ro": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${originalCiphersRaw}_${originalExtensionsRaw}`,
};
}
+
+
+/**
+ * Calculate the JA4Server from a given TLS Server Hello Stream
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+export function toJA4S(bytes) {
+ let tlsr = {};
+ try {
+ tlsr = parseTLSRecord(bytes);
+ if (tlsr.handshake.value.handshakeType.value !== 0x02) {
+ throw new Error();
+ }
+ } catch (err) {
+ throw new OperationError("Data is not a valid TLS Server Hello. QUIC is not yet supported.\n" + err);
+ }
+
+ /* QUIC
+ “q” or “t”, which denotes whether the hello packet is for QUIC or TCP.
+ TODO: Implement QUIC
+ */
+ const ptype = "t";
+
+ /* TLS Version
+ TLS version is shown in 3 different places. If extension 0x002b exists (supported_versions), then the version
+ is the highest value in the extension. Remember to ignore GREASE values. If the extension doesn’t exist, then
+ the TLS version is the value of the Protocol Version. Handshake version (located at the top of the packet)
+ should be ignored.
+ */
+ let version = tlsr.version.value;
+ for (const ext of tlsr.handshake.value.extensions.value) {
+ if (ext.type.value === "supported_versions") {
+ version = parseHighestSupportedVersion(ext.value.data);
+ break;
+ }
+ }
+ version = tlsVersionMapper(version);
+
+ /* Number of Extensions
+ 2 character number of cipher suites, so if there’s 6 cipher suites in the hello packet, then the value should be “06”.
+ If there’s > 99, which there should never be, then output “99”.
+ */
+ let extLen = tlsr.handshake.value.extensions.value.length;
+ extLen = extLen > 99 ? "99" : extLen.toString().padStart(2, "0");
+
+ /* ALPN Extension Chosen Value
+ The first and last characters of the ALPN (Application-Layer Protocol Negotiation) first value.
+ If there are no ALPN values or no ALPN extension then we print “00” as the value in the fingerprint.
+ */
+ let alpn = "00";
+ for (const ext of tlsr.handshake.value.extensions.value) {
+ if (ext.type.value === "application_layer_protocol_negotiation") {
+ alpn = parseFirstALPNValue(ext.value.data);
+ alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1);
+ if (alpn.charCodeAt(0) > 127) alpn = "99";
+ break;
+ }
+ }
+
+ /* Chosen Cipher
+ The hex value of the chosen cipher suite
+ */
+ const cipher = toHexFast(tlsr.handshake.value.cipherSuite.data);
+
+ /* Extension hash
+ A 12 character truncated sha256 hash of the list of extensions.
+ The extension list is created using the 4 character hex values of the extensions, lower case, comma delimited.
+ */
+ const extensionsList = [];
+ for (const ext of tlsr.handshake.value.extensions.value) {
+ extensionsList.push(toHexFast(ext.type.data));
+ }
+ const extensionsRaw = extensionsList.join(",");
+ const extensionsHash = runHash(
+ "sha256",
+ Utils.strToArrayBuffer(extensionsRaw)
+ ).substring(0, 12);
+
+ return {
+ "JA4S": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsHash}`,
+ "JA4S_r": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsRaw}`,
+ };
+}
+
+
+/**
+ * Takes a TLS version value and returns a JA4 TLS version string
+ * @param {Uint8Array} version - Two byte array of version number
+ * @returns {string}
+ */
+function tlsVersionMapper(version) {
+ switch (version) {
+ case 0x0304: return "13"; // TLS 1.3
+ case 0x0303: return "12"; // TLS 1.2
+ case 0x0302: return "11"; // TLS 1.1
+ case 0x0301: return "10"; // TLS 1.0
+ case 0x0300: return "s3"; // SSL 3.0
+ case 0x0200: return "s2"; // SSL 2.0
+ case 0x0100: return "s1"; // SSL 1.0
+ default: return "00"; // Unknown
+ }
+}
diff --git a/src/core/lib/TLS.mjs b/src/core/lib/TLS.mjs
index e3f18eb3c0..6373bfa25f 100644
--- a/src/core/lib/TLS.mjs
+++ b/src/core/lib/TLS.mjs
@@ -70,13 +70,11 @@ function parseHandshake(bytes) {
// Handshake type
h.handshakeType = {
- description: "Client Hello",
+ description: "Handshake Type",
length: 1,
data: b.getBytes(1),
value: s.readInt(1)
};
- if (h.handshakeType.value !== 0x01)
- throw new OperationError("Not a Client Hello.");
// Handshake length
h.handshakeLength = {
@@ -86,8 +84,33 @@ function parseHandshake(bytes) {
value: s.readInt(3)
};
if (s.length !== h.handshakeLength.value + 4)
- throw new OperationError("Not enough data in Client Hello.");
+ throw new OperationError("Not enough data in Handshake message.");
+
+
+ switch (h.handshakeType.value) {
+ case 0x01:
+ h.handshakeType.description = "Client Hello";
+ parseClientHello(s, b, h);
+ break;
+ case 0x02:
+ h.handshakeType.description = "Server Hello";
+ parseServerHello(s, b, h);
+ break;
+ default:
+ throw new OperationError("Not a known handshake message.");
+ }
+ return h;
+}
+
+/**
+ * Parse a TLS Client Hello
+ * @param {Stream} s
+ * @param {Stream} b
+ * @param {Object} h
+ * @returns {JSON}
+ */
+function parseClientHello(s, b, h) {
// Hello version
h.helloVersion = {
description: "Client Hello Version",
@@ -171,6 +194,79 @@ function parseHandshake(bytes) {
return h;
}
+/**
+ * Parse a TLS Server Hello
+ * @param {Stream} s
+ * @param {Stream} b
+ * @param {Object} h
+ * @returns {JSON}
+ */
+function parseServerHello(s, b, h) {
+ // Hello version
+ h.helloVersion = {
+ description: "Server Hello Version",
+ length: 2,
+ data: b.getBytes(2),
+ value: s.readInt(2)
+ };
+
+ // Random
+ h.random = {
+ description: "Server Random",
+ length: 32,
+ data: b.getBytes(32),
+ value: s.getBytes(32)
+ };
+
+ // Session ID Length
+ h.sessionIDLength = {
+ description: "Session ID Length",
+ length: 1,
+ data: b.getBytes(1),
+ value: s.readInt(1)
+ };
+
+ // Session ID
+ h.sessionID = {
+ description: "Session ID",
+ length: h.sessionIDLength.value,
+ data: b.getBytes(h.sessionIDLength.value),
+ value: s.getBytes(h.sessionIDLength.value)
+ };
+
+ // Cipher Suite
+ h.cipherSuite = {
+ description: "Selected Cipher Suite",
+ length: 2,
+ data: b.getBytes(2),
+ value: CIPHER_SUITES_LOOKUP[s.readInt(2)] || "Unknown"
+ };
+
+ // Compression Method
+ h.compressionMethod = {
+ description: "Selected Compression Method",
+ length: 1,
+ data: b.getBytes(1),
+ value: s.readInt(1) // TODO: Compression method name here
+ };
+
+ // Extensions Length
+ h.extensionsLength = {
+ description: "Extensions Length",
+ length: 2,
+ data: b.getBytes(2),
+ value: s.readInt(2)
+ };
+
+ // Extensions
+ h.extensions = {
+ description: "Extensions",
+ length: h.extensionsLength.value,
+ data: b.getBytes(h.extensionsLength.value),
+ value: parseExtensions(s.getBytes(h.extensionsLength.value))
+ };
+}
+
/**
* Parse Cipher Suites
* @param {Uint8Array} bytes
@@ -748,6 +844,11 @@ export const GREASE_VALUES = [
export function parseHighestSupportedVersion(bytes) {
const s = new Stream(bytes);
+ // The Server Hello supported_versions extension simply contains the chosen version
+ if (s.length === 2) {
+ return s.readInt(2);
+ }
+
// Length
let i = s.readInt(1);
diff --git a/src/core/operations/JA4ServerFingerprint.mjs b/src/core/operations/JA4ServerFingerprint.mjs
new file mode 100644
index 0000000000..662285a8f0
--- /dev/null
+++ b/src/core/operations/JA4ServerFingerprint.mjs
@@ -0,0 +1,66 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2024
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import Utils from "../Utils.mjs";
+import {toJA4S} from "../lib/JA4.mjs";
+
+/**
+ * JA4Server Fingerprint operation
+ */
+class JA4ServerFingerprint extends Operation {
+
+ /**
+ * JA4ServerFingerprint constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "JA4Server Fingerprint";
+ this.module = "Crypto";
+ this.description = "Generates a JA4Server Fingerprint (JA4S) to help identify TLS servers or sessions based on hashing together values from the Server Hello.
Input: A hex stream of the TLS or QUIC Server Hello packet application layer.";
+ this.infoURL = "https://medium.com/foxio/ja4-network-fingerprinting-9376fe9ca637";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Input format",
+ type: "option",
+ value: ["Hex", "Base64", "Raw"]
+ },
+ {
+ name: "Output format",
+ type: "option",
+ value: ["JA4S", "JA4S Raw", "Both"]
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [inputFormat, outputFormat] = args;
+ input = Utils.convertToByteArray(input, inputFormat);
+ const ja4s = toJA4S(new Uint8Array(input));
+
+ // Output
+ switch (outputFormat) {
+ case "JA4S":
+ return ja4s.JA4S;
+ case "JA4S Raw":
+ return ja4s.JA4S_r;
+ case "Both":
+ default:
+ return `JA4S: ${ja4s.JA4S}\nJA4S_r: ${ja4s.JA4S_r}`;
+ }
+ }
+
+}
+
+export default JA4ServerFingerprint;
diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs
index 9d0e418bc3..ab6dac6884 100644
--- a/tests/operations/index.mjs
+++ b/tests/operations/index.mjs
@@ -83,7 +83,7 @@ import "./tests/HKDF.mjs";
import "./tests/Image.mjs";
import "./tests/IndexOfCoincidence.mjs";
import "./tests/JA3Fingerprint.mjs";
-import "./tests/JA4Fingerprint.mjs";
+import "./tests/JA4.mjs";
import "./tests/JA3SFingerprint.mjs";
import "./tests/JSONBeautify.mjs";
import "./tests/JSONMinify.mjs";
diff --git a/tests/operations/tests/JA4Fingerprint.mjs b/tests/operations/tests/JA4.mjs
similarity index 63%
rename from tests/operations/tests/JA4Fingerprint.mjs
rename to tests/operations/tests/JA4.mjs
index dba84a387a..0fb4624eaf 100644
--- a/tests/operations/tests/JA4Fingerprint.mjs
+++ b/tests/operations/tests/JA4.mjs
@@ -1,5 +1,5 @@
/**
- * JA4Fingerprint tests.
+ * JA4 tests.
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2024
@@ -52,4 +52,70 @@ TestRegister.addTests([
}
],
},
+ {
+ name: "JA4Server Fingerprint: TLS 1.2 h2 ALPN",
+ input: "16030300640200006003035f0236c07f47bfb12dc2da706ecb3fe7f9eeac9968cc2ddf444f574e4752440120b89ff1ab695278c69b8a73f76242ef755e0b13dc6d459aaaa784fec9c2dfce34cca900001800000000ff01000100000b00020100001000050003026832",
+ expectedOutput: "t1204h2_cca9_1428ce7b4018",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S"]
+ }
+ ]
+ },
+ {
+ name: "JA4Server Fingerprint: TLS 1.2 h2 ALPN Raw",
+ input: "16030300640200006003035f0236c07f47bfb12dc2da706ecb3fe7f9eeac9968cc2ddf444f574e4752440120b89ff1ab695278c69b8a73f76242ef755e0b13dc6d459aaaa784fec9c2dfce34cca900001800000000ff01000100000b00020100001000050003026832",
+ expectedOutput: "t1204h2_cca9_0000,ff01,000b,0010",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S Raw"]
+ }
+ ]
+ },
+ {
+ name: "JA4Server Fingerprint: TLS 1.3",
+ input: "160303007a020000760303236d214556452c55a0754487e64b1a8b0262c50ba23004c9d504166a6de3439920d0b0099243c9296a0c84153ea4ada7d87ad017f4211c2ea1350b0b3cc5514d5f130100002e00330024001d002099e3cc43a2c9941ae75af1b2c7a629bee3ee7031973cad85c82f2f23677fb244002b00020304",
+ expectedOutput: "t130200_1301_234ea6891581",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S"]
+ }
+ ]
+ },
+ {
+ name: "JA4Server Fingerprint: TLS 1.3 Raw",
+ input: "160303007a020000760303236d214556452c55a0754487e64b1a8b0262c50ba23004c9d504166a6de3439920d0b0099243c9296a0c84153ea4ada7d87ad017f4211c2ea1350b0b3cc5514d5f130100002e00330024001d002099e3cc43a2c9941ae75af1b2c7a629bee3ee7031973cad85c82f2f23677fb244002b00020304",
+ expectedOutput: "t130200_1301_0033,002b",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S Raw"]
+ }
+ ]
+ },
+ {
+ name: "JA4Server Fingerprint: TLS 1.3 non-ascii ALPN",
+ input: "160303007a020000760303897c232e3ee313314f2b662307ff4f7e2cf1caeec1b27711bca77f469519168520bc58b92f865e6b9aa4a6371cadcb0afe1da1c0f705209a11d52357f56d5dd962130100002e00330024001d002076b8b7ed0f96b63a773d85ab6f3a87a151c130529785b41a4defb53184055957002b00020304",
+ expectedOutput: "t130200_1301_234ea6891581",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S"]
+ }
+ ]
+ },
+ {
+ name: "JA4Server Fingerprint: TLS 1.3 non-ascii ALPN Raw",
+ input: "160303007a020000760303897c232e3ee313314f2b662307ff4f7e2cf1caeec1b27711bca77f469519168520bc58b92f865e6b9aa4a6371cadcb0afe1da1c0f705209a11d52357f56d5dd962130100002e00330024001d002076b8b7ed0f96b63a773d85ab6f3a87a151c130529785b41a4defb53184055957002b00020304",
+ expectedOutput: "t130200_1301_0033,002b",
+ recipeConfig: [
+ {
+ "op": "JA4Server Fingerprint",
+ "args": ["Hex", "JA4S Raw"]
+ }
+ ]
+ },
]);