//! Cryptographic utilities: constant-time comparison, key generation, feed signing. /// Constant-time byte comparison for tokens, MACs, and other fixed-shape /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl) /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256. /// /// Length mismatch short-circuits — leaking the length of fixed-format /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets) /// reveals nothing useful to an attacker, since the format already fixes /// the length. Don't use this for variable-length sensitive payloads /// where length is itself secret. pub fn constant_time_compare(a: &str, b: &str) -> bool { use subtle::ConstantTimeEq; let a = a.as_bytes(); let b = b.as_bytes(); if a.len() != b.len() { return false; } a.ct_eq(b).into() } /// Generate a license key code in word-word-word-word-word-word format. /// /// Six random words from the 2048-word list (~66 bits of entropy). Six was /// chosen over five (~55 bits) after a birthday-collision review: at five /// words, ~190M keys gives a coin-flip chance of collision; at six, the /// equivalent threshold rises to ~6B keys — far past the lifetime cap of /// any realistic license catalog. Returns a `KeyCode` via `from_trusted` — /// the wordlist guarantees validity. pub fn generate_key_code() -> crate::db::KeyCode { use rand::Rng; let mut rng = rand::rng(); let words: Vec<&str> = (0..6) .map(|_| { let idx = rng.random_range(0..crate::wordlist::WORDLIST.len()); crate::wordlist::WORDLIST[idx] }) .collect(); crate::db::KeyCode::from_trusted(words.join("-")) } /// Generate an HMAC-signed personal RSS feed URL for a user. /// /// The URL is permanent (no expiry) and tied to the signing secret. /// If the secret rotates, old URLs become invalid. pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("feed:{}", user_id); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let sig = hex::encode(mac.finalize().into_bytes()); format!("{}/feed/{}?sig={}", host_url, user_id, sig) } /// Verify a personal feed URL signature. pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("feed:{}", user_id); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes()); constant_time_compare(&expected, signature) } #[cfg(test)] mod tests { use super::*; // ── constant_time_compare ── #[test] fn compare_equal_strings() { assert!(constant_time_compare("abc123", "abc123")); } #[test] fn compare_different_strings() { assert!(!constant_time_compare("abc123", "abc124")); } #[test] fn compare_different_lengths() { assert!(!constant_time_compare("short", "longer")); } #[test] fn compare_empty_strings() { assert!(constant_time_compare("", "")); } #[test] fn adversarial_timing_safety() { assert!(!constant_time_compare("a", "b")); assert!(!constant_time_compare("a", "aa")); assert!(!constant_time_compare("", "x")); assert!(constant_time_compare("same", "same")); } // ── generate_key_code ── #[test] fn key_code_format() { let code = generate_key_code(); let parts: Vec<&str> = code.split('-').collect(); assert_eq!(parts.len(), 6, "Key code should have 6 words"); for word in &parts { assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word); assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word); assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word); } } #[test] fn key_code_uniqueness() { let codes: std::collections::HashSet = (0..100).map(|_| generate_key_code()).collect(); assert_eq!(codes.len(), 100, "100 generated key codes should all be unique"); } // ── feed URL signing ── #[test] fn feed_url_round_trip() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, "secret"); assert!(url.contains(&user_id.to_string())); assert!(url.contains("sig=")); let sig = url.split("sig=").nth(1).unwrap(); assert!(verify_feed_signature(user_id, sig, "secret")); } #[test] fn feed_url_wrong_secret_rejected() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, "secret"); let sig = url.split("sig=").nth(1).unwrap(); assert!(!verify_feed_signature(user_id, sig, "wrong-secret")); } #[test] fn feed_url_wrong_user_rejected() { let user_id = crate::db::UserId::new(); let other_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, "secret"); let sig = url.split("sig=").nth(1).unwrap(); assert!(!verify_feed_signature(other_id, sig, "secret")); } #[test] fn feed_signature_empty_string_rejected() { let user_id = crate::db::UserId::new(); assert!(!verify_feed_signature(user_id, "", "secret")); } #[test] fn feed_signature_tampered_rejected() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, "secret"); let sig = url.split("sig=").nth(1).unwrap(); let mut tampered = sig.to_string(); let first = tampered.remove(0); tampered.insert(0, if first == '0' { '1' } else { '0' }); assert!(!verify_feed_signature(user_id, &tampered, "secret")); } }