#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum EchState {
NotPresent,
LikelyReal,
LikelyGrease,
Rejected,
}
impl EchState {
pub fn as_str(&self) -> &'static str {
match self {
EchState::NotPresent => "not_present",
EchState::LikelyReal => "likely_real",
EchState::LikelyGrease => "likely_grease",
EchState::Rejected => "rejected",
}
}
}
impl std::fmt::Display for EchState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub fn ech_cover_domains() -> &'static [&'static str] {
&["cloudflare-ech.com"]
}
pub fn is_ech_cover_domain(sni: &str) -> bool {
if sni.is_empty() {
return false;
}
let lower = sni.to_ascii_lowercase();
for &suffix in ech_cover_domains() {
if lower == suffix {
return true;
}
if lower.len() > suffix.len() + 1 {
let dot_offset = lower.len() - suffix.len() - 1;
if &lower[dot_offset..dot_offset + 1] == "." && &lower[dot_offset + 1..] == suffix {
return true;
}
}
}
false
}
pub fn classify_client_hello(ech_present: bool, sni: Option<&str>) -> EchState {
if !ech_present {
return EchState::NotPresent;
}
match sni {
Some(s) if is_ech_cover_domain(s) => EchState::LikelyReal,
_ => EchState::LikelyGrease,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_vocabulary_locked() {
assert_eq!(EchState::NotPresent.as_str(), "not_present");
assert_eq!(EchState::LikelyReal.as_str(), "likely_real");
assert_eq!(EchState::LikelyGrease.as_str(), "likely_grease");
assert_eq!(EchState::Rejected.as_str(), "rejected");
}
#[test]
fn cover_domain_known_suffix() {
assert!(is_ech_cover_domain("cloudflare-ech.com"));
assert!(is_ech_cover_domain("public.cloudflare-ech.com"));
assert!(is_ech_cover_domain("foo.bar.cloudflare-ech.com"));
}
#[test]
fn cover_domain_case_insensitive() {
assert!(is_ech_cover_domain("CLOUDFLARE-ECH.COM"));
assert!(is_ech_cover_domain("Public.CloudFlare-Ech.Com"));
}
#[test]
fn cover_domain_unknown() {
assert!(!is_ech_cover_domain("example.com"));
assert!(!is_ech_cover_domain("google.com"));
assert!(!is_ech_cover_domain(""));
}
#[test]
fn cover_domain_suffix_not_substring() {
assert!(!is_ech_cover_domain("cloudflare-ech.com.attacker.tld"));
assert!(!is_ech_cover_domain("fakecloudflare-ech.com"));
}
#[test]
fn classify_no_extension_is_not_present() {
assert_eq!(classify_client_hello(false, None), EchState::NotPresent);
assert_eq!(
classify_client_hello(false, Some("example.com")),
EchState::NotPresent
);
assert_eq!(
classify_client_hello(false, Some("cloudflare-ech.com")),
EchState::NotPresent
);
}
#[test]
fn classify_extension_present_known_cover_is_likely_real() {
assert_eq!(
classify_client_hello(true, Some("cloudflare-ech.com")),
EchState::LikelyReal
);
assert_eq!(
classify_client_hello(true, Some("a.cloudflare-ech.com")),
EchState::LikelyReal
);
}
#[test]
fn classify_extension_present_other_sni_is_likely_grease() {
assert_eq!(
classify_client_hello(true, Some("example.com")),
EchState::LikelyGrease
);
assert_eq!(classify_client_hello(true, None), EchState::LikelyGrease);
}
}