//! High-entropy opaque tokens for sessions and bot API access. //! //! `generate_token` draws 32 bytes from the OS CSPRNG, encodes them as //! URL-safe base64 (no padding), and returns the raw string alongside its //! SHA-256 hash. Storage holds only the hash; the raw value lives in the //! cookie or `Authorization` header. Comparison goes through //! `constant_time_eq` to keep timing side channels off the table. use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; pub const TOKEN_BYTES: usize = 32; pub const HASH_BYTES: usize = 32; pub fn generate_token() -> (String, [u8; HASH_BYTES]) { let mut raw = [0u8; TOKEN_BYTES]; OsRng.fill_bytes(&mut raw); let encoded = URL_SAFE_NO_PAD.encode(raw); let hash = hash_token(&encoded); (encoded, hash) } pub fn hash_token(raw: &str) -> [u8; HASH_BYTES] { let mut hasher = Sha256::new(); hasher.update(raw.as_bytes()); hasher.finalize().into() } pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.ct_eq(b).into() } #[cfg(test)] mod tests { use super::*; #[test] fn generates_unique_tokens() { let (a, _) = generate_token(); let (b, _) = generate_token(); assert_ne!(a, b); // 32 bytes base64url-no-pad → 43 chars. assert_eq!(a.len(), 43); } #[test] fn hash_matches_generated_pair() { let (raw, hash) = generate_token(); assert_eq!(hash_token(&raw), hash); } #[test] fn hash_is_deterministic() { assert_eq!(hash_token("abc"), hash_token("abc")); assert_ne!(hash_token("abc"), hash_token("abd")); } #[test] fn constant_time_eq_compares_correctly() { assert!(constant_time_eq(b"abc", b"abc")); assert!(!constant_time_eq(b"abc", b"abd")); assert!(!constant_time_eq(b"abc", b"abcd")); } }