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,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4H HTTP Request Fingerprinting implementation.
|
|
3
|
+
|
|
4
|
+
JA4H fingerprints HTTP requests based on:
|
|
5
|
+
1. HTTP method, version, cookie flag, referer flag, header count, and language
|
|
6
|
+
2. Headers hash in the order they appear
|
|
7
|
+
3. Sorted cookie field names hash
|
|
8
|
+
4. Sorted cookie fields + values hash
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from scapy.all import IP, TCP, Raw
|
|
13
|
+
from ja4plus.utils.http_utils import extract_http_info
|
|
14
|
+
from ja4plus.fingerprinters.base import BaseFingerprinter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JA4HFingerprinter(BaseFingerprinter):
|
|
18
|
+
"""
|
|
19
|
+
JA4H HTTP Fingerprinting implementation.
|
|
20
|
+
|
|
21
|
+
JA4H fingerprints HTTP requests based on method, headers, and cookies.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def process_packet(self, packet):
|
|
25
|
+
"""
|
|
26
|
+
Process a packet and extract JA4H fingerprint if applicable.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
packet: A network packet to analyze
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The extracted fingerprint if successful, None otherwise
|
|
33
|
+
"""
|
|
34
|
+
fingerprint = generate_ja4h(packet)
|
|
35
|
+
if fingerprint:
|
|
36
|
+
self.add_fingerprint(fingerprint, packet)
|
|
37
|
+
return fingerprint
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_ja4h(packet):
|
|
42
|
+
"""
|
|
43
|
+
Generate JA4H fingerprint from HTTP request.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
packet: An HTTP request packet
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
JA4H fingerprint string if successful, None otherwise
|
|
50
|
+
"""
|
|
51
|
+
# Extract HTTP info
|
|
52
|
+
http_info = extract_http_info(packet)
|
|
53
|
+
if not http_info:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Part A: HTTP method, version, cookies, referer, header count, language
|
|
58
|
+
method = http_info.get('method', '').lower()
|
|
59
|
+
version = http_info.get('version', '').replace('HTTP/', '')
|
|
60
|
+
|
|
61
|
+
# Format version (e.g., "1.1" to "11", "2.0" to "20")
|
|
62
|
+
version_str = version.replace('.', '')
|
|
63
|
+
|
|
64
|
+
# Cookie indicator
|
|
65
|
+
has_cookie = 'c' if http_info.get('cookie_fields', []) else 'n'
|
|
66
|
+
|
|
67
|
+
# Referer indicator
|
|
68
|
+
has_referer = 'r' if http_info.get('referer', '') else 'n'
|
|
69
|
+
|
|
70
|
+
# Count of headers (excluding Cookie and Referer) - 2 digit format
|
|
71
|
+
header_count = 0
|
|
72
|
+
for header in http_info.get('headers', []):
|
|
73
|
+
if header.lower() not in ['cookie', 'referer']:
|
|
74
|
+
header_count += 1
|
|
75
|
+
header_count = min(header_count, 99)
|
|
76
|
+
header_count_str = f"{header_count:02d}"
|
|
77
|
+
|
|
78
|
+
# Extract language code per FoxIO spec:
|
|
79
|
+
# Replace hyphens, replace semicolons with commas, lowercase, take first,
|
|
80
|
+
# truncate to 4 chars, pad with zeros to 4 chars
|
|
81
|
+
language = http_info.get('language', '')
|
|
82
|
+
lang_code = '0000'
|
|
83
|
+
if language:
|
|
84
|
+
lang_clean = language.replace('-', '').replace(';', ',').lower().split(',')[0]
|
|
85
|
+
lang_clean = lang_clean[:4]
|
|
86
|
+
lang_code = f"{lang_clean}{'0' * (4 - len(lang_clean))}" if lang_clean else '0000'
|
|
87
|
+
|
|
88
|
+
part_a = f"{method[:2]}{version_str}{has_cookie}{has_referer}{header_count_str}{lang_code}"
|
|
89
|
+
|
|
90
|
+
# Part B: Header names hash - keep original order
|
|
91
|
+
# Per FoxIO spec: exclude pseudo-headers (starting with ':'),
|
|
92
|
+
# Cookie, Referer, and empty headers from the hash
|
|
93
|
+
headers = http_info.get('headers', [])
|
|
94
|
+
filtered_headers = [
|
|
95
|
+
h for h in headers
|
|
96
|
+
if not h.startswith(':')
|
|
97
|
+
and h.lower() != 'cookie'
|
|
98
|
+
and h.lower() != 'referer'
|
|
99
|
+
and h
|
|
100
|
+
]
|
|
101
|
+
headers_str = ','.join(filtered_headers)
|
|
102
|
+
if headers_str:
|
|
103
|
+
part_b = hashlib.sha256(headers_str.encode()).hexdigest()[:12]
|
|
104
|
+
else:
|
|
105
|
+
part_b = '000000000000'
|
|
106
|
+
|
|
107
|
+
# Part C: Cookie field names hash (sorted alphabetically)
|
|
108
|
+
cookie_fields = sorted(http_info.get('cookie_fields', []))
|
|
109
|
+
cookie_fields_str = ','.join(cookie_fields)
|
|
110
|
+
if cookie_fields_str:
|
|
111
|
+
part_c = hashlib.sha256(cookie_fields_str.encode()).hexdigest()[:12]
|
|
112
|
+
else:
|
|
113
|
+
part_c = '000000000000'
|
|
114
|
+
|
|
115
|
+
# Part D: Cookie values hash - per FoxIO spec, hash the full
|
|
116
|
+
# "name=value" cookie strings sorted alphabetically by name
|
|
117
|
+
cookie_dict = http_info.get('cookies', {})
|
|
118
|
+
sorted_cookie_pairs = sorted(cookie_dict.items())
|
|
119
|
+
cookie_values_str = ','.join(f"{k}={v}" for k, v in sorted_cookie_pairs)
|
|
120
|
+
if cookie_values_str:
|
|
121
|
+
part_d = hashlib.sha256(cookie_values_str.encode()).hexdigest()[:12]
|
|
122
|
+
else:
|
|
123
|
+
part_d = '000000000000'
|
|
124
|
+
|
|
125
|
+
# Form the JA4H fingerprint
|
|
126
|
+
ja4h = f"{part_a}_{part_b}_{part_c}_{part_d}"
|
|
127
|
+
|
|
128
|
+
return ja4h
|
|
129
|
+
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4L Light Distance/Location Fingerprinting implementation.
|
|
3
|
+
|
|
4
|
+
JA4L measures the latency between client and server by analyzing
|
|
5
|
+
TCP handshake timing, allowing for physical distance estimation.
|
|
6
|
+
Format: JA4L-C=<latency_us>_<ttl> and JA4L-S=<latency_us>_<ttl>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from scapy.all import IP, TCP, UDP
|
|
11
|
+
from ja4plus.fingerprinters.base import BaseFingerprinter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JA4LFingerprinter(BaseFingerprinter):
|
|
15
|
+
"""
|
|
16
|
+
JA4L Light Distance/Location Fingerprinting implementation.
|
|
17
|
+
|
|
18
|
+
JA4L measures latency between client and server to estimate physical distance.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
"""Initialize the fingerprinter."""
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.connections = {}
|
|
25
|
+
|
|
26
|
+
def process_packet(self, packet):
|
|
27
|
+
"""
|
|
28
|
+
Process a packet and extract JA4L fingerprint if applicable.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
packet: A network packet to analyze
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The extracted fingerprint if successful, None otherwise
|
|
35
|
+
"""
|
|
36
|
+
if IP not in packet or (TCP not in packet and UDP not in packet):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
if TCP in packet:
|
|
40
|
+
proto = 'tcp'
|
|
41
|
+
sport = packet[TCP].sport
|
|
42
|
+
dport = packet[TCP].dport
|
|
43
|
+
else:
|
|
44
|
+
proto = 'udp'
|
|
45
|
+
sport = packet[UDP].sport
|
|
46
|
+
dport = packet[UDP].dport
|
|
47
|
+
|
|
48
|
+
src_ip = packet[IP].src
|
|
49
|
+
dst_ip = packet[IP].dst
|
|
50
|
+
|
|
51
|
+
# Normalize connection key (ordered src/dst)
|
|
52
|
+
if src_ip < dst_ip or (src_ip == dst_ip and sport < dport):
|
|
53
|
+
conn_key = f"{proto}_{src_ip}:{sport}_{dst_ip}:{dport}"
|
|
54
|
+
direction = 'forward'
|
|
55
|
+
else:
|
|
56
|
+
conn_key = f"{proto}_{dst_ip}:{dport}_{src_ip}:{sport}"
|
|
57
|
+
direction = 'reverse'
|
|
58
|
+
|
|
59
|
+
if conn_key not in self.connections:
|
|
60
|
+
self.connections[conn_key] = {
|
|
61
|
+
'proto': proto,
|
|
62
|
+
'direction': direction,
|
|
63
|
+
'conn_key': conn_key,
|
|
64
|
+
'timestamps': {},
|
|
65
|
+
'ttls': {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
conn = self.connections[conn_key]
|
|
69
|
+
fingerprint = generate_ja4l(packet, conn)
|
|
70
|
+
|
|
71
|
+
if fingerprint:
|
|
72
|
+
self.fingerprints.append({
|
|
73
|
+
'fingerprint': fingerprint,
|
|
74
|
+
'packet': packet,
|
|
75
|
+
'connection': conn_key
|
|
76
|
+
})
|
|
77
|
+
return fingerprint
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def reset(self):
|
|
82
|
+
"""Reset all fingerprints and connection tracking."""
|
|
83
|
+
super().reset()
|
|
84
|
+
self.connections = {}
|
|
85
|
+
|
|
86
|
+
def calculate_distance(self, latency_us, propagation_factor=1.6):
|
|
87
|
+
"""
|
|
88
|
+
Calculate the physical distance based on JA4L latency.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
latency_us: One-way latency in microseconds
|
|
92
|
+
propagation_factor: Propagation delay factor (default: 1.6)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Distance in miles
|
|
96
|
+
"""
|
|
97
|
+
# Speed of light per us in fiber (miles/us)
|
|
98
|
+
speed_of_light = 0.128
|
|
99
|
+
distance = (latency_us * speed_of_light) / propagation_factor
|
|
100
|
+
return distance
|
|
101
|
+
|
|
102
|
+
def calculate_distance_km(self, latency_us, propagation_factor=1.6):
|
|
103
|
+
"""
|
|
104
|
+
Calculate the physical distance in kilometers.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
latency_us: One-way latency in microseconds
|
|
108
|
+
propagation_factor: Propagation delay factor (default: 1.6)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Distance in kilometers
|
|
112
|
+
"""
|
|
113
|
+
speed_of_light_km = 0.206 # km/us in fiber
|
|
114
|
+
distance = (latency_us * speed_of_light_km) / propagation_factor
|
|
115
|
+
return distance
|
|
116
|
+
|
|
117
|
+
def estimate_os(self, ttl):
|
|
118
|
+
"""
|
|
119
|
+
Estimate the operating system based on TTL value.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
ttl: Observed TTL value
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
String indicating likely OS or device type
|
|
126
|
+
"""
|
|
127
|
+
if ttl <= 64:
|
|
128
|
+
return "Mac, Linux, Phone, or IoT device (initial TTL: 64)"
|
|
129
|
+
elif ttl <= 128:
|
|
130
|
+
return "Windows (initial TTL: 128)"
|
|
131
|
+
else:
|
|
132
|
+
return "Cisco, F5, or Networking Device (initial TTL: 255)"
|
|
133
|
+
|
|
134
|
+
def estimate_hop_count(self, ttl):
|
|
135
|
+
"""
|
|
136
|
+
Estimate the hop count based on TTL value.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
ttl: Observed TTL value
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Estimated hop count
|
|
143
|
+
"""
|
|
144
|
+
if ttl <= 64:
|
|
145
|
+
return 64 - ttl
|
|
146
|
+
elif ttl <= 128:
|
|
147
|
+
return 128 - ttl
|
|
148
|
+
else:
|
|
149
|
+
return 255 - ttl
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def generate_ja4l(packet, conn=None):
|
|
153
|
+
"""
|
|
154
|
+
Generate JA4L latency fingerprint based on packet timing.
|
|
155
|
+
|
|
156
|
+
Uses the TCP 3-way handshake or QUIC handshake to measure one-way latency.
|
|
157
|
+
Time is measured in microseconds.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
packet: A network packet
|
|
161
|
+
conn: Connection tracking object with timestamps dict
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
JA4L fingerprint string if successful, None otherwise
|
|
165
|
+
"""
|
|
166
|
+
if not conn:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if not packet.haslayer(IP):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
if 'timestamps' not in conn:
|
|
174
|
+
conn['timestamps'] = {}
|
|
175
|
+
if 'ttls' not in conn:
|
|
176
|
+
conn['ttls'] = {}
|
|
177
|
+
|
|
178
|
+
ttl = packet[IP].ttl
|
|
179
|
+
|
|
180
|
+
# Use pcap timestamp if available (for offline analysis), else wall clock
|
|
181
|
+
current_time = float(packet.time) if hasattr(packet, 'time') else time.time()
|
|
182
|
+
|
|
183
|
+
# Handle TCP protocol
|
|
184
|
+
if packet.haslayer(TCP):
|
|
185
|
+
tcp_flags = packet[TCP].flags
|
|
186
|
+
|
|
187
|
+
# SYN packet (point A) - client initiates
|
|
188
|
+
if tcp_flags & 0x02 == 0x02 and not tcp_flags & 0x10:
|
|
189
|
+
conn['timestamps']['A'] = current_time
|
|
190
|
+
conn['ttls']['client'] = ttl
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# SYN-ACK packet (point B) - server responds
|
|
194
|
+
elif tcp_flags & 0x12 == 0x12:
|
|
195
|
+
conn['timestamps']['B'] = current_time
|
|
196
|
+
conn['ttls']['server'] = ttl
|
|
197
|
+
|
|
198
|
+
if 'A' in conn['timestamps']:
|
|
199
|
+
# Per FoxIO spec: use raw time difference (not divided by 2)
|
|
200
|
+
diff = conn['timestamps']['B'] - conn['timestamps']['A']
|
|
201
|
+
latency = max(1, int(diff * 1000000))
|
|
202
|
+
return f"JA4L-S={latency}_{ttl}"
|
|
203
|
+
|
|
204
|
+
# ACK packet (point C) - client completes handshake
|
|
205
|
+
elif tcp_flags & 0x10 == 0x10 and not tcp_flags & 0x02:
|
|
206
|
+
conn['timestamps']['C'] = current_time
|
|
207
|
+
|
|
208
|
+
if 'B' in conn['timestamps']:
|
|
209
|
+
diff = conn['timestamps']['C'] - conn['timestamps']['B']
|
|
210
|
+
latency = max(1, int(diff * 1000000))
|
|
211
|
+
return f"JA4L-C={latency}_{ttl}"
|
|
212
|
+
|
|
213
|
+
# Handle QUIC (UDP) protocol
|
|
214
|
+
elif packet.haslayer(UDP) and conn.get('proto') == 'udp':
|
|
215
|
+
is_client = _src_is_client(packet, conn)
|
|
216
|
+
|
|
217
|
+
if 'A' not in conn['timestamps'] and is_client:
|
|
218
|
+
conn['timestamps']['A'] = current_time
|
|
219
|
+
conn['ttls']['client'] = ttl
|
|
220
|
+
|
|
221
|
+
elif 'A' in conn['timestamps'] and not is_client and 'B' not in conn['timestamps']:
|
|
222
|
+
conn['timestamps']['B'] = current_time
|
|
223
|
+
conn['ttls']['server'] = ttl
|
|
224
|
+
diff = conn['timestamps']['B'] - conn['timestamps']['A']
|
|
225
|
+
latency = max(1, int(diff * 1000000))
|
|
226
|
+
return f"JA4L-S={latency}_{ttl}"
|
|
227
|
+
|
|
228
|
+
elif 'B' in conn['timestamps'] and is_client and 'C' not in conn['timestamps']:
|
|
229
|
+
conn['timestamps']['C'] = current_time
|
|
230
|
+
|
|
231
|
+
elif 'C' in conn['timestamps'] and not is_client and 'D' not in conn['timestamps']:
|
|
232
|
+
conn['timestamps']['D'] = current_time
|
|
233
|
+
diff = conn['timestamps']['D'] - conn['timestamps']['C']
|
|
234
|
+
latency = max(1, int(diff * 1000000))
|
|
235
|
+
return f"JA4L-C={latency}_{conn['ttls'].get('client', ttl)}"
|
|
236
|
+
|
|
237
|
+
return None
|
|
238
|
+
except Exception:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _src_is_client(packet, conn):
|
|
243
|
+
"""
|
|
244
|
+
Determine if the source of the packet is the client side.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
packet: The packet to analyze
|
|
248
|
+
conn: Connection tracking information
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
True if the source is the client, False otherwise
|
|
252
|
+
"""
|
|
253
|
+
if not packet.haslayer(IP):
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
src_ip = packet[IP].src
|
|
257
|
+
|
|
258
|
+
if conn.get('direction') == 'forward':
|
|
259
|
+
conn_key = conn.get('conn_key', '')
|
|
260
|
+
parts = conn_key.split('_')
|
|
261
|
+
if len(parts) >= 2:
|
|
262
|
+
client_part = parts[1].split(':')[0]
|
|
263
|
+
return src_ip == client_part
|
|
264
|
+
|
|
265
|
+
return False
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4S TLS Server Hello Fingerprinting implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from scapy.all import IP, TCP, Raw
|
|
7
|
+
from ja4plus.utils.tls_utils import extract_tls_info, is_grease_value
|
|
8
|
+
from ja4plus.fingerprinters.base import BaseFingerprinter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JA4SFingerprinter(BaseFingerprinter):
|
|
12
|
+
"""
|
|
13
|
+
JA4S TLS Server Hello Fingerprinting implementation.
|
|
14
|
+
|
|
15
|
+
JA4S fingerprints server responses in TLS handshakes.
|
|
16
|
+
Format: <proto><version><ext_count><alpn>_<cipher>_<ext_hash>
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def process_packet(self, packet):
|
|
20
|
+
"""
|
|
21
|
+
Process a packet and extract JA4S fingerprint if applicable.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
packet: A network packet to analyze
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The extracted fingerprint if successful, None otherwise
|
|
28
|
+
"""
|
|
29
|
+
fingerprint = generate_ja4s(packet)
|
|
30
|
+
if fingerprint:
|
|
31
|
+
self.add_fingerprint(fingerprint, packet)
|
|
32
|
+
return fingerprint
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_ja4s(packet):
|
|
37
|
+
"""
|
|
38
|
+
Generate a JA4S fingerprint from a packet.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
packet: A packet containing a TLS Server Hello message
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A JA4S fingerprint string or None if not applicable
|
|
45
|
+
"""
|
|
46
|
+
# Extract TLS info
|
|
47
|
+
tls_info = extract_tls_info(packet)
|
|
48
|
+
if not tls_info or tls_info.get('handshake_type') != 'server_hello':
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Protocol indicator (t for TCP/TLS, q for QUIC, d for DTLS)
|
|
53
|
+
proto = 'q' if tls_info.get('is_quic') else 'd' if tls_info.get('is_dtls') else 't'
|
|
54
|
+
|
|
55
|
+
# Get TLS version - check supported_versions first (for TLS 1.3)
|
|
56
|
+
version = tls_info.get('version')
|
|
57
|
+
supported_versions = tls_info.get('supported_versions', [])
|
|
58
|
+
if supported_versions:
|
|
59
|
+
non_grease = [v for v in supported_versions if not is_grease_value(v)]
|
|
60
|
+
if non_grease:
|
|
61
|
+
version = non_grease[0]
|
|
62
|
+
|
|
63
|
+
version_str = _version_to_str(version)
|
|
64
|
+
|
|
65
|
+
# Get extensions - JA4S INCLUDES GREASE values per FoxIO spec
|
|
66
|
+
# (unlike JA4 client which excludes them)
|
|
67
|
+
extensions = tls_info.get('extensions', [])
|
|
68
|
+
ext_count = f"{min(len(extensions), 99):02d}"
|
|
69
|
+
|
|
70
|
+
# Extract ALPN - use first and last character of first protocol
|
|
71
|
+
alpn_protocols = tls_info.get('alpn_protocols', [])
|
|
72
|
+
if not alpn_protocols:
|
|
73
|
+
# Check extension_data for backward compatibility
|
|
74
|
+
for ext_id, ext_data in tls_info.get('extension_data', {}).items():
|
|
75
|
+
if ext_id == 0x0010 and 'protocols' in ext_data and ext_data['protocols']:
|
|
76
|
+
alpn_protocols = ext_data['protocols']
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
alpn_value = _get_alpn_value(alpn_protocols)
|
|
80
|
+
|
|
81
|
+
# Form part_a of the fingerprint
|
|
82
|
+
part_a = f"{proto}{version_str}{ext_count}{alpn_value}"
|
|
83
|
+
|
|
84
|
+
# Get selected cipher
|
|
85
|
+
cipher = tls_info.get('cipher')
|
|
86
|
+
if cipher is None:
|
|
87
|
+
return None
|
|
88
|
+
cipher_str = f"{cipher:04x}"
|
|
89
|
+
|
|
90
|
+
# Hash the extensions as comma-separated hex values
|
|
91
|
+
# JA4S extensions are NOT sorted - they maintain original order per spec
|
|
92
|
+
# JA4S INCLUDES GREASE values in the hash per FoxIO spec
|
|
93
|
+
if extensions:
|
|
94
|
+
ext_str = ','.join([f"{e:04x}" for e in extensions])
|
|
95
|
+
extensions_hash = hashlib.sha256(ext_str.encode()).hexdigest()[:12]
|
|
96
|
+
else:
|
|
97
|
+
extensions_hash = '000000000000'
|
|
98
|
+
|
|
99
|
+
# Form the complete JA4S fingerprint
|
|
100
|
+
ja4s = f"{part_a}_{cipher_str}_{extensions_hash}"
|
|
101
|
+
|
|
102
|
+
return ja4s
|
|
103
|
+
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _version_to_str(version):
|
|
109
|
+
"""Convert TLS version number to JA4 version string."""
|
|
110
|
+
version_map = {
|
|
111
|
+
0x0304: '13', # TLS 1.3
|
|
112
|
+
0x0303: '12', # TLS 1.2
|
|
113
|
+
0x0302: '11', # TLS 1.1
|
|
114
|
+
0x0301: '10', # TLS 1.0
|
|
115
|
+
0x0300: 's3', # SSL 3.0
|
|
116
|
+
0x0200: 's2', # SSL 2.0
|
|
117
|
+
0xfeff: 'd1', # DTLS 1.0
|
|
118
|
+
0xfefd: 'd2', # DTLS 1.2
|
|
119
|
+
0xfefc: 'd3', # DTLS 1.3
|
|
120
|
+
}
|
|
121
|
+
return version_map.get(version, '00')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_alpn_value(alpn_protocols):
|
|
125
|
+
"""
|
|
126
|
+
Extract ALPN value for JA4S fingerprint.
|
|
127
|
+
Per FoxIO spec: first and last char of first protocol.
|
|
128
|
+
Non-ASCII (ord > 127) -> '99'.
|
|
129
|
+
"""
|
|
130
|
+
if not alpn_protocols:
|
|
131
|
+
return '00'
|
|
132
|
+
|
|
133
|
+
first_alpn = alpn_protocols[0]
|
|
134
|
+
if not first_alpn:
|
|
135
|
+
return '00'
|
|
136
|
+
|
|
137
|
+
# FoxIO spec: if first char is non-ASCII, use '99'
|
|
138
|
+
if ord(first_alpn[0]) > 127:
|
|
139
|
+
return '99'
|
|
140
|
+
|
|
141
|
+
if len(first_alpn) == 1:
|
|
142
|
+
return first_alpn[0] + first_alpn[0]
|
|
143
|
+
|
|
144
|
+
return f"{first_alpn[0]}{first_alpn[-1]}"
|