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.
@@ -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]}"