use std::net::IpAddr;
use std::sync::Arc;
use axum::http::header;
use axum::http::request::Parts;
use axum::http::{HeaderMap, Request};
use hmac::Mac;
use crate::authn::ids::TenantId;
use crate::device::types::FingerprintHash;
pub trait DeviceFingerprintExtractor: Send + Sync + 'static {
fn extract(
&self,
tenant_id: &TenantId,
parts: &Parts,
client_ip: Option<IpAddr>,
) -> Option<FingerprintHash>;
fn extract_from_request<B>(
&self,
tenant_id: &TenantId,
request: &Request<B>,
client_ip: Option<IpAddr>,
) -> Option<FingerprintHash> {
let placeholder: Request<()> = Request::new(());
let (mut synthetic, _) = placeholder.into_parts();
synthetic.headers = request.headers().clone();
synthetic.uri = request.uri().clone();
synthetic.method = request.method().clone();
synthetic.version = request.version();
self.extract(tenant_id, &synthetic, client_ip)
}
}
pub type TenantPepperResolver = Arc<dyn Fn(&TenantId) -> [u8; 32] + Send + Sync>;
pub struct DefaultFingerprintExtractor {
pepper: TenantPepperResolver,
}
impl DefaultFingerprintExtractor {
pub fn new(pepper: TenantPepperResolver) -> Self {
Self { pepper }
}
}
impl DeviceFingerprintExtractor for DefaultFingerprintExtractor {
fn extract(
&self,
tenant_id: &TenantId,
parts: &Parts,
client_ip: Option<IpAddr>,
) -> Option<FingerprintHash> {
let headers = &parts.headers;
let ua = headers.get(header::USER_AGENT)?;
let pepper = (self.pepper)(tenant_id);
let mut mac = crate::hmac::new_signer(&pepper);
mac.update(b"axess.device.v1\0");
mac.update(b"ua\0");
mac.update(ua.as_bytes());
mac.update(b"\0");
if let Some(top) = accept_language_top(headers) {
mac.update(b"al\0");
mac.update(top.as_bytes());
mac.update(b"\0");
}
if let Some(ip) = client_ip {
mac.update(b"ip\0");
mac.update(&truncate_ip(ip));
mac.update(b"\0");
}
let bytes: [u8; 32] = mac.finalize().into_bytes().into();
Some(FingerprintHash::from_bytes(bytes))
}
}
fn accept_language_top(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::ACCEPT_LANGUAGE)?.to_str().ok()?;
let head = raw.split(',').next()?.trim();
let head = head.split(';').next()?.trim();
if head.is_empty() {
return None;
}
Some(head.to_ascii_lowercase())
}
fn truncate_ip(ip: IpAddr) -> Vec<u8> {
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
vec![o[0], o[1], o[2], 0]
}
IpAddr::V6(v6) => {
let mut o = v6.octets();
for byte in &mut o[6..] {
*byte = 0;
}
o.to_vec()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
fn parts(ua: Option<&str>, al: Option<&str>) -> Parts {
let mut r: Request<()> = Request::new(());
if let Some(v) = ua {
r.headers_mut()
.insert(header::USER_AGENT, HeaderValue::from_str(v).unwrap());
}
if let Some(v) = al {
r.headers_mut()
.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_str(v).unwrap());
}
r.into_parts().0
}
fn tenant() -> TenantId {
crate::authn::ids::testing::tenant("tenant-1")
}
fn fixed_pepper() -> TenantPepperResolver {
Arc::new(|t: &TenantId| {
t.as_uuid();
[42u8; 32]
})
}
fn extractor() -> DefaultFingerprintExtractor {
DefaultFingerprintExtractor::new(fixed_pepper())
}
#[test]
fn deterministic_on_same_inputs() {
let ext = extractor();
let r1 = parts(Some("Mozilla/5.0"), Some("en-CH;q=0.9,en;q=0.5"));
let r2 = parts(Some("Mozilla/5.0"), Some("en-CH;q=0.9,en;q=0.5"));
let ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)));
let f1 = ext.extract(&tenant(), &r1, ip).unwrap();
let f2 = ext.extract(&tenant(), &r2, ip).unwrap();
assert_eq!(f1, f2);
}
#[test]
fn different_user_agents_yield_different_hashes() {
let ext = extractor();
let r1 = parts(Some("Mozilla/5.0 (Macintosh)"), None);
let r2 = parts(Some("Mozilla/5.0 (Windows)"), None);
let f1 = ext.extract(&tenant(), &r1, None).unwrap();
let f2 = ext.extract(&tenant(), &r2, None).unwrap();
assert_ne!(f1, f2);
}
#[test]
fn missing_ua_returns_none() {
let ext = extractor();
let r = parts(None, Some("en"));
assert!(ext.extract(&tenant(), &r, None).is_none());
}
#[test]
fn different_tenants_yield_different_hashes() {
let pepper: TenantPepperResolver = Arc::new(|t: &TenantId| {
let mut k = [0u8; 32];
for (i, b) in t.as_bytes().iter().copied().enumerate().take(32) {
k[i] = b;
}
k
});
let ext = DefaultFingerprintExtractor::new(pepper);
let r = parts(Some("Mozilla/5.0"), None);
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
let t1 = crate::authn::ids::testing::tenant("acme");
let t2 = crate::authn::ids::testing::tenant("globex");
let f1 = ext.extract(&t1, &r, ip).unwrap();
let f2 = ext.extract(&t2, &r, ip).unwrap();
assert_ne!(
f1, f2,
"same physical device under two tenants must hash differently"
);
}
#[test]
fn ipv4_truncates_to_slash_24() {
let ext = extractor();
let r = parts(Some("Mozilla/5.0"), None);
let f_a = ext
.extract(&tenant(), &r, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 42))))
.unwrap();
let f_b = ext
.extract(&tenant(), &r, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7))))
.unwrap();
let f_c = ext
.extract(&tenant(), &r, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 42))))
.unwrap();
assert_eq!(f_a, f_b, "/24 partners must collide");
assert_ne!(f_a, f_c, "different /24s must produce different hashes");
}
#[test]
fn ipv6_truncates_to_slash_48() {
let ext = extractor();
let r = parts(Some("Mozilla/5.0"), None);
let same_a = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0xabcd, 0x0001, 0, 0, 0, 0x42));
let same_b = IpAddr::V6(Ipv6Addr::new(
0x2001, 0xdb8, 0xabcd, 0x9999, 0xffff, 0xffff, 0xffff, 0x07,
));
let other = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0xfeed, 0x0001, 0, 0, 0, 0x42));
let f_a = ext.extract(&tenant(), &r, Some(same_a)).unwrap();
let f_b = ext.extract(&tenant(), &r, Some(same_b)).unwrap();
let f_c = ext.extract(&tenant(), &r, Some(other)).unwrap();
assert_eq!(f_a, f_b, "/48 partners must collide");
assert_ne!(f_a, f_c);
}
#[test]
fn accept_language_takes_top_priority_only() {
let ext = extractor();
let r1 = parts(Some("Mozilla/5.0"), Some("en-CH;q=0.9,en;q=0.5"));
let r2 = parts(Some("Mozilla/5.0"), Some("en-ch"));
let f1 = ext.extract(&tenant(), &r1, None).unwrap();
let f2 = ext.extract(&tenant(), &r2, None).unwrap();
assert_eq!(
f1, f2,
"top entry of Accept-Language with q-value must equal the bare top entry"
);
}
#[test]
fn accept_language_value_actually_feeds_the_hash() {
let ext = extractor();
let no_al = parts(Some("Mozilla/5.0"), None);
let with_en = parts(Some("Mozilla/5.0"), Some("en-CH"));
let with_de = parts(Some("Mozilla/5.0"), Some("de-CH"));
let h_none = ext.extract(&tenant(), &no_al, None).unwrap();
let h_en = ext.extract(&tenant(), &with_en, None).unwrap();
let h_de = ext.extract(&tenant(), &with_de, None).unwrap();
assert_ne!(
h_none, h_en,
"no Accept-Language vs en-CH must hash differently; \
a constant-`None` mutant on accept_language_top would collapse them"
);
assert_ne!(
h_en, h_de,
"two distinct Accept-Language values must hash differently; \
a constant-`Some(\"xyzzy\")` mutant would collapse them"
);
}
#[test]
fn extract_from_request_matches_extract_on_equivalent_inputs() {
let ext = extractor();
let mut req: Request<()> = Request::new(());
req.headers_mut().insert(
header::USER_AGENT,
HeaderValue::from_str("Mozilla/5.0").unwrap(),
);
req.headers_mut().insert(
header::ACCEPT_LANGUAGE,
HeaderValue::from_str("en-CH").unwrap(),
);
let from_request = ext
.extract_from_request(&tenant(), &req, None)
.expect("UA-bearing request must produce a fingerprint");
let p = parts(Some("Mozilla/5.0"), Some("en-CH"));
let from_parts = ext.extract(&tenant(), &p, None).unwrap();
assert_eq!(
from_request, from_parts,
"extract_from_request must agree with extract on equivalent headers"
);
}
}