feat(ja3-ja4-tls-fingerprinting): JA3/JA4 TLS fingerprinting tool in Rust (M0-M5)

Passive TLS client and server fingerprinting from packet captures. Two-crate
workspace: tlsfp-core (parsing and fingerprint logic, no I/O, unsafe forbidden)
and tlsfp (clap CLI). Edition 2024, MSRV 1.85.

Core (M0-M4): a hand-rolled bounds-checked TLS parser for ClientHello,
ServerHello, and the TLS 1.2 Certificate chain, plus the full fingerprint
family computed byte-exact against published vectors: JA3/JA3S (MD5), JA4/JA4_r,
JA4S, JA4H, JA4X, and JA4T. GREASE is stripped, wire order and unknown cipher
and extension values are preserved.

Pipeline (M5): a pcap and pcapng file reader behind a PacketSource trait,
etherparse L2-L4 decode (Ethernet, raw IP, BSD loopback, Linux SLL and SLL2,
VLAN and QinQ), and a hand-rolled per-flow TCP reassembler with wrapping serial
arithmetic, out-of-order parking, first-write-wins overlap resolution, and
bounded memory. The tlsfp pcap subcommand fingerprints every handshake in a
capture, as readable lines or NDJSON.

Verified three ways: 94 tests including unit KATs, nine real-pcap full-pipeline
KATs against the FoxIO sample corpus, and property tests that reassemble
arbitrarily segmented and reordered streams. Builds clean on stable and MSRV
1.85; clippy -D warnings and rustfmt clean.

Test captures under testdata/pcap are vendored unmodified from the FoxIO ja4
repository with per-file SHA-256 provenance.
This commit is contained in:
CarterPerez-dev 2026-06-10 08:01:24 -04:00
parent d03b9a37ad
commit e4207b343a
53 changed files with 7378 additions and 0 deletions

View File

@ -0,0 +1,46 @@
# ©AngelaMos | 2026
# ci.yml
name: ci
on:
push:
paths:
- "PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/**"
pull_request:
paths:
- "PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/**"
defaults:
run:
working-directory: PROJECTS/intermediate/ja3-ja4-tls-fingerprinting
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, "1.85"]
steps:
- uses: actions/checkout@v4
- name: Install libpcap
run: sudo apt-get update && sudo apt-get install -y libpcap-dev
- name: Install Rust ${{ matrix.rust }}
run: rustup toolchain install ${{ matrix.rust }} --component clippy rustfmt --profile minimal
- name: Build
run: cargo +${{ matrix.rust }} build --workspace --all-targets
- name: Test
run: cargo +${{ matrix.rust }} test --workspace
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install libpcap
run: sudo apt-get update && sudo apt-get install -y libpcap-dev
- name: Install Rust
run: rustup toolchain install stable --component clippy rustfmt --profile minimal
- name: Format
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

View File

@ -0,0 +1,9 @@
# ©AngelaMos | 2026
# .gitignore
docs/
target/
*.db
*.sqlite
fuzz/corpus/
fuzz/artifacts/

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
# ©AngelaMos | 2026
# Cargo.toml
[workspace]
resolver = "3"
members = ["crates/tlsfp-core", "crates/tlsfp"]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT"
authors = ["Carter Perez"]
repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
[workspace.dependencies]
tlsfp-core = { path = "crates/tlsfp-core" }
winnow = "0.7"
md-5 = "0.10"
sha2 = "0.10"
hex = "0.4"
smallvec = "1"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
etherparse = "0.18"
pcap-parser = "0.16"
pcap = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
flume = "0.11"
rusqlite = { version = "0.32", features = ["bundled"] }
axum = "0.8"
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
tower = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
hkdf = "0.12"
aes = "0.8"
aes-gcm = "0.10"
ctr = "0.9"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
doc_markdown = "allow"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Carter Perez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,61 @@
# Third-party fingerprint algorithm licensing
This project implements several published TLS fingerprinting algorithms. They do
not all carry the same license, and the split matters. This notice records what
applies to what.
## Our own code
Everything under `crates/` is original work licensed under the MIT License (see
`LICENSE`). No source code from any reference implementation was copied. The
algorithms were implemented from their published specifications.
## JA3 and JA3S
JA3 and JA3S were created by John Althouse, Jeff Atkinson, and Josh Atkins at
Salesforce and released under the BSD 3-Clause license at
`https://github.com/salesforce/ja3`. The algorithm is free to implement and use.
That repository was archived in May 2025 and is no longer maintained.
## JA4 (TLS client fingerprint)
JA4, the TLS client fingerprint, is licensed by FoxIO under the BSD 3-Clause
license, separately from the rest of the JA4+ suite. FoxIO has stated it holds
no patents on JA4 TLS client fingerprinting. The canonical license text is at
`https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE-JA4`. JA4 may be implemented
and used without restriction.
## JA4S, JA4H, JA4X, JA4T (the rest of the JA4+ suite)
The remaining JA4+ fingerprints implemented here are licensed under the FoxIO
License 1.1, and the methods are patent pending. The canonical license text is
at `https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE`.
The FoxIO License 1.1 grants the right to use and modify these methods for non
commercial purposes, which it defines to include personal use, academic research
and development, and internal evaluation. It excludes any use for which a fee is
charged and excludes providing the methods as a hosted or managed service to
others.
This project is a free, open source, educational tool. It is not sold, not
monetized, and not offered as a service. It therefore falls within the non
commercial grant of the FoxIO License 1.1. Anyone who forks this project and
intends to monetize it, or to offer it as a hosted service, must obtain an OEM
license from FoxIO for the JA4+ methods first.
This is the same boundary that led the Suricata project to ship only JA4 and not
the rest of the JA4+ suite: their GPL licensing is incompatible with the FoxIO
License 1.1. This project is MIT licensed and non commercial, so both the GPL
incompatibility and the monetization restriction are avoided.
## Seeded fingerprint data
The intelligence database is seeded from public sources with their own terms:
- abuse.ch SSLBL JA3 feed is released under CC0 and a snapshot may be
redistributed. Its known limitation, that the fingerprints have not been
tested against benign traffic and may produce false positives, is surfaced in
the tool output.
- The salesforce/ja3 application lists are MIT licensed.
- The ja4db.com database has no stated redistribution license, so it is fetched
at install time rather than bundled, and entries are validated on import.

View File

@ -0,0 +1,34 @@
# ©AngelaMos | 2026
# Cargo.toml
[package]
name = "tlsfp-core"
description = "TLS handshake parsing and JA3/JA4 fingerprint computation"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
md-5.workspace = true
sha2.workspace = true
hex.workspace = true
smallvec.workspace = true
thiserror.workspace = true
serde.workspace = true
etherparse.workspace = true
pcap-parser.workspace = true
hkdf.workspace = true
aes.workspace = true
aes-gcm.workspace = true
ctr.workspace = true
[dev-dependencies]
hex.workspace = true
proptest = "1"
serde_json.workspace = true

View File

@ -0,0 +1,110 @@
// ©AngelaMos | 2026
// der.rs
use crate::error::{ParseError, Result};
/// DER tag bytes that the certificate walk needs to recognize.
pub mod tag {
pub const OBJECT_IDENTIFIER: u8 = 0x06;
pub const SEQUENCE: u8 = 0x30;
pub const SET: u8 = 0x31;
pub const CONTEXT_3: u8 = 0xa3;
}
/// A minimal reader for the subset of DER that X.509 certificates use.
///
/// It decodes one tag length value triple at a time and hands back the content
/// as a borrowed slice. It deliberately does not understand the meaning of any
/// structure; the JA4X walk drives it, deciding which fields to descend into and
/// which to skip. Keeping the reader this small keeps it auditable, which
/// matters because certificate parsers are a classic source of memory safety
/// bugs and this one has no `unsafe` and cannot read out of bounds.
pub struct Der<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Der<'a> {
#[must_use]
pub const fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.pos >= self.buf.len()
}
fn byte(&mut self) -> Result<u8> {
if self.pos >= self.buf.len() {
return Err(ParseError::Truncated { needed: 1, have: 0 });
}
let b = self.buf[self.pos];
self.pos += 1;
Ok(b)
}
/// Reads one tag length value triple and returns the tag and the content
/// slice, advancing past the whole triple.
pub fn read_tlv(&mut self) -> Result<(u8, &'a [u8])> {
let tag = self.byte()?;
let len = self.read_length()?;
if self.buf.len() - self.pos < len {
return Err(ParseError::LengthOverrun {
field: "der",
declared: len,
available: self.buf.len() - self.pos,
});
}
let content = &self.buf[self.pos..self.pos + len];
self.pos += len;
Ok((tag, content))
}
fn read_length(&mut self) -> Result<usize> {
let first = self.byte()?;
if first & 0x80 == 0 {
return Ok(first as usize);
}
let count = (first & 0x7f) as usize;
if count == 0 || count > core::mem::size_of::<usize>() {
return Err(ParseError::Misaligned(count));
}
let mut len = 0usize;
for _ in 0..count {
len = (len << 8) | self.byte()? as usize;
}
Ok(len)
}
}
#[cfg(test)]
mod tests {
use super::{Der, tag};
#[test]
fn reads_short_form_sequence() {
let data = [0x30, 0x03, 0x06, 0x01, 0x55];
let mut der = Der::new(&data);
let (t, content) = der.read_tlv().unwrap();
assert_eq!(t, tag::SEQUENCE);
assert_eq!(content, &[0x06, 0x01, 0x55]);
assert!(der.is_empty());
}
#[test]
fn reads_long_form_length() {
let mut data = vec![0x04, 0x82, 0x01, 0x00];
data.extend(std::iter::repeat_n(0xaa, 256));
let mut der = Der::new(&data);
let (_t, content) = der.read_tlv().unwrap();
assert_eq!(content.len(), 256);
}
#[test]
fn rejects_length_overrun() {
let data = [0x06, 0x05, 0x55, 0x04];
let mut der = Der::new(&data);
assert!(der.read_tlv().is_err());
}
}

View File

@ -0,0 +1,57 @@
// ©AngelaMos | 2026
// error.rs
use thiserror::Error;
/// Errors produced while parsing TLS records, handshake messages, or QUIC
/// initial packets.
///
/// Every variant is stack only. No variant carries a heap allocation, so the
/// malformed packet path never touches the allocator. This matters because a
/// fingerprinting engine spends most of its time rejecting traffic that is not
/// a clean handshake, and allocating on each rejection adds jitter at line rate.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
#[error("buffer too short: needed {needed} bytes, had {have}")]
Truncated { needed: usize, have: usize },
#[error("not a TLS handshake record (content type {0:#04x})")]
NotHandshake(u8),
#[error("unexpected handshake message type {0:#04x}")]
UnexpectedHandshake(u8),
#[error("length field {field} declares {declared} bytes but {available} remain")]
LengthOverrun {
field: &'static str,
declared: usize,
available: usize,
},
#[error("vector length {0} is not a whole number of elements")]
Misaligned(usize),
#[error("trailing {0} bytes after a complete message")]
Trailing(usize),
#[error("handshake message spans more bytes than the reassembly cap allows")]
OversizedHandshake,
#[error("malformed extension {ext_type:#06x}")]
BadExtension { ext_type: u16 },
#[error("not a QUIC long header initial packet")]
NotQuicInitial,
#[error("unsupported QUIC version {0:#010x}")]
UnsupportedQuicVersion(u32),
#[error("QUIC header protection or AEAD removal failed")]
QuicCryptoFailure,
#[error("no CRYPTO frame carrying a ClientHello was present")]
NoCryptoClientHello,
}
pub type Result<T> = core::result::Result<T, ParseError>;

View File

@ -0,0 +1,100 @@
// ©AngelaMos | 2026
// fingerprint.rs
use core::fmt;
use serde::Serialize;
/// A JA3 or JA3S fingerprint: the MD5 digest of the pre hash string.
///
/// JA3 is carried as the raw sixteen byte digest rather than a hex string so
/// that equality, hashing, and database keys operate on the compact binary form
/// and the hex rendering happens only at display boundaries.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(into = "String")]
pub struct Ja3([u8; 16]);
impl Ja3 {
#[must_use]
pub const fn from_digest(digest: [u8; 16]) -> Self {
Self(digest)
}
#[must_use]
pub const fn bytes(&self) -> &[u8; 16] {
&self.0
}
}
impl fmt::Display for Ja3 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
impl fmt::Debug for Ja3 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Ja3({self})")
}
}
impl From<Ja3> for String {
fn from(value: Ja3) -> Self {
value.to_string()
}
}
/// A fingerprint from the JA4 family, carried in both its hashed canonical form
/// and its raw pre hash form.
///
/// The raw form is the unhashed list of cipher and extension values. It is kept
/// alongside the hash because it is the form an analyst reads when explaining
/// why two clients differ, and because clustering on the raw lists is more
/// informative than clustering on opaque digests.
#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
pub struct Ja4Family {
pub hash: String,
pub raw: String,
}
impl Ja4Family {
#[must_use]
pub fn new(hash: String, raw: String) -> Self {
Self { hash, raw }
}
}
impl fmt::Display for Ja4Family {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.hash)
}
}
impl fmt::Debug for Ja4Family {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Ja4Family(hash={}, raw={})", self.hash, self.raw)
}
}
#[cfg(test)]
mod tests {
use super::{Ja3, Ja4Family};
#[test]
fn ja3_display_is_lowercase_hex() {
let fp = Ja3::from_digest([
0xad, 0xa7, 0x02, 0x06, 0xe4, 0x06, 0x42, 0xa3, 0xe4, 0x46, 0x1f, 0x35, 0x50, 0x32,
0x41, 0xd5,
]);
assert_eq!(fp.to_string(), "ada70206e40642a3e4461f35503241d5");
}
#[test]
fn ja4_family_displays_hash() {
let fp = Ja4Family::new("t13d1516h2_8daaf6152771_e5627efa2ab1".into(), "raw".into());
assert_eq!(fp.to_string(), "t13d1516h2_8daaf6152771_e5627efa2ab1");
}
}

View File

@ -0,0 +1,52 @@
// ©AngelaMos | 2026
// grease.rs
/// The sixteen GREASE values reserved by RFC 8701.
///
/// GREASE (Generate Random Extensions And Sustain Extensibility) values are
/// inserted by clients into cipher lists, extension lists, supported groups,
/// supported versions, and signature algorithms. They are deliberate noise
/// whose only purpose is to keep middleboxes tolerant of unknown values. Both
/// JA3 and JA4 strip them before hashing so that the same client produces a
/// stable fingerprint regardless of which GREASE values it happened to pick.
///
/// Every value has the form `0xZaZa` where the two bytes are equal and the low
/// nibble of each is `a`.
pub const GREASE_VALUES: [u16; 16] = [
0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
0xcaca, 0xdada, 0xeaea, 0xfafa,
];
/// Returns true when `value` is one of the sixteen RFC 8701 GREASE values.
///
/// The check exploits the structural regularity of the GREASE set rather than
/// scanning the table: both bytes must be equal and the low nibble of each must
/// be `0xa`. This is a single pair of comparisons rather than a sixteen way
/// branch, which keeps it cheap on the per packet path.
#[inline]
#[must_use]
pub const fn is_grease(value: u16) -> bool {
let high = (value >> 8) as u8;
let low = (value & 0x00ff) as u8;
high == low && (low & 0x0f) == 0x0a
}
#[cfg(test)]
mod tests {
use super::{GREASE_VALUES, is_grease};
#[test]
fn table_matches_structural_check() {
for v in 0..=u16::MAX {
let in_table = GREASE_VALUES.contains(&v);
assert_eq!(in_table, is_grease(v), "mismatch for {v:#06x}");
}
}
#[test]
fn known_non_grease_values() {
for v in [0x0000_u16, 0x0010, 0x1301, 0x00ff, 0x5600, 0xc02f] {
assert!(!is_grease(v), "{v:#06x} should not be grease");
}
}
}

View File

@ -0,0 +1,26 @@
// ©AngelaMos | 2026
// hash.rs
use sha2::{Digest, Sha256};
/// Returns the first twelve hex characters of the SHA256 digest of `bytes`.
///
/// The whole JA4 family truncates SHA256 to twelve hex characters, which is six
/// bytes of digest. Twelve characters is enough to make accidental collisions
/// vanishingly unlikely across the fingerprint space while keeping the
/// fingerprint short enough to read and to paste into a search box.
#[must_use]
pub fn sha256_hex12(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex::encode(&digest[..6])
}
#[cfg(test)]
mod tests {
use super::sha256_hex12;
#[test]
fn known_digest_prefix() {
assert_eq!(sha256_hex12(b""), "e3b0c44298fc");
}
}

View File

@ -0,0 +1,125 @@
// ©AngelaMos | 2026
// ja3.rs
use std::fmt::Write as _;
use md5::{Digest, Md5};
use smallvec::SmallVec;
use crate::fingerprint::Ja3;
use crate::grease::is_grease;
use crate::parse::{ClientHello, ServerHello};
/// Computes the JA3 string and digest for a ClientHello.
///
/// JA3 concatenates five fields, in order: the legacy version, the cipher
/// suites, the extension types, the supported groups, and the elliptic curve
/// point formats. Within a field the values are decimal and joined with
/// hyphens; the fields themselves are joined with commas. GREASE values are
/// removed from every list field so that the deliberate noise a client inserts
/// does not change its fingerprint. The MD5 of that string is the JA3 hash.
///
/// JA3 is kept here despite being effectively dead for modern browser traffic,
/// because malware fingerprints in public feeds are still expressed as JA3 and
/// because watching JA3 collapse next to JA4 is the clearest way to understand
/// why JA4 exists.
#[must_use]
pub fn ja3_string(ch: &ClientHello) -> String {
let ext_types: SmallVec<[u16; 16]> = ch.extensions.iter().map(|e| e.ext_type).collect();
let groups = ch.supported_groups();
let formats = ch.ec_point_formats();
let mut s = String::new();
let _ = write!(s, "{}", ch.legacy_version);
s.push(',');
append_u16_hyphenated(&mut s, &ch.cipher_suites);
s.push(',');
append_u16_hyphenated(&mut s, &ext_types);
s.push(',');
append_u16_hyphenated(&mut s, &groups);
s.push(',');
append_u8_hyphenated(&mut s, &formats);
s
}
/// Computes the JA3 digest for a ClientHello.
#[must_use]
pub fn ja3(ch: &ClientHello) -> Ja3 {
digest(&ja3_string(ch))
}
/// Computes the JA3S string for a ServerHello.
///
/// JA3S mirrors JA3 on the server side with three fields: the version, the
/// single chosen cipher suite, and the extension types. A server and the exact
/// ClientHello it answered together identify a deployment more tightly than
/// either side alone.
#[must_use]
pub fn ja3s_string(sh: &ServerHello) -> String {
let ext_types: SmallVec<[u16; 16]> = sh.extensions.iter().map(|e| e.ext_type).collect();
let mut s = String::new();
let _ = write!(s, "{},{},", sh.legacy_version, sh.cipher_suite);
append_u16_hyphenated(&mut s, &ext_types);
s
}
/// Computes the JA3S digest for a ServerHello.
#[must_use]
pub fn ja3s(sh: &ServerHello) -> Ja3 {
digest(&ja3s_string(sh))
}
fn digest(pre_hash: &str) -> Ja3 {
let out = Md5::digest(pre_hash.as_bytes());
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&out);
Ja3::from_digest(bytes)
}
fn append_u16_hyphenated(out: &mut String, values: &[u16]) {
let mut first = true;
for &v in values {
if is_grease(v) {
continue;
}
if !first {
out.push('-');
}
first = false;
let _ = write!(out, "{v}");
}
}
fn append_u8_hyphenated(out: &mut String, values: &[u8]) {
let mut first = true;
for &v in values {
if !first {
out.push('-');
}
first = false;
let _ = write!(out, "{v}");
}
}
#[cfg(test)]
mod tests {
use super::digest;
#[test]
fn salesforce_client_vector_one() {
let pre = "769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0";
assert_eq!(digest(pre).to_string(), "ada70206e40642a3e4461f35503241d5");
}
#[test]
fn salesforce_client_vector_two_empty_fields() {
let pre = "769,4-5-10-9-100-98-3-6-19-18-99,,,";
assert_eq!(digest(pre).to_string(), "de350869b8c85de67a350c8d186f11e6");
}
#[test]
fn server_vector_round_trips_through_md5() {
let pre = "769,47,65281-0-11-35-5-16";
assert_eq!(digest(pre).to_string(), "836ce314215654b5b1f85f97c73e506f");
}
}

View File

