//! §5a tokens and client-IP attribution. //! //! A token is **not an account** — it is an anonymous bearer capability. No //! email, no verification, no personal data. It is stored only as a hash, so the //! server cannot enumerate who holds tokens, and its sole purposes are //! rate-limiting attribution (§5) and revocation. //! //! Discarding a token and requesting another is trivially easy, and that is //! fine: the token is not the defence, the content checks are. Sybil resistance //! is not required because identity is not load-bearing. use std::net::IpAddr; use axum::http::HeaderMap; use sha2::{Digest, Sha256}; /// Hashes a bearer token for storage and lookup. /// /// Plain SHA-256 rather than a password KDF is deliberate and sufficient here: /// tokens are 256 bits of server-generated randomness, not user-chosen secrets, /// so there is no dictionary to attack. /// TRACES: UR-005 | SR-004 pub fn hash_token(token: &str) -> String { let mut h = Sha256::new(); h.update(token.as_bytes()); hex(&h.finalize()) } /// Hashes a client IP for report attribution (§7 `reports.source_ip_hash`). /// /// Salted with the server id so hashes are not comparable across instances. pub fn hash_ip(ip: &str, server_id: &str) -> String { let mut h = Sha256::new(); h.update(server_id.as_bytes()); h.update(b"\0"); h.update(ip.as_bytes()); hex(&h.finalize()) } fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } /// Generates a new token. Returned once to the caller; only its hash is stored. pub fn generate_token() -> String { use rand::RngCore; let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); format!("jray_{}", hex(&bytes)) } /// Extracts a bearer token from an `Authorization` header. pub fn bearer_token(headers: &HeaderMap) -> Option { let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; let (scheme, value) = raw.split_once(' ')?; if !scheme.eq_ignore_ascii_case("bearer") { return None; } let value = value.trim(); if value.is_empty() { return None; } Some(value.to_string()) } /// Resolves the client IP for rate-limiting and report attribution. /// /// §8: the app must trust `X-Forwarded-For` **only** from the operator's proxy. /// Rate limiting and report attribution key on client IP, so a spoofable header /// defeats both — hence `trusted_proxies` is explicit configuration and an /// untrusted peer's header is ignored outright. /// TRACES: UR-004 | DR-008 | SR-004 pub fn client_ip(headers: &HeaderMap, peer: Option, trusted_proxies: &[IpAddr]) -> String { let peer_is_trusted = peer.is_some_and(|p| trusted_proxies.contains(&p)); if peer_is_trusted { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { // Right-most entry is the one our trusted proxy appended; entries to // its left are client-supplied and forgeable. Walk from the right // past any further trusted hops. for candidate in xff.split(',').rev().map(str::trim).filter(|s| !s.is_empty()) { match candidate.parse::() { Ok(ip) if trusted_proxies.contains(&ip) => continue, Ok(ip) => return ip.to_string(), Err(_) => break, } } } } peer.map(|p| p.to_string()).unwrap_or_else(|| "unknown".to_string()) } #[cfg(test)] mod tests { use super::*; use axum::http::HeaderValue; fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { let mut h = HeaderMap::new(); for (k, v) in pairs { h.insert(*k, HeaderValue::from_str(v).unwrap()); } h } #[test] fn token_hash_is_stable_and_distinguishing() { assert_eq!(hash_token("abc"), hash_token("abc")); assert_ne!(hash_token("abc"), hash_token("abd")); assert_eq!(hash_token("abc").len(), 64); } #[test] fn generated_tokens_are_unique_and_prefixed() { let a = generate_token(); let b = generate_token(); assert_ne!(a, b); assert!(a.starts_with("jray_")); assert_eq!(a.len(), 5 + 64); } #[test] fn ip_hash_is_salted_per_server() { // Hashes must not be comparable across instances. assert_ne!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "b.example")); assert_eq!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "a.example")); } #[test] fn parses_bearer_tokens_case_insensitively() { assert_eq!( bearer_token(&headers(&[("authorization", "Bearer xyz")])).as_deref(), Some("xyz") ); assert_eq!( bearer_token(&headers(&[("authorization", "bearer xyz")])).as_deref(), Some("xyz") ); assert!(bearer_token(&headers(&[("authorization", "Basic xyz")])).is_none()); assert!(bearer_token(&headers(&[("authorization", "Bearer ")])).is_none()); assert!(bearer_token(&HeaderMap::new()).is_none()); } #[test] fn forwarded_header_from_an_untrusted_peer_is_ignored() { // The whole point of §8's explicit trusted-proxy configuration: an // arbitrary client must not be able to choose its own rate-limit key. let h = headers(&[("x-forwarded-for", "9.9.9.9")]); let peer: IpAddr = "203.0.113.7".parse().unwrap(); assert_eq!(client_ip(&h, Some(peer), &[]), "203.0.113.7"); } #[test] fn forwarded_header_from_a_trusted_proxy_is_honoured() { let h = headers(&[("x-forwarded-for", "9.9.9.9")]); let proxy: IpAddr = "127.0.0.1".parse().unwrap(); assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "9.9.9.9"); } #[test] fn client_supplied_entries_left_of_the_proxy_cannot_spoof() { // A client that sends its own XFF gets its value appended to, not // replaced, so only the right-most entry is trustworthy. let h = headers(&[("x-forwarded-for", "9.9.9.9, 203.0.113.7")]); let proxy: IpAddr = "127.0.0.1".parse().unwrap(); assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "203.0.113.7"); } #[test] fn walks_past_additional_trusted_hops() { let inner: IpAddr = "10.0.0.2".parse().unwrap(); let proxy: IpAddr = "127.0.0.1".parse().unwrap(); let h = headers(&[("x-forwarded-for", "203.0.113.7, 10.0.0.2")]); assert_eq!(client_ip(&h, Some(proxy), &[proxy, inner]), "203.0.113.7"); } #[test] fn malformed_forwarded_value_falls_back_to_the_peer() { let h = headers(&[("x-forwarded-for", "not-an-ip")]); let proxy: IpAddr = "127.0.0.1".parse().unwrap(); assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "127.0.0.1"); } }