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 ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ JA4+ - Network Fingerprinting Library
3
+
4
+ A Python implementation of the JA4+ network fingerprinting methods
5
+ created by FoxIO LLC. Supports TLS, TCP, HTTP, SSH, and X.509
6
+ fingerprinting for network security monitoring and traffic analysis.
7
+ """
8
+
9
+ from ja4plus.fingerprinters.ja4 import JA4Fingerprinter
10
+ from ja4plus.fingerprinters.ja4s import JA4SFingerprinter
11
+ from ja4plus.fingerprinters.ja4h import JA4HFingerprinter
12
+ from ja4plus.fingerprinters.ja4l import JA4LFingerprinter
13
+ from ja4plus.fingerprinters.ja4x import JA4XFingerprinter
14
+ from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
15
+ from ja4plus.fingerprinters.ja4t import JA4TFingerprinter
16
+ from ja4plus.fingerprinters.ja4ts import JA4TSFingerprinter
17
+
18
+ # Function-based API
19
+ from ja4plus.fingerprinters.ja4 import generate_ja4
20
+ from ja4plus.fingerprinters.ja4s import generate_ja4s
21
+ from ja4plus.fingerprinters.ja4h import generate_ja4h
22
+ from ja4plus.fingerprinters.ja4l import generate_ja4l
23
+ from ja4plus.fingerprinters.ja4x import generate_ja4x
24
+ from ja4plus.fingerprinters.ja4ssh import generate_ja4ssh
25
+ from ja4plus.fingerprinters.ja4t import generate_ja4t
26
+ from ja4plus.fingerprinters.ja4ts import generate_ja4ts
27
+
28
+ __version__ = "0.2.0"
29
+ __author__ = "ja4plus contributors"
30
+ __license__ = "BSD-3-Clause"
ja4plus/collector.py ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ JA4+ collector for Elastic Agent integration.
4
+ Captures network traffic and outputs JA4+ fingerprints as JSON.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ import time
11
+ import signal
12
+ from scapy.all import sniff
13
+
14
+ from ja4plus.fingerprinters.ja4 import JA4Fingerprinter
15
+ from ja4plus.fingerprinters.ja4s import JA4SFingerprinter
16
+ from ja4plus.fingerprinters.ja4h import JA4HFingerprinter
17
+ from ja4plus.fingerprinters.ja4t import JA4TFingerprinter
18
+ from ja4plus.fingerprinters.ja4ts import JA4TSFingerprinter
19
+ from ja4plus.fingerprinters.ja4x import JA4XFingerprinter
20
+ from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
21
+
22
+ # Global variables for signal handling
23
+ packet_count = 0
24
+ max_packets = 0
25
+ start_time = 0
26
+ timeout_seconds = 0
27
+
28
+ def signal_handler(signum, frame):
29
+ """Handle termination signals gracefully"""
30
+ sys.stderr.write("Received signal, shutting down gracefully...\n")
31
+ sys.exit(0)
32
+
33
+ def main():
34
+ """Main collector function"""
35
+ global packet_count, max_packets, start_time, timeout_seconds
36
+
37
+ parser = argparse.ArgumentParser(description="JA4+ Network Fingerprinting Collector")
38
+ parser.add_argument("--interface", "-i", default="any", help="Network interface to capture on")
39
+ parser.add_argument("--filter", "-f", default="tcp or udp", help="BPF filter expression")
40
+ parser.add_argument("--fingerprinters", default="ja4,ja4s,ja4h,ja4t,ja4ts,ja4x,ja4ssh",
41
+ help="Comma-separated list of fingerprinters to enable")
42
+ parser.add_argument("--max-packets", type=int, default=0,
43
+ help="Maximum packets to process (0 = unlimited)")
44
+ parser.add_argument("--timeout", type=float, default=0,
45
+ help="Timeout in seconds (0 = no timeout)")
46
+ parser.add_argument("--output", choices=["json", "text"], default="json",
47
+ help="Output format")
48
+
49
+ args = parser.parse_args()
50
+
51
+ # Set up signal handlers
52
+ signal.signal(signal.SIGINT, signal_handler)
53
+ signal.signal(signal.SIGTERM, signal_handler)
54
+
55
+ # Initialize global variables
56
+ max_packets = args.max_packets
57
+ timeout_seconds = args.timeout
58
+ start_time = time.time()
59
+
60
+ # Initialize enabled fingerprinters
61
+ available_fingerprinters = {
62
+ "ja4": JA4Fingerprinter(),
63
+ "ja4s": JA4SFingerprinter(),
64
+ "ja4h": JA4HFingerprinter(),
65
+ "ja4t": JA4TFingerprinter(),
66
+ "ja4ts": JA4TSFingerprinter(),
67
+ "ja4x": JA4XFingerprinter(),
68
+ "ja4ssh": JA4SSHFingerprinter()
69
+ }
70
+
71
+ enabled_fps = args.fingerprinters.split(",")
72
+ fingerprinters = {}
73
+ for name in enabled_fps:
74
+ name = name.strip()
75
+ if name in available_fingerprinters:
76
+ fingerprinters[name] = available_fingerprinters[name]
77
+ else:
78
+ sys.stderr.write(f"Warning: Unknown fingerprinter '{name}'\n")
79
+
80
+ if not fingerprinters:
81
+ sys.stderr.write("Error: No valid fingerprinters enabled\n")
82
+ sys.exit(1)
83
+
84
+ def process_packet(packet):
85
+ """Process packet with enabled fingerprinters and output results"""
86
+ global packet_count
87
+
88
+ # Check packet limit
89
+ if max_packets > 0 and packet_count >= max_packets:
90
+ return
91
+
92
+ # Check timeout
93
+ if timeout_seconds > 0 and (time.time() - start_time) >= timeout_seconds:
94
+ return
95
+
96
+ packet_count += 1
97
+
98
+ results = {
99
+ "@timestamp": int(time.time() * 1000), # Elasticsearch expects milliseconds
100
+ "ja4plus": {}
101
+ }
102
+
103
+ # Apply each enabled fingerprinter
104
+ for name, fp in fingerprinters.items():
105
+ try:
106
+ fingerprint = fp.process_packet(packet)
107
+ if fingerprint:
108
+ results["ja4plus"][name] = fingerprint
109
+ except Exception as e:
110
+ sys.stderr.write(f"Error in {name} fingerprinter: {e}\n")
111
+
112
+ # Only output if we have fingerprints
113
+ if results["ja4plus"]:
114
+ # Add network metadata - properly extract IP addresses
115
+ try:
116
+ from scapy.all import IP, IPv6
117
+
118
+ # Look for IP layer (IPv4 or IPv6)
119
+ if packet.haslayer(IP):
120
+ ip_layer = packet[IP]
121
+ results["source"] = {"ip": ip_layer.src}
122
+ results["destination"] = {"ip": ip_layer.dst}
123
+ elif packet.haslayer(IPv6):
124
+ ip_layer = packet[IPv6]
125
+ results["source"] = {"ip": ip_layer.src}
126
+ results["destination"] = {"ip": ip_layer.dst}
127
+ elif hasattr(packet, "src") and hasattr(packet, "dst"):
128
+ # Fallback to packet-level src/dst (might be MAC addresses)
129
+ # Only use if they look like IP addresses
130
+ src_str = str(packet.src)
131
+ dst_str = str(packet.dst)
132
+ if ":" not in src_str or len(src_str.split(":")) <= 4: # Not a MAC address
133
+ results["source"] = {"ip": src_str}
134
+ results["destination"] = {"ip": dst_str}
135
+
136
+ # Add port information if available
137
+ from scapy.all import TCP, UDP
138
+ if packet.haslayer(TCP):
139
+ tcp_layer = packet[TCP]
140
+ if "source" in results:
141
+ results["source"]["port"] = tcp_layer.sport
142
+ if "destination" in results:
143
+ results["destination"]["port"] = tcp_layer.dport
144
+ elif packet.haslayer(UDP):
145
+ udp_layer = packet[UDP]
146
+ if "source" in results:
147
+ results["source"]["port"] = udp_layer.sport
148
+ if "destination" in results:
149
+ results["destination"]["port"] = udp_layer.dport
150
+
151
+ except Exception as e:
152
+ sys.stderr.write(f"Error extracting network metadata: {e}\n")
153
+
154
+ # Add collection metadata
155
+ results["ja4plus"]["interface"] = args.interface
156
+ results["ja4plus"]["filter"] = args.filter
157
+ results["ja4plus"]["collection_timestamp"] = int(time.time() * 1000)
158
+
159
+ # Output based on format
160
+ if args.output == "json":
161
+ print(json.dumps(results))
162
+ else:
163
+ print(f"Packet {packet_count}: {results}")
164
+
165
+ sys.stdout.flush()
166
+
167
+ # Stop if we've reached the packet limit
168
+ if max_packets > 0 and packet_count >= max_packets:
169
+ sys.stderr.write(f"Reached packet limit of {max_packets}, stopping...\n")
170
+ sys.exit(0)
171
+
172
+ # Stop if we've reached the timeout
173
+ if timeout_seconds > 0 and (time.time() - start_time) >= timeout_seconds:
174
+ sys.stderr.write(f"Reached timeout of {timeout_seconds} seconds, stopping...\n")
175
+ sys.exit(0)
176
+
177
+ try:
178
+ sys.stderr.write(f"Starting JA4+ collection on interface '{args.interface}' with filter '{args.filter}'\n")
179
+ sys.stderr.write(f"Enabled fingerprinters: {', '.join(fingerprinters.keys())}\n")
180
+
181
+ # Start sniffing
182
+ sniff(prn=process_packet,
183
+ filter=args.filter,
184
+ iface=args.interface if args.interface != "any" else None,
185
+ store=0)
186
+
187
+ except KeyboardInterrupt:
188
+ sys.stderr.write("Interrupted by user\n")
189
+ except Exception as e:
190
+ sys.stderr.write(f"Error during packet capture: {e}\n")
191
+ sys.exit(1)
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,23 @@
1
+ """
2
+ JA4+ fingerprinters for various protocols and traffic types.
3
+ """
4
+
5
+ from .ja4 import JA4Fingerprinter
6
+ from .ja4s import JA4SFingerprinter
7
+ from .ja4h import JA4HFingerprinter
8
+ from .ja4l import JA4LFingerprinter
9
+ from .ja4x import JA4XFingerprinter
10
+ from .ja4ssh import JA4SSHFingerprinter
11
+ from .ja4t import JA4TFingerprinter
12
+ from .ja4ts import JA4TSFingerprinter
13
+
14
+ __all__ = [
15
+ 'JA4Fingerprinter',
16
+ 'JA4SFingerprinter',
17
+ 'JA4HFingerprinter',
18
+ 'JA4LFingerprinter',
19
+ 'JA4XFingerprinter',
20
+ 'JA4SSHFingerprinter',
21
+ 'JA4TFingerprinter',
22
+ 'JA4TSFingerprinter'
23
+ ]
@@ -0,0 +1,44 @@
1
+ """
2
+ Base fingerprinter class for JA4+ fingerprinters.
3
+ """
4
+
5
+ class BaseFingerprinter:
6
+ """Base class for all JA4+ fingerprinters."""
7
+
8
+ def __init__(self):
9
+ """Initialize the fingerprinter."""
10
+ self.fingerprints = []
11
+
12
+ def process_packet(self, packet):
13
+ """
14
+ Process a packet and extract fingerprint if applicable.
15
+ This method should be overridden by subclasses.
16
+
17
+ Args:
18
+ packet: A network packet to analyze
19
+
20
+ Returns:
21
+ The extracted fingerprint if successful, None otherwise
22
+ """
23
+ raise NotImplementedError("Subclasses must implement this method.")
24
+
25
+ def add_fingerprint(self, fingerprint, packet):
26
+ """
27
+ Add a fingerprint to the collection.
28
+
29
+ Args:
30
+ fingerprint: The extracted fingerprint
31
+ packet: The packet that generated this fingerprint
32
+ """
33
+ self.fingerprints.append({
34
+ 'fingerprint': fingerprint,
35
+ 'packet': packet
36
+ })
37
+
38
+ def get_fingerprints(self):
39
+ """Return all collected fingerprints."""
40
+ return self.fingerprints
41
+
42
+ def reset(self):
43
+ """Reset the fingerprinter state."""
44
+ self.fingerprints = []
@@ -0,0 +1,284 @@
1
+ """
2
+ JA4 TLS Client Hello Fingerprinting implementation.
3
+ """
4
+
5
+ import hashlib
6
+ from scapy.all import TCP, UDP, Raw, IP
7
+ from ja4plus.utils.tls_utils import extract_tls_info, is_grease_value
8
+ from ja4plus.fingerprinters.base import BaseFingerprinter
9
+
10
+ def generate_ja4(tls_info):
11
+ """
12
+ Generate a JA4 fingerprint from TLS Client Hello info.
13
+
14
+ Args:
15
+ tls_info: A dictionary with TLS handshake information
16
+
17
+ Returns:
18
+ A JA4 fingerprint string or None if not applicable
19
+ """
20
+ if not tls_info or tls_info.get('type') != 'client_hello':
21
+ return None
22
+
23
+ try:
24
+ # Determine protocol type (q=QUIC, d=DTLS, t=TLS over TCP)
25
+ proto = 'q' if tls_info.get('is_quic') else 'd' if tls_info.get('is_dtls') else 't'
26
+
27
+ # Get TLS version - prioritize supported_versions extension (0x002b)
28
+ version = tls_info.get('version')
29
+ supported_versions = tls_info.get('supported_versions', [])
30
+
31
+ # Filter out GREASE values
32
+ supported_versions = [v for v in supported_versions if not is_grease_value(v)]
33
+
34
+ if supported_versions:
35
+ # Use highest supported version
36
+ version = max(supported_versions)
37
+
38
+ # Convert version to string format
39
+ if version == 0x0304: # TLS 1.3
40
+ version_str = '13'
41
+ elif version == 0x0303: # TLS 1.2
42
+ version_str = '12'
43
+ elif version == 0x0302: # TLS 1.1
44
+ version_str = '11'
45
+ elif version == 0x0301: # TLS 1.0
46
+ version_str = '10'
47
+ elif version == 0x0300: # SSL 3.0
48
+ version_str = 's3'
49
+ elif version == 0x0200: # SSL 2.0
50
+ version_str = 's2'
51
+ elif version == 0xfeff: # DTLS 1.0
52
+ version_str = 'd1'
53
+ elif version == 0xfefd: # DTLS 1.2
54
+ version_str = 'd2'
55
+ elif version == 0xfefc: # DTLS 1.3
56
+ version_str = 'd3'
57
+ else:
58
+ version_str = '00'
59
+
60
+ # SNI type - 'd' if SNI exists, 'i' if not
61
+ sni = tls_info.get('sni')
62
+ sni_type = 'd' if sni else 'i'
63
+
64
+ # Get cipher suites - filter out GREASE values
65
+ ciphers = [c for c in tls_info.get('ciphers', []) if not is_grease_value(c)]
66
+ cipher_count = min(len(ciphers), 99) # Cap at 99
67
+ cipher_count_str = f"{cipher_count:02d}"
68
+
69
+ # Get extensions - filter out GREASE values
70
+ extensions = [e for e in tls_info.get('extensions', []) if not is_grease_value(e)]
71
+ ext_count = min(len(extensions), 99) # Cap at 99
72
+ ext_count_str = f"{ext_count:02d}"
73
+
74
+ # Get ALPN value - extract first and last character
75
+ # Per FoxIO spec: first+last alphanumeric char of first ALPN protocol
76
+ # Non-ASCII (ord > 127) -> '99'
77
+ alpn_protocols = tls_info.get('alpn_protocols', [])
78
+ if not alpn_protocols:
79
+ alpn_value = '00'
80
+ else:
81
+ first_alpn = alpn_protocols[0]
82
+
83
+ if not first_alpn:
84
+ alpn_value = '00'
85
+ else:
86
+ # FoxIO spec: if first char is non-ASCII, use '99'
87
+ if ord(first_alpn[0]) > 127:
88
+ alpn_value = '99'
89
+ elif len(first_alpn) == 1:
90
+ alpn_value = first_alpn[0] + first_alpn[0]
91
+ else:
92
+ alpn_value = f"{first_alpn[0]}{first_alpn[-1]}"
93
+
94
+ # Form part_a of the fingerprint
95
+ part_a = f"{proto}{version_str}{sni_type}{cipher_count_str}{ext_count_str}{alpn_value}"
96
+
97
+ # Generate cipher hash - sort ciphers first
98
+ if ciphers:
99
+ sorted_ciphers = sorted(ciphers)
100
+ cipher_str = ','.join([f"{c:04x}" for c in sorted_ciphers])
101
+ cipher_hash = hashlib.sha256(cipher_str.encode()).hexdigest()[:12]
102
+ else:
103
+ cipher_hash = '000000000000'
104
+
105
+ # Generate extension hash
106
+ # 1. Remove SNI (0x0000) and ALPN (0x0010) from extensions for hashing
107
+ filtered_extensions = [e for e in extensions if e != 0x0000 and e != 0x0010]
108
+
109
+ # 2. Sort filtered extensions
110
+ sorted_extensions = sorted(filtered_extensions)
111
+
112
+ # 3. Get signature algorithms in original order
113
+ sig_algs = tls_info.get('signature_algorithms', [])
114
+
115
+ # 4. Form extension string - sorted extensions + underscore + sig algorithms if present
116
+ ext_str = ','.join([f"{e:04x}" for e in sorted_extensions])
117
+ if sig_algs:
118
+ sig_alg_str = ','.join([f"{s:04x}" for s in sig_algs])
119
+ ext_str = f"{ext_str}_{sig_alg_str}"
120
+
121
+ # 5. Generate extension hash
122
+ if ext_str:
123
+ ext_hash = hashlib.sha256(ext_str.encode()).hexdigest()[:12]
124
+ else:
125
+ ext_hash = '000000000000'
126
+
127
+ # Form the complete JA4 fingerprint
128
+ ja4 = f"{part_a}_{cipher_hash}_{ext_hash}"
129
+
130
+ return ja4
131
+
132
+ except Exception:
133
+ return None
134
+
135
+ def get_raw_fingerprint(tls_info, original_order=False):
136
+ """
137
+ Generate a raw JA4 fingerprint with all values visible.
138
+
139
+ Args:
140
+ tls_info: A dictionary with TLS handshake information
141
+ original_order: Whether to maintain original ordering (True) or sort (False)
142
+
143
+ Returns:
144
+ A raw JA4 fingerprint string or None if not applicable
145
+ """
146
+ if not tls_info or tls_info.get('type') != 'client_hello':
147
+ return None
148
+
149
+ try:
150
+ # Get the same components as in generate_ja4
151
+ proto = 'q' if tls_info.get('is_quic') else 'd' if tls_info.get('is_dtls') else 't'
152
+
153
+ # Version
154
+ version = tls_info.get('version')
155
+ supported_versions = tls_info.get('supported_versions', [])
156
+ supported_versions = [v for v in supported_versions if not is_grease_value(v)]
157
+
158
+ if supported_versions:
159
+ version = max(supported_versions)
160
+
161
+ # Map version to string format (same as in generate_ja4)
162
+ if version == 0x0304: # TLS 1.3
163
+ version_str = '13'
164
+ elif version == 0x0303: # TLS 1.2
165
+ version_str = '12'
166
+ elif version == 0x0302: # TLS 1.1
167
+ version_str = '11'
168
+ elif version == 0x0301: # TLS 1.0
169
+ version_str = '10'
170
+ elif version == 0x0300: # SSL 3.0
171
+ version_str = 's3'
172
+ elif version == 0x0200: # SSL 2.0
173
+ version_str = 's2'
174
+ elif version == 0xfeff: # DTLS 1.0
175
+ version_str = 'd1'
176
+ elif version == 0xfefd: # DTLS 1.2
177
+ version_str = 'd2'
178
+ elif version == 0xfefc: # DTLS 1.3
179
+ version_str = 'd3'
180
+ else:
181
+ version_str = '00'
182
+
183
+ # SNI
184
+ sni = tls_info.get('sni')
185
+ sni_type = 'd' if sni else 'i'
186
+
187
+ # Ciphers - filter GREASE
188
+ ciphers = [c for c in tls_info.get('ciphers', []) if not is_grease_value(c)]
189
+ cipher_count = min(len(ciphers), 99)
190
+ cipher_count_str = f"{cipher_count:02d}"
191
+
192
+ # Extensions - filter GREASE
193
+ extensions = [e for e in tls_info.get('extensions', []) if not is_grease_value(e)]
194
+ ext_count = min(len(extensions), 99)
195
+ ext_count_str = f"{ext_count:02d}"
196
+
197
+ # ALPN - same as in generate_ja4
198
+ alpn_protocols = tls_info.get('alpn_protocols', [])
199
+ if not alpn_protocols:
200
+ alpn_value = '00'
201
+ else:
202
+ first_alpn = alpn_protocols[0]
203
+
204
+ if not first_alpn:
205
+ alpn_value = '00'
206
+ elif ord(first_alpn[0]) > 127:
207
+ alpn_value = '99'
208
+ elif len(first_alpn) == 1:
209
+ alpn_value = first_alpn[0] + first_alpn[0]
210
+ else:
211
+ alpn_value = f"{first_alpn[0]}{first_alpn[-1]}"
212
+
213
+ # First part of fingerprint
214
+ part_a = f"{proto}{version_str}{sni_type}{cipher_count_str}{ext_count_str}{alpn_value}"
215
+
216
+ # Cipher list - either sorted or original
217
+ if original_order:
218
+ cipher_list = ','.join([f"{c:04x}" for c in tls_info.get('ciphers', []) if not is_grease_value(c)])
219
+ else:
220
+ cipher_list = ','.join([f"{c:04x}" for c in sorted(ciphers)])
221
+
222
+ # Extension list - either with or without SNI/ALPN based on original_order
223
+ if original_order:
224
+ ext_list = ','.join([f"{e:04x}" for e in tls_info.get('extensions', []) if not is_grease_value(e)])
225
+ else:
226
+ ext_list = ','.join([f"{e:04x}" for e in sorted([e for e in extensions if e != 0x0000 and e != 0x0010])])
227
+
228
+ # Signature algorithms
229
+ sig_algs = tls_info.get('signature_algorithms', [])
230
+ sig_alg_list = ','.join([f"{s:04x}" for s in sig_algs])
231
+
232
+ # Final format
233
+ if sig_algs:
234
+ if original_order:
235
+ raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}_{sig_alg_list}"
236
+ else:
237
+ raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}_{sig_alg_list}"
238
+ else:
239
+ raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}"
240
+
241
+ # Add suffix to indicate format
242
+ if original_order:
243
+ return f"JA4_ro = {raw_ja4}"
244
+ else:
245
+ return f"JA4_r = {raw_ja4}"
246
+
247
+ except Exception:
248
+ return None
249
+
250
+ class JA4Fingerprinter(BaseFingerprinter):
251
+ """Fingerprinter for JA4 (TLS Client Hello)."""
252
+
253
+ def process_packet(self, packet):
254
+ """Process a packet and extract JA4 fingerprint if applicable."""
255
+ # First extract TLS info from the packet
256
+ tls_info = extract_tls_info(packet)
257
+
258
+ if not tls_info:
259
+ return None
260
+
261
+ # Then generate JA4 from the extracted TLS info
262
+ fingerprint = generate_ja4(tls_info)
263
+
264
+ if fingerprint:
265
+ self.add_fingerprint(fingerprint, packet)
266
+
267
+ return fingerprint
268
+
269
+ def get_raw_fingerprint(self, packet, original_order=False):
270
+ """
271
+ Get raw JA4 fingerprint with visible components.
272
+
273
+ Args:
274
+ packet: A packet containing a TLS Client Hello
275
+ original_order: Whether to maintain original ordering
276
+
277
+ Returns:
278
+ Raw JA4 fingerprint string or None
279
+ """
280
+ tls_info = extract_tls_info(packet)
281
+ if not tls_info:
282
+ return None
283
+
284
+ return get_raw_fingerprint(tls_info, original_order)