use sha2::{Digest, Sha256};
use super::types::{TlsClientHello, TlsVersion};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ja4Parts {
pub header: String,
pub cipher_hash: String,
pub extension_hash: String,
}
impl std::fmt::Display for Ja4Parts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}_{}_{}",
self.header, self.cipher_hash, self.extension_hash
)
}
}
pub fn ja4(ch: &TlsClientHello) -> String {
ja4_parts(ch).to_string()
}
pub fn ja4_parts(ch: &TlsClientHello) -> Ja4Parts {
let header = build_header(ch);
let ciphers = sorted_non_grease_hex(ch.cipher_suites.iter().copied());
let exts = sorted_non_grease_hex(ch.extension_types.iter().copied());
let cipher_hash = sha256_prefix(&ciphers);
let extension_hash = sha256_prefix(&exts);
Ja4Parts {
header,
cipher_hash,
extension_hash,
}
}
fn build_header(ch: &TlsClientHello) -> String {
let transport = 't';
let version = pick_version(ch);
let version_code = version_to_two_digits(version);
let sni_flag = if ch.sni.is_some() { 'd' } else { 'i' };
let cipher_count = count_non_grease(ch.cipher_suites.iter().copied()).min(99);
let ext_count = count_non_grease(ch.extension_types.iter().copied()).min(99);
let alpn = alpn_code(ch.alpn.first().map(String::as_str));
format!("{transport}{version_code}{sni_flag}{cipher_count:02}{ext_count:02}{alpn}")
}
fn pick_version(ch: &TlsClientHello) -> TlsVersion {
ch.supported_versions
.iter()
.copied()
.filter(|v| !is_grease_version(*v))
.max_by_key(version_rank)
.unwrap_or(ch.legacy_version)
}
fn version_rank(v: &TlsVersion) -> u8 {
match v {
TlsVersion::Ssl3_0 => 0,
TlsVersion::Tls1_0 => 1,
TlsVersion::Tls1_1 => 2,
TlsVersion::Tls1_2 => 3,
TlsVersion::Tls1_3 => 4,
_ => 0,
}
}
pub(super) fn version_to_two_digits(v: TlsVersion) -> &'static str {
match v {
TlsVersion::Ssl3_0 => "s3",
TlsVersion::Tls1_0 => "10",
TlsVersion::Tls1_1 => "11",
TlsVersion::Tls1_2 => "12",
TlsVersion::Tls1_3 => "13",
_ => "00",
}
}
pub(super) fn is_grease_version(v: TlsVersion) -> bool {
is_grease_u16(v.to_raw())
}
pub(super) fn is_grease_u16(v: u16) -> bool {
let hi = (v >> 8) & 0xff;
let lo = v & 0xff;
hi == lo && (lo & 0x0f) == 0x0a
}
pub(super) fn count_non_grease<I: Iterator<Item = u16>>(iter: I) -> usize {
iter.filter(|v| !is_grease_u16(*v)).count()
}
fn sorted_non_grease_hex<I: Iterator<Item = u16>>(iter: I) -> String {
let mut items: Vec<u16> = iter.filter(|v| !is_grease_u16(*v)).collect();
items.sort_unstable();
non_grease_hex_joined(items.into_iter())
}
pub(super) fn non_grease_hex_joined<I: Iterator<Item = u16>>(iter: I) -> String {
iter.filter(|v| !is_grease_u16(*v))
.map(|v| format!("{v:04x}"))
.collect::<Vec<_>>()
.join(",")
}
pub(super) fn alpn_code(alpn: Option<&str>) -> String {
match alpn.filter(|s| !s.is_empty()) {
Some(s) => {
let first = s.chars().next().unwrap_or('0');
let last = s.chars().next_back().unwrap_or('0');
format!("{first}{last}")
}
None => "00".to_string(),
}
}
pub(super) fn sha256_prefix(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..6]) }
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
fn sample_ch() -> TlsClientHello {
TlsClientHello {
record_version: TlsVersion::Tls1_0,
legacy_version: TlsVersion::Tls1_2,
random: [0u8; 32],
session_id: Bytes::new(),
cipher_suites: vec![
0x1301, 0x1302, 0xc02f, ],
compression: bytes::Bytes::from_static(&[0]),
sni: Some("example.com".to_string()),
alpn: vec!["h2".to_string()],
supported_versions: vec![TlsVersion::Tls1_2, TlsVersion::Tls1_3],
supported_groups: vec![],
extension_types: vec![0, 16, 43, 45],
ech_present: false,
ech_config_id: None,
sni_is_outer: false,
}
}
#[test]
fn grease_detection() {
assert!(is_grease_u16(0x0a0a));
assert!(is_grease_u16(0xfafa));
assert!(!is_grease_u16(0x1301));
assert!(!is_grease_u16(0x0000));
}
#[test]
fn header_format() {
let ch = sample_ch();
let parts = ja4_parts(&ch);
assert!(parts.header.starts_with("t13d"));
assert!(parts.header.ends_with("0304h2"));
}
#[test]
fn no_sni_uses_i_flag() {
let mut ch = sample_ch();
ch.sni = None;
let parts = ja4_parts(&ch);
assert!(parts.header.contains('i'));
assert!(!parts.header.contains('d'));
}
#[test]
fn grease_excluded_from_count_and_hash() {
let mut ch = sample_ch();
ch.cipher_suites.insert(0, 0x0a0a);
ch.extension_types.insert(0, 0x1a1a);
let parts = ja4_parts(&ch);
assert!(parts.header.contains("0304"));
}
#[test]
fn ordering_invariance() {
let mut a = sample_ch();
let mut b = sample_ch();
a.cipher_suites = vec![0x1301, 0x1302, 0xc02f];
b.cipher_suites = vec![0xc02f, 0x1301, 0x1302];
let pa = ja4_parts(&a);
let pb = ja4_parts(&b);
assert_eq!(pa.cipher_hash, pb.cipher_hash);
}
#[test]
fn display_formats_underscore_joined() {
let ch = sample_ch();
let parts = ja4_parts(&ch);
let s = parts.to_string();
assert_eq!(s.matches('_').count(), 2);
}
}