@ -0,0 +1,263 @@
// ©AngelaMos | 2026
// ja4.rs
use std::fmt::Write as _;
use smallvec::SmallVec;
use crate::fingerprint::Ja4Family;
use crate::grease::is_grease;
use crate::hash::sha256_hex12;
use crate::parse::{ClientHello, ServerHello};
use crate::registry::{extension, ja4_version_code};
/// The transport that carried the handshake.
///
/// JA4 records the transport in its first character because the same TLS stack
/// produces a recognizably different ClientHello over QUIC than over TCP, and an
/// analyst wants to see that difference at a glance rather than infer it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Tcp,
Quic,
Dtls,
}
impl Transport {
const fn marker(self) -> char {
match self {
Transport::Tcp => 't',
Transport::Quic => 'q',
Transport::Dtls => 'd',
}
}
}
const EMPTY_HASH: &str = "000000000000";
/// Computes the JA4 fingerprint of a ClientHello in both hashed and raw forms.
///
/// The fingerprint has three underscore separated sections. The first is ten
/// human readable characters: transport, version, whether SNI was present,
/// cipher count, extension count, and two characters derived from the first
/// ALPN value. The second is a truncated SHA256 of the cipher list sorted by
/// value. The third is a truncated SHA256 of the extension list sorted by value,
/// with SNI and ALPN removed and the signature algorithms appended in their
/// original order.
///
/// Sorting the cipher and extension lists before hashing is the whole reason
/// JA4 survived what killed JA3. When Chrome began shuffling its extension order
/// on every connection, JA3, which hashes extensions in wire order, produced a
/// fresh hash for every Chrome request. JA4 sorts first, so order shuffling
/// leaves the fingerprint unchanged.
#[must_use]
pub fn ja4(ch: &ClientHello, transport: Transport) -> Ja4Family {
let prefix = ja4_prefix(ch, transport);
let (cipher_csv, _) = sorted_hex_csv(&ch.cipher_suites, &[]);
let cipher_hash = truncated_sha256(&cipher_csv);
let ext_raw = ja4_extension_raw(ch);
let ext_hash = truncated_sha256(&ext_raw);
let hash = format!("{prefix}_{cipher_hash}_{ext_hash}");
let raw = format!("{prefix}_{cipher_csv}_{ext_raw}");
Ja4Family::new(hash, raw)
}
/// Computes the JA4S fingerprint of a ServerHello.
///
/// JA4S mirrors JA4 on the server side, with three differences that follow from
/// what a server controls. The server picks exactly one cipher suite, so the
/// cipher section is that single value in hex rather than a hash of a list. The
/// extensions are hashed in the order the server sent them, not sorted, because
/// a server does not shuffle its own extensions. And there is no SNI field,
/// because the server is not the party naming a host.
#[must_use]
pub fn ja4s(sh: &ServerHello, transport: Transport) -> Ja4Family {
let version = ja4_version_code(sh.selected_version());
let alpn = alpn_chars(sh.alpn_protocol());
let ext_count = sh.extensions.len().min(99);
let mut prefix = String::with_capacity(7);
prefix.push(transport.marker());
prefix.push_str(version);
let _ = write!(prefix, "{ext_count:02}");
prefix.push_str(&alpn);
let cipher_hex = format!("{:04x}", sh.cipher_suite);
let ext_csv = wire_order_hex_csv(
&sh.extensions
.iter()
.map(|e| e.ext_type)
.collect::<SmallVec<[u16; 16]>>(),
);
let ext_hash = truncated_sha256(&ext_csv);
let hash = format!("{prefix}_{cipher_hex}_{ext_hash}");
let raw = format!("{prefix}_{cipher_hex}_{ext_csv}");
Ja4Family::new(hash, raw)
}
fn wire_order_hex_csv(values: &[u16]) -> String {
let hexed: SmallVec<[String; 16]> = values.iter().map(|v| format!("{v:04x}")).collect();
hexed.join(",")
}
fn ja4_prefix(ch: &ClientHello, transport: Transport) -> String {
let version = select_version(ch);
let sni = if ch.has_extension(extension::SERVER_NAME) {
'd'
} else {
'i'
};
let cipher_count = capped_count(ch.cipher_suites.iter().copied());
let ext_count = capped_count(ch.extensions.iter().map(|e| e.ext_type));
let alpn = ja4_alpn(ch);
let mut prefix = String::with_capacity(10);
prefix.push(transport.marker());
prefix.push_str(ja4_version_code(version));
prefix.push(sni);
let _ = write!(prefix, "{cipher_count:02}{ext_count:02}");
prefix.push_str(&alpn);
prefix
}
/// Selects the JA4 version word: the highest non GREASE value from the supported
/// versions extension when present, otherwise the legacy record version.
fn select_version(ch: &ClientHello) -> u16 {
ch.supported_versions()
.iter()
.copied()
.filter(|v| !is_grease(*v))
.max()
.unwrap_or(ch.legacy_version)
}
fn capped_count(values: impl Iterator<Item = u16>) -> usize {
values.filter(|v| !is_grease(*v)).count().min(99)
}
/// Derives the two ALPN characters.
///
/// The implementation follows the published JA4 specification rather than the
/// FoxIO Python reference. The two diverge for ALPN values whose first or last
/// byte is not an ASCII alphanumeric: the specification says to print the first
/// and last characters of the hex encoding of the value, while the Python
/// reference emits a fixed fallback. The specification is the more informative
/// and more portable choice, so it is what this code does.
fn ja4_alpn(ch: &ClientHello) -> String {
alpn_chars(ch.alpn_protocols().first().copied())
}
fn alpn_chars(first: Option<&[u8]>) -> String {
let Some(first) = first else {
return "00".to_string();
};
if first.is_empty() {
return "00".to_string();
}
let first_byte = first[0];
let last_byte = first[first.len() - 1];
if is_ascii_alphanumeric(first_byte) && is_ascii_alphanumeric(last_byte) {
let mut out = String::with_capacity(2);
out.push(first_byte as char);
out.push(last_byte as char);
out
} else {
let encoded = hex::encode(first);
let mut chars = encoded.chars();
let first_char = chars.next().unwrap_or('0');
let last_char = encoded.chars().last().unwrap_or('0');
let mut out = String::with_capacity(2);
out.push(first_char);
out.push(last_char);
out
}
}
const fn is_ascii_alphanumeric(byte: u8) -> bool {
byte.is_ascii_digit() || byte.is_ascii_uppercase() || byte.is_ascii_lowercase()
}
/// Builds the raw, pre hash extension string for section three.
///
/// The extension types are sorted by hex value after removing GREASE, SNI, and
/// ALPN. If a signature algorithms extension is present, its values are appended
/// after an underscore, in their original order. If it is absent, the string
/// ends without a trailing underscore, which is the behavior the specification
/// requires and which changes the resulting hash.
fn ja4_extension_raw(ch: &ClientHello) -> String {
let excluded = [extension::SERVER_NAME, extension::ALPN];
let ext_types: SmallVec<[u16; 16]> = ch.extensions.iter().map(|e| e.ext_type).collect();
let (ext_csv, _) = sorted_hex_csv(&ext_types, &excluded);
let sig_algs = ch.signature_algorithms();
if ch.has_extension(extension::SIGNATURE_ALGORITHMS) {
let sig_csv = unsorted_hex_csv(&sig_algs);
format!("{ext_csv}_{sig_csv}")
} else {
ext_csv
}
}
fn sorted_hex_csv(values: &[u16], excluded: &[u16]) -> (String, usize) {
let mut hexed: SmallVec<[String; 32]> = values
.iter()
.copied()
.filter(|v| !is_grease(*v) && !excluded.contains(v))
.map(|v| format!("{v:04x}"))
.collect();
hexed.sort_unstable();
(hexed.join(","), hexed.len())
}
fn unsorted_hex_csv(values: &[u16]) -> String {
let hexed: SmallVec<[String; 16]> = values
.iter()
.copied()
.filter(|v| !is_grease(*v))
.map(|v| format!("{v:04x}"))
.collect();
hexed.join(",")
}
fn truncated_sha256(input: &str) -> String {
if input.is_empty() {
return EMPTY_HASH.to_string();
}
sha256_hex12(input.as_bytes())
}
#[cfg(test)]
mod tests {
use super::{EMPTY_HASH, truncated_sha256};
#[test]
fn empty_input_is_the_zero_hash() {
assert_eq!(truncated_sha256(""), EMPTY_HASH);
}
#[test]
fn truncation_is_twelve_hex_chars() {
let h = truncated_sha256("1301,1302,1303");
assert_eq!(h.len(), 12);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn foxio_cipher_section_vector() {
let ciphers = "002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9";
assert_eq!(truncated_sha256(ciphers), "8daaf6152771");
}
#[test]
fn foxio_extension_section_vector() {
let exts = "0005,000a,000b,000d,0012,0015,0017,001b,0023,002b,002d,0033,4469,ff01_0403,0804,0401,0503,0805,0501,0806,0601";
assert_eq!(truncated_sha256(exts), "e5627efa2ab1");
}
}

View File

@ -0,0 +1,195 @@
// ©AngelaMos | 2026
// ja4h.rs
use crate::fingerprint::Ja4Family;
use crate::hash::sha256_hex12;
/// A parsed HTTP request, holding what JA4H reads.
///
/// JA4H fingerprints an HTTP client from one request: its method, version,
/// whether it carries cookies and a referer, the names of its other headers in
/// the order they were sent, its accept language, and its cookie names and
/// values. A request that omits an accept language and sends no cookies is far
/// more likely to be a script than a person, and JA4H makes that visible in the
/// first few characters.
///
/// This applies to cleartext HTTP only. Over HTTPS the request is encrypted and
/// invisible to a passive observer, and HTTP/2 carries its headers in HPACK,
/// which a passive tool cannot decode without following the whole connection.
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: String,
pub version: String,
pub headers: Vec<(String, String)>,
}
/// Parses an HTTP/1.x request from the start of a reassembled byte stream.
///
/// Returns `None` when the bytes are not a well formed request line followed by
/// headers. Header values keep their original bytes; header names keep their
/// original case because JA4H hashes them as sent.
#[must_use]
pub fn parse_http_request(bytes: &[u8]) -> Option<HttpRequest> {
let text = std::str::from_utf8(bytes).ok()?;
let mut lines = text.split("\r\n");
let request_line = lines.next()?;
let mut parts = request_line.split(' ');
let method = parts.next()?.to_string();
let _target = parts.next()?;
let http_token = parts.next()?;
if !http_token.starts_with("HTTP/") {
return None;
}
let version = http_token.trim_start_matches("HTTP/").replace('.', "");
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
break;
}
let (name, value) = line.split_once(':')?;
headers.push((name.to_string(), value.trim_start().to_string()));
}
Some(HttpRequest {
method,
version,
headers,
})
}
/// Computes the JA4H fingerprint for a parsed HTTP request.
#[must_use]
pub fn ja4h(req: &HttpRequest) -> Ja4Family {
let method = method_code(&req.method);
let version = version_code(&req.version);
let cookie_flag = if has_header(req, "cookie") { 'c' } else { 'n' };
let referer_flag = if has_named_header(req, "referer") {
'r'
} else {
'n'
};
let counted: Vec<&str> = req
.headers
.iter()
.map(|(name, _)| name.as_str())
.filter(|name| is_counted_header(name))
.collect();
let header_len = counted.len().min(99);
let lang = accept_language(req);
let prefix = format!("{method}{version}{cookie_flag}{referer_flag}{header_len:02}{lang}");
let header_hash = sha12(&counted.join(","));
let cookies = cookie_pairs(req);
let (cookie_hash, value_hash, raw_cookie_tail) = if let Some(mut pairs) = cookies {
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let names: Vec<&str> = pairs.iter().map(|p| p.0.as_str()).collect();
let entries: Vec<&str> = pairs.iter().map(|p| p.1.as_str()).collect();
let tail = format!("{}_{}", names.join(","), entries.join(","));
(sha12(&names.join(",")), sha12(&entries.join(",")), tail)
} else {
(ZERO_HASH.to_string(), ZERO_HASH.to_string(), String::new())
};
let hash = format!("{prefix}_{header_hash}_{cookie_hash}_{value_hash}");
let raw = format!("{prefix}_{}_{raw_cookie_tail}", counted.join(","));
Ja4Family::new(hash, raw)
}
const ZERO_HASH: &str = "000000000000";
fn method_code(method: &str) -> String {
method.to_lowercase().chars().take(2).collect()
}
fn version_code(version: &str) -> String {
match version {
"2" | "20" => "20".to_string(),
"3" | "30" => "30".to_string(),
"10" => "10".to_string(),
_ => "11".to_string(),
}
}
fn has_header(req: &HttpRequest, prefix_lower: &str) -> bool {
req.headers
.iter()
.any(|(name, _)| name.to_lowercase().starts_with(prefix_lower))
}
fn has_named_header(req: &HttpRequest, name_lower: &str) -> bool {
req.headers
.iter()
.any(|(name, _)| name.to_lowercase() == name_lower)
}
fn is_counted_header(name: &str) -> bool {
let lower = name.to_lowercase();
!name.starts_with(':') && !lower.starts_with("cookie") && lower != "referer" && !name.is_empty()
}
fn accept_language(req: &HttpRequest) -> String {
let Some((_, value)) = req
.headers
.iter()
.find(|(name, _)| name.to_lowercase() == "accept-language")
else {
return "0000".to_string();
};
let normalized = value.replace('-', "").replace(';', ",").to_lowercase();
let first = normalized.split(',').next().unwrap_or("");
let mut code: String = first.chars().take(4).collect();
while code.len() < 4 {
code.push('0');
}
code
}
fn cookie_pairs(req: &HttpRequest) -> Option<Vec<(String, String)>> {
let (_, value) = req
.headers
.iter()
.find(|(name, _)| name.to_lowercase() == "cookie")?;
let pairs = value
.split(';')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| {
let name = p.split('=').next().unwrap_or(p).trim().to_string();
(name, p.to_string())
})
.collect();
Some(pairs)
}
fn sha12(joined: &str) -> String {
sha256_hex12(joined.as_bytes())
}
#[cfg(test)]
mod tests {
use super::{parse_http_request, version_code};
#[test]
fn parses_request_line_and_headers() {
let raw = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n";
let req = parse_http_request(raw).unwrap();
assert_eq!(req.method, "GET");
assert_eq!(req.version, "11");
assert_eq!(req.headers.len(), 2);
assert_eq!(
req.headers[0],
("Host".to_string(), "example.com".to_string())
);
}
#[test]
fn version_codes() {
assert_eq!(version_code("11"), "11");
assert_eq!(version_code("10"), "10");
assert_eq!(version_code("2"), "20");
}
}

View File

@ -0,0 +1,81 @@
// ©AngelaMos | 2026
// ja4t.rs
use std::fmt::Write as _;
use smallvec::SmallVec;
/// The TCP layer inputs to a JA4T fingerprint, read from a SYN or SYN ACK.
///
/// JA4T fingerprints the TCP stack rather than the TLS stack. It is computed
/// from fields that an operating system sets the same way on every connection
/// but that differ between operating systems: the advertised window size, the
/// kinds and order of TCP options, the maximum segment size, and the window
/// scale factor. Pairing it with JA4 exposes a class of evasion that TLS only
/// fingerprinting misses, where a tool wears a browser's TLS clothing while its
/// host operating system speaks with a different TCP accent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TcpFingerprintInput {
pub window_size: u16,
pub option_kinds: SmallVec<[u8; 8]>,
pub mss: u16,
pub window_scale: u8,
}
/// Computes the JA4T or JA4TS string.
///
/// The format is the window size, then the option kinds joined with hyphens,
/// then the maximum segment size, then the window scale, with the four parts
/// separated by underscores. A missing MSS or window scale option is reported as
/// zero. The same function serves both the client SYN, which yields JA4T, and
/// the server SYN ACK, which yields JA4TS.
#[must_use]
pub fn ja4t(input: &TcpFingerprintInput) -> String {
let mut options = String::new();
let mut first = true;
for kind in &input.option_kinds {
if !first {
options.push('-');
}
first = false;
let _ = write!(options, "{kind}");
}
format!(
"{}_{}_{}_{}",
input.window_size, options, input.mss, input.window_scale
)
}
#[cfg(test)]
mod tests {
use super::{TcpFingerprintInput, ja4t};
use smallvec::SmallVec;
fn input(window: u16, kinds: &[u8], mss: u16, scale: u8) -> TcpFingerprintInput {
TcpFingerprintInput {
window_size: window,
option_kinds: SmallVec::from_slice(kinds),
mss,
window_scale: scale,
}
}
#[test]
fn foxio_linux_default_vector() {
let i = input(64240, &[2, 1, 3, 1, 1, 4], 1460, 8);
assert_eq!(ja4t(&i), "64240_2-1-3-1-1-4_1460_8");
}
#[test]
fn foxio_windowed_vector() {
let i = input(65535, &[2, 1, 3, 1, 1, 8, 4, 0, 0], 1346, 6);
assert_eq!(ja4t(&i), "65535_2-1-3-1-1-8-4-0-0_1346_6");
}
#[test]
fn missing_mss_and_scale_report_zero() {
let i = input(8192, &[2, 4], 0, 0);
assert_eq!(ja4t(&i), "8192_2-4_0_0");
}
}

View File

@ -0,0 +1,175 @@
// ©AngelaMos | 2026
// ja4x.rs
use smallvec::SmallVec;
use crate::der::{Der, tag};
use crate::error::{ParseError, Result};
use crate::hash::sha256_hex12;
/// Computes the JA4X fingerprint of one DER encoded X.509 certificate.
///
/// JA4X does not fingerprint the contents of a certificate. It fingerprints how
/// the certificate was built: which object identifiers appear in the issuer
/// name, which appear in the subject name, and which appear among the
/// extensions, each in the order they were written. Two certificates minted by
/// the same software with the same template share a JA4X even when every name
/// and serial differs, which is what makes it useful for clustering certificates
/// from one toolchain or one malware family.
///
/// Passively this only works on TLS 1.2 and earlier, where the certificate
/// travels in the clear. TLS 1.3 encrypts the certificate message, so a passive
/// observer never sees it.
pub fn ja4x(cert_der: &[u8]) -> Result<String> {
let (issuer_oids, subject_oids, ext_oids) = certificate_oids(cert_der)?;
let issuer_hash = sha256_hex12(issuer_oids.join(",").as_bytes());
let subject_hash = sha256_hex12(subject_oids.join(",").as_bytes());
let ext_hash = sha256_hex12(ext_oids.join(",").as_bytes());
Ok(format!("{issuer_hash}_{subject_hash}_{ext_hash}"))
}
type OidList = SmallVec<[String; 8]>;
fn certificate_oids(cert_der: &[u8]) -> Result<(OidList, OidList, OidList)> {
let mut outer = Der::new(cert_der);
let (_, certificate) = outer.read_tlv()?;
let mut cert = Der::new(certificate);
let (_, tbs) = cert.read_tlv()?;
let mut fields: SmallVec<[(u8, &[u8]); 10]> = SmallVec::new();
let mut walker = Der::new(tbs);
while !walker.is_empty() {
fields.push(walker.read_tlv()?);
}
let mut idx = 0;
if fields.first().is_some_and(|(t, _)| *t == 0xa0) {
idx += 1;
}
idx += 2;
let issuer = field_content(&fields, idx)?;
idx += 1;
idx += 1;
let subject = field_content(&fields, idx)?;
idx += 1;
idx += 1;
let extensions = fields[idx..]
.iter()
.find(|(t, _)| *t == tag::CONTEXT_3)
.map(|(_, c)| *c);
let issuer_oids = name_oids(issuer)?;
let subject_oids = name_oids(subject)?;
let ext_oids = match extensions {
Some(content) => extension_oids(content)?,
None => OidList::new(),
};
Ok((issuer_oids, subject_oids, ext_oids))
}
fn field_content<'a>(fields: &[(u8, &'a [u8])], idx: usize) -> Result<&'a [u8]> {
fields
.get(idx)
.map(|(_, c)| *c)
.ok_or(ParseError::Truncated { needed: 1, have: 0 })
}
/// Collects the attribute type object identifiers from a Name, in order.
///
/// A Name is a sequence of relative distinguished names, each a set of attribute
/// type and value pairs. The fingerprint reads only the attribute type, the
/// leading object identifier of each pair, and renders it as the hex of its DER
/// content bytes, which is exactly the form JA4X hashes.
fn name_oids(name: &[u8]) -> Result<OidList> {
let mut oids = OidList::new();
let mut rdns = Der::new(name);
while !rdns.is_empty() {
let (_, rdn) = rdns.read_tlv()?;
let mut set = Der::new(rdn);
while !set.is_empty() {
let (_, atv) = set.read_tlv()?;
let mut pair = Der::new(atv);
let (oid_tag, oid) = pair.read_tlv()?;
if oid_tag == tag::OBJECT_IDENTIFIER {
oids.push(hex::encode(oid));
}
}
}
Ok(oids)
}
/// Collects the extension object identifiers, in order.
fn extension_oids(context: &[u8]) -> Result<OidList> {
let mut oids = OidList::new();
let mut wrapper = Der::new(context);
let (_, sequence) = wrapper.read_tlv()?;
let mut exts = Der::new(sequence);
while !exts.is_empty() {
let (_, ext) = exts.read_tlv()?;
let mut fields = Der::new(ext);
let (oid_tag, oid) = fields.read_tlv()?;
if oid_tag == tag::OBJECT_IDENTIFIER {
oids.push(hex::encode(oid));
}
}
Ok(oids)
}
#[cfg(test)]
mod tests {
use super::{extension_oids, name_oids};
fn oid(content: &[u8]) -> Vec<u8> {
let mut v = vec![0x06, u8::try_from(content.len()).unwrap()];
v.extend_from_slice(content);
v
}
fn tlv(tag: u8, content: &[u8]) -> Vec<u8> {
let mut v = vec![tag, u8::try_from(content.len()).unwrap()];
v.extend_from_slice(content);
v
}
#[test]
fn extracts_issuer_oids_in_order() {
let mut name = Vec::new();
for content in [
&[0x55u8, 0x04, 0x06][..],
&[0x55, 0x04, 0x0a],
&[0x55, 0x04, 0x0b],
&[0x55, 0x04, 0x03],
] {
let atv = tlv(0x30, &{
let mut a = oid(content);
a.extend(tlv(0x13, b"x"));
a
});
name.extend(tlv(0x31, &atv));
}
let oids = name_oids(&name).unwrap();
assert_eq!(oids.as_slice(), &["550406", "55040a", "55040b", "550403"]);
}
#[test]
fn extracts_extension_oids() {
let mut sequence = Vec::new();
for content in [&[0x55u8, 0x1d, 0x23][..], &[0x55, 0x1d, 0x0e]] {
let ext = tlv(0x30, &{
let mut e = oid(content);
e.extend(tlv(0x04, b"v"));
e
});
sequence.extend(ext);
}
let context = tlv(0x30, &sequence);
let oids = extension_oids(&context).unwrap();
assert_eq!(oids.as_slice(), &["551d23", "551d0e"]);
}
}

View File

