8.7 KiB
JA3/JA4 TLS Fingerprinting: Conformance
A fingerprint is only useful if it agrees with everyone else's. A JA4 this tool computes must equal the JA4 FoxIO's reference implementation computes for the same handshake, or a hash on a shared feed means nothing. This document states exactly what each fingerprint conforms to, the published vector it is pinned to in the test suite, and every place the tool deliberately narrows its scope. Where it says "pinned," there is a known-answer test that fails if the output drifts.
Reference specifications
| Fingerprint | Authority | Document |
|---|---|---|
| JA3 / JA3S | Salesforce (2017) | the original TLS Fingerprinting with JA3 and JA3S |
| JA4 / JA4S | FoxIO (2023) | JA4, JA4S |
| JA4H | FoxIO | JA4H |
| JA4X | FoxIO | JA4X |
| JA4T / JA4TS | FoxIO | JA4T |
| GREASE handling | IETF | RFC 8701 |
| QUIC v1 keys | IETF | RFC 9001 Section 5.2, Appendix A |
| QUIC v2 keys | IETF | RFC 9369 Section 3.3.1, Appendix A |
The FoxIO JA4+ specifications carry their own license (FoxIO License 1.1), which governs the specification, not this independent implementation of it. This tool ships under AGPL 3.0.
Per-fingerprint conformance
JA3 / JA3S
- Conforms to the original Salesforce field order and the MD5 of the comma-joined decimal fields.
- Pinned to the two client vectors from the Salesforce post and a server vector, in
ja3.rs(salesforce_client_vector_oneproducesada70206e40642a3e4461f35503241d5;salesforce_client_vector_two_empty_fieldscovers the empty-field case;server_vector_round_trips_through_md5covers JA3S). - Scope. Computed from the ClientHello and ServerHello. GREASE is stripped per RFC 8701. JA3 is retained despite being unstable on modern browsers because the public malware feeds are expressed in it; see 01-CONCEPTS.md.
JA4 / JA4S
- Conforms to the FoxIO JA4 construction: a 10-character readable prefix, a truncated-SHA-256 of the sorted cipher list, and a truncated-SHA-256 of the sorted extension list with SNI and ALPN removed and the signature algorithms appended in original order.
- Pinned to the FoxIO section vectors in
ja4.rs: the cipher section8daaf6152771and the extension sectione5627efa2ab1, which together with the prefix form the canonicalt13d1516h2_8daaf6152771_e5627efa2ab1. An all-empty ClientHello produces the zero hash000000000000(empty_input_is_the_zero_hash), and the truncation is asserted to be exactly 12 hex characters (truncation_is_twelve_hex_chars). - Scope. Both the hashed and the raw form are produced; the CLI prints the hashed form and exposes the raw form under
--json. The version word is taken fromsupported_versions, not the frozen legacy field. JA4S hashes server extensions in wire order, not sorted.
JA4H
- Conforms to the FoxIO JA4H construction over a single cleartext HTTP request.
- Pinned to the request-line and header parse in
ja4h.rs(parses_request_line_and_headers,version_codes). - Scope. Cleartext HTTP/1.x only. HTTPS requests are encrypted and HTTP/2 headers are HPACK-compressed, so neither is fingerprinted passively. This is a property of passive observation, not a limitation of the implementation.
JA4X
- Conforms to the FoxIO JA4X construction: three truncated-SHA-256 hashes over the issuer OIDs, the subject OIDs, and the extension OIDs, each in DER order.
- Pinned to the OID-extraction tests in
ja4x.rs(extracts_issuer_oids_in_order,extracts_extension_oids) and hardened by the property-test fuzz harness intests/ja4x.rs. - Scope. Passive JA4X works on TLS 1.2 and earlier, where the Certificate message is in the clear; TLS 1.3 encrypts it. The DER reader (
der.rs) is bounded: a certificate that ends mid-field returns an error or an empty section, never a panic (certificate_ending_after_subject_is_handled_without_panic). An out-of-bounds slice on a crafted certificate is a security bug, not a cosmetic one, which is why this path is fuzzed.
JA4T / JA4TS
- Conforms to the FoxIO JA4T format: window size, option kinds, MSS, and window scale, joined with underscores.
- Pinned to the FoxIO vectors in
ja4t.rs(foxio_windows_default_vectorproduces64240_2-1-3-1-1-4_1460_8;foxio_windowed_vector;missing_mss_and_scale_report_zero) and to the decoder that walks SYN options inpipeline/decode.rs(ja4t_walk_reproduces_the_windows_default_vector,ja4t_walk_counts_trailing_end_of_list_padding,ja4t_walk_survives_truncated_options). - Scope. Read from a SYN (JA4T) or SYN-ACK (JA4TS). A missing MSS or window scale option is reported as
0, per the spec. The option walker tolerates truncated and padded option lists.
QUIC conformance
- Conforms to RFC 9001 (QUIC v1) and RFC 9369 (QUIC v2) for client Initial key derivation and packet protection removal.
- Pinned to the RFC Appendix A key vectors in
quic.rs:client_initial_keys_match_rfc9001_appendix_a1andclient_initial_keys_match_rfc9369_appendix_aderive the key, IV, and header-protection key from the appendix connection ID and assert them byte-for-byte. The integration capturequic-with-several-tls-frames.pcapngyields the stableq13d0310h3_55b375c5d22e_cd85d2d88918. - Scope and a deliberate boundary. The tool decrypts client Initial packets only, deriving keys from each packet's own Destination Connection ID with no server secret and no direction detection. It does not track connection migration, does not decrypt 0-RTT or Handshake packets, and does not reassemble across a connection ID change. A version field other than v1 or v2 increments
quic_version_unsupportedand is skipped rather than guessed. The full v2 protected-packet decrypt is not pinned to a published full-packet vector (the only available transcription was byte-corrupted in transit); the v2 key schedule is pinned to RFC 9369 Appendix A, and the decrypt path is the same AEAD the v1 full-packet test exercises.
Parsing and robustness boundaries
The engine forbids unsafe, so the following are guarantees, not aspirations:
- A malformed length is an error, never an out-of-bounds read. Every parser reads through the bounds-checked
Reader; a field that claims more bytes than remain returnsParseError. Theparseandreassemblytest suites feed truncated and overlapping input and assert clean errors. - An adversarial capture costs bounded memory. The flow table caps the number of tracked flows, evicts on an idle timeout, and ceilings both contiguous and parked-out-of-order bytes per direction. A stream that hits a cap is marked
cappedand stops growing. The counters exposesegments_droppedandunfinished_tls_streams. - A QUIC packet that fails its AEAD tag is dropped. Unverified plaintext is never released to the TLS parser.
Deliberate non-goals
These are choices, recorded so they are not mistaken for gaps:
- No application-data decryption. The tool reads only what is plaintext on the wire. It has no private key and never sees TLS 1.3 certificates or HTTP/2 headers.
- No active probing. The tool sends nothing. It cannot be detected by watching for scans.
- No attribution. A fingerprint identifies a toolchain, not an actor. The shared
72a589da586844d7f0818ce684948eeaJA3 across some Emotet and Cobalt Strike samples is the standing example: same TLS library, different malware. Aknown_badverdict means "known-suspicious toolchain," nothing more. - No DTLS fingerprinting yet. The transport marker
dexists in the JA4 code, but DTLS handshake capture is not wired into the pipeline. This is the most natural extension; see 04-CHALLENGES.md.
How to re-verify
cargo test --workspace # every pinned vector above runs here
cargo test -p tlsfp-core quic # just the QUIC key-schedule vectors
Against a second implementation, capture a handshake and compare:
tlsfp pcap capture.pcapng --json | jq '.ja4.hash, .ja4.raw'
tshark -r capture.pcapng -T fields -e tls.handshake.ja4 # Wireshark's JA4
If the two disagree on a real capture, that is a bug worth a report, and the raw form in the JSON is where to start reading.