use md5::{Digest, Md5};
use super::types::TlsClientHello;
pub(crate) fn ja3(ch: &TlsClientHello) -> (String, String) {
let canonical = canonical_string(ch);
let mut hasher = Md5::new();
hasher.update(canonical.as_bytes());
let digest = hasher.finalize();
let hex_md5 = hex::encode(digest);
(canonical, hex_md5)
}
fn canonical_string(ch: &TlsClientHello) -> String {
let version = ch.legacy_version.to_raw();
let ciphers = join_dash(ch.cipher_suites.iter().copied().filter(|c| !is_grease(*c)));
let exts = join_dash(
ch.extension_types
.iter()
.copied()
.filter(|e| !is_grease(*e)),
);
let groups = join_dash(
ch.supported_groups
.iter()
.copied()
.filter(|g| !is_grease(*g)),
);
let ec_point_formats = String::new();
format!("{version},{ciphers},{exts},{groups},{ec_point_formats}")
}
fn join_dash<I: IntoIterator<Item = u16>>(iter: I) -> String {
let mut out = String::new();
for (i, v) in iter.into_iter().enumerate() {
if i > 0 {
out.push('-');
}
out.push_str(&v.to_string());
}
out
}
fn is_grease(v: u16) -> bool {
let lo = v & 0x00FF;
let hi = (v & 0xFF00) >> 8;
lo == hi && (lo & 0x0F) == 0x0A
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grease_detection() {
assert!(is_grease(0x0A0A));
assert!(is_grease(0xFAFA));
assert!(!is_grease(0x1301));
assert!(!is_grease(0x0000));
}
#[test]
fn ja3_known_shape() {
use crate::tls::types::TlsVersion;
use bytes::Bytes;
let ch = TlsClientHello {
record_version: TlsVersion::Tls1_2,
legacy_version: TlsVersion::Tls1_2,
random: [0u8; 32],
session_id: Bytes::new(),
cipher_suites: vec![0x1301, 0x1302, 0x0A0A], compression: vec![0],
sni: None,
alpn: vec![],
supported_versions: vec![],
supported_groups: vec![29, 23],
extension_types: vec![0, 23, 65281],
};
let (canonical, _hash) = ja3(&ch);
assert!(canonical.starts_with("771,4865-4866,"), "got {canonical:?}");
assert!(canonical.ends_with(",29-23,"), "got {canonical:?}");
assert!(!canonical.contains("2570"), "GREASE leaked: {canonical:?}");
}
}