@ -0,0 +1,37 @@
// ©AngelaMos | 2026
// lib.rs
//! TLS handshake parsing and JA3/JA4 family fingerprint computation.
//!
//! This crate is the engine. It parses TLS records and handshake messages, the
//! TLS carried inside QUIC initial packets, and computes the JA3, JA3S, JA4,
//! JA4S, JA4H, JA4X, and JA4T fingerprints. It depends on nothing that touches
//! a network interface, a database, or an async runtime, so it can be embedded,
//! fuzzed, and unit tested in isolation.
pub mod der;
pub mod error;
pub mod fingerprint;
pub mod grease;
pub mod hash;
pub mod ja3;
pub mod ja4;
pub mod ja4h;
pub mod ja4t;
pub mod ja4x;
pub mod parse;
pub mod pipeline;
pub mod registry;
pub use error::{ParseError, Result};
pub use fingerprint::{Ja3, Ja4Family};
pub use grease::{GREASE_VALUES, is_grease};
pub use ja3::{ja3, ja3_string, ja3s, ja3s_string};
pub use ja4::{Transport, ja4, ja4s};
pub use ja4h::{HttpRequest, ja4h, parse_http_request};
pub use ja4t::{TcpFingerprintInput, ja4t};
pub use ja4x::ja4x;
pub use parse::{ClientHello, Extension, ServerHello};
pub use pipeline::event::{FingerprintEvent, StreamEvent};
pub use pipeline::source::{PacketSource, PcapFileSource, RawFrame, SourceError};
pub use pipeline::{Counters, Pipeline, PipelineConfig};

View File

@ -0,0 +1,70 @@
// ©AngelaMos | 2026
// cert.rs
use smallvec::SmallVec;
use crate::error::Result;
use crate::parse::reader::Reader;
/// The DER certificates carried by one TLS Certificate handshake message.
pub type CertificateList<'pkt> = SmallVec<[&'pkt [u8]; 4]>;
/// Extracts the DER encoded certificates from a Certificate message body.
///
/// The body is a three byte total length followed by entries that are each a
/// three byte length and the raw DER bytes. This is the TLS 1.2 framing; it is
/// the only framing a passive observer ever parses, because TLS 1.3 moved the
/// Certificate message inside the encrypted part of the handshake. The
/// certificates come back in wire order, leaf first, which is the order JA4X
/// reports them in.
pub fn certificate_der_list(body: &[u8]) -> Result<CertificateList<'_>> {
let mut r = Reader::new(body);
let mut list = r.sub_u24_vec()?;
let mut certs = CertificateList::new();
while !list.is_empty() {
certs.push(list.take_u24_vec()?);
}
Ok(certs)
}
#[cfg(test)]
mod tests {
use super::certificate_der_list;
use crate::error::ParseError;
fn message(certs: &[&[u8]]) -> Vec<u8> {
let total: usize = certs.iter().map(|c| c.len() + 3).sum();
let mut v = Vec::new();
v.extend_from_slice(&u32::try_from(total).unwrap().to_be_bytes()[1..]);
for cert in certs {
v.extend_from_slice(&u32::try_from(cert.len()).unwrap().to_be_bytes()[1..]);
v.extend_from_slice(cert);
}
v
}
#[test]
fn splits_a_two_certificate_chain() {
let body = message(&[&[0x30, 0x01, 0xaa], &[0x30, 0x02, 0xbb, 0xcc]]);
let certs = certificate_der_list(&body).unwrap();
assert_eq!(certs.len(), 2);
assert_eq!(certs[0], &[0x30, 0x01, 0xaa]);
assert_eq!(certs[1], &[0x30, 0x02, 0xbb, 0xcc]);
}
#[test]
fn truncated_entry_is_an_error_not_a_panic() {
let mut body = message(&[&[0x30, 0x01, 0xaa]]);
body.truncate(body.len() - 1);
assert!(matches!(
certificate_der_list(&body),
Err(ParseError::Truncated { .. })
));
}
#[test]
fn empty_chain_is_empty() {
let certs = certificate_der_list(&[0, 0, 0]).unwrap();
assert!(certs.is_empty());
}
}

View File

@ -0,0 +1,246 @@
// ©AngelaMos | 2026
// hello.rs
use smallvec::SmallVec;
use crate::error::Result;
use crate::parse::reader::Reader;
use crate::registry::extension;
/// A single TLS extension as it appeared on the wire.
///
/// The extension body is borrowed from the packet, not copied. Specific
/// extensions are decoded on demand through the accessor methods on
/// [`ClientHello`] so that the common parse path does no work for extensions a
/// given fingerprint does not read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Extension<'pkt> {
pub ext_type: u16,
pub data: &'pkt [u8],
}
/// A parsed ClientHello, holding exactly what the fingerprint algorithms read.
///
/// Cipher suites and extensions are stored in wire order. Order is preserved
/// because JA3 hashes extensions in their original order, and because the
/// difference between wire order and sorted order is itself a signal: a client
/// that permutes its extension order on every connection is doing something a
/// fixed order client is not.
#[derive(Debug, Clone)]
pub struct ClientHello<'pkt> {
pub legacy_version: u16,
pub cipher_suites: SmallVec<[u16; 32]>,
pub extensions: SmallVec<[Extension<'pkt>; 16]>,
pub is_sslv2: bool,
}
/// A parsed ServerHello.
///
/// The server selects exactly one cipher suite, so `cipher_suite` is a single
/// value rather than a list. Extensions are again kept in wire order.
#[derive(Debug, Clone)]
pub struct ServerHello<'pkt> {
pub legacy_version: u16,
pub cipher_suite: u16,
pub extensions: SmallVec<[Extension<'pkt>; 16]>,
}
fn parse_extensions<'pkt>(r: &mut Reader<'pkt>) -> Result<SmallVec<[Extension<'pkt>; 16]>> {
let mut exts = SmallVec::new();
if r.is_empty() {
return Ok(exts);
}
let mut block = r.sub_u16_vec()?;
while !block.is_empty() {
let ext_type = block.u16()?;
let data = block.take_u16_vec()?;
exts.push(Extension { ext_type, data });
}
Ok(exts)
}
/// Parses the body of a ClientHello handshake message.
///
/// The input is the message body, meaning the four byte handshake header has
/// already been stripped. The session id, which fingerprinting ignores, is
/// skipped, as are the compression methods; getting those two skips right is a
/// classic source of bugs because a parser that forgets them reads the wrong
/// bytes as cipher suites.
pub fn parse_client_hello(body: &[u8]) -> Result<ClientHello<'_>> {
let mut r = Reader::new(body);
let legacy_version = r.u16()?;
let _random = r.take(32)?;
let _session_id = r.take_u8_vec()?;
let mut cipher_reader = r.sub_u16_vec()?;
let mut cipher_suites = SmallVec::new();
while !cipher_reader.is_empty() {
cipher_suites.push(cipher_reader.u16()?);
}
let _compression = r.take_u8_vec()?;
let extensions = parse_extensions(&mut r)?;
Ok(ClientHello {
legacy_version,
cipher_suites,
extensions,
is_sslv2: false,
})
}
/// Parses the body of a ServerHello handshake message.
pub fn parse_server_hello(body: &[u8]) -> Result<ServerHello<'_>> {
let mut r = Reader::new(body);
let legacy_version = r.u16()?;
let _random = r.take(32)?;
let _session_id = r.take_u8_vec()?;
let cipher_suite = r.u16()?;
let _compression = r.u8()?;
let extensions = parse_extensions(&mut r)?;
Ok(ServerHello {
legacy_version,
cipher_suite,
extensions,
})
}
impl<'pkt> ServerHello<'pkt> {
fn extension(&self, ext_type: u16) -> Option<&Extension<'pkt>> {
self.extensions.iter().find(|e| e.ext_type == ext_type)
}
/// Returns the version the server selected.
///
/// In TLS 1.3 the negotiated version lives in the supported versions
/// extension as a single value rather than in the legacy version word, which
/// the server pins to TLS 1.2 for compatibility. JA4S reads the real version
/// from the extension when it is present.
#[must_use]
pub fn selected_version(&self) -> u16 {
self.extension(extension::SUPPORTED_VERSIONS)
.and_then(|ext| {
let mut r = Reader::new(ext.data);
r.u16().ok()
})
.unwrap_or(self.legacy_version)
}
/// Returns the ALPN protocol the server chose, if any.
#[must_use]
pub fn alpn_protocol(&self) -> Option<&'pkt [u8]> {
let ext = self.extension(extension::ALPN)?;
let mut r = Reader::new(ext.data);
let mut list = r.sub_u16_vec().ok()?;
list.take_u8_vec().ok().filter(|p| !p.is_empty())
}
}
impl<'pkt> ClientHello<'pkt> {
fn extension(&self, ext_type: u16) -> Option<&Extension<'pkt>> {
self.extensions.iter().find(|e| e.ext_type == ext_type)
}
#[must_use]
pub fn has_extension(&self, ext_type: u16) -> bool {
self.extension(ext_type).is_some()
}
/// Returns the server name from the SNI extension, if a host name entry is
/// present.
#[must_use]
pub fn server_name(&self) -> Option<&'pkt str> {
let ext = self.extension(extension::SERVER_NAME)?;
let mut list = Reader::new(ext.data).sub_u16_vec().ok()?;
while !list.is_empty() {
let name_type = list.u8().ok()?;
let name = list.take_u16_vec().ok()?;
if name_type == 0 {
return core::str::from_utf8(name).ok();
}
}
None
}
/// Returns the supported groups, the field JA3 calls elliptic curves.
#[must_use]
pub fn supported_groups(&self) -> SmallVec<[u16; 16]> {
self.u16_list(extension::SUPPORTED_GROUPS)
}
/// Returns the elliptic curve point formats.
#[must_use]
pub fn ec_point_formats(&self) -> SmallVec<[u8; 4]> {
let mut out = SmallVec::new();
let Some(ext) = self.extension(extension::EC_POINT_FORMATS) else {
return out;
};
let mut r = Reader::new(ext.data);
let Ok(list) = r.take_u8_vec() else {
return out;
};
out.extend_from_slice(list);
out
}
/// Returns the protocol versions the client offers in the supported versions
/// extension. JA4 selects its version field from the highest non GREASE
/// value here when the extension is present.
#[must_use]
pub fn supported_versions(&self) -> SmallVec<[u16; 8]> {
let mut out = SmallVec::new();
let Some(ext) = self.extension(extension::SUPPORTED_VERSIONS) else {
return out;
};
let mut r = Reader::new(ext.data);
let Ok(list) = r.take_u8_vec() else {
return out;
};
let mut inner = Reader::new(list);
while let Ok(v) = inner.u16() {
out.push(v);
}
out
}
/// Returns the signature algorithms in their original order. JA4 appends
/// these, unsorted, to the extension hash input.
#[must_use]
pub fn signature_algorithms(&self) -> SmallVec<[u16; 16]> {
self.u16_list(extension::SIGNATURE_ALGORITHMS)
}
/// Returns the ALPN protocol identifiers in order. JA4 uses the first one.
#[must_use]
pub fn alpn_protocols(&self) -> SmallVec<[&'pkt [u8]; 4]> {
let mut out = SmallVec::new();
let Some(ext) = self.extension(extension::ALPN) else {
return out;
};
let mut r = Reader::new(ext.data);
let Ok(mut list) = r.sub_u16_vec() else {
return out;
};
while let Ok(proto) = list.take_u8_vec() {
out.push(proto);
}
out
}
fn u16_list(&self, ext_type: u16) -> SmallVec<[u16; 16]> {
let mut out = SmallVec::new();
let Some(ext) = self.extension(ext_type) else {
return out;
};
let mut r = Reader::new(ext.data);
let Ok(list) = r.take_u16_vec() else {
return out;
};
let mut inner = Reader::new(list);
while let Ok(v) = inner.u16() {
out.push(v);
}
out
}
}

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// mod.rs
//! Hand written, bounds checked TLS parsing.
//!
//! The parser is deliberately not built on a parser combinator framework. It is
//! the security critical core of the tool, and the byte by byte reader here maps
//! directly onto the wire format described in the TLS RFCs, which makes it easy
//! to audit against the specification. Reassembly of fragmented handshakes is
//! handled before parsing, so every parse function sees a complete message and
//! never has to model partial input.
pub mod cert;
pub mod hello;
pub mod reader;
pub mod record;
pub use cert::{CertificateList, certificate_der_list};
pub use hello::{ClientHello, Extension, ServerHello, parse_client_hello, parse_server_hello};
pub use reader::Reader;
pub use record::{
first_handshake_message, handshake_bytes, is_sslv2_client_hello, parse_sslv2_client_hello,
};

View File

@ -0,0 +1,173 @@
// ©AngelaMos | 2026
// reader.rs
use crate::error::{ParseError, Result};
/// A forward only cursor over a borrowed byte slice with bounds checked reads.
///
/// Every read advances the cursor and returns an error rather than panicking
/// when the buffer is too short. This is the foundation the whole parser stands
/// on: because the cursor can never read past the end of the slice, the parser
/// has no `unsafe`, cannot index out of bounds, and treats a truncated or
/// hostile packet as an ordinary error instead of a crash. The slices it hands
/// back borrow from the original packet buffer, so parsing copies nothing.
pub struct Reader<'pkt> {
buf: &'pkt [u8],
pos: usize,
}
impl<'pkt> Reader<'pkt> {
#[must_use]
pub const fn new(buf: &'pkt [u8]) -> Self {
Self { buf, pos: 0 }
}
#[must_use]
pub const fn remaining(&self) -> usize {
self.buf.len() - self.pos
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.remaining() == 0
}
fn need(&self, n: usize) -> Result<()> {
if self.remaining() < n {
return Err(ParseError::Truncated {
needed: n,
have: self.remaining(),
});
}
Ok(())
}
pub fn u8(&mut self) -> Result<u8> {
self.need(1)?;
let v = self.buf[self.pos];
self.pos += 1;
Ok(v)
}
pub fn u16(&mut self) -> Result<u16> {
self.need(2)?;
let v = u16::from_be_bytes([self.buf[self.pos], self.buf[self.pos + 1]]);
self.pos += 2;
Ok(v)
}
/// Reads a 24 bit big endian length, the width TLS uses for handshake
/// message bodies and certificate entries.
pub fn u24(&mut self) -> Result<u32> {
self.need(3)?;
let v = u32::from_be_bytes([
0,
self.buf[self.pos],
self.buf[self.pos + 1],
self.buf[self.pos + 2],
]);
self.pos += 3;
Ok(v)
}
pub fn u32(&mut self) -> Result<u32> {
self.need(4)?;
let v = u32::from_be_bytes([
self.buf[self.pos],
self.buf[self.pos + 1],
self.buf[self.pos + 2],
self.buf[self.pos + 3],
]);
self.pos += 4;
Ok(v)
}
/// Borrows the next `n` bytes and advances past them.
pub fn take(&mut self, n: usize) -> Result<&'pkt [u8]> {
self.need(n)?;
let slice = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(slice)
}
/// Reads a one byte length prefix, then borrows that many bytes.
pub fn take_u8_vec(&mut self) -> Result<&'pkt [u8]> {
let len = self.u8()? as usize;
self.take(len)
}
/// Reads a two byte length prefix, then borrows that many bytes.
pub fn take_u16_vec(&mut self) -> Result<&'pkt [u8]> {
let len = self.u16()? as usize;
self.take(len)
}
/// Reads a three byte length prefix, then borrows that many bytes.
pub fn take_u24_vec(&mut self) -> Result<&'pkt [u8]> {
let len = self.u24()? as usize;
self.take(len)
}
/// Returns a sub reader over a two byte length prefixed region.
///
/// This is the workhorse for nested vectors such as the extensions block,
/// where an outer length governs a region that itself contains a sequence of
/// length prefixed elements.
pub fn sub_u16_vec(&mut self) -> Result<Reader<'pkt>> {
let len = self.u16()? as usize;
let slice = self.take(len)?;
Ok(Reader::new(slice))
}
/// Returns a sub reader over a three byte length prefixed region, the width
/// the Certificate message uses for its certificate list.
pub fn sub_u24_vec(&mut self) -> Result<Reader<'pkt>> {
let len = self.u24()? as usize;
let slice = self.take(len)?;
Ok(Reader::new(slice))
}
}
#[cfg(test)]
mod tests {
use super::Reader;
use crate::error::ParseError;
#[test]
fn reads_widths_in_order() {
let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
let mut r = Reader::new(&data);
assert_eq!(r.u8().unwrap(), 0x01);
assert_eq!(r.u16().unwrap(), 0x0203);
assert_eq!(r.u24().unwrap(), 0x0004_0506);
assert!(r.is_empty());
}
#[test]
fn short_read_is_an_error_not_a_panic() {
let data = [0x01];
let mut r = Reader::new(&data);
assert_eq!(
r.u16().unwrap_err(),
ParseError::Truncated { needed: 2, have: 1 }
);
}
#[test]
fn length_prefixed_take_respects_bounds() {
let data = [0x03, 0xaa, 0xbb, 0xcc, 0xff];
let mut r = Reader::new(&data);
assert_eq!(r.take_u8_vec().unwrap(), &[0xaa, 0xbb, 0xcc]);
assert_eq!(r.u8().unwrap(), 0xff);
}
#[test]
fn sub_vector_isolates_a_region() {
let data = [0x00, 0x02, 0x11, 0x22, 0x33];
let mut r = Reader::new(&data);
let mut sub = r.sub_u16_vec().unwrap();
assert_eq!(sub.remaining(), 2);
assert_eq!(sub.u16().unwrap(), 0x1122);
assert_eq!(r.u8().unwrap(), 0x33);
}
}

View File

@ -0,0 +1,179 @@
// ©AngelaMos | 2026
// record.rs
use std::borrow::Cow;
use smallvec::SmallVec;
use crate::error::{ParseError, Result};
use crate::parse::hello::ClientHello;
use crate::parse::reader::Reader;
use crate::registry::content_type;
/// Reassembles the cleartext handshake flight from a TLS record stream.
///
/// A handshake message can be split across several TLS records, and several
/// short messages can share one record. This walks the record framing and
/// concatenates the payloads of the handshake records so the caller sees one
/// contiguous handshake byte stream. The common case is a single record holding
/// a single ClientHello, and that case borrows the original bytes with no copy.
/// Only genuinely fragmented flights allocate.
///
/// Records carrying anything other than handshake data are ignored. In TLS 1.3
/// the later handshake messages travel inside records typed as application data
/// and are encrypted, so they never reach this function, which is correct: the
/// only handshake bytes we can read in the clear are the first flight.
pub fn handshake_bytes(stream: &[u8]) -> Result<Cow<'_, [u8]>> {
let mut segments: SmallVec<[&[u8]; 4]> = SmallVec::new();
let mut r = Reader::new(stream);
while r.remaining() >= 5 {
let ctype = r.u8()?;
let _version = r.u16()?;
let payload = r.take_u16_vec()?;
if ctype == content_type::HANDSHAKE {
segments.push(payload);
} else if !segments.is_empty() {
break;
}
}
match segments.as_slice() {
[] => Err(ParseError::Truncated {
needed: 5,
have: stream.len(),
}),
[only] => Ok(Cow::Borrowed(*only)),
many => {
let mut joined = Vec::with_capacity(many.iter().map(|s| s.len()).sum());
for seg in many {
joined.extend_from_slice(seg);
}
Ok(Cow::Owned(joined))
}
}
}
/// Returns the body of the first handshake message of the requested type.
///
/// The handshake header is a one byte type and a three byte length. This walks
/// the messages in the reassembled flight and returns the body slice of the
/// first one whose type matches, so the caller never has to reason about the
/// header widths.
pub fn first_handshake_message(handshake: &[u8], want_type: u8) -> Result<&[u8]> {
let mut r = Reader::new(handshake);
while r.remaining() >= 4 {
let msg_type = r.u8()?;
let len = r.u24()? as usize;
let body = r.take(len)?;
if msg_type == want_type {
return Ok(body);
}
}
Err(ParseError::UnexpectedHandshake(want_type))
}
/// Returns true when the stream begins with an SSLv2 style ClientHello.
///
/// SSLv2 framing sets the high bit of the first length byte and places the
/// message type in the first body byte. Type 1 is CLIENT-HELLO. Some old
/// malware opens with this backward compatible hello even when it intends to
/// negotiate TLS, so detecting it keeps the TLS parser from misreading the
/// SSLv2 header as a TLS record.
#[must_use]
pub fn is_sslv2_client_hello(stream: &[u8]) -> bool {
stream.len() >= 3 && (stream[0] & 0x80) != 0 && stream[2] == 1
}
/// Parses an SSLv2 style ClientHello into the common ClientHello shape.
///
/// SSLv2 carries no extensions, supported groups, or point formats, so those
/// stay empty, which matches the community consensus for fingerprinting an
/// SSLv2 hello. Cipher specs are three bytes each. Specs that begin with a zero
/// byte are SSLv3 and TLS cipher suites carried in the backward compatible
/// hello, and those are the values a fingerprint cares about, so they are
/// extracted as their two byte suite numbers. True SSLv2 only specs are
/// counted but cannot be expressed as two byte suites and are skipped.
pub fn parse_sslv2_client_hello(stream: &[u8]) -> Result<ClientHello<'static>> {
let mut r = Reader::new(stream);
let len_hi = r.u8()? & 0x7f;
let len_lo = r.u8()?;
let _record_len = (u16::from(len_hi) << 8) | u16::from(len_lo);
let msg_type = r.u8()?;
if msg_type != 1 {
return Err(ParseError::UnexpectedHandshake(msg_type));
}
let legacy_version = r.u16()?;
let cipher_spec_len = r.u16()? as usize;
let session_id_len = r.u16()? as usize;
let challenge_len = r.u16()? as usize;
let cipher_specs = r.take(cipher_spec_len)?;
let _session_id = r.take(session_id_len)?;
let _challenge = r.take(challenge_len)?;
let mut cipher_suites = SmallVec::new();
let mut specs = Reader::new(cipher_specs);
while specs.remaining() >= 3 {
let kind = specs.u8()?;
let suite = specs.u16()?;
if kind == 0 {
cipher_suites.push(suite);
}
}
Ok(ClientHello {
legacy_version,
cipher_suites,
extensions: SmallVec::new(),
is_sslv2: true,
})
}
#[cfg(test)]
mod tests {
use super::{first_handshake_message, handshake_bytes, is_sslv2_client_hello};
use crate::registry::handshake_type;
fn record(ctype: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![ctype, 0x03, 0x03];
let len = u16::try_from(payload.len()).unwrap();
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(payload);
v
}
#[test]
fn single_record_borrows() {
let stream = record(22, &[0x01, 0x00, 0x00, 0x00]);
let hs = handshake_bytes(&stream).unwrap();
assert!(matches!(hs, std::borrow::Cow::Borrowed(_)));
assert_eq!(hs.as_ref(), &[0x01, 0x00, 0x00, 0x00]);
}
#[test]
fn fragmented_records_join() {
let mut stream = record(22, &[0x01, 0x00, 0x00, 0x06, 0xaa, 0xbb]);
stream.extend(record(22, &[0xcc, 0xdd, 0xee, 0xff]));
let hs = handshake_bytes(&stream).unwrap();
assert!(matches!(hs, std::borrow::Cow::Owned(_)));
assert_eq!(hs.as_ref().len(), 10);
}
#[test]
fn finds_the_requested_message() {
let hs = [
0x02, 0x00, 0x00, 0x01, 0x99, 0x01, 0x00, 0x00, 0x02, 0xaa, 0xbb,
];
let body = first_handshake_message(&hs, handshake_type::CLIENT_HELLO).unwrap();
assert_eq!(body, &[0xaa, 0xbb]);
}
#[test]
fn sslv2_detection() {
assert!(is_sslv2_client_hello(&[0x80, 0x2e, 0x01, 0x00, 0x02]));
assert!(!is_sslv2_client_hello(&[0x16, 0x03, 0x01, 0x00]));
}
}

