ja4plus 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ja4plus/__init__.py +30 -0
- ja4plus/collector.py +194 -0
- ja4plus/fingerprinters/__init__.py +23 -0
- ja4plus/fingerprinters/base.py +44 -0
- ja4plus/fingerprinters/ja4.py +284 -0
- ja4plus/fingerprinters/ja4h.py +131 -0
- ja4plus/fingerprinters/ja4l.py +265 -0
- ja4plus/fingerprinters/ja4s.py +144 -0
- ja4plus/fingerprinters/ja4ssh.py +402 -0
- ja4plus/fingerprinters/ja4t.py +88 -0
- ja4plus/fingerprinters/ja4ts.py +90 -0
- ja4plus/fingerprinters/ja4x.py +300 -0
- ja4plus/utils/__init__.py +3 -0
- ja4plus/utils/http_utils.py +181 -0
- ja4plus/utils/ssh_utils.py +221 -0
- ja4plus/utils/tls_utils.py +387 -0
- ja4plus/utils/x509_utils.py +352 -0
- ja4plus-0.2.0.dist-info/METADATA +178 -0
- ja4plus-0.2.0.dist-info/RECORD +22 -0
- ja4plus-0.2.0.dist-info/WHEEL +5 -0
- ja4plus-0.2.0.dist-info/licenses/LICENSE +28 -0
- ja4plus-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced TLS utility functions for JA4+ fingerprinting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from scapy.all import Raw
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_tls_info(packet):
|
|
10
|
+
"""
|
|
11
|
+
Extract TLS information from a packet.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
packet: A network packet
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Dictionary with TLS handshake information or None if not applicable
|
|
18
|
+
"""
|
|
19
|
+
# Special handling for test packets with embedded TLS info
|
|
20
|
+
if hasattr(packet, 'tls_info'):
|
|
21
|
+
return packet.tls_info
|
|
22
|
+
|
|
23
|
+
if Raw not in packet:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
raw_data = bytes(packet[Raw])
|
|
28
|
+
return parse_tls_handshake(raw_data)
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_tls_handshake(raw_data):
|
|
34
|
+
"""
|
|
35
|
+
Parse a TLS handshake message from raw bytes.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
raw_data: Raw bytes of a TLS record
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dictionary with TLS handshake information or None
|
|
42
|
+
"""
|
|
43
|
+
# Check minimum length for TLS record header
|
|
44
|
+
if len(raw_data) < 5:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
record_type = raw_data[0]
|
|
48
|
+
if record_type != 0x16: # 0x16 = Handshake
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Get TLS record version and length
|
|
52
|
+
record_length = (raw_data[3] << 8) | raw_data[4]
|
|
53
|
+
|
|
54
|
+
# Ensure we have enough data
|
|
55
|
+
if len(raw_data) < 5 + record_length:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# Extract handshake type
|
|
59
|
+
if len(raw_data) < 6:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
handshake_type = raw_data[5]
|
|
63
|
+
|
|
64
|
+
if handshake_type == 1:
|
|
65
|
+
return _parse_client_hello(raw_data)
|
|
66
|
+
elif handshake_type == 2:
|
|
67
|
+
return _parse_server_hello(raw_data)
|
|
68
|
+
else:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_client_hello(raw_data):
|
|
73
|
+
"""Parse a TLS ClientHello message."""
|
|
74
|
+
if len(raw_data) < 11:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# ClientHello version is at offset 9-10 (after record header + handshake header)
|
|
78
|
+
version = (raw_data[9] << 8) | raw_data[10]
|
|
79
|
+
|
|
80
|
+
tls_info = {
|
|
81
|
+
'handshake_type': 'client_hello',
|
|
82
|
+
'type': 'client_hello',
|
|
83
|
+
'version': version,
|
|
84
|
+
'is_quic': False,
|
|
85
|
+
'is_dtls': False,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Skip past record header (5) + handshake header (4) + version (2) + random (32)
|
|
89
|
+
pos = 11 + 32
|
|
90
|
+
|
|
91
|
+
# Session ID
|
|
92
|
+
if pos + 1 > len(raw_data):
|
|
93
|
+
return tls_info
|
|
94
|
+
session_id_len = raw_data[pos]
|
|
95
|
+
pos += 1 + session_id_len
|
|
96
|
+
|
|
97
|
+
# Cipher suites
|
|
98
|
+
if pos + 2 > len(raw_data):
|
|
99
|
+
return tls_info
|
|
100
|
+
cipher_suites_len = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
101
|
+
pos += 2
|
|
102
|
+
|
|
103
|
+
ciphers = []
|
|
104
|
+
for i in range(0, cipher_suites_len, 2):
|
|
105
|
+
if pos + i + 2 > len(raw_data):
|
|
106
|
+
break
|
|
107
|
+
cipher = (raw_data[pos + i] << 8) | raw_data[pos + i + 1]
|
|
108
|
+
ciphers.append(cipher)
|
|
109
|
+
|
|
110
|
+
tls_info['ciphers'] = ciphers
|
|
111
|
+
pos += cipher_suites_len
|
|
112
|
+
|
|
113
|
+
# Compression methods
|
|
114
|
+
if pos + 1 > len(raw_data):
|
|
115
|
+
return tls_info
|
|
116
|
+
compression_len = raw_data[pos]
|
|
117
|
+
pos += 1 + compression_len
|
|
118
|
+
|
|
119
|
+
# Parse extensions
|
|
120
|
+
extensions = []
|
|
121
|
+
extension_data = {}
|
|
122
|
+
supported_versions = []
|
|
123
|
+
alpn_protocols = []
|
|
124
|
+
signature_algorithms = []
|
|
125
|
+
sni = None
|
|
126
|
+
|
|
127
|
+
if pos + 2 <= len(raw_data):
|
|
128
|
+
extensions_len = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
129
|
+
pos += 2
|
|
130
|
+
extensions_end = min(pos + extensions_len, len(raw_data))
|
|
131
|
+
|
|
132
|
+
while pos + 4 <= extensions_end:
|
|
133
|
+
ext_type = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
134
|
+
ext_len = (raw_data[pos + 2] << 8) | raw_data[pos + 3]
|
|
135
|
+
ext_data_start = pos + 4
|
|
136
|
+
ext_data_end = min(ext_data_start + ext_len, len(raw_data))
|
|
137
|
+
|
|
138
|
+
extensions.append(ext_type)
|
|
139
|
+
|
|
140
|
+
# Parse SNI (0x0000)
|
|
141
|
+
if ext_type == 0x0000:
|
|
142
|
+
sni = _parse_sni(raw_data[ext_data_start:ext_data_end])
|
|
143
|
+
|
|
144
|
+
# Parse supported_versions (0x002b)
|
|
145
|
+
elif ext_type == 0x002b:
|
|
146
|
+
supported_versions = _parse_supported_versions_client(
|
|
147
|
+
raw_data[ext_data_start:ext_data_end]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Parse ALPN (0x0010)
|
|
151
|
+
elif ext_type == 0x0010:
|
|
152
|
+
alpn_protocols = _parse_alpn(raw_data[ext_data_start:ext_data_end])
|
|
153
|
+
|
|
154
|
+
# Parse signature_algorithms (0x000d)
|
|
155
|
+
elif ext_type == 0x000d:
|
|
156
|
+
signature_algorithms = _parse_signature_algorithms(
|
|
157
|
+
raw_data[ext_data_start:ext_data_end]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
pos = ext_data_start + ext_len
|
|
161
|
+
|
|
162
|
+
tls_info['extensions'] = extensions
|
|
163
|
+
tls_info['extension_data'] = extension_data
|
|
164
|
+
tls_info['supported_versions'] = supported_versions
|
|
165
|
+
tls_info['alpn_protocols'] = alpn_protocols
|
|
166
|
+
tls_info['signature_algorithms'] = signature_algorithms
|
|
167
|
+
if sni is not None:
|
|
168
|
+
tls_info['sni'] = sni
|
|
169
|
+
|
|
170
|
+
return tls_info
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_server_hello(raw_data):
|
|
174
|
+
"""Parse a TLS ServerHello message."""
|
|
175
|
+
if len(raw_data) < 11:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# ServerHello version at offset 9-10
|
|
179
|
+
version = (raw_data[9] << 8) | raw_data[10]
|
|
180
|
+
|
|
181
|
+
tls_info = {
|
|
182
|
+
'handshake_type': 'server_hello',
|
|
183
|
+
'type': 'server_hello',
|
|
184
|
+
'version': version,
|
|
185
|
+
'is_quic': False,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Skip past record header (5) + handshake header (4) + version (2) + random (32)
|
|
189
|
+
pos = 11 + 32
|
|
190
|
+
|
|
191
|
+
# Session ID
|
|
192
|
+
if pos + 1 > len(raw_data):
|
|
193
|
+
return tls_info
|
|
194
|
+
session_id_len = raw_data[pos]
|
|
195
|
+
pos += 1 + session_id_len
|
|
196
|
+
|
|
197
|
+
# Cipher suite (single cipher for ServerHello)
|
|
198
|
+
if pos + 2 > len(raw_data):
|
|
199
|
+
return tls_info
|
|
200
|
+
cipher = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
201
|
+
tls_info['cipher'] = cipher
|
|
202
|
+
pos += 2
|
|
203
|
+
|
|
204
|
+
# Compression method
|
|
205
|
+
if pos + 1 > len(raw_data):
|
|
206
|
+
return tls_info
|
|
207
|
+
pos += 1
|
|
208
|
+
|
|
209
|
+
# Parse extensions
|
|
210
|
+
extensions = []
|
|
211
|
+
extension_data = {}
|
|
212
|
+
alpn_protocols = []
|
|
213
|
+
supported_versions = []
|
|
214
|
+
|
|
215
|
+
if pos + 2 <= len(raw_data):
|
|
216
|
+
extensions_len = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
217
|
+
pos += 2
|
|
218
|
+
extensions_end = min(pos + extensions_len, len(raw_data))
|
|
219
|
+
|
|
220
|
+
while pos + 4 <= extensions_end:
|
|
221
|
+
ext_type = (raw_data[pos] << 8) | raw_data[pos + 1]
|
|
222
|
+
ext_len = (raw_data[pos + 2] << 8) | raw_data[pos + 3]
|
|
223
|
+
ext_data_start = pos + 4
|
|
224
|
+
ext_data_end = min(ext_data_start + ext_len, len(raw_data))
|
|
225
|
+
|
|
226
|
+
extensions.append(ext_type)
|
|
227
|
+
|
|
228
|
+
# Parse ALPN (0x0010)
|
|
229
|
+
if ext_type == 0x0010:
|
|
230
|
+
alpn_protocols = _parse_alpn(raw_data[ext_data_start:ext_data_end])
|
|
231
|
+
extension_data[0x0010] = {'protocols': alpn_protocols}
|
|
232
|
+
|
|
233
|
+
# Parse supported_versions (0x002b) - server selects one version
|
|
234
|
+
elif ext_type == 0x002b:
|
|
235
|
+
if ext_len >= 2:
|
|
236
|
+
sv = (raw_data[ext_data_start] << 8) | raw_data[ext_data_start + 1]
|
|
237
|
+
supported_versions = [sv]
|
|
238
|
+
|
|
239
|
+
pos = ext_data_start + ext_len
|
|
240
|
+
|
|
241
|
+
tls_info['extensions'] = extensions
|
|
242
|
+
tls_info['extension_data'] = extension_data
|
|
243
|
+
tls_info['alpn_protocols'] = alpn_protocols
|
|
244
|
+
tls_info['supported_versions'] = supported_versions
|
|
245
|
+
|
|
246
|
+
# If supported_versions indicates TLS 1.3, update the version
|
|
247
|
+
if supported_versions:
|
|
248
|
+
non_grease = [v for v in supported_versions if not is_grease_value(v)]
|
|
249
|
+
if non_grease:
|
|
250
|
+
tls_info['version'] = non_grease[0]
|
|
251
|
+
|
|
252
|
+
return tls_info
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_sni(data):
|
|
256
|
+
"""Parse Server Name Indication extension data."""
|
|
257
|
+
if len(data) < 5:
|
|
258
|
+
return True # Extension exists but can't parse hostname
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
# SNI list length (2 bytes)
|
|
262
|
+
sni_list_len = (data[0] << 8) | data[1]
|
|
263
|
+
pos = 2
|
|
264
|
+
|
|
265
|
+
if pos + 3 > len(data):
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
# SNI type (1 byte) - 0 = hostname
|
|
269
|
+
sni_type = data[pos]
|
|
270
|
+
pos += 1
|
|
271
|
+
|
|
272
|
+
# Hostname length (2 bytes)
|
|
273
|
+
hostname_len = (data[pos] << 8) | data[pos + 1]
|
|
274
|
+
pos += 2
|
|
275
|
+
|
|
276
|
+
if sni_type == 0 and pos + hostname_len <= len(data):
|
|
277
|
+
hostname = data[pos:pos + hostname_len].decode('ascii', errors='ignore')
|
|
278
|
+
return hostname if hostname else True
|
|
279
|
+
|
|
280
|
+
return True
|
|
281
|
+
except Exception:
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_supported_versions_client(data):
|
|
286
|
+
"""Parse supported_versions extension from ClientHello."""
|
|
287
|
+
versions = []
|
|
288
|
+
if len(data) < 1:
|
|
289
|
+
return versions
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
# First byte is the length of the version list
|
|
293
|
+
list_len = data[0]
|
|
294
|
+
pos = 1
|
|
295
|
+
|
|
296
|
+
while pos + 2 <= min(1 + list_len, len(data)):
|
|
297
|
+
ver = (data[pos] << 8) | data[pos + 1]
|
|
298
|
+
versions.append(ver)
|
|
299
|
+
pos += 2
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
return versions
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_alpn(data):
|
|
307
|
+
"""Parse Application-Layer Protocol Negotiation extension data."""
|
|
308
|
+
protocols = []
|
|
309
|
+
if len(data) < 2:
|
|
310
|
+
return protocols
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# ALPN list length (2 bytes)
|
|
314
|
+
alpn_list_len = (data[0] << 8) | data[1]
|
|
315
|
+
pos = 2
|
|
316
|
+
|
|
317
|
+
while pos < min(2 + alpn_list_len, len(data)):
|
|
318
|
+
if pos + 1 > len(data):
|
|
319
|
+
break
|
|
320
|
+
proto_len = data[pos]
|
|
321
|
+
pos += 1
|
|
322
|
+
|
|
323
|
+
if pos + proto_len > len(data):
|
|
324
|
+
break
|
|
325
|
+
protocol = data[pos:pos + proto_len].decode('ascii', errors='ignore')
|
|
326
|
+
protocols.append(protocol)
|
|
327
|
+
pos += proto_len
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
return protocols
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _parse_signature_algorithms(data):
|
|
335
|
+
"""Parse signature_algorithms extension data."""
|
|
336
|
+
algorithms = []
|
|
337
|
+
if len(data) < 2:
|
|
338
|
+
return algorithms
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Signature algorithms list length (2 bytes)
|
|
342
|
+
list_len = (data[0] << 8) | data[1]
|
|
343
|
+
pos = 2
|
|
344
|
+
|
|
345
|
+
while pos + 2 <= min(2 + list_len, len(data)):
|
|
346
|
+
alg = (data[pos] << 8) | data[pos + 1]
|
|
347
|
+
algorithms.append(alg)
|
|
348
|
+
pos += 2
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
return algorithms
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def is_grease_value(value):
|
|
356
|
+
"""
|
|
357
|
+
Check if a value is a TLS GREASE value.
|
|
358
|
+
|
|
359
|
+
GREASE values match the pattern 0x?A?A where ? is the same nibble.
|
|
360
|
+
The canonical check is: (value & 0x0F0F) == 0x0A0A and high byte == low byte.
|
|
361
|
+
|
|
362
|
+
Known GREASE values: 0x0A0A, 0x1A1A, 0x2A2A, 0x3A3A, 0x4A4A, 0x5A5A,
|
|
363
|
+
0x6A6A, 0x7A7A, 0x8A8A, 0x9A9A, 0xAAAA, 0xBABA,
|
|
364
|
+
0xCACA, 0xDADA, 0xEAEA, 0xFAFA
|
|
365
|
+
"""
|
|
366
|
+
if not value and value != 0:
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
if isinstance(value, str):
|
|
371
|
+
int_val = int(value, 16)
|
|
372
|
+
elif isinstance(value, int):
|
|
373
|
+
int_val = value
|
|
374
|
+
else:
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
return (int_val & 0x0F0F) == 0x0A0A and ((int_val >> 8) & 0xFF) == (int_val & 0xFF)
|
|
378
|
+
except (ValueError, TypeError):
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def find_tls_extension(extensions, extension_type):
|
|
383
|
+
"""Find a specific TLS extension by type."""
|
|
384
|
+
for ext in extensions:
|
|
385
|
+
if hasattr(ext, 'type') and ext.type == extension_type:
|
|
386
|
+
return ext
|
|
387
|
+
return None
|