#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenShape {
Plausible,
Suspect,
Decoy,
}
pub struct TokenOracle {
vendor: &'static str,
min_len: usize,
max_len: usize,
char_ok: fn(char) -> bool,
extra: fn(&str) -> Option<&'static str>,
min_entropy_bits_per_byte: f32,
}
impl TokenOracle {
pub fn vendor(&self) -> &'static str {
self.vendor
}
pub fn classify(&self, token: &str) -> TokenShape {
if token.is_empty() {
return TokenShape::Decoy;
}
if token.len() < self.min_len {
return TokenShape::Decoy;
}
if token.len() > self.max_len {
return TokenShape::Suspect;
}
let mut bad_chars = 0usize;
for c in token.chars() {
if !(self.char_ok)(c) {
bad_chars += 1;
if bad_chars > 2 {
return TokenShape::Decoy;
}
}
}
let entropy = shannon_entropy_bits_per_byte(token.as_bytes());
if entropy < self.min_entropy_bits_per_byte - 1.0 {
return TokenShape::Decoy;
}
if entropy < self.min_entropy_bits_per_byte {
return TokenShape::Suspect;
}
if let Some(_failure) = (self.extra)(token) {
return TokenShape::Suspect;
}
TokenShape::Plausible
}
}
pub fn for_vendor(vendor: &str) -> Option<TokenOracle> {
match vendor {
"cloudflare-turnstile" | "turnstile" => Some(TokenOracle {
vendor: "cloudflare-turnstile",
min_len: 200,
max_len: 2048,
char_ok: is_base64url_or_dot,
extra: turnstile_extra,
min_entropy_bits_per_byte: 5.0,
}),
"recaptcha-v2" | "recaptcha" => Some(TokenOracle {
vendor: "recaptcha-v2",
min_len: 200,
max_len: 8192,
char_ok: is_base64url_or_dot_or_dash_or_underscore,
extra: recaptcha_v2_extra,
min_entropy_bits_per_byte: 5.0,
}),
"recaptcha-v3" => Some(TokenOracle {
vendor: "recaptcha-v3",
min_len: 200,
max_len: 8192,
char_ok: is_base64url_or_dot_or_dash_or_underscore,
extra: |_| None,
min_entropy_bits_per_byte: 5.0,
}),
"recaptcha-enterprise" => Some(TokenOracle {
vendor: "recaptcha-enterprise",
min_len: 200,
max_len: 8192,
char_ok: is_base64url_or_dot_or_dash_or_underscore,
extra: |_| None,
min_entropy_bits_per_byte: 5.0,
}),
"hcaptcha" => Some(TokenOracle {
vendor: "hcaptcha",
min_len: 80,
max_len: 4096,
char_ok: is_base64url_or_dot_or_dash_or_underscore,
extra: |_| None,
min_entropy_bits_per_byte: 4.5,
}),
_ => None,
}
}
fn is_base64url_or_dot(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
}
fn is_base64url_or_dot_or_dash_or_underscore(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+' || c == '/' || c == '='
}
fn turnstile_extra(token: &str) -> Option<&'static str> {
if token.starts_with("0.") || token.starts_with("1.") {
None
} else {
Some("turnstile token missing 0./1. revision prefix")
}
}
fn recaptcha_v2_extra(token: &str) -> Option<&'static str> {
let dot_count = token.bytes().filter(|b| *b == b'.').count();
if dot_count >= 2 {
None
} else {
Some("recaptcha v2 token must have JWT-shape (>=2 dots)")
}
}
fn shannon_entropy_bits_per_byte(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 0.0;
}
let mut freq = [0u32; 256];
for b in bytes {
freq[*b as usize] += 1;
}
let len = bytes.len() as f32;
let mut entropy = 0.0f32;
for f in freq.iter() {
if *f == 0 {
continue;
}
let p = (*f as f32) / len;
entropy -= p * p.log2();
}
entropy
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_token_is_always_decoy_regardless_of_vendor() {
for vendor in ["cloudflare-turnstile", "recaptcha-v2", "hcaptcha", "recaptcha-v3"] {
let o = for_vendor(vendor).unwrap();
assert_eq!(o.classify(""), TokenShape::Decoy, "{vendor} on empty token");
}
}
#[test]
fn for_vendor_recognises_canonical_aliases() {
assert!(for_vendor("turnstile").is_some());
assert!(for_vendor("cloudflare-turnstile").is_some());
assert!(for_vendor("recaptcha").is_some());
assert!(for_vendor("recaptcha-v2").is_some());
}
#[test]
fn for_vendor_returns_none_for_unknown_vendor() {
assert!(for_vendor("totally-not-a-vendor").is_none());
assert!(for_vendor("").is_none());
assert!(for_vendor("Cloudflare-Turnstile").is_none());
}
#[test]
fn turnstile_short_decoy_is_decoy() {
let o = for_vendor("cloudflare-turnstile").unwrap();
assert_eq!(o.classify("decoy"), TokenShape::Decoy);
assert_eq!(o.classify("0.short"), TokenShape::Decoy);
}
#[test]
fn turnstile_realistic_long_random_token_is_plausible() {
let mut tok = String::from("0.");
for i in 0..248u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 17 % 64) as usize] as char);
}
let o = for_vendor("cloudflare-turnstile").unwrap();
assert_eq!(
o.classify(&tok),
TokenShape::Plausible,
"synthetic CF-shaped token must be Plausible"
);
}
#[test]
fn turnstile_long_token_without_revision_prefix_is_suspect_not_decoy() {
let mut tok = String::with_capacity(250);
for i in 0..250u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 17 % 64) as usize] as char);
}
let o = for_vendor("cloudflare-turnstile").unwrap();
assert_eq!(o.classify(&tok), TokenShape::Suspect);
}
#[test]
fn recaptcha_v2_jwt_shape_passes() {
let mut tok = String::with_capacity(300);
for i in 0..100u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 13 % 64) as usize] as char);
}
tok.push('.');
for i in 0..100u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 19 % 64) as usize] as char);
}
tok.push('.');
for i in 0..50u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 23 % 64) as usize] as char);
}
let o = for_vendor("recaptcha-v2").unwrap();
assert_eq!(o.classify(&tok), TokenShape::Plausible);
}
#[test]
fn recaptcha_v2_single_segment_is_suspect() {
let tok: String = (0..300u32)
.map(|i| {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
ALPH[(i * 17 % 62) as usize] as char
})
.collect();
let o = for_vendor("recaptcha-v2").unwrap();
assert_eq!(o.classify(&tok), TokenShape::Suspect);
}
#[test]
fn token_with_whitespace_is_decoy() {
let o = for_vendor("cloudflare-turnstile").unwrap();
let mut decoy = String::from("0.this is not a real token because it has spaces");
while decoy.len() < 250 {
decoy.push_str(" decoy");
}
assert_eq!(o.classify(&decoy), TokenShape::Decoy);
}
#[test]
fn low_entropy_repeating_is_decoy() {
let o = for_vendor("cloudflare-turnstile").unwrap();
let decoy: String = "a".repeat(250);
assert_eq!(o.classify(&decoy), TokenShape::Decoy);
}
#[test]
fn shannon_entropy_of_uniform_random_is_near_eight() {
let bytes: Vec<u8> = (0u8..=255).collect();
let e = shannon_entropy_bits_per_byte(&bytes);
assert!(e > 7.5, "entropy of full-alphabet was {e}");
}
#[test]
fn shannon_entropy_of_constant_is_zero() {
let bytes = [b'a'; 100];
let e = shannon_entropy_bits_per_byte(&bytes);
assert!(e < 0.001);
}
#[test]
fn over_long_token_is_suspect_not_decoy() {
let o = for_vendor("hcaptcha").unwrap();
let huge: String = (0..10_000u32)
.map(|i| {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
ALPH[(i as usize) % ALPH.len()] as char
})
.collect();
assert_eq!(o.classify(&huge), TokenShape::Suspect);
}
}