use crate::types::{TlsClientFingerprint, TlsVersion};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub(crate) struct ClientHelloFields {
pub tls_version: TlsVersion,
pub cipher_suites: Vec<u16>,
pub extensions: Vec<u16>,
pub sni: Option<String>,
pub supported_groups: Vec<u16>,
pub alpn_protocols: Vec<String>,
}
fn is_grease(val: u16) -> bool {
(val & 0x0f0f) == 0x0a0a
}
fn truncated_sha256(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let digest = hasher.finalize();
hex_encode(&digest[..6]) }
fn ja4_version_str(v: TlsVersion) -> &'static str {
match v {
TlsVersion::Tls13 => "13",
TlsVersion::Tls12 => "12",
}
}
pub(crate) fn compute_ja4(fields: &ClientHelloFields) -> String {
let q = 't'; let version = ja4_version_str(fields.tls_version);
let sni_flag = if fields.sni.is_some() { 'd' } else { 'i' };
let alpn_indicator = if fields.alpn_protocols.is_empty() {
"00".to_string()
} else if fields.alpn_protocols.iter().any(|p| p == "h2") {
"h2".to_string()
} else {
let first = &fields.alpn_protocols[0];
if first.len() >= 2 {
first[..2].to_string()
} else {
format!("{first}0")
}
};
let mut ciphers: Vec<u16> = fields
.cipher_suites
.iter()
.copied()
.filter(|c| !is_grease(*c))
.collect();
let mut exts: Vec<u16> = fields
.extensions
.iter()
.copied()
.filter(|e| !is_grease(*e))
.filter(|e| *e != 0 && *e != 16)
.collect();
ciphers.sort_unstable();
exts.sort_unstable();
let cipher_count = ciphers.len().min(99);
let ext_count = exts.len().min(99);
let cipher_str: String = ciphers
.iter()
.map(|c| format!("{c:04x}"))
.collect::<Vec<_>>()
.join(",");
let ext_str: String = exts
.iter()
.map(|e| format!("{e:04x}"))
.collect::<Vec<_>>()
.join(",");
let cipher_hash = truncated_sha256(&cipher_str);
let ext_hash = truncated_sha256(&ext_str);
format!(
"{q}{version}{sni_flag}{alpn_indicator}{cipher_count:02}{ext_count:02}_{cipher_hash}_{ext_hash}"
)
}
pub(crate) fn build_fingerprint(fields: &ClientHelloFields) -> TlsClientFingerprint {
let ja4 = compute_ja4(fields);
let ciphers_str: String = fields
.cipher_suites
.iter()
.filter(|c| !is_grease(**c))
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join("-");
let exts_str: String = fields
.extensions
.iter()
.filter(|e| !is_grease(**e))
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("-");
let curves_str: String = fields
.supported_groups
.iter()
.filter(|g| !is_grease(**g))
.map(|g| g.to_string())
.collect::<Vec<_>>()
.join("-");
let version_num: u16 = match fields.tls_version {
TlsVersion::Tls13 => 771, TlsVersion::Tls12 => 771,
};
let ja3 = format!("{version_num},{ciphers_str},{exts_str},{curves_str},");
TlsClientFingerprint {
ja4,
ja3,
tls_version: fields.tls_version,
cipher_suites: fields
.cipher_suites
.iter()
.copied()
.filter(|c| !is_grease(*c))
.collect(),
extensions: fields
.extensions
.iter()
.copied()
.filter(|e| !is_grease(*e))
.collect(),
elliptic_curves: fields
.supported_groups
.iter()
.copied()
.filter(|g| !is_grease(*g))
.collect(),
}
}
pub(crate) fn parse_and_fingerprint(buf: &[u8]) -> Option<TlsClientFingerprint> {
let fields = parse_client_hello(buf)?;
Some(build_fingerprint(&fields))
}
fn parse_client_hello(buf: &[u8]) -> Option<ClientHelloFields> {
if buf.len() < 5 {
return None;
}
let content_type = buf[0];
if content_type != 22 {
return None;
}
let record_length = u16::from_be_bytes([buf[3], buf[4]]) as usize;
let record_end = 5 + record_length;
if buf.len() < record_end.min(buf.len()) {
}
let handshake = &buf[5..buf.len().min(record_end)];
if handshake.is_empty() {
return None;
}
let msg_type = handshake[0];
if msg_type != 1 {
return None;
}
if handshake.len() < 4 {
return None;
}
let _hs_length =
((handshake[1] as usize) << 16) | ((handshake[2] as usize) << 8) | (handshake[3] as usize);
let body = &handshake[4..];
parse_client_hello_body(body)
}
fn parse_client_hello_body(buf: &[u8]) -> Option<ClientHelloFields> {
let mut pos = 0;
if buf.len() < pos + 2 {
return None;
}
let client_version = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
pos += 2;
if buf.len() < pos + 32 {
return None;
}
pos += 32;
if buf.len() < pos + 1 {
return None;
}
let session_id_len = buf[pos] as usize;
pos += 1 + session_id_len;
if pos > buf.len() {
return None;
}
if buf.len() < pos + 2 {
return None;
}
let cs_len = u16::from_be_bytes([buf[pos], buf[pos + 1]]) as usize;
pos += 2;
if buf.len() < pos + cs_len {
return None;
}
let mut cipher_suites = Vec::with_capacity(cs_len / 2);
let cs_end = pos + cs_len;
while pos + 1 < cs_end {
cipher_suites.push(u16::from_be_bytes([buf[pos], buf[pos + 1]]));
pos += 2;
}
pos = cs_end;
if buf.len() < pos + 1 {
return None;
}
let comp_len = buf[pos] as usize;
pos += 1 + comp_len;
if pos > buf.len() {
return None;
}
let mut extensions = Vec::new();
let mut sni: Option<String> = None;
let mut supported_groups = Vec::new();
let mut alpn_protocols = Vec::new();
let mut has_supported_versions = false;
let mut max_supported_version: u16 = 0;
if buf.len() > pos + 2 {
let ext_total_len = u16::from_be_bytes([buf[pos], buf[pos + 1]]) as usize;
pos += 2;
let ext_end = (pos + ext_total_len).min(buf.len());
while pos + 4 <= ext_end {
let ext_type = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
let ext_len = u16::from_be_bytes([buf[pos + 2], buf[pos + 3]]) as usize;
pos += 4;
extensions.push(ext_type);
let ext_data_end = (pos + ext_len).min(ext_end);
match ext_type {
0 => {
sni = parse_sni_extension(&buf[pos..ext_data_end]);
}
10 => {
supported_groups = parse_u16_list(&buf[pos..ext_data_end]);
}
16 => {
alpn_protocols = parse_alpn_extension(&buf[pos..ext_data_end]);
}
43 => {
has_supported_versions = true;
if let Some(max_ver) = parse_supported_versions(&buf[pos..ext_data_end]) {
max_supported_version = max_ver;
}
}
_ => {}
}
pos = ext_data_end;
}
}
let tls_version = if has_supported_versions && max_supported_version >= 0x0304 {
TlsVersion::Tls13
} else if client_version >= 0x0303 {
TlsVersion::Tls12
} else {
TlsVersion::Tls12 };
Some(ClientHelloFields {
tls_version,
cipher_suites,
extensions,
sni,
supported_groups,
alpn_protocols,
})
}
fn parse_sni_extension(buf: &[u8]) -> Option<String> {
if buf.len() < 2 {
return None;
}
let mut pos = 2;
if buf.len() < pos + 3 {
return None;
}
let _name_type = buf[pos];
pos += 1;
let name_len = u16::from_be_bytes([buf[pos], buf[pos + 1]]) as usize;
pos += 2;
if buf.len() < pos + name_len {
return None;
}
let name = std::str::from_utf8(&buf[pos..pos + name_len]).ok()?;
Some(name.to_ascii_lowercase())
}
fn parse_u16_list(buf: &[u8]) -> Vec<u16> {
if buf.len() < 2 {
return Vec::new();
}
let list_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
let mut result = Vec::with_capacity(list_len / 2);
let mut pos = 2;
let end = (2 + list_len).min(buf.len());
while pos + 1 < end {
result.push(u16::from_be_bytes([buf[pos], buf[pos + 1]]));
pos += 2;
}
result
}
fn parse_alpn_extension(buf: &[u8]) -> Vec<String> {
if buf.len() < 2 {
return Vec::new();
}
let list_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
let mut result = Vec::new();
let mut pos = 2;
let end = (2 + list_len).min(buf.len());
while pos < end {
let proto_len = buf[pos] as usize;
pos += 1;
if pos + proto_len > end {
break;
}
if let Ok(proto) = std::str::from_utf8(&buf[pos..pos + proto_len]) {
result.push(proto.to_string());
}
pos += proto_len;
}
result
}
fn parse_supported_versions(buf: &[u8]) -> Option<u16> {
if buf.is_empty() {
return None;
}
let list_len = buf[0] as usize;
let mut max_version: u16 = 0;
let mut pos = 1;
let end = (1 + list_len).min(buf.len());
while pos + 1 < end {
let ver = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
if !is_grease(ver) && ver > max_version {
max_version = ver;
}
pos += 2;
}
Some(max_version)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
let mut result = String::with_capacity(bytes.len() * 2);
for &b in bytes {
result.push(HEX_CHARS[(b >> 4) as usize] as char);
result.push(HEX_CHARS[(b & 0x0f) as usize] as char);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grease_values_detected() {
assert!(is_grease(0x0a0a));
assert!(is_grease(0x1a1a));
assert!(is_grease(0xfafa));
assert!(!is_grease(0x0035));
assert!(!is_grease(0x1301));
}
#[test]
fn ja4_format_is_correct() {
let fields = ClientHelloFields {
tls_version: TlsVersion::Tls13,
cipher_suites: vec![0x1301, 0x1302, 0x1303],
extensions: vec![0, 10, 11, 13, 16, 43, 45, 51],
sni: Some("api.openai.com".to_string()),
supported_groups: vec![29, 23, 24],
alpn_protocols: vec!["h2".to_string(), "http/1.1".to_string()],
};
let ja4 = compute_ja4(&fields);
assert!(ja4.starts_with("t13dh2"), "got: {ja4}");
assert!(ja4.contains("0306_"), "got: {ja4}");
assert_eq!(ja4.matches('_').count(), 2, "got: {ja4}");
assert_eq!(ja4.len(), 36, "got: {ja4}");
}
#[test]
fn ja4_no_sni_no_alpn() {
let fields = ClientHelloFields {
tls_version: TlsVersion::Tls12,
cipher_suites: vec![0x002f, 0x0035],
extensions: vec![10, 11, 13],
sni: None,
supported_groups: vec![23],
alpn_protocols: vec![],
};
let ja4 = compute_ja4(&fields);
assert!(ja4.starts_with("t12i00"), "got: {ja4}");
assert!(ja4.contains("0203_"), "got: {ja4}");
}
#[test]
fn parse_minimal_clienthello() {
let mut record = Vec::new();
record.push(22);
record.extend_from_slice(&[0x03, 0x01]);
let length_pos = record.len();
record.extend_from_slice(&[0x00, 0x00]);
record.push(1);
let hs_length_pos = record.len();
record.extend_from_slice(&[0x00, 0x00, 0x00]);
let body_start = record.len();
record.extend_from_slice(&[0x03, 0x03]);
record.extend_from_slice(&[0u8; 32]);
record.push(0);
record.extend_from_slice(&[0x00, 0x04]); record.extend_from_slice(&[0x13, 0x01]); record.extend_from_slice(&[0x13, 0x02]);
record.push(1);
record.push(0);
record.extend_from_slice(&[0x00, 0x00]);
let body_len = record.len() - body_start;
record[hs_length_pos] = ((body_len >> 16) & 0xff) as u8;
record[hs_length_pos + 1] = ((body_len >> 8) & 0xff) as u8;
record[hs_length_pos + 2] = (body_len & 0xff) as u8;
let record_body_len = record.len() - 5;
record[length_pos] = ((record_body_len >> 8) & 0xff) as u8;
record[length_pos + 1] = (record_body_len & 0xff) as u8;
let result = parse_client_hello(&record);
assert!(result.is_some(), "should parse minimal ClientHello");
let fields = result.unwrap();
assert_eq!(fields.cipher_suites, vec![0x1301, 0x1302]);
assert!(fields.sni.is_none());
assert!(fields.alpn_protocols.is_empty());
}
#[test]
fn non_handshake_returns_none() {
let buf = [23, 0x03, 0x03, 0x00, 0x05, 0, 0, 0, 0, 0];
assert!(parse_client_hello(&buf).is_none());
}
#[test]
fn too_short_returns_none() {
assert!(parse_client_hello(&[22, 0x03]).is_none());
assert!(parse_client_hello(&[]).is_none());
}
#[test]
fn fingerprint_round_trip() {
let fields = ClientHelloFields {
tls_version: TlsVersion::Tls13,
cipher_suites: vec![0x1301, 0x1302, 0x1303],
extensions: vec![0, 10, 11, 13, 16, 43, 45, 51],
sni: Some("api.anthropic.com".to_string()),
supported_groups: vec![29, 23, 24],
alpn_protocols: vec!["h2".to_string()],
};
let fp = build_fingerprint(&fields);
assert!(fp.ja4.starts_with("t13dh2"));
assert_eq!(fp.tls_version, TlsVersion::Tls13);
assert_eq!(fp.cipher_suites, vec![0x1301, 0x1302, 0x1303]);
assert!(!fp.ja3.is_empty());
}
}