use super::{
ja4::{
alpn_code, count_non_grease, is_grease_version, non_grease_hex_joined, sha256_prefix,
version_to_two_digits,
},
types::{TlsServerHello, TlsVersion},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Ja4sParts {
pub header: String,
pub cipher: String,
pub extension_hash: String,
}
impl std::fmt::Display for Ja4sParts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}_{}_{}", self.header, self.cipher, self.extension_hash)
}
}
pub fn ja4s(sh: &TlsServerHello) -> String {
ja4s_parts(sh).to_string()
}
pub fn ja4s_parts(sh: &TlsServerHello) -> Ja4sParts {
Ja4sParts {
header: build_header(sh),
cipher: format!("{:04x}", sh.cipher_suite),
extension_hash: sha256_prefix(&non_grease_hex_joined(sh.extension_types.iter().copied())),
}
}
fn build_header(sh: &TlsServerHello) -> String {
let transport = 't';
let version = pick_version(sh);
let version_code = version_to_two_digits(version);
let ext_count = count_non_grease(sh.extension_types.iter().copied()).min(99);
let alpn = alpn_code(sh.alpn.as_deref());
format!("{transport}{version_code}{ext_count:02}{alpn}")
}
fn pick_version(sh: &TlsServerHello) -> TlsVersion {
match sh.supported_version {
Some(v) if !is_grease_version(v) => v,
_ => sh.legacy_version,
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use super::*;
fn sample_sh() -> TlsServerHello {
TlsServerHello {
record_version: TlsVersion::Tls1_2,
legacy_version: TlsVersion::Tls1_2,
random: [0u8; 32],
session_id: Bytes::new(),
cipher_suite: 0x1301, compression: 0,
alpn: Some("h2".to_string()),
supported_version: Some(TlsVersion::Tls1_3),
extension_types: vec![43, 51], ech_retry_configs: false,
}
}
#[test]
fn header_uses_negotiated_version_ext_count_and_alpn() {
let parts = ja4s_parts(&sample_sh());
assert_eq!(parts.header, "t1302h2");
assert_eq!(parts.cipher, "1301");
assert_eq!(parts.extension_hash.len(), 12);
}
#[test]
fn falls_back_to_legacy_version_without_supported_versions() {
let mut sh = sample_sh();
sh.supported_version = None; let parts = ja4s_parts(&sh);
assert!(parts.header.starts_with("t12"), "header = {}", parts.header);
}
#[test]
fn no_alpn_renders_double_zero() {
let mut sh = sample_sh();
sh.alpn = None;
let parts = ja4s_parts(&sh);
assert!(parts.header.ends_with("00"), "header = {}", parts.header);
}
#[test]
fn alpn_first_and_last_char() {
let mut sh = sample_sh();
sh.alpn = Some("http/1.1".to_string());
let parts = ja4s_parts(&sh);
assert!(parts.header.ends_with("h1"), "header = {}", parts.header);
}
#[test]
fn grease_excluded_from_ext_count_and_hash() {
let mut sh = sample_sh();
let clean = ja4s_parts(&sh);
sh.extension_types.insert(0, 0x0a0a);
let greased = ja4s_parts(&sh);
assert_eq!(clean.header, greased.header, "GREASE changed the header");
assert_eq!(
clean.extension_hash, greased.extension_hash,
"GREASE changed the extension hash"
);
}
#[test]
fn extension_order_is_significant() {
let mut a = sample_sh();
let mut b = sample_sh();
a.extension_types = vec![43, 51];
b.extension_types = vec![51, 43];
assert_ne!(ja4s_parts(&a).extension_hash, ja4s_parts(&b).extension_hash);
}
#[test]
fn display_is_underscore_joined() {
let s = ja4s(&sample_sh());
assert_eq!(s.matches('_').count(), 2);
}
}