View File

@ -0,0 +1,347 @@
// ©AngelaMos | 2026
// decode.rs
use std::net::{IpAddr, SocketAddr};
use etherparse::{EtherType, NetSlice, SlicedPacket, TransportSlice};
use smallvec::SmallVec;
use crate::ja4t::TcpFingerprintInput;
/// LINKTYPE registry numbers this decoder understands.
///
/// The values come from the tcpdump link layer header type registry. They are
/// redeclared here as plain constants because pcap file readers and live
/// captures both report them as bare integers, and the decoder is the single
/// place that interprets them.
pub mod link_type {
pub const NULL: i32 = 0;
pub const ETHERNET: i32 = 1;
pub const RAW: i32 = 101;
pub const LOOP: i32 = 108;
pub const LINUX_SLL: i32 = 113;
pub const IPV4: i32 = 228;
pub const IPV6: i32 = 229;
pub const LINUX_SLL2: i32 = 276;
}
/// The BSD null and loopback link headers are four bytes of address family.
const NULL_HEADER_LEN: usize = 4;
/// The Linux cooked capture v2 header is twenty bytes with the protocol in the
/// first two.
const SLL2_HEADER_LEN: usize = 20;
/// The TCP kind numbers the JA4T walk extracts values from.
const TCP_OPT_END: u8 = 0;
const TCP_OPT_NOP: u8 = 1;
const TCP_OPT_MSS: u8 = 2;
const TCP_OPT_WSCALE: u8 = 3;
const TCP_OPT_MSS_LEN: u8 = 4;
const TCP_OPT_WSCALE_LEN: u8 = 3;
/// The TCP flag bits, exactly as byte thirteen of the header carries them.
///
/// Keeping the flags as the wire bitfield instead of a fistful of bools means
/// the struct mirrors the protocol and reads the byte the packet already
/// holds, rather than rebuilding it from a handful of accessor calls. Adding
/// a flag later is then a constant, not a field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TcpFlags(u8);
impl TcpFlags {
pub const FIN: u8 = 0x01;
pub const SYN: u8 = 0x02;
pub const RST: u8 = 0x04;
pub const ACK: u8 = 0x10;
/// The offset of the flags byte within a TCP header.
const FLAGS_BYTE: usize = 13;
#[must_use]
pub const fn new(bits: u8) -> Self {
Self(bits)
}
/// Reads the flags byte from a TCP header slice.
///
/// A missing byte cannot happen for a slice the decoder hands in, since
/// the transport layer is only present when a full header parsed, but the
/// bounds checked read keeps this honest under direct unit testing.
#[must_use]
fn from_header(header: &[u8]) -> Self {
Self(header.get(Self::FLAGS_BYTE).copied().unwrap_or(0))
}
#[must_use]
pub const fn syn(self) -> bool {
self.0 & Self::SYN != 0
}
#[must_use]
pub const fn ack(self) -> bool {
self.0 & Self::ACK != 0
}
#[must_use]
pub const fn fin(self) -> bool {
self.0 & Self::FIN != 0
}
#[must_use]
pub const fn rst(self) -> bool {
self.0 & Self::RST != 0
}
}
/// The TCP level facts about one decoded segment.
#[derive(Debug, Clone, Copy)]
pub struct TcpMeta {
pub seq: u32,
pub flags: TcpFlags,
pub window_size: u16,
}
/// One TCP segment decoded out of a captured frame.
///
/// Addresses are directional: `src` sent this segment to `dst`. The JA4T
/// input is walked eagerly, but only for SYN packets, because those are the
/// only packets whose options JA4T reads and the walk needs the option bytes
/// that do not outlive the decode.
#[derive(Debug)]
pub struct DecodedSegment<'pkt> {
pub src: SocketAddr,
pub dst: SocketAddr,
pub tcp: TcpMeta,
pub syn_fingerprint: Option<TcpFingerprintInput>,
pub payload: &'pkt [u8],
}
/// Why a frame produced no segment. The distinction only feeds counters, but
/// the counters are how an operator learns what a capture was made of.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Skip {
UnsupportedLinkType,
NotIp,
NotTcp,
Malformed,
}
/// Decodes a captured frame down to its TCP segment, if it has one.
///
/// VLAN tags, including stacked QinQ, are stepped over by etherparse. Frames
/// the decoder does not understand are skipped with a reason rather than
/// failing the capture: a fingerprinting pipeline must shrug off the GRE
/// tunnel, the ARP chatter, and the malformed frame that share every real
/// network with the TLS it cares about.
pub fn decode_frame(link: i32, data: &[u8]) -> Result<DecodedSegment<'_>, Skip> {
let sliced = match link {
link_type::ETHERNET => SlicedPacket::from_ethernet(data),
link_type::LINUX_SLL => SlicedPacket::from_linux_sll(data),
link_type::RAW | link_type::IPV4 | link_type::IPV6 => SlicedPacket::from_ip(data),
link_type::NULL | link_type::LOOP => {
let Some(inner) = data.get(NULL_HEADER_LEN..) else {
return Err(Skip::Malformed);
};
SlicedPacket::from_ip(inner)
}
link_type::LINUX_SLL2 => {
let Some(proto) = data.first_chunk::<2>() else {
return Err(Skip::Malformed);
};
let Some(inner) = data.get(SLL2_HEADER_LEN..) else {
return Err(Skip::Malformed);
};
SlicedPacket::from_ether_type(EtherType(u16::from_be_bytes(*proto)), inner)
}
_ => return Err(Skip::UnsupportedLinkType),
};
let sliced = sliced.map_err(|_| Skip::Malformed)?;
let (src_ip, dst_ip): (IpAddr, IpAddr) = match &sliced.net {
Some(NetSlice::Ipv4(v4)) => (
IpAddr::V4(v4.header().source_addr()),
IpAddr::V4(v4.header().destination_addr()),
),
Some(NetSlice::Ipv6(v6)) => (
IpAddr::V6(v6.header().source_addr()),
IpAddr::V6(v6.header().destination_addr()),
),
Some(NetSlice::Arp(_)) | None => return Err(Skip::NotIp),
};
let Some(TransportSlice::Tcp(tcp)) = &sliced.transport else {
return Err(Skip::NotTcp);
};
let flags = TcpFlags::from_header(tcp.slice());
let syn_fingerprint = flags
.syn()
.then(|| tcp_fingerprint_input(tcp.window_size(), tcp.options()));
Ok(DecodedSegment {
src: SocketAddr::new(src_ip, tcp.source_port()),
dst: SocketAddr::new(dst_ip, tcp.destination_port()),
tcp: TcpMeta {
seq: tcp.sequence_number(),
flags,
window_size: tcp.window_size(),
},
syn_fingerprint,
payload: tcp.payload(),
})
}
/// Walks raw TCP options into the JA4T input.
///
/// JA4T records every option kind in order, including each NOP and each
/// trailing end of list byte, because the padding pattern is part of how an
/// operating system's stack writes a SYN. The walk is deliberately tolerant:
/// a truncated or nonsense length byte ends the walk after recording the kind
/// it was found on, so a hostile SYN cannot push the parser out of bounds.
pub fn tcp_fingerprint_input(window_size: u16, options: &[u8]) -> TcpFingerprintInput {
let mut kinds: SmallVec<[u8; 8]> = SmallVec::new();
let mut mss = 0u16;
let mut window_scale = 0u8;
let mut i = 0;
while i < options.len() {
let kind = options[i];
kinds.push(kind);
if kind == TCP_OPT_END || kind == TCP_OPT_NOP {
i += 1;
continue;
}
let Some(&len) = options.get(i + 1) else {
break;
};
if len < 2 {
break;
}
let Some(body) = options.get(i + 2..i + usize::from(len)) else {
break;
};
if kind == TCP_OPT_MSS && len == TCP_OPT_MSS_LEN {
if let Some(value) = body.first_chunk::<2>() {
mss = u16::from_be_bytes(*value);
}
}
if kind == TCP_OPT_WSCALE && len == TCP_OPT_WSCALE_LEN {
if let Some(&value) = body.first() {
window_scale = value;
}
}
i += usize::from(len);
}
TcpFingerprintInput {
window_size,
option_kinds: kinds,
mss,
window_scale,
}
}
#[cfg(test)]
mod tests {
use super::{Skip, TcpFlags, decode_frame, link_type, tcp_fingerprint_input};
use crate::ja4t::ja4t;
use etherparse::PacketBuilder;
fn tcp_frame(payload: &[u8]) -> Vec<u8> {
let builder = PacketBuilder::ethernet2([1; 6], [2; 6])
.ipv4([10, 0, 0, 1], [10, 0, 0, 2], 64)
.tcp(40000, 443, 1000, 64240);
let mut out = Vec::with_capacity(builder.size(payload.len()));
builder.write(&mut out, payload).unwrap();
out
}
#[test]
fn flags_byte_decodes_to_the_right_bits() {
let syn = TcpFlags::from_header(&[0u8; 14]);
assert!(!syn.syn());
let mut header = [0u8; 20];
header[13] = TcpFlags::SYN | TcpFlags::ACK;
let flags = TcpFlags::from_header(&header);
assert!(flags.syn() && flags.ack());
assert!(!flags.fin() && !flags.rst());
assert!(!TcpFlags::from_header(&[]).syn());
}
#[test]
fn decodes_an_ethernet_tcp_frame() {
let frame = tcp_frame(b"hello");
let seg = decode_frame(link_type::ETHERNET, &frame).unwrap();
assert_eq!(seg.src.to_string(), "10.0.0.1:40000");
assert_eq!(seg.dst.to_string(), "10.0.0.2:443");
assert_eq!(seg.tcp.seq, 1000);
assert_eq!(seg.payload, b"hello");
}
#[test]
fn decodes_a_vlan_tagged_frame() {
let builder = PacketBuilder::ethernet2([1; 6], [2; 6])
.single_vlan(etherparse::VlanId::try_new(7).unwrap())
.ipv4([10, 0, 0, 1], [10, 0, 0, 2], 64)
.tcp(40000, 443, 1, 64240);
let mut frame = Vec::with_capacity(builder.size(0));
builder.write(&mut frame, &[]).unwrap();
let seg = decode_frame(link_type::ETHERNET, &frame).unwrap();
assert_eq!(seg.dst.port(), 443);
}
#[test]
fn non_tcp_and_garbage_are_skips_not_panics() {
let builder = PacketBuilder::ethernet2([1; 6], [2; 6])
.ipv4([10, 0, 0, 1], [10, 0, 0, 2], 64)
.udp(5000, 53);
let mut udp = Vec::with_capacity(builder.size(4));
builder.write(&mut udp, &[0xde, 0xad, 0xbe, 0xef]).unwrap();
assert!(matches!(
decode_frame(link_type::ETHERNET, &udp),
Err(Skip::NotTcp)
));
assert!(matches!(
decode_frame(link_type::ETHERNET, &[0x01, 0x02]),
Err(Skip::Malformed)
));
assert!(matches!(
decode_frame(147, &udp),
Err(Skip::UnsupportedLinkType)
));
}
#[test]
fn ja4t_walk_reproduces_the_linux_default_vector() {
let options = [
0x02, 0x04, 0x05, 0xb4, 0x01, 0x03, 0x03, 0x08, 0x01, 0x01, 0x04, 0x02,
];
let input = tcp_fingerprint_input(64240, &options);
assert_eq!(ja4t(&input), "64240_2-1-3-1-1-4_1460_8");
}
#[test]
fn ja4t_walk_counts_trailing_end_of_list_padding() {
let options = [
0x02, 0x04, 0x05, 0x42, 0x01, 0x03, 0x03, 0x06, 0x01, 0x01, 0x08, 0x0a, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x02, 0x00, 0x00,
];
let input = tcp_fingerprint_input(65535, &options);
assert_eq!(ja4t(&input), "65535_2-1-3-1-1-8-4-0-0_1346_6");
}
#[test]
fn ja4t_walk_survives_truncated_options() {
let input = tcp_fingerprint_input(1024, &[0x02, 0x04, 0x05]);
assert_eq!(input.option_kinds.as_slice(), &[0x02]);
assert_eq!(input.mss, 0);
let zero_len = tcp_fingerprint_input(1024, &[0x05, 0x00, 0x02]);
assert_eq!(zero_len.option_kinds.as_slice(), &[0x05]);
}
}

View File

@ -0,0 +1,135 @@
// ©AngelaMos | 2026
// event.rs
use std::fmt;
use std::net::SocketAddr;
use serde::Serialize;
use crate::fingerprint::{Ja3, Ja4Family};
/// A fingerprint produced by one direction of one flow, without addressing.
///
/// The protocol layer emits these; the pipeline wraps them with the flow's
/// addresses and timestamp to make a [`FingerprintEvent`]. Keeping the two
/// layers apart means the protocol extractor can be tested with bare byte
/// streams, no packets required.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StreamEvent {
ClientHello {
ja3: Ja3,
ja3_raw: String,
ja4: Ja4Family,
sni: Option<String>,
alpn: Option<String>,
},
ServerHello {
ja3s: Ja3,
ja3s_raw: String,
ja4s: Ja4Family,
},
Certificate {
ja4x: String,
},
HttpRequest {
ja4h: Ja4Family,
method: String,
host: Option<String>,
},
TcpSyn {
ja4t: String,
},
TcpSynAck {
ja4ts: String,
},
}
/// One fingerprint observation, addressed and timestamped.
///
/// `src` is always the party that sent the fingerprinted bytes: the client
/// for a ClientHello or SYN, the server for a ServerHello or certificate.
#[derive(Debug, Clone, Serialize)]
pub struct FingerprintEvent {
pub ts_nanos: u64,
pub src: SocketAddr,
pub dst: SocketAddr,
#[serde(flatten)]
pub event: StreamEvent,
}
impl fmt::Display for FingerprintEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let secs = self.ts_nanos / 1_000_000_000;
let millis = self.ts_nanos % 1_000_000_000 / 1_000_000;
write!(f, "{secs}.{millis:03} {} -> {} ", self.src, self.dst)?;
match &self.event {
StreamEvent::ClientHello {
ja3,
ja4,
sni,
alpn,
..
} => {
write!(f, "client_hello ja4={} ja3={ja3}", ja4.hash)?;
if let Some(sni) = sni {
write!(f, " sni={sni}")?;
}
if let Some(alpn) = alpn {
write!(f, " alpn={alpn}")?;
}
Ok(())
}
StreamEvent::ServerHello { ja3s, ja4s, .. } => {
write!(f, "server_hello ja4s={} ja3s={ja3s}", ja4s.hash)
}
StreamEvent::Certificate { ja4x } => write!(f, "certificate ja4x={ja4x}"),
StreamEvent::HttpRequest { ja4h, method, host } => {
write!(f, "http_request ja4h={} method={method}", ja4h.hash)?;
if let Some(host) = host {
write!(f, " host={host}")?;
}
Ok(())
}
StreamEvent::TcpSyn { ja4t } => write!(f, "tcp_syn ja4t={ja4t}"),
StreamEvent::TcpSynAck { ja4ts } => write!(f, "tcp_syn_ack ja4ts={ja4ts}"),
}
}
}
#[cfg(test)]
mod tests {
use super::{FingerprintEvent, StreamEvent};
#[test]
fn json_shape_is_tagged_and_flat() {
let event = FingerprintEvent {
ts_nanos: 1_500_000_000,
src: "10.0.0.1:40000".parse().unwrap(),
dst: "10.0.0.2:443".parse().unwrap(),
event: StreamEvent::Certificate {
ja4x: "7d5dbb3783b4_ba7ce0880c07_7bf9a7bf7029".into(),
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["kind"], "certificate");
assert_eq!(json["ja4x"], "7d5dbb3783b4_ba7ce0880c07_7bf9a7bf7029");
assert_eq!(json["src"], "10.0.0.1:40000");
}
#[test]
fn display_is_one_greppable_line() {
let event = FingerprintEvent {
ts_nanos: 1_234_000_000,
src: "10.0.0.1:40000".parse().unwrap(),
dst: "10.0.0.2:443".parse().unwrap(),
event: StreamEvent::TcpSyn {
ja4t: "64240_2-1-3-1-1-4_1460_8".into(),
},
};
assert_eq!(
event.to_string(),
"1.234 10.0.0.1:40000 -> 10.0.0.2:443 tcp_syn ja4t=64240_2-1-3-1-1-4_1460_8"
);
}
}

View File

@ -0,0 +1,430 @@
// ©AngelaMos | 2026
// flow.rs
use std::collections::BTreeMap;
use std::net::SocketAddr;
/// A bidirectional flow identity.
///
/// The two endpoints are stored in sorted order so that a packet and its reply
/// hash to the same key. Which endpoint is the client is a separate question,
/// answered by who sent the SYN or, failing that, who spoke a ClientHello, and
/// it is deliberately not baked into the key: captures routinely start in the
/// middle of connections, and a key that guessed wrong would split one
/// conversation into two.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FlowKey {
pub lo: SocketAddr,
pub hi: SocketAddr,
}
/// Which endpoint of a [`FlowKey`] sent a given segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
FromLo,
FromHi,
}
impl Direction {
#[must_use]
pub const fn index(self) -> usize {
match self {
Direction::FromLo => 0,
Direction::FromHi => 1,
}
}
/// The source and destination addresses of traffic flowing this way.
#[must_use]
pub const fn addresses(self, key: &FlowKey) -> (SocketAddr, SocketAddr) {
match self {
Direction::FromLo => (key.lo, key.hi),
Direction::FromHi => (key.hi, key.lo),
}
}
}
impl FlowKey {
/// Normalizes a directional (source, destination) pair into a key plus the
/// direction the packet travelled.
#[must_use]
pub fn from_pair(src: SocketAddr, dst: SocketAddr) -> (Self, Direction) {
if src <= dst {
(Self { lo: src, hi: dst }, Direction::FromLo)
} else {
(Self { lo: dst, hi: src }, Direction::FromHi)
}
}
}
/// The midpoint of the sequence space. In TCP serial arithmetic an offset at
/// or beyond this point is read as the segment sitting behind the anchor, not
/// absurdly far ahead of it.
const HALF_SERIAL_SPACE: u32 = 0x8000_0000;
/// Resource limits for one reassembled direction of one flow.
#[derive(Debug, Clone, Copy)]
pub struct ReassemblyLimits {
/// Most contiguous bytes kept. Everything a passive fingerprinter reads
/// sits in the first kilobytes of a stream, so this is a cap on patience,
/// not on correctness.
pub max_assembled_bytes: usize,
/// Most bytes parked in the out of order buffer.
pub max_pending_bytes: usize,
/// Most segments parked in the out of order buffer.
pub max_pending_segments: usize,
}
/// What [`StreamReassembler::push`] did with a segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushOutcome {
/// The contiguous stream grew; the protocol layer should look again.
Grew,
/// Nothing new: a duplicate, pure overlap, or empty segment.
Unchanged,
/// The segment was parked out of order for later.
Parked,
/// The segment fell outside the window this reassembler is willing to
/// track, or a buffer limit was hit, and it was dropped.
Dropped,
}
/// Reassembles one direction of a TCP stream into contiguous bytes.
///
/// This is the piece most toy fingerprinting tools skip, and skipping it is
/// why they miss handshakes: a ClientHello, and even more so a certificate
/// chain, regularly spans several segments, and those segments arrive
/// reordered on any path with packet loss. The reassembler anchors at the
/// sequence number the SYN names or, on a flow whose start the capture
/// missed, at the first segment it sees. Everything else is a relative
/// offset from that anchor in wrapping serial arithmetic: in order segments
/// append to one contiguous buffer, out of order segments park in a map
/// keyed by offset until the gap before them fills.
///
/// Data from before the anchor on a SYN-less flow is gone; a streaming
/// engine cannot retroactively prepend, and accepting that loss explicitly
/// is what Suricata does for midstream pickup too. A segment that straddles
/// the anchor is trimmed to its useful part rather than discarded.
///
/// Overlaps resolve first write wins: bytes already accepted are never
/// rewritten by a later segment. A passive observer cannot know which copy
/// the receiver kept, and the CVE-2018-6794 capture in the test corpus exists
/// precisely because inconsistent overlap handling let attackers show an IDS
/// a different stream than the one the victim read. First write wins is one
/// deterministic, documented answer.
#[derive(Debug)]
pub struct StreamReassembler {
limits: ReassemblyLimits,
anchor: Option<u32>,
assembled: Vec<u8>,
pending: BTreeMap<u32, Vec<u8>>,
pending_bytes: usize,
released: bool,
capped: bool,
}
impl StreamReassembler {
#[must_use]
pub fn new(limits: ReassemblyLimits) -> Self {
Self {
limits,
anchor: None,
assembled: Vec::new(),
pending: BTreeMap::new(),
pending_bytes: 0,
released: false,
capped: false,
}
}
/// The contiguous bytes assembled so far, from the anchor onward.
#[must_use]
pub fn data(&self) -> &[u8] {
&self.assembled
}
/// Pins the stream start, used when a SYN reveals the true initial
/// sequence number before any data arrives. Later anchors are ignored.
pub fn anchor(&mut self, seq: u32) {
if self.anchor.is_none() {
self.anchor = Some(seq);
}
}
/// Drops every buffer and refuses all future data.
///
/// Called once the protocol layer has what it needs, or knows it never
/// will. This is what keeps memory flat when a capture contains long
/// lived flows: the flow entry stays, the payload buffers do not.
pub fn release(&mut self) {
self.assembled = Vec::new();
self.pending = BTreeMap::new();
self.pending_bytes = 0;
self.released = true;
}
#[must_use]
pub fn released(&self) -> bool {
self.released
}
/// True when the assembled cap was hit and the tail of the stream is gone.
#[must_use]
pub fn capped(&self) -> bool {
self.capped
}
/// Offers one segment to the stream.
pub fn push(&mut self, seq: u32, payload: &[u8]) -> PushOutcome {
if self.released || payload.is_empty() {
return PushOutcome::Unchanged;
}
if self.capped {
return PushOutcome::Dropped;
}
let anchor = *self.anchor.get_or_insert(seq);
let offset = seq.wrapping_sub(anchor);
if offset >= HALF_SERIAL_SPACE {
let stale = offset.wrapping_neg() as usize;
if stale >= payload.len() {
return PushOutcome::Unchanged;
}
return self.push(anchor, &payload[stale..]);
}
let window_end = self
.limits
.max_assembled_bytes
.saturating_add(self.limits.max_pending_bytes);
if offset as usize > window_end {
return PushOutcome::Dropped;
}
let assembled_len = self.assembled.len();
if (offset as usize) < assembled_len {
let overlap = assembled_len - offset as usize;
if overlap >= payload.len() {
return PushOutcome::Unchanged;
}
return self.append_in_order(&payload[overlap..]);
}
if offset as usize == assembled_len {
return self.append_in_order(payload);
}
if self.pending.len() >= self.limits.max_pending_segments
|| self.pending_bytes.saturating_add(payload.len()) > self.limits.max_pending_bytes
{
return PushOutcome::Dropped;
}
match self.pending.entry(offset) {
std::collections::btree_map::Entry::Occupied(existing) => {
if existing.get().len() >= payload.len() {
return PushOutcome::Unchanged;
}
self.pending_bytes += payload.len() - existing.get().len();
*existing.into_mut() = payload.to_vec();
}
std::collections::btree_map::Entry::Vacant(slot) => {
self.pending_bytes += payload.len();
slot.insert(payload.to_vec());
}
}
PushOutcome::Parked
}
fn append_in_order(&mut self, payload: &[u8]) -> PushOutcome {
let room = self
.limits
.max_assembled_bytes
.saturating_sub(self.assembled.len());
if room == 0 {
self.mark_capped();
return PushOutcome::Dropped;
}
let take = payload.len().min(room);
self.assembled.extend_from_slice(&payload[..take]);
if take < payload.len() {
self.mark_capped();
} else {
self.drain_pending();
}
PushOutcome::Grew
}
/// Splices parked segments onto the contiguous buffer while they touch it.
fn drain_pending(&mut self) {
while let Some(entry) = self.pending.first_entry() {
let offset = *entry.key() as usize;
if offset > self.assembled.len() {
break;
}
let segment = entry.remove();
self.pending_bytes -= segment.len();
let overlap = self.assembled.len() - offset;
if overlap >= segment.len() {
continue;
}
let room = self
.limits
.max_assembled_bytes
.saturating_sub(self.assembled.len());
let take = (segment.len() - overlap).min(room);
self.assembled
.extend_from_slice(&segment[overlap..overlap + take]);
if take < segment.len() - overlap {
self.mark_capped();
return;
}
}
}
/// Once the assembled cap is hit nothing later can ever become contiguous,
/// so the parked segments are garbage. Drop them and refuse new data, but
/// keep the assembled prefix: it is still a valid stream head and whatever
/// the protocol layer already read from it stands.
fn mark_capped(&mut self) {
self.capped = true;
self.pending = BTreeMap::new();
self.pending_bytes = 0;
}
}
#[cfg(test)]
mod tests {
use super::{FlowKey, PushOutcome, ReassemblyLimits, StreamReassembler};
fn limits() -> ReassemblyLimits {
ReassemblyLimits {
max_assembled_bytes: 64,
max_pending_bytes: 64,
max_pending_segments: 4,
}
}
fn reasm() -> StreamReassembler {
StreamReassembler::new(limits())
}
#[test]
fn both_directions_share_one_key() {
let a: std::net::SocketAddr = "10.0.0.1:40000".parse().unwrap();
let b: std::net::SocketAddr = "10.0.0.2:443".parse().unwrap();
let (forward, fwd_dir) = FlowKey::from_pair(a, b);
let (reverse, rev_dir) = FlowKey::from_pair(b, a);
assert_eq!(forward, reverse);
assert_ne!(fwd_dir, rev_dir);
assert_eq!(fwd_dir.addresses(&forward), (a, b));
assert_eq!(rev_dir.addresses(&reverse), (b, a));
}
#[test]
fn in_order_segments_concatenate() {
let mut r = reasm();
assert_eq!(r.push(100, b"hell"), PushOutcome::Grew);
assert_eq!(r.push(104, b"o"), PushOutcome::Grew);
assert_eq!(r.data(), b"hello");
}
#[test]
fn out_of_order_segments_wait_for_the_gap() {
let mut r = reasm();
assert_eq!(r.push(100, b"hell"), PushOutcome::Grew);
assert_eq!(r.push(107, b"orld"), PushOutcome::Parked);
assert_eq!(r.data(), b"hell");
assert_eq!(r.push(104, b"o w"), PushOutcome::Grew);
assert_eq!(r.data(), b"hello world");
}
#[test]
fn anchor_from_syn_orders_data_arriving_backwards() {
let mut r = reasm();
r.anchor(1000);
assert_eq!(r.push(1004, b"data"), PushOutcome::Parked);
assert_eq!(r.push(1000, b"more"), PushOutcome::Grew);
assert_eq!(r.data(), b"moredata");
}
#[test]
fn retransmissions_change_nothing() {
let mut r = reasm();
r.push(100, b"abcdef");
assert_eq!(r.push(100, b"abcdef"), PushOutcome::Unchanged);
assert_eq!(r.push(102, b"cd"), PushOutcome::Unchanged);
assert_eq!(r.data(), b"abcdef");
}
#[test]
fn overlapping_segment_keeps_the_first_write() {
let mut r = reasm();
r.push(100, b"abcdef");
assert_eq!(r.push(103, b"XXXghi"), PushOutcome::Grew);
assert_eq!(r.data(), b"abcdefghi");
}
#[test]
fn parked_overlap_keeps_the_first_write_too() {
let mut r = reasm();
r.anchor(100);
assert_eq!(r.push(104, b"efgh"), PushOutcome::Parked);
assert_eq!(r.push(100, b"abcdEFG"), PushOutcome::Grew);
assert_eq!(r.data(), b"abcdEFGh");
}
#[test]
fn sequence_numbers_wrap_around_zero() {
let mut r = reasm();
let anchor = u32::MAX - 1;
assert_eq!(r.push(anchor, b"ab"), PushOutcome::Grew);
assert_eq!(r.push(0, b"cd"), PushOutcome::Grew);
assert_eq!(r.push(2, b"ef"), PushOutcome::Grew);
assert_eq!(r.data(), b"abcdef");
}
#[test]
fn stale_pre_anchor_data_is_ignored_and_far_future_dropped() {
let mut r = reasm();
r.push(1000, b"ab");
assert_eq!(r.push(990, b"old"), PushOutcome::Unchanged);
assert_eq!(r.push(100_000, b"far"), PushOutcome::Dropped);
assert_eq!(r.data(), b"ab");
}
#[test]
fn segment_straddling_the_anchor_is_trimmed_not_lost() {
let mut r = reasm();
r.anchor(1000);
assert_eq!(r.push(996, b"oldNEW"), PushOutcome::Grew);
assert_eq!(r.data(), b"EW");
}
#[test]
fn assembled_cap_truncates_but_keeps_the_prefix() {
let mut r = reasm();
let big = vec![0x41u8; 100];
assert_eq!(r.push(0, &big), PushOutcome::Grew);
assert_eq!(r.data().len(), limits().max_assembled_bytes);
assert_eq!(r.push(100, b"more"), PushOutcome::Dropped);
}
#[test]
fn pending_limits_drop_excess_segments() {
let mut r = reasm();
r.anchor(0);
assert_eq!(r.push(10, b"a"), PushOutcome::Parked);
assert_eq!(r.push(20, b"b"), PushOutcome::Parked);
assert_eq!(r.push(30, b"c"), PushOutcome::Parked);
assert_eq!(r.push(40, b"d"), PushOutcome::Parked);
assert_eq!(r.push(50, b"e"), PushOutcome::Dropped);
}
#[test]
fn release_drops_buffers_and_refuses_data() {
let mut r = reasm();
r.push(0, b"abc");
r.release();
assert!(r.released());
assert_eq!(r.data(), b"");
assert_eq!(r.push(3, b"def"), PushOutcome::Unchanged);
}
}

View File

@ -0,0 +1,433 @@
// ©AngelaMos | 2026
// mod.rs
//! The passive fingerprinting pipeline: frames in, fingerprint events out.
//!
//! The stages are deliberately separable. A [`PacketSource`] yields raw link
//! layer frames from a capture file or, later, a live interface. The decoder
//! strips the frame down to a TCP segment. The flow table reassembles each
//! direction of each conversation into a contiguous byte stream, surviving
//! reordering, retransmission, and overlap. The protocol layer watches each
//! stream until it recognizes a TLS flight or an HTTP request head and emits
//! fingerprints. Nothing in here touches a network interface, so the whole
//! pipeline runs byte exact in tests against vendored captures.
pub mod decode;
pub mod event;
pub mod flow;
pub mod source;
pub mod tls;
use std::collections::HashMap;
use serde::Serialize;
use crate::ja4t::ja4t;
use crate::pipeline::decode::{Skip, decode_frame};
use crate::pipeline::event::{FingerprintEvent, StreamEvent};
use crate::pipeline::flow::{FlowKey, PushOutcome, ReassemblyLimits, StreamReassembler};
use crate::pipeline::source::{PacketSource, RawFrame, SourceError};
use crate::pipeline::tls::StreamProtocol;
/// Tuning knobs for the pipeline.
///
/// The defaults are sized for handshake harvesting: generous enough that a
/// fat certificate chain or a multi segment ClientHello always fits, small
/// enough that an adversarial capture cannot turn the flow table into a
/// memory bomb.
#[derive(Debug, Clone, Copy)]
pub struct PipelineConfig {
/// Flows tracked at once before the table sheds its oldest entries.
pub max_flows: usize,
/// A flow untouched for this long is eligible for eviction.
pub idle_timeout_nanos: u64,
/// Contiguous bytes kept per direction.
pub max_assembled_bytes: usize,
/// Out of order bytes parked per direction.
pub max_pending_bytes: usize,
/// Out of order segments parked per direction.
pub max_pending_segments: usize,
}
impl PipelineConfig {
pub const DEFAULT_MAX_FLOWS: usize = 65_536;
pub const DEFAULT_IDLE_TIMEOUT_NANOS: u64 = 60 * 1_000_000_000;
pub const DEFAULT_MAX_ASSEMBLED_BYTES: usize = 256 * 1024;
pub const DEFAULT_MAX_PENDING_BYTES: usize = 256 * 1024;
pub const DEFAULT_MAX_PENDING_SEGMENTS: usize = 128;
fn limits(&self) -> ReassemblyLimits {
ReassemblyLimits {
max_assembled_bytes: self.max_assembled_bytes,
max_pending_bytes: self.max_pending_bytes,
max_pending_segments: self.max_pending_segments,
}
}
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
max_flows: Self::DEFAULT_MAX_FLOWS,
idle_timeout_nanos: Self::DEFAULT_IDLE_TIMEOUT_NANOS,
max_assembled_bytes: Self::DEFAULT_MAX_ASSEMBLED_BYTES,
max_pending_bytes: Self::DEFAULT_MAX_PENDING_BYTES,
max_pending_segments: Self::DEFAULT_MAX_PENDING_SEGMENTS,
}
}
}
/// What the pipeline saw, for the operator and for the miss rate honesty
/// check: a fingerprinting tool that cannot say what it failed to read is a
/// tool whose silence gets mistaken for absence.
#[derive(Debug, Default, Clone, Copy, Serialize)]
pub struct Counters {
pub frames: u64,
pub bytes: u64,
pub tcp_segments: u64,
pub skipped_unsupported_link_type: u64,
pub skipped_not_ip: u64,
pub skipped_not_tcp: u64,
pub skipped_malformed: u64,
pub flows_created: u64,
pub flows_evicted_idle: u64,
pub flows_evicted_pressure: u64,
pub segments_dropped: u64,
pub events: u64,
pub streams_capped: u64,
pub unfinished_tls_streams: u64,
}
/// One direction of one tracked flow.
struct StreamHalf {
reassembler: StreamReassembler,
protocol: StreamProtocol,
syn_fingerprint_emitted: bool,
}
impl StreamHalf {
fn new(limits: ReassemblyLimits) -> Self {
Self {
reassembler: StreamReassembler::new(limits),
protocol: StreamProtocol::Undecided,
syn_fingerprint_emitted: false,
}
}
}
struct FlowState {
halves: [StreamHalf; 2],
last_seen_nanos: u64,
}
impl FlowState {
fn new(limits: ReassemblyLimits) -> Self {
Self {
halves: [StreamHalf::new(limits), StreamHalf::new(limits)],
last_seen_nanos: 0,
}
}
fn finished(&self) -> bool {
self.halves.iter().all(|h| h.protocol.finished())
}
}
/// The passive fingerprinting engine.
///
/// Feed it frames, take events out through the sink closure. The pipeline is
/// synchronous and single threaded by design: one pipeline owns its flow
/// table outright, and running one per worker beats sharing a locked table
/// between workers.
pub struct Pipeline {
config: PipelineConfig,
flows: HashMap<FlowKey, FlowState>,
counters: Counters,
}
impl Pipeline {
#[must_use]
pub fn new(config: PipelineConfig) -> Self {
Self {
config,
flows: HashMap::new(),
counters: Counters::default(),
}
}
#[must_use]
pub fn counters(&self) -> &Counters {
&self.counters
}
/// Drains a source through the pipeline, sending every event to `sink`.
pub fn run<S: PacketSource>(
&mut self,
source: &mut S,
mut sink: impl FnMut(FingerprintEvent),
) -> Result<(), SourceError> {
while let Some(frame) = source.next_frame()? {
self.feed(&frame, &mut sink);
}
self.finish();
Ok(())
}
/// Processes one captured frame.
pub fn feed(&mut self, frame: &RawFrame<'_>, sink: &mut impl FnMut(FingerprintEvent)) {
self.counters.frames += 1;
self.counters.bytes += frame.data.len() as u64;
let segment = match decode_frame(frame.link_type, frame.data) {
Ok(segment) => segment,
Err(skip) => {
match skip {
Skip::UnsupportedLinkType => {
self.counters.skipped_unsupported_link_type += 1;
}
Skip::NotIp => self.counters.skipped_not_ip += 1,
Skip::NotTcp => self.counters.skipped_not_tcp += 1,
Skip::Malformed => self.counters.skipped_malformed += 1,
}
return;
}
};
self.counters.tcp_segments += 1;
let (key, direction) = FlowKey::from_pair(segment.src, segment.dst);
if !self.flows.contains_key(&key) {
if self.flows.len() >= self.config.max_flows {
self.evict(frame.ts_nanos);
}
self.flows.insert(key, FlowState::new(self.config.limits()));
self.counters.flows_created += 1;
}
let Some(flow) = self.flows.get_mut(&key) else {
return;
};
flow.last_seen_nanos = flow.last_seen_nanos.max(frame.ts_nanos);
let (src, dst) = direction.addresses(&key);
let half = &mut flow.halves[direction.index()];
if !half.syn_fingerprint_emitted {
if let Some(input) = &segment.syn_fingerprint {
half.syn_fingerprint_emitted = true;
let fingerprint = ja4t(input);
let event = if segment.tcp.flags.ack() {
StreamEvent::TcpSynAck { ja4ts: fingerprint }
} else {
StreamEvent::TcpSyn { ja4t: fingerprint }
};
self.counters.events += 1;
sink(FingerprintEvent {
ts_nanos: frame.ts_nanos,
src,
dst,
event,
});
}
}
if segment.tcp.flags.syn() {
half.reassembler.anchor(segment.tcp.seq.wrapping_add(1));
}
let payload_seq = if segment.tcp.flags.syn() {
segment.tcp.seq.wrapping_add(1)
} else {
segment.tcp.seq
};
let outcome = half.reassembler.push(payload_seq, segment.payload);
if outcome == PushOutcome::Dropped {
self.counters.segments_dropped += 1;
}
if outcome == PushOutcome::Grew {
let mut emitted = 0u64;
tls::advance(&mut half.protocol, half.reassembler.data(), &mut |event| {
emitted += 1;
sink(FingerprintEvent {
ts_nanos: frame.ts_nanos,
src,
dst,
event,
});
});
self.counters.events += emitted;
if half.protocol.finished() && !half.reassembler.released() {
half.reassembler.release();
}
}
}
/// Settles the books at end of capture.
///
/// Streams that were recognized as TLS but never produced a complete
/// handshake message are counted: each one is a handshake the capture
/// clipped, which is exactly the number an operator needs before trusting
/// an absence of fingerprints.
pub fn finish(&mut self) {
for flow in self.flows.values() {
for half in &flow.halves {
if half.protocol.unfinished_tls() {
self.counters.unfinished_tls_streams += 1;
}
if half.reassembler.capped() {
self.counters.streams_capped += 1;
}
}
}
self.flows.clear();
}
/// Sheds flows when the table is full: everything idle past the timeout
/// or fully harvested goes, and if nothing qualifies, the single stalest
/// flow goes, so the table never refuses a brand new conversation in
/// favor of a dead one.
fn evict(&mut self, now_nanos: u64) {
let timeout = self.config.idle_timeout_nanos;
let idle: Vec<FlowKey> = self
.flows
.iter()
.filter(|(_, flow)| {
now_nanos.saturating_sub(flow.last_seen_nanos) > timeout || flow.finished()
})
.map(|(key, _)| *key)
.collect();
if idle.is_empty() {
let stalest = self
.flows
.iter()
.min_by_key(|(_, flow)| flow.last_seen_nanos)
.map(|(key, _)| *key);
if let Some(key) = stalest {
self.drop_flow(key);
self.counters.flows_evicted_pressure += 1;
}
return;
}
for key in idle {
self.drop_flow(key);
self.counters.flows_evicted_idle += 1;
}
}
fn drop_flow(&mut self, key: FlowKey) {
if let Some(flow) = self.flows.remove(&key) {
for half in &flow.halves {
if half.protocol.unfinished_tls() {
self.counters.unfinished_tls_streams += 1;
}
if half.reassembler.capped() {
self.counters.streams_capped += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{Pipeline, PipelineConfig};
use crate::pipeline::event::FingerprintEvent;
use crate::pipeline::source::RawFrame;
use etherparse::PacketBuilder;
fn tcp_frame(src: ([u8; 4], u16), dst: ([u8; 4], u16), seq: u32, payload: &[u8]) -> Vec<u8> {
let builder = PacketBuilder::ethernet2([1; 6], [2; 6])
.ipv4(src.0, dst.0, 64)
.tcp(src.1, dst.1, seq, 64240);
let mut out = Vec::with_capacity(builder.size(payload.len()));
builder.write(&mut out, payload).unwrap();
out
}
fn feed_all(pipeline: &mut Pipeline, frames: &[Vec<u8>]) -> Vec<FingerprintEvent> {
let mut events = Vec::new();
for (i, data) in frames.iter().enumerate() {
let frame = RawFrame {
ts_nanos: u64::try_from(i).unwrap() * 1_000_000,
link_type: 1,
data,
};
pipeline.feed(&frame, &mut |e| events.push(e));
}
events
}
#[test]
fn http_request_split_across_segments_fingerprints_once() {
let client = ([10, 0, 0, 1], 40000);
let server = ([10, 0, 0, 2], 80);
let request = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n";
let (a, b) = request.split_at(20);
let frames = vec![
tcp_frame(client, server, 1000, a),
tcp_frame(client, server, 1000 + u32::try_from(a.len()).unwrap(), b),
];
let mut pipeline = Pipeline::new(PipelineConfig::default());
let events = feed_all(&mut pipeline, &frames);
assert_eq!(events.len(), 1);
assert_eq!(events[0].src.to_string(), "10.0.0.1:40000");
assert_eq!(pipeline.counters().tcp_segments, 2);
}
#[test]
fn out_of_order_delivery_after_a_syn_still_fingerprints() {
let client = ([10, 0, 0, 1], 40001);
let server = ([10, 0, 0, 2], 80);
let request = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n";
let (a, b) = request.split_at(20);
let syn = {
let builder = PacketBuilder::ethernet2([1; 6], [2; 6])
.ipv4(client.0, server.0, 64)
.tcp(client.1, server.1, 999, 64240)
.syn();
let mut out = Vec::with_capacity(builder.size(0));
builder.write(&mut out, &[]).unwrap();
out
};
let frames = vec![
syn,
tcp_frame(client, server, 1000 + u32::try_from(a.len()).unwrap(), b),
tcp_frame(client, server, 1000, a),
];
let mut pipeline = Pipeline::new(PipelineConfig::default());
let events = feed_all(&mut pipeline, &frames);
assert_eq!(events.len(), 2);
assert!(events[0].to_string().contains("tcp_syn ja4t="));
assert!(events[1].to_string().contains("http_request"));
}
#[test]
fn pressure_eviction_keeps_the_table_bounded() {
let config = PipelineConfig {
max_flows: 4,
..PipelineConfig::default()
};
let mut pipeline = Pipeline::new(config);
let mut frames = Vec::new();
for i in 0..8u16 {
let port = 40000 + i;
frames.push(tcp_frame(
([10, 0, 0, 1], port),
([10, 0, 0, 2], 80),
1,
b"x",
));
}
feed_all(&mut pipeline, &frames);
assert_eq!(pipeline.counters().flows_created, 8);
assert!(pipeline.counters().flows_evicted_pressure >= 4);
}
}

View File

@ -0,0 +1,362 @@
// ©AngelaMos | 2026
// source.rs
use std::fs::File;
use std::io::Read;
use std::path::Path;
use pcap_parser::traits::PcapReaderIterator;
use pcap_parser::{Block, PcapBlockOwned, PcapError, PcapHeader, create_reader};
use thiserror::Error;
/// How many bytes of buffer the file reader starts with.
///
/// A single capture block must fit in the buffer. Offload features such as TSO
/// can put frames far larger than an MTU into a capture, so the buffer starts
/// generous and can still grow up to [`MAX_BUFFER_CAPACITY`] if a bigger block
/// appears.
const INITIAL_BUFFER_CAPACITY: usize = 1024 * 1024;
/// The ceiling for buffer growth. A block larger than this is treated as a
/// malformed capture rather than a reason to exhaust memory.
const MAX_BUFFER_CAPACITY: usize = 64 * 1024 * 1024;
/// Timestamp units per second when a capture does not say otherwise.
///
/// Both the legacy pcap format and the pcapng default are microsecond
/// resolution.
const DEFAULT_UNITS_PER_SECOND: u64 = 1_000_000;
const NANOS_PER_SECOND: u64 = 1_000_000_000;
const NANOS_PER_MICRO: u64 = 1_000;
/// Errors produced while reading frames from a capture source.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SourceError {
#[error("failed to read capture: {0}")]
Io(#[from] std::io::Error),
#[error("not a pcap or pcapng capture")]
NotACapture,
#[error("capture block exceeds the {MAX_BUFFER_CAPACITY} byte buffer ceiling")]
BlockTooLarge,
#[error("malformed capture: {0}")]
Malformed(String),
}
/// One link layer frame as captured, with the metadata needed to decode it.
#[derive(Debug, Clone, Copy)]
pub struct RawFrame<'src> {
/// Capture timestamp in nanoseconds since the epoch. Zero when the capture
/// format carries no timestamp for this frame.
pub ts_nanos: u64,
/// The link layer type, using the tcpdump LINKTYPE registry numbers.
pub link_type: i32,
pub data: &'src [u8],
}
/// A source of captured frames.
///
/// The trait is a lending iterator: each frame borrows from the source and is
/// only valid until the next call. That shape fits both file readers, which
/// hand out windows into an internal buffer, and live captures, which hand out
/// the kernel's buffer. A consumer that needs to keep a frame longer copies
/// it, and that decision stays visible at the call site.
pub trait PacketSource {
/// Returns the next frame, or `None` when the source is exhausted.
fn next_frame(&mut self) -> Result<Option<RawFrame<'_>>, SourceError>;
}
/// Per interface metadata from a pcapng interface description block.
#[derive(Debug, Clone, Copy)]
struct InterfaceInfo {
link_type: i32,
units_per_second: u64,
ts_offset_seconds: i64,
}
/// Everything in the source except the parser, split out so the borrow of the
/// parser's buffer held by a block and the mutable borrow needed to stage a
/// frame land on different fields.
#[derive(Default)]
struct SourceState {
interfaces: Vec<InterfaceInfo>,
legacy: Option<InterfaceInfo>,
legacy_nanos: bool,
frame: Vec<u8>,
frame_ts_nanos: u64,
frame_link_type: i32,
}
impl SourceState {
/// Copies a frame out of the parser's buffer so the borrow on the parser
/// can end before the block is consumed.
fn stage(&mut self, ts_nanos: u64, link_type: i32, data: &[u8]) {
self.frame.clear();
self.frame.extend_from_slice(data);
self.frame_ts_nanos = ts_nanos;
self.frame_link_type = link_type;
}
fn handle_legacy_header(&mut self, header: &PcapHeader) {
self.legacy = Some(InterfaceInfo {
link_type: header.network.0,
units_per_second: DEFAULT_UNITS_PER_SECOND,
ts_offset_seconds: 0,
});
self.legacy_nanos = header.is_nanosecond_precision();
}
/// Stages a packet block. Returns false for metadata blocks.
fn handle_block(&mut self, block: &PcapBlockOwned<'_>) -> bool {
match block {
PcapBlockOwned::LegacyHeader(header) => {
self.handle_legacy_header(header);
false
}
PcapBlockOwned::Legacy(frame) => {
let Some(meta) = self.legacy else {
return false;
};
let fraction = if self.legacy_nanos {
u64::from(frame.ts_usec)
} else {
u64::from(frame.ts_usec) * NANOS_PER_MICRO
};
let ts = u64::from(frame.ts_sec)
.saturating_mul(NANOS_PER_SECOND)
.saturating_add(fraction);
let len = frame.data.len().min(frame.caplen as usize);
self.stage(ts, meta.link_type, &frame.data[..len]);
true
}
PcapBlockOwned::NG(Block::SectionHeader(_)) => {
self.interfaces.clear();
false
}
PcapBlockOwned::NG(Block::InterfaceDescription(idb)) => {
self.interfaces.push(InterfaceInfo {
link_type: idb.linktype.0,
units_per_second: idb.ts_resolution().unwrap_or(DEFAULT_UNITS_PER_SECOND),
ts_offset_seconds: idb.ts_offset(),
});
false
}
PcapBlockOwned::NG(Block::EnhancedPacket(epb)) => {
let Some(meta) = self.interfaces.get(epb.if_id as usize).copied() else {
return false;
};
let units = (u64::from(epb.ts_high) << 32) | u64::from(epb.ts_low);
let ts = scale_to_nanos(units, meta.units_per_second, meta.ts_offset_seconds);
let len = epb.data.len().min(epb.caplen as usize);
self.stage(ts, meta.link_type, &epb.data[..len]);
true
}
PcapBlockOwned::NG(Block::SimplePacket(spb)) => {
let Some(meta) = self.interfaces.first().copied() else {
return false;
};
let len = spb.data.len().min(spb.origlen as usize);
self.stage(0, meta.link_type, &spb.data[..len]);
true
}
PcapBlockOwned::NG(_) => false,
}
}
}
/// Reads frames from a pcap or pcapng file.
///
/// The two formats are probed automatically. pcapng is handled with its full
/// generality: every interface carries its own link type and timestamp
/// resolution, multiple sections reset the interface list, and metadata blocks
/// such as name resolution and decryption secrets are skipped rather than
/// treated as packets. A truncated final packet, the signature of a capture
/// that was stopped rather than closed, ends iteration cleanly and is reported
/// through [`PcapFileSource::truncated`] instead of failing the whole file.
pub struct PcapFileSource {
reader: Box<dyn PcapReaderIterator>,
state: SourceState,
buffer_capacity: usize,
truncated: bool,
finished: bool,
}
impl PcapFileSource {
/// Opens a capture file from a path.
pub fn open(path: impl AsRef<Path>) -> Result<Self, SourceError> {
Self::from_reader(File::open(path)?)
}
/// Builds a source from any byte reader holding pcap or pcapng data.
pub fn from_reader(reader: impl Read + 'static) -> Result<Self, SourceError> {
let reader = create_reader(INITIAL_BUFFER_CAPACITY, reader).map_err(|e| match e {
PcapError::HeaderNotRecognized | PcapError::Eof => SourceError::NotACapture,
PcapError::ReadError => SourceError::Io(std::io::Error::other("read failed")),
other => SourceError::Malformed(other.to_string()),
})?;
Ok(Self {
reader,
state: SourceState::default(),
buffer_capacity: INITIAL_BUFFER_CAPACITY,
truncated: false,
finished: false,
})
}
/// Returns true when the file ended in the middle of a block.
pub fn truncated(&self) -> bool {
self.truncated
}
}
impl PacketSource for PcapFileSource {
fn next_frame(&mut self) -> Result<Option<RawFrame<'_>>, SourceError> {
if self.finished {
return Ok(None);
}
loop {
let staged = match self.reader.next() {
Ok((offset, block)) => {
let staged = self.state.handle_block(&block);
self.reader.consume(offset);
staged
}
Err(PcapError::Eof) => {
self.finished = true;
return Ok(None);
}
Err(PcapError::UnexpectedEof) => {
self.finished = true;
self.truncated = true;
return Ok(None);
}
Err(PcapError::Incomplete(_)) => {
self.reader
.refill()
.map_err(|e| SourceError::Malformed(e.to_string()))?;
continue;
}
Err(PcapError::BufferTooSmall) => {
let grown = self.buffer_capacity.saturating_mul(2);
if grown > MAX_BUFFER_CAPACITY || !self.reader.grow(grown) {
self.finished = true;
return Err(SourceError::BlockTooLarge);
}
self.buffer_capacity = grown;
continue;
}
Err(PcapError::ReadError) => {
self.finished = true;
return Err(SourceError::Io(std::io::Error::other("read failed")));
}
Err(
e @ (PcapError::HeaderNotRecognized
| PcapError::NomError(..)
| PcapError::OwnedNomError(..)),
) => {
self.finished = true;
return Err(SourceError::Malformed(e.to_string()));
}
};
if staged {
return Ok(Some(RawFrame {
ts_nanos: self.state.frame_ts_nanos,
link_type: self.state.frame_link_type,
data: &self.state.frame,
}));
}
}
}
}
/// Converts a timestamp in interface units to nanoseconds since the epoch.
///
/// The arithmetic runs in 128 bits so the conversion stays exact for every
/// resolution pcapng can express, including nanosecond counts that already
/// fill most of a u64.
fn scale_to_nanos(units: u64, units_per_second: u64, offset_seconds: i64) -> u64 {
if units_per_second == 0 {
return 0;
}
let nanos = u128::from(units) * u128::from(NANOS_PER_SECOND) / u128::from(units_per_second);
let offset = i128::from(offset_seconds) * i128::from(NANOS_PER_SECOND);
u64::try_from(
i128::try_from(nanos)
.unwrap_or(i128::MAX)
.saturating_add(offset),
)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{PacketSource, PcapFileSource, SourceError, scale_to_nanos};
fn legacy_pcap(frames: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&0xa1b2_c3d4_u32.to_le_bytes());
v.extend_from_slice(&2u16.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
v.extend_from_slice(&0i32.to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&65535u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
for (i, frame) in frames.iter().enumerate() {
let len = u32::try_from(frame.len()).unwrap();
v.extend_from_slice(&u32::try_from(i + 1).unwrap().to_le_bytes());
v.extend_from_slice(&500_000u32.to_le_bytes());
v.extend_from_slice(&len.to_le_bytes());
v.extend_from_slice(&len.to_le_bytes());
v.extend_from_slice(frame);
}
v
}
#[test]
fn reads_legacy_frames_with_timestamps() {
let data = legacy_pcap(&[&[0xaa; 14], &[0xbb; 20]]);
let mut source = PcapFileSource::from_reader(std::io::Cursor::new(data)).unwrap();
let one = source.next_frame().unwrap().unwrap();
assert_eq!(one.link_type, 1);
assert_eq!(one.ts_nanos, 1_500_000_000);
assert_eq!(one.data.len(), 14);
let two = source.next_frame().unwrap().unwrap();
assert_eq!(two.data, &[0xbb; 20]);
assert!(source.next_frame().unwrap().is_none());
assert!(!source.truncated());
}
#[test]
fn truncated_final_frame_ends_cleanly() {
let mut data = legacy_pcap(&[&[0xaa; 14], &[0xbb; 20]]);
data.truncate(data.len() - 5);
let mut source = PcapFileSource::from_reader(std::io::Cursor::new(data)).unwrap();
assert!(source.next_frame().unwrap().is_some());
assert!(source.next_frame().unwrap().is_none());
assert!(source.truncated());
assert!(source.next_frame().unwrap().is_none());
}
#[test]
fn garbage_is_not_a_capture() {
let err = PcapFileSource::from_reader(std::io::Cursor::new(vec![0x55; 64]))
.err()
.unwrap();
assert!(matches!(err, SourceError::NotACapture));
}
#[test]
fn timestamp_scaling_is_exact_for_common_resolutions() {
assert_eq!(scale_to_nanos(1_500_000, 1_000_000, 0), 1_500_000_000);
assert_eq!(scale_to_nanos(7, 1_000_000_000, 0), 7);
assert_eq!(scale_to_nanos(1, 1, 1), 2_000_000_000);
}
}

View File

@ -0,0 +1,401 @@
// ©AngelaMos | 2026
// tls.rs
use std::borrow::Cow;
use crate::ja3::{ja3, ja3_string, ja3s, ja3s_string};
use crate::ja4::{Transport, ja4, ja4s};
use crate::ja4h::{ja4h, parse_http_request};
use crate::ja4x::ja4x;
use crate::parse::reader::Reader;
use crate::parse::{
certificate_der_list, is_sslv2_client_hello, parse_client_hello, parse_server_hello,
parse_sslv2_client_hello,
};
use crate::pipeline::event::StreamEvent;
use crate::registry::{content_type, handshake_type, version};
/// How many bytes the sniffer needs before it gives up on classifying a
/// stream. Every protocol this pipeline recognizes shows its hand within the
/// first few bytes; eight covers the longest HTTP method prefix.
const SNIFF_DECISION_LEN: usize = 8;
/// The HTTP methods the sniffer accepts as the start of a cleartext request.
const HTTP_METHOD_PREFIXES: [&[u8]; 9] = [
b"GET ",
b"POST ",
b"PUT ",
b"HEAD ",
b"DELETE ",
b"OPTIONS ",
b"PATCH ",
b"TRACE ",
b"CONNECT ",
];
/// A stream whose HTTP request head has not finished inside this many bytes
/// is not worth waiting on.
const HTTP_HEAD_CAP: usize = 8 * 1024;
const HTTP_HEAD_TERMINATOR: &[u8] = b"\r\n\r\n";
/// A TLS record payload cannot exceed 2^14 plus expansion; RFC 8446 allows
/// 255 bytes of expansion on top of the 16384 byte plaintext limit. A length
/// beyond that means the stream is not actually TLS record framing.
const MAX_TLS_RECORD_LEN: usize = 16384 + 255;
/// What one direction of a flow is, as far as the protocol layer can tell.
#[derive(Debug)]
pub enum StreamProtocol {
/// Not enough bytes yet to say.
Undecided,
/// TLS record framing; the cleartext first flight is being extracted.
Tls(TlsFlight),
/// A cleartext HTTP/1.x request head is being accumulated.
Http,
/// Recognized and fully harvested; the stream needs no more buffering.
Done,
/// Unrecognized or unparseable; the stream is ignored.
Ignored,
}
impl StreamProtocol {
/// True when this direction will never produce another event, which is
/// the signal to drop its reassembly buffers.
#[must_use]
pub fn finished(&self) -> bool {
matches!(self, StreamProtocol::Done | StreamProtocol::Ignored)
}
/// True when the stream was recognized as TLS but the capture ended
/// before a complete hello could be read. Feeds the miss rate counter
/// that tells an operator their capture is clipping handshakes.
#[must_use]
pub fn unfinished_tls(&self) -> bool {
match self {
StreamProtocol::Tls(flight) => !flight.saw_any_message,
_ => false,
}
}
}
/// Incremental extraction state for one direction's cleartext TLS flight.
///
/// Only what must survive between walks lives here. A ClientHello ends its
/// direction immediately, so it needs no flag; a ServerHello does not, since
/// a TLS 1.2 Certificate may still be in flight behind it, so the emission
/// guard for it persists.
#[derive(Debug, Default)]
pub struct TlsFlight {
emitted_server_hello: bool,
saw_any_message: bool,
}
/// Drives protocol detection and extraction over one direction of a stream.
///
/// `stream` is always the full contiguous bytes from the start of the
/// direction; the extractor re-walks them on each call. That sounds wasteful
/// and is not: the walk is linear over at most the reassembly cap, the
/// interesting messages sit in the first packets, and re-walking from the
/// start is what makes a message that arrives split across three segments
/// parse correctly with no incremental parser state to get wrong.
///
/// Returns events through `sink` and updates `proto` in place.
pub fn advance(proto: &mut StreamProtocol, stream: &[u8], sink: &mut impl FnMut(StreamEvent)) {
if matches!(proto, StreamProtocol::Undecided) {
*proto = sniff(stream);
}
match proto {
StreamProtocol::Undecided | StreamProtocol::Done | StreamProtocol::Ignored => {}
StreamProtocol::Tls(_) => advance_tls(proto, stream, sink),
StreamProtocol::Http => advance_http(proto, stream, sink),
}
}
/// Classifies the first bytes of a stream.
fn sniff(stream: &[u8]) -> StreamProtocol {
if stream.len() >= 3 {
if stream[0] == content_type::HANDSHAKE && stream[1] == 0x03 && stream[2] <= 0x04 {
return StreamProtocol::Tls(TlsFlight::default());
}
if is_sslv2_client_hello(stream) {
return StreamProtocol::Tls(TlsFlight::default());
}
}
if stream.len() >= SNIFF_DECISION_LEN {
if HTTP_METHOD_PREFIXES.iter().any(|m| stream.starts_with(m)) {
return StreamProtocol::Http;
}
return StreamProtocol::Ignored;
}
StreamProtocol::Undecided
}
fn advance_http(proto: &mut StreamProtocol, stream: &[u8], sink: &mut impl FnMut(StreamEvent)) {
let head_end = stream
.windows(HTTP_HEAD_TERMINATOR.len())
.position(|w| w == HTTP_HEAD_TERMINATOR);
let Some(head_end) = head_end else {
if stream.len() > HTTP_HEAD_CAP {
*proto = StreamProtocol::Ignored;
}
return;
};
let head = &stream[..head_end + HTTP_HEAD_TERMINATOR.len()];
if let Some(request) = parse_http_request(head) {
let host = request
.headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("host"))
.map(|(_, value)| value.clone());
sink(StreamEvent::HttpRequest {
ja4h: ja4h(&request),
method: request.method.clone(),
host,
});
*proto = StreamProtocol::Done;
} else {
*proto = StreamProtocol::Ignored;
}
}
fn advance_tls(proto: &mut StreamProtocol, stream: &[u8], sink: &mut impl FnMut(StreamEvent)) {
if is_sslv2_client_hello(stream) {
advance_sslv2(proto, stream, sink);
return;
}
let Some(flight_bytes) = collect_flight(stream) else {
*proto = StreamProtocol::Ignored;
return;
};
let StreamProtocol::Tls(flight) = proto else {
return;
};
let mut done = walk_messages(flight, flight_bytes.handshake.as_ref(), sink);
if flight_bytes.flight_closed && !done {
done = true;
}
if done {
*proto = StreamProtocol::Done;
}
}
fn advance_sslv2(proto: &mut StreamProtocol, stream: &[u8], sink: &mut impl FnMut(StreamEvent)) {
match parse_sslv2_client_hello(stream) {
Ok(hello) => {
sink(StreamEvent::ClientHello {
ja3: ja3(&hello),
ja3_raw: ja3_string(&hello),
ja4: ja4(&hello, Transport::Tcp),
sni: None,
alpn: None,
});
*proto = StreamProtocol::Done;
}
Err(crate::error::ParseError::Truncated { .. }) => {}
Err(_) => *proto = StreamProtocol::Ignored,
}
}
struct FlightBytes<'stream> {
handshake: Cow<'stream, [u8]>,
/// True when a non handshake record followed the handshake records, which
/// in cleartext TLS means the readable part of the flight is over.
flight_closed: bool,
}
/// Collects the payloads of the leading complete handshake records.
///
/// Unlike the strict reassembled flight walker in the parse module, this
/// tolerates a trailing partial record, because the stream is still growing.
/// Returns `None` when the bytes stop looking like TLS record framing at all.
fn collect_flight(stream: &[u8]) -> Option<FlightBytes<'_>> {
let mut segments: Vec<&[u8]> = Vec::new();
let mut r = Reader::new(stream);
let mut flight_closed = false;
while r.remaining() >= 5 {
let Ok(ctype) = r.u8() else { break };
let Ok(record_version) = r.u16() else { break };
if !matches!(
ctype,
content_type::CHANGE_CIPHER_SPEC
| content_type::ALERT
| content_type::HANDSHAKE
| content_type::APPLICATION_DATA
) {
return None;
}
if (record_version & 0xff00) != 0x0300 || (record_version & 0x00ff) > 0x04 {
return None;
}
let Ok(declared) = r.u16() else { break };
if declared as usize > MAX_TLS_RECORD_LEN {
return None;
}
let Ok(payload) = r.take(declared as usize) else {
break;
};
if ctype == content_type::HANDSHAKE {
segments.push(payload);
} else if !segments.is_empty() {
flight_closed = true;
break;
}
}
let handshake = match segments.as_slice() {
[] => Cow::Borrowed(&[][..]),
[only] => Cow::Borrowed(*only),
many => {
let mut joined = Vec::with_capacity(many.iter().map(|s| s.len()).sum());
for seg in many {
joined.extend_from_slice(seg);
}
Cow::Owned(joined)
}
};
Some(FlightBytes {
handshake,
flight_closed,
})
}
/// Walks the complete handshake messages in the flight, emitting fingerprints
/// for the ones that carry them. Returns true when this direction has yielded
/// everything it ever will.
fn walk_messages(
flight: &mut TlsFlight,
handshake: &[u8],
sink: &mut impl FnMut(StreamEvent),
) -> bool {
let mut r = Reader::new(handshake);
while r.remaining() >= 4 {
let Ok(msg_type) = r.u8() else { break };
let Ok(len) = r.u24() else { break };
let Ok(body) = r.take(len as usize) else {
break;
};
flight.saw_any_message = true;
match msg_type {
handshake_type::CLIENT_HELLO => {
if let Ok(hello) = parse_client_hello(body) {
sink(StreamEvent::ClientHello {
ja3: ja3(&hello),
ja3_raw: ja3_string(&hello),
ja4: ja4(&hello, Transport::Tcp),
sni: hello.server_name().map(str::to_owned),
alpn: hello
.alpn_protocols()
.first()
.map(|p| String::from_utf8_lossy(p).into_owned()),
});
}
return true;
}
handshake_type::SERVER_HELLO => {
if !flight.emitted_server_hello {
if let Ok(hello) = parse_server_hello(body) {
flight.emitted_server_hello = true;
let negotiated_tls13 = hello.selected_version() == version::TLS_1_3;
sink(StreamEvent::ServerHello {
ja3s: ja3s(&hello),
ja3s_raw: ja3s_string(&hello),
ja4s: ja4s(&hello, Transport::Tcp),
});
if negotiated_tls13 {
return true;
}
}
}
}
handshake_type::CERTIFICATE => {
if let Ok(certs) = certificate_der_list(body) {
for cert in certs {
if let Ok(fingerprint) = ja4x(cert) {
sink(StreamEvent::Certificate { ja4x: fingerprint });
}
}
}
return true;
}
_ => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::{StreamProtocol, advance, sniff};
use crate::pipeline::event::StreamEvent;
fn record(payload: &[u8]) -> Vec<u8> {
let mut v = vec![0x16, 0x03, 0x01];
v.extend_from_slice(&u16::try_from(payload.len()).unwrap().to_be_bytes());
v.extend_from_slice(payload);
v
}
#[test]
fn sniffs_tls_http_and_garbage() {
assert!(matches!(
sniff(&[0x16, 0x03, 0x01, 0x00, 0x05]),
StreamProtocol::Tls(_)
));
assert!(matches!(sniff(b"GET / HTTP/1.1\r\n"), StreamProtocol::Http));
assert!(matches!(
sniff(b"SSH-2.0-OpenSSH_9.7"),
StreamProtocol::Ignored
));
assert!(matches!(sniff(b"GE"), StreamProtocol::Undecided));
}
#[test]
fn http_request_yields_ja4h_once_head_completes() {
let mut proto = StreamProtocol::Undecided;
let mut events = Vec::new();
let partial = b"GET / HTTP/1.1\r\nHost: example.com\r\n";
advance(&mut proto, partial, &mut |e| events.push(e));
assert!(events.is_empty());
assert!(matches!(proto, StreamProtocol::Http));
let full = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n";
advance(&mut proto, full, &mut |e| events.push(e));
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
StreamEvent::HttpRequest { method, host: Some(h), .. }
if method == "GET" && h == "example.com"
));
assert!(proto.finished());
}
#[test]
fn partial_tls_record_waits_for_more_bytes() {
let mut proto = StreamProtocol::Undecided;
let mut events = Vec::new();
let full = record(&[0x01, 0x00, 0x00, 0x02, 0xaa, 0xbb]);
advance(&mut proto, &full[..7], &mut |e| events.push(e));
assert!(events.is_empty());
assert!(matches!(proto, StreamProtocol::Tls(_)));
assert!(!proto.finished());
}
#[test]
fn nonsense_record_length_poisons_the_stream() {
let mut proto = StreamProtocol::Undecided;
let mut events = Vec::new();
let stream = [0x16, 0x03, 0x01, 0xff, 0xff, 0x00, 0x00, 0x00];
advance(&mut proto, &stream, &mut |e| events.push(e));
assert!(matches!(proto, StreamProtocol::Ignored));
assert!(events.is_empty());
}
}

View File

@ -0,0 +1,91 @@
// ©AngelaMos | 2026
// registry.rs
//! Named constants for the slices of the TLS and DTLS registries that the
//! fingerprint algorithms reference by number.
//!
//! Wire values are kept as raw `u16` everywhere in this crate rather than being
//! decoded into a closed enum. A fingerprinting engine must preserve cipher and
//! extension values it has never seen, because a future RFC value or a vendor
//! specific extension is exactly the kind of detail that makes one client
//! distinguishable from another. Decoding into a closed enum would collapse all
//! unknown values into a single bucket and corrupt the fingerprint.
/// TLS and DTLS record content types (the first byte of a record).
pub mod content_type {
pub const CHANGE_CIPHER_SPEC: u8 = 20;
pub const ALERT: u8 = 21;
pub const HANDSHAKE: u8 = 22;
pub const APPLICATION_DATA: u8 = 23;
}
/// Handshake message types (the first byte of a handshake message body).
pub mod handshake_type {
pub const CLIENT_HELLO: u8 = 1;
pub const SERVER_HELLO: u8 = 2;
pub const CERTIFICATE: u8 = 11;
}
/// Extension type numbers that the fingerprint algorithms treat specially.
pub mod extension {
pub const SERVER_NAME: u16 = 0x0000;
pub const SUPPORTED_GROUPS: u16 = 0x000a;
pub const EC_POINT_FORMATS: u16 = 0x000b;
pub const SIGNATURE_ALGORITHMS: u16 = 0x000d;
pub const ALPN: u16 = 0x0010;
pub const SUPPORTED_VERSIONS: u16 = 0x002b;
}
/// Legacy and negotiated protocol version words.
pub mod version {
pub const SSL_2_0: u16 = 0x0002;
pub const SSL_3_0: u16 = 0x0300;
pub const TLS_1_0: u16 = 0x0301;
pub const TLS_1_1: u16 = 0x0302;
pub const TLS_1_2: u16 = 0x0303;
pub const TLS_1_3: u16 = 0x0304;
pub const DTLS_1_0: u16 = 0xfeff;
pub const DTLS_1_2: u16 = 0xfefd;
pub const DTLS_1_3: u16 = 0xfefc;
}
/// Maps a protocol version word to the two character JA4 version code.
///
/// The mapping is taken verbatim from the FoxIO JA4 specification. Unknown
/// words collapse to `00`, which is the specified fallback. DTLS words are
/// included even though the Python reference omits them, because the published
/// specification lists them and the Wireshark and Rust references honor them.
#[must_use]
pub fn ja4_version_code(word: u16) -> &'static str {
match word {
version::TLS_1_3 => "13",
version::TLS_1_2 => "12",
version::TLS_1_1 => "11",
version::TLS_1_0 => "10",
version::SSL_3_0 => "s3",
version::SSL_2_0 => "s2",
version::DTLS_1_0 => "d1",
version::DTLS_1_2 => "d2",
version::DTLS_1_3 => "d3",
_ => "00",
}
}
#[cfg(test)]
mod tests {
use super::ja4_version_code;
use super::version;
#[test]
fn known_versions_map() {
assert_eq!(ja4_version_code(version::TLS_1_3), "13");
assert_eq!(ja4_version_code(version::TLS_1_2), "12");
assert_eq!(ja4_version_code(version::SSL_3_0), "s3");
assert_eq!(ja4_version_code(version::DTLS_1_2), "d2");
}
#[test]
fn unknown_version_falls_back() {
assert_eq!(ja4_version_code(0x7f1d), "00");
}
}

View File

@ -0,0 +1,161 @@
// ©AngelaMos | 2026
// mod.rs
//! Builders that assemble TLS handshake bytes for tests.
//!
//! Hand assembling wire bytes keeps the fingerprint tests honest. Rather than
//! trusting a higher level library to produce a ClientHello, the tests state the
//! exact cipher list, extension list, and extension bodies, then assert that the
//! parser and the fingerprint algorithms read them back the way the
//! specifications require.
//!
//! Each integration test binary links this module independently, so a builder
//! method that one binary does not call reads as dead code there even though
//! another binary uses it. The allow keeps that shared infrastructure honest
//! without scattering per method annotations.
#![allow(dead_code)]
/// Assembles a ClientHello handshake message body.
#[derive(Default)]
pub struct ClientHelloBuilder {
legacy_version: u16,
cipher_suites: Vec<u16>,
extensions: Vec<(u16, Vec<u8>)>,
}
impl ClientHelloBuilder {
pub fn new() -> Self {
Self {
legacy_version: 0x0303,
..Self::default()
}
}
pub fn legacy_version(mut self, v: u16) -> Self {
self.legacy_version = v;
self
}
pub fn ciphers(mut self, suites: &[u16]) -> Self {
self.cipher_suites = suites.to_vec();
self
}
pub fn extension(mut self, ext_type: u16, data: Vec<u8>) -> Self {
self.extensions.push((ext_type, data));
self
}
/// Adds a server name indication extension for the given host.
pub fn sni(self, host: &str) -> Self {
let host = host.as_bytes();
let mut entry = vec![0u8];
push_u16_vec(&mut entry, host);
let mut data = Vec::new();
push_u16_vec(&mut data, &entry);
self.extension(0x0000, data)
}
/// Adds a supported groups extension.
pub fn supported_groups(self, groups: &[u16]) -> Self {
let mut list = Vec::new();
for g in groups {
list.extend_from_slice(&g.to_be_bytes());
}
let mut data = Vec::new();
push_u16_vec(&mut data, &list);
self.extension(0x000a, data)
}
/// Adds an elliptic curve point formats extension.
pub fn ec_point_formats(self, formats: &[u8]) -> Self {
let mut data = Vec::new();
push_u8_vec(&mut data, formats);
self.extension(0x000b, data)
}
/// Adds a signature algorithms extension.
pub fn signature_algorithms(self, algs: &[u16]) -> Self {
let mut list = Vec::new();
for a in algs {
list.extend_from_slice(&a.to_be_bytes());
}
let mut data = Vec::new();
push_u16_vec(&mut data, &list);
self.extension(0x000d, data)
}
/// Adds an ALPN extension advertising the given protocols.
pub fn alpn(self, protos: &[&[u8]]) -> Self {
let mut list = Vec::new();
for p in protos {
push_u8_vec(&mut list, p);
}
let mut data = Vec::new();
push_u16_vec(&mut data, &list);
self.extension(0x0010, data)
}
/// Adds a supported versions extension.
pub fn supported_versions(self, versions: &[u16]) -> Self {
let mut list = Vec::new();
for v in versions {
list.extend_from_slice(&v.to_be_bytes());
}
let mut data = Vec::new();
push_u8_vec(&mut data, &list);
self.extension(0x002b, data)
}
/// Returns the handshake message body, the input to `parse_client_hello`.
pub fn build_body(&self) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&self.legacy_version.to_be_bytes());
body.extend_from_slice(&[0u8; 32]);
body.push(0);
let mut ciphers = Vec::new();
for c in &self.cipher_suites {
ciphers.extend_from_slice(&c.to_be_bytes());
}
push_u16_vec(&mut body, &ciphers);
push_u8_vec(&mut body, &[0]);
let mut exts = Vec::new();
for (ext_type, data) in &self.extensions {
exts.extend_from_slice(&ext_type.to_be_bytes());
push_u16_vec(&mut exts, data);
}
push_u16_vec(&mut body, &exts);
body
}
/// Wraps the handshake body in a handshake header and a TLS record so the
/// result is a complete record stream.
pub fn build_record(&self) -> Vec<u8> {
let body = self.build_body();
let mut msg = vec![1u8];
push_u24_vec(&mut msg, &body);
let mut record = vec![22u8, 0x03, 0x01];
push_u16_vec(&mut record, &msg);
record
}
}
fn push_u8_vec(out: &mut Vec<u8>, data: &[u8]) {
out.push(u8::try_from(data.len()).unwrap());
out.extend_from_slice(data);
}
fn push_u16_vec(out: &mut Vec<u8>, data: &[u8]) {
out.extend_from_slice(&u16::try_from(data.len()).unwrap().to_be_bytes());
out.extend_from_slice(data);
}
fn push_u24_vec(out: &mut Vec<u8>, data: &[u8]) {
let len = u32::try_from(data.len()).unwrap();
out.extend_from_slice(&len.to_be_bytes()[1..]);
out.extend_from_slice(data);
}

View File

@ -0,0 +1,55 @@
// ©AngelaMos | 2026
// ja3.rs
mod common;
use common::ClientHelloBuilder;
use tlsfp_core::ja3::{ja3, ja3_string};
use tlsfp_core::parse::parse_client_hello;
/// Rebuilds the exact ClientHello that produces the first published Salesforce
/// JA3 vector, then checks both the pre hash string and the digest end to end.
/// The SNI body content is arbitrary because JA3 records only that extension
/// type zero was present, not its value.
#[test]
fn end_to_end_matches_salesforce_vector_one() {
let body = ClientHelloBuilder::new()
.legacy_version(0x0301)
.ciphers(&[47, 53, 5, 10, 49161, 49162, 49171, 49172, 50, 56, 19, 4])
.extension(0x0000, vec![0x00, 0x00])
.supported_groups(&[23, 24, 25])
.ec_point_formats(&[0])
.build_body();
let ch = parse_client_hello(&body).unwrap();
assert_eq!(
ja3_string(&ch),
"769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0"
);
assert_eq!(ja3(&ch).to_string(), "ada70206e40642a3e4461f35503241d5");
}
/// A ClientHello with GREASE in ciphers, extensions, and supported groups must
/// produce the same JA3 as the same hello without GREASE, because GREASE is
/// stripped from every list field before hashing.
#[test]
fn grease_does_not_change_the_fingerprint() {
let clean = ClientHelloBuilder::new()
.legacy_version(0x0303)
.ciphers(&[0x1301, 0x1302])
.supported_groups(&[0x001d, 0x0017])
.ec_point_formats(&[0])
.build_body();
let greasy = ClientHelloBuilder::new()
.legacy_version(0x0303)
.ciphers(&[0x0a0a, 0x1301, 0x1302])
.extension(0x1a1a, vec![])
.supported_groups(&[0x2a2a, 0x001d, 0x0017])
.ec_point_formats(&[0])
.build_body();
let clean = parse_client_hello(&clean).unwrap();
let greasy = parse_client_hello(&greasy).unwrap();
assert_eq!(ja3(&clean), ja3(&greasy));
}

View File

@ -0,0 +1,88 @@
// ©AngelaMos | 2026
// ja4.rs
mod common;
use common::ClientHelloBuilder;
use tlsfp_core::ja4::{Transport, ja4};
use tlsfp_core::parse::parse_client_hello;
const FOXIO_HASH: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1";
const FOXIO_RAW: &str = "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0015,0017,001b,0023,002b,002d,0033,4469,ff01_0403,0804,0401,0503,0805,0501,0806,0601";
const CIPHERS_ORIGINAL_ORDER: [u16; 15] = [
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8, 0xc013, 0xc014, 0x009c,
0x009d, 0x002f, 0x0035,
];
const OPAQUE_EXTENSIONS: [u16; 10] = [
0x0005, 0x0012, 0x0015, 0x0017, 0x001b, 0x0023, 0x002d, 0x0033, 0x4469, 0xff01,
];
const SIG_ALGS: [u16; 8] = [
0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601,
];
/// Assembles the handshake body for the canonical FoxIO example.
///
/// The cipher suites are supplied out of order with an injected GREASE value, to
/// prove that sorting and GREASE removal happen before hashing. When `reversed`
/// is set the opaque extensions are added in reverse, which must not change the
/// fingerprint because JA4 sorts the extension list.
fn foxio_body(reversed: bool) -> Vec<u8> {
let mut ciphers = vec![0x0a0a];
ciphers.extend_from_slice(&CIPHERS_ORIGINAL_ORDER);
let mut builder = ClientHelloBuilder::new()
.ciphers(&ciphers)
.sni("example.com")
.supported_groups(&[0x001d, 0x0017])
.ec_point_formats(&[0x00])
.signature_algorithms(&SIG_ALGS)
.alpn(&[b"h2"])
.supported_versions(&[0x2a2a, 0x0304, 0x0303]);
if reversed {
for ext in OPAQUE_EXTENSIONS.iter().rev() {
builder = builder.extension(*ext, vec![]);
}
} else {
for ext in OPAQUE_EXTENSIONS {
builder = builder.extension(ext, vec![]);
}
}
builder.build_body()
}
#[test]
fn reproduces_foxio_canonical_example() {
let body = foxio_body(false);
let ch = parse_client_hello(&body).unwrap();
let fp = ja4(&ch, Transport::Tcp);
assert_eq!(fp.raw, FOXIO_RAW);
assert_eq!(fp.hash, FOXIO_HASH);
}
#[test]
fn extension_order_does_not_change_the_hash() {
let normal = foxio_body(false);
let reversed = foxio_body(true);
let normal = parse_client_hello(&normal).unwrap();
let reversed = parse_client_hello(&reversed).unwrap();
assert_eq!(
ja4(&normal, Transport::Tcp).hash,
ja4(&reversed, Transport::Tcp).hash
);
}
#[test]
fn transport_marker_switches_first_character() {
let body = foxio_body(false);
let ch = parse_client_hello(&body).unwrap();
let tcp = ja4(&ch, Transport::Tcp);
let quic = ja4(&ch, Transport::Quic);
assert!(tcp.hash.starts_with('t'));
assert!(quic.hash.starts_with('q'));
assert_eq!(&tcp.hash[1..], &quic.hash[1..]);
}

View File

@ -0,0 +1,64 @@
// ©AngelaMos | 2026
// ja4_suite.rs
use tlsfp_core::ja4::{Transport, ja4s};
use tlsfp_core::ja4h::{ja4h, parse_http_request};
use tlsfp_core::parse::parse_server_hello;
/// Builds the ServerHello behind the FoxIO JA4S example: TLS 1.3 selected via the
/// supported versions extension, the cipher 0x1301, and two extensions in the
/// order key share then supported versions.
fn foxio_server_hello() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes());
body.extend_from_slice(&[0u8; 32]);
body.push(0);
body.extend_from_slice(&0x1301u16.to_be_bytes());
body.push(0);
let mut exts = Vec::new();
exts.extend_from_slice(&0x0033u16.to_be_bytes());
exts.extend_from_slice(&0u16.to_be_bytes());
exts.extend_from_slice(&0x002bu16.to_be_bytes());
exts.extend_from_slice(&2u16.to_be_bytes());
exts.extend_from_slice(&0x0304u16.to_be_bytes());
body.extend_from_slice(&u16::try_from(exts.len()).unwrap().to_be_bytes());
body.extend_from_slice(&exts);
body
}
#[test]
fn ja4s_reproduces_foxio_example() {
let body = foxio_server_hello();
let sh = parse_server_hello(&body).unwrap();
let fp = ja4s(&sh, Transport::Tcp);
assert_eq!(fp.hash, "t130200_1301_234ea6891581");
assert_eq!(fp.raw, "t130200_1301_0033,002b");
}
/// The published JA4H example for a request with four uppercase headers, no
/// cookies, no referer, and no accept language. The header hash is the SHA256 of
/// the comma joined header names in wire order.
#[test]
fn ja4h_reproduces_published_example() {
let raw = b"GET * HTTP/1.1\r\nHOST: a\r\nMAN: b\r\nMX: c\r\nST: d\r\n\r\n";
let req = parse_http_request(raw).unwrap();
let fp = ja4h(&req);
assert_eq!(
fp.hash,
"ge11nn040000_a3c882e23515_000000000000_000000000000"
);
}
/// Cookies and a referer must flip their flags and stop being counted as plain
/// headers, while the accept language must populate the language characters.
#[test]
fn ja4h_flags_cookies_referer_and_language() {
let raw = b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Language: en-US,en;q=0.9\r\nReferer: http://x\r\nCookie: a=1; b=2\r\n\r\n";
let req = parse_http_request(raw).unwrap();
let fp = ja4h(&req);
let prefix = fp.hash.split('_').next().unwrap();
assert_eq!(prefix, "ge11cr02enus");
assert!(!fp.hash.contains("_000000000000_000000000000"));
}

View File

@ -0,0 +1,105 @@
// ©AngelaMos | 2026
// parse.rs
mod common;
use common::ClientHelloBuilder;
use proptest::prelude::*;
use tlsfp_core::parse::{
first_handshake_message, handshake_bytes, parse_client_hello, parse_server_hello,
};
use tlsfp_core::registry::handshake_type;
fn sample() -> ClientHelloBuilder {
ClientHelloBuilder::new()
.ciphers(&[0x0a0a, 0x1301, 0x1302, 0x1303, 0xc02b])
.sni("example.com")
.supported_groups(&[0x1a1a, 0x001d, 0x0017])
.ec_point_formats(&[0x00])
.signature_algorithms(&[0x0403, 0x0804, 0x0401])
.alpn(&[b"h2", b"http/1.1"])
.supported_versions(&[0x2a2a, 0x0304, 0x0303])
}
#[test]
fn parses_every_field_a_fingerprint_reads() {
let body = sample().build_body();
let ch = parse_client_hello(&body).unwrap();
assert_eq!(ch.legacy_version, 0x0303);
assert_eq!(
ch.cipher_suites.as_slice(),
&[0x0a0a, 0x1301, 0x1302, 0x1303, 0xc02b]
);
assert_eq!(ch.extensions.len(), 6);
assert_eq!(ch.server_name(), Some("example.com"));
assert_eq!(ch.supported_groups().as_slice(), &[0x1a1a, 0x001d, 0x0017]);
assert_eq!(ch.ec_point_formats().as_slice(), &[0x00]);
assert_eq!(
ch.signature_algorithms().as_slice(),
&[0x0403, 0x0804, 0x0401]
);
assert_eq!(ch.alpn_protocols().first().copied(), Some(b"h2".as_slice()));
assert_eq!(
ch.supported_versions().as_slice(),
&[0x2a2a, 0x0304, 0x0303]
);
assert!(!ch.is_sslv2);
}
#[test]
fn reads_client_hello_through_the_record_layer() {
let stream = sample().build_record();
let hs = handshake_bytes(&stream).unwrap();
let body = first_handshake_message(&hs, handshake_type::CLIENT_HELLO).unwrap();
let ch = parse_client_hello(body).unwrap();
assert_eq!(ch.cipher_suites.len(), 5);
}
#[test]
fn server_hello_parses_single_cipher() {
let mut body = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes());
body.extend_from_slice(&[0u8; 32]);
body.push(0);
body.extend_from_slice(&0x1301u16.to_be_bytes());
body.push(0);
body.extend_from_slice(&0u16.to_be_bytes());
let sh = parse_server_hello(&body).unwrap();
assert_eq!(sh.cipher_suite, 0x1301);
assert_eq!(sh.legacy_version, 0x0303);
}
#[test]
fn client_hello_without_extensions_parses() {
let body = ClientHelloBuilder::new()
.ciphers(&[0x002f, 0x0035])
.build_body();
let body = &body[..body.len() - 2];
let ch = parse_client_hello(body).unwrap();
assert_eq!(ch.cipher_suites.as_slice(), &[0x002f, 0x0035]);
assert!(ch.extensions.is_empty());
}
proptest! {
#[test]
fn parser_never_panics_on_arbitrary_bytes(bytes in proptest::collection::vec(any::<u8>(), 0..2048)) {
let _ = parse_client_hello(&bytes);
let _ = parse_server_hello(&bytes);
if let Ok(hs) = handshake_bytes(&bytes) {
let _ = first_handshake_message(&hs, handshake_type::CLIENT_HELLO);
}
}
#[test]
fn well_formed_prefix_with_random_extension_tail_is_stable(
tail in proptest::collection::vec(any::<u8>(), 0..256)
) {
let mut ch = ClientHelloBuilder::new().ciphers(&[0x1301, 0x1302]);
ch = ch.extension(0xabcd, tail);
let body = ch.build_body();
let parsed = parse_client_hello(&body).unwrap();
prop_assert_eq!(parsed.cipher_suites.as_slice(), &[0x1301, 0x1302]);
}
}

View File

@ -0,0 +1,196 @@
// ©AngelaMos | 2026
// pipeline.rs
//! Full pipeline known answer tests over the vendored FoxIO captures.
//!
//! These read a real capture file off disk, run it through the entire stack
//! the binary uses, and assert the fingerprints against the values FoxIO
//! published for the same files. A unit test proves an algorithm in isolation;
//! these prove that the file reader, the link layer decoder, the TCP
//! reassembler, the protocol sniffer, and the algorithms together still land
//! on the published answer. Every hash asserted here was independently
//! recomputed from its raw pre hash string with a shell tool before being
//! pinned, so a regression in any stage shows up as a mismatch rather than a
//! silently wrong but self consistent number.
use std::path::PathBuf;
use tlsfp_core::pipeline::event::StreamEvent;
use tlsfp_core::{FingerprintEvent, PcapFileSource, Pipeline, PipelineConfig};
fn pcap_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../testdata/pcap")
.join(name)
}
/// Runs one capture all the way through the pipeline and returns its events.
fn fingerprint(name: &str) -> Vec<FingerprintEvent> {
let mut source =
PcapFileSource::open(pcap_path(name)).unwrap_or_else(|e| panic!("opening {name}: {e}"));
let mut pipeline = Pipeline::new(PipelineConfig::default());
let mut events = Vec::new();
pipeline
.run(&mut source, |event| events.push(event))
.unwrap_or_else(|e| panic!("running {name}: {e}"));
events
}
fn client_hellos(events: &[FingerprintEvent]) -> Vec<(&str, &str)> {
events
.iter()
.filter_map(|e| match &e.event {
StreamEvent::ClientHello { ja4, .. } => Some((ja4.hash.as_str(), ja4.raw.as_str())),
_ => None,
})
.collect()
}
fn ja4s_hashes(events: &[FingerprintEvent]) -> Vec<(&str, &str)> {
events
.iter()
.filter_map(|e| match &e.event {
StreamEvent::ServerHello { ja4s, .. } => Some((ja4s.hash.as_str(), ja4s.raw.as_str())),
_ => None,
})
.collect()
}
fn ja4x_hashes(events: &[FingerprintEvent]) -> Vec<&str> {
events
.iter()
.filter_map(|e| match &e.event {
StreamEvent::Certificate { ja4x } => Some(ja4x.as_str()),
_ => None,
})
.collect()
}
fn ja4h_hashes(events: &[FingerprintEvent]) -> Vec<&str> {
events
.iter()
.filter_map(|e| match &e.event {
StreamEvent::HttpRequest { ja4h, .. } => Some(ja4h.hash.as_str()),
_ => None,
})
.collect()
}
#[test]
fn tls_alpn_h2_reproduces_published_ja4_and_ja4x() {
let events = fingerprint("tls-alpn-h2.pcap");
let hellos = client_hellos(&events);
assert_eq!(hellos.len(), 1);
assert_eq!(hellos[0].0, "t12d4605h2_85626a9a5f7f_aaf95bb78ec9");
let certs = ja4x_hashes(&events);
assert_eq!(
certs.first().copied(),
Some("7d5dbb3783b4_ba7ce0880c07_7bf9a7bf7029")
);
}
#[test]
fn chrome_cloudflare_tcp_handshake_reproduces_published_ja4() {
let events = fingerprint("chrome-cloudflare-quic-with-secrets.pcapng");
let hellos = client_hellos(&events);
assert!(
hellos
.iter()
.any(|(hash, _)| *hash == "t13d1516h2_8daaf6152771_e5627efa2ab1"),
"expected the published chrome JA4, saw {hellos:?}"
);
let servers = ja4s_hashes(&events);
assert!(
servers
.iter()
.any(|(hash, _)| *hash == "t130200_1301_234ea6891581"),
"expected the published cloudflare JA4S, saw {servers:?}"
);
}
#[test]
fn tls_handshake_reproduces_published_ja4s_raw_and_hash() {
let events = fingerprint("tls-handshake.pcapng");
// The capture holds many flows to many Google and Cloudflare endpoints, so
// several distinct JA4S values appear. The published vector is the one for
// the first flow, and it must carry both its published hash and raw form.
let servers = ja4s_hashes(&events);
assert!(
servers
.iter()
.any(|(hash, raw)| *hash == "t130200_1301_234ea6891581"
&& *raw == "t130200_1301_0033,002b"),
"expected the published JA4S with its raw form, saw {servers:?}"
);
let hellos = client_hellos(&events);
assert!(hellos.len() >= 5);
assert!(
hellos
.iter()
.any(|(hash, _)| *hash == "t13d1516h2_8daaf6152771_e5627efa2ab1")
);
}
#[test]
fn http1_with_cookies_reproduces_pinned_ja4h() {
let events = fingerprint("http1-with-cookies.pcapng");
let hashes = ja4h_hashes(&events);
assert_eq!(
hashes,
vec!["ge11cr04da00_8ddaef5d77af_280f366eaa04_c2fb0fe53442"]
);
}
#[test]
fn tls12_capture_recomputes_to_pinned_ja4() {
let events = fingerprint("tls12.pcap");
let hellos = client_hellos(&events);
assert_eq!(hellos.len(), 1);
assert_eq!(hellos[0].0, "t13d1715h2_5b57614c22b0_3d5424432f57");
}
#[test]
fn non_ascii_alpn_follows_the_spec_hex_rule() {
let events = fingerprint("tls-non-ascii-alpn.pcapng");
let hellos = client_hellos(&events);
assert_eq!(hellos.len(), 1);
let (hash, _) = hellos[0];
let alpn_chars = &hash[8..10];
assert_eq!(
alpn_chars, "bd",
"spec rule prints first and last hex chars of the raw ALPN value"
);
}
#[test]
fn browsers_x509_extracts_every_certificate_in_chain_order() {
let events = fingerprint("browsers-x509.pcapng");
let certs = ja4x_hashes(&events);
assert!(
certs.len() >= 7,
"expected several certificates, saw {}",
certs.len()
);
assert!(certs.iter().all(|c| c.matches('_').count() == 2));
}
#[test]
fn tunneled_capture_yields_nothing_without_crashing() {
let events = fingerprint("gre-erspan-vxlan.pcap");
assert!(events.is_empty());
}
#[test]
fn evasion_capture_is_processed_without_panicking() {
let events = fingerprint("CVE-2018-6794.pcap");
assert!(
ja4h_hashes(&events).len() >= 2,
"the reassembler should still recover the HTTP requests"
);
}

View File

@ -0,0 +1,114 @@
// ©AngelaMos | 2026
// reassembly.rs
//! Property tests for the TCP stream reassembler.
//!
//! The reassembler is the component an adversary reaches first: it consumes
//! attacker controlled sequence numbers and segment boundaries on every
//! connection. Two properties pin it down. The first is correctness under
//! reordering: any stream cut into any segments and delivered in any order
//! must reassemble back to the original bytes, because that is the whole job.
//! The second is the absence of panics under fully arbitrary input, because a
//! passive sensor that can be crashed by a crafted segment is a denial of
//! service waiting to happen.
use proptest::prelude::*;
use tlsfp_core::pipeline::flow::{PushOutcome, ReassemblyLimits, StreamReassembler};
fn generous_limits() -> ReassemblyLimits {
ReassemblyLimits {
max_assembled_bytes: 1 << 20,
max_pending_bytes: 1 << 20,
max_pending_segments: 4096,
}
}
/// A stream plus its segment boundaries and a delivery permutation.
fn stream_and_delivery() -> impl Strategy<Value = (Vec<u8>, Vec<(usize, usize)>, Vec<usize>)> {
prop::collection::vec(any::<u8>(), 1..2048).prop_flat_map(|data| {
let len = data.len();
let cuts = prop::collection::vec(0..len, 0..16);
(Just(data), cuts).prop_flat_map(|(data, mut cuts)| {
cuts.sort_unstable();
cuts.dedup();
let boundaries = boundaries_from_cuts(&cuts, data.len());
let permutation = Just((0..boundaries.len()).collect::<Vec<_>>()).prop_shuffle();
(Just(data), Just(boundaries), permutation)
})
})
}
fn boundaries_from_cuts(cuts: &[usize], len: usize) -> Vec<(usize, usize)> {
let mut points = vec![0];
points.extend(cuts.iter().copied().filter(|&c| c > 0 && c < len));
points.push(len);
points.dedup();
points.windows(2).map(|w| (w[0], w[1])).collect()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
/// Any segmentation delivered in any order reassembles to the original.
#[test]
fn arbitrary_segmentation_and_reordering_reconstructs_the_stream(
(data, boundaries, permutation) in stream_and_delivery(),
) {
let base_seq = 0x1234_5678u32;
let mut reasm = StreamReassembler::new(generous_limits());
reasm.anchor(base_seq);
for &segment_index in &permutation {
let (start, end) = boundaries[segment_index];
let seq = base_seq.wrapping_add(u32::try_from(start).unwrap());
reasm.push(seq, &data[start..end]);
}
prop_assert_eq!(reasm.data(), data.as_slice());
}
/// Duplicates layered on top of a complete stream change nothing.
#[test]
fn duplicate_delivery_is_idempotent(
(data, boundaries, permutation) in stream_and_delivery(),
) {
let base_seq = 42u32;
let mut reasm = StreamReassembler::new(generous_limits());
reasm.anchor(base_seq);
for round in 0..2 {
for &segment_index in &permutation {
let (start, end) = boundaries[segment_index];
let seq = base_seq.wrapping_add(u32::try_from(start).unwrap());
let outcome = reasm.push(seq, &data[start..end]);
if round == 1 {
prop_assert!(matches!(
outcome,
PushOutcome::Unchanged | PushOutcome::Grew
));
}
}
}
prop_assert_eq!(reasm.data(), data.as_slice());
}
/// Fully arbitrary segments never panic and never exceed the cap.
#[test]
fn arbitrary_input_never_panics_or_overruns(
segments in prop::collection::vec(
(any::<u32>(), prop::collection::vec(any::<u8>(), 0..64)),
0..256,
),
) {
let limits = ReassemblyLimits {
max_assembled_bytes: 512,
max_pending_bytes: 512,
max_pending_segments: 32,
};
let mut reasm = StreamReassembler::new(limits);
for (seq, payload) in &segments {
reasm.push(*seq, payload);
prop_assert!(reasm.data().len() <= limits.max_assembled_bytes);
}
}
}

View File

@ -0,0 +1,38 @@
# ©AngelaMos | 2026
# Cargo.toml
[package]
name = "tlsfp"
description = "JA3/JA4 TLS fingerprinting tool with live capture, intel matching, and anomaly detection"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
default-run = "tlsfp"
[[bin]]
name = "tlsfp"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
tlsfp-core.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
pcap-parser.workspace = true
pcap.workspace = true
tokio.workspace = true
flume.workspace = true
rusqlite.workspace = true
axum.workspace = true
tower-http.workspace = true
tower.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
etherparse.workspace = true

View File

@ -0,0 +1,126 @@
// ©AngelaMos | 2026
// cli.rs
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use tlsfp_core::{PcapFileSource, Pipeline, PipelineConfig};
/// JA3/JA4 TLS fingerprinting tool.
///
/// Fingerprints TLS clients and servers from live capture or packet captures,
/// matches them against a local intelligence database, and flags anomalies such
/// as a fingerprint that disagrees with its own User-Agent.
#[derive(Debug, Parser)]
#[command(name = "tlsfp", version, about, long_about = None)]
pub struct Cli {
/// Increase log verbosity (repeat for more detail).
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Fingerprint every TLS and QUIC handshake in a packet capture file.
Pcap {
/// Path to a pcap or pcapng file.
path: std::path::PathBuf,
/// Emit one JSON object per event instead of readable lines.
#[arg(long)]
json: bool,
},
/// Capture live from a network interface and fingerprint in real time.
Live {
/// Interface name, for example eth0.
interface: String,
},
/// Serve the web dashboard and HTTP API.
Serve {
/// Address to bind, for example 127.0.0.1:8080.
#[arg(default_value = "127.0.0.1:8080")]
bind: String,
},
}
impl Cli {
pub fn init_tracing(&self) {
let default = match self.verbose {
0 => "tlsfp=info",
1 => "tlsfp=debug",
_ => "tlsfp=trace",
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
tracing_subscriber::fmt().with_env_filter(filter).init();
}
pub fn run(self) -> Result<()> {
match self.command {
Command::Pcap { path, json } => run_pcap(&path, json),
Command::Live { interface } => {
anyhow::bail!("live capture on {interface} is not wired up yet")
}
Command::Serve { bind } => {
anyhow::bail!("dashboard on {bind} is not wired up yet")
}
}
}
}
/// Fingerprints a capture file and prints one event per line on stdout.
///
/// The summary goes to the log rather than stdout so that piping the output
/// into a tool sees only events, while a human still learns how much of the
/// capture was readable and whether the file was cut short mid packet.
fn run_pcap(path: &std::path::Path, json: bool) -> Result<()> {
let mut source = PcapFileSource::open(path)
.with_context(|| format!("cannot open capture {}", path.display()))?;
let mut pipeline = Pipeline::new(PipelineConfig::default());
let stdout = std::io::stdout().lock();
let mut out = std::io::BufWriter::new(stdout);
let mut write_failure = None;
pipeline.run(&mut source, |event| {
use std::io::Write as _;
let result = if json {
serde_json::to_writer(&mut out, &event)
.map_err(anyhow::Error::from)
.and_then(|()| writeln!(out).map_err(anyhow::Error::from))
} else {
writeln!(out, "{event}").map_err(anyhow::Error::from)
};
if write_failure.is_none() {
if let Err(error) = result {
write_failure = Some(error);
}
}
})?;
if let Some(error) = write_failure {
return Err(error.context("writing events to stdout"));
}
{
use std::io::Write as _;
out.flush().context("flushing events to stdout")?;
}
let counters = pipeline.counters();
tracing::info!(
frames = counters.frames,
tcp_segments = counters.tcp_segments,
events = counters.events,
flows = counters.flows_created,
unfinished_tls_streams = counters.unfinished_tls_streams,
segments_dropped = counters.segments_dropped,
"capture processed"
);
if source.truncated() {
tracing::warn!("capture file ended mid packet; the tail was not read");
}
Ok(())
}

View File

@ -0,0 +1,15 @@
// ©AngelaMos | 2026
// main.rs
mod cli;
use anyhow::Result;
use clap::Parser;
use crate::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
cli.init_tracing();
cli.run()
}

View File

@ -0,0 +1,30 @@
# ©AngelaMos | 2026
# deny.toml
[graph]
targets = ["x86_64-unknown-linux-gnu"]
all-features = true
[advisories]
version = 2
yanked = "deny"
[licenses]
version = 2
confidence-threshold = 0.93
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unicode-DFS-2016",
"MPL-2.0",
"CDLA-Permissive-2.0",
]
[bans]
multiple-versions = "warn"
wildcards = "deny"

View File

@ -0,0 +1,4 @@
# ©AngelaMos | 2026
# rustfmt.toml
edition = "2024"

View File

@ -0,0 +1,49 @@
<!-- ©AngelaMos | 2026 -->
<!-- PROVENANCE.md -->
# Test capture provenance
Every capture file in this directory is vendored, bit for bit, from the
FoxIO JA4 reference repository. None of them were created or modified here,
and the SHA-256 digests below let anyone verify that claim offline.
## Source
- Repository: <https://github.com/FoxIO-LLC/ja4>
- Commit: `4ab8e3f18f4b27e0b896c4ec2c251e20506eb87e` (fetched 2026-06-10)
- Path within the repository: `pcap/`
- Fetch URL pattern: `https://raw.githubusercontent.com/FoxIO-LLC/ja4/<commit>/pcap/<file>`
## Licensing
The FoxIO repository carries two licenses at the commit above: `LICENSE-JA4`
(BSD 3-Clause, covering the JA4 TLS client fingerprint) and `LICENSE`
(FoxIO License 1.1, covering the JA4+ suite). These capture files are the
repository's own test fixtures and are redistributed here unmodified, with
attribution, solely as test inputs for this non-commercial educational
project. See `NOTICE.md` at the project root for how the licensing split
applies to this project as a whole.
## Files
| File | SHA-256 | Why it is here |
| --- | --- | --- |
| `tls-handshake.pcapng` | `5a0c9f3d0f437e16fc68c3ce0d87998edf4f229b335c391361ed301bd22a513e` | TLS 1.3 handshake; anchors the published JA4S vector `t130200_1301_234ea6891581` |
| `tls-alpn-h2.pcap` | `8c00fd3e6c370b39dac61ad3a15c693088f74f3dbc836ee4e8f57105b1e84a91` | TLS 1.2 with ALPN h2; anchors the published JA4 vector `t12d4605h2_85626a9a5f7f_aaf95bb78ec9` and the JA4X DigiCert chain vectors |
| `tls12.pcap` | `d8c9ae8781c9bbba3a1bf5a95d7a6f309a3edd14c64a7c8adbc673d337fd5af4` | Minimal TLS 1.2 ClientHello |
| `tls-non-ascii-alpn.pcapng` | `cf1dd939619b8d65904dfd23b4f21c3255b6f513dc2e12117d5de2633d063f71` | ALPN value with non-ASCII bytes; exercises the spec-vs-reference divergence (FoxIO issue 178) |
| `chrome-cloudflare-quic-with-secrets.pcapng` | `b7c9de1238aef44d53dbe1add125a7b9e344e9063b98850c78dec23632b83942` | Chrome to Cloudflare; TCP stream anchors the published JA4 vector `t13d1516h2_8daaf6152771_e5627efa2ab1`, QUIC stream reserved for the QUIC milestone |
| `browsers-x509.pcapng` | `e05937fe5f3659f1b94b46305e419b878ba309ebcba8308750acaac704112906` | Browser certificate chains for JA4X over real captures |
| `http1-with-cookies.pcapng` | `7083ca41bcd09b21cb92e7f2d5bd09d73f25703be8555bb82642c2495eb15ef9` | Cleartext HTTP/1.1 request with cookies for JA4H over a reassembled stream |
| `gre-erspan-vxlan.pcap` | `5bbdb30a0707e21070ece3cd26068f72c34a0750d97c1e7720166cb4d0baf6d6` | Tunneled traffic; proves the decoder skips what it does not understand instead of crashing |
| `CVE-2018-6794.pcap` | `d1aa18b493bc68bf7cb367ce5fc1ee493262b47baff886bbe42533e7495d8b1d` | TCP stream evasion capture; torture input for the reassembler |
| `quic-with-several-tls-frames.pcapng` | `8d2c8a6787b942091aa63e33bde9f28c214b306dc024a66c552077ad30640e71` | QUIC initial with CRYPTO frames split across packets, reserved for the QUIC milestone |
## Verifying
```sh
cd testdata/pcap && sha256sum *.pcap *.pcapng
```
Compare the output against the table above. Any mismatch means a file no
longer matches what FoxIO published at the pinned commit.