//! 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("-")) } /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`. fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("feed:{user_id}:{version}"); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Generate an HMAC-signed personal RSS feed URL for a user. /// /// The signature covers `feed:{user_id}:{version}`. `version` is the user's /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL" /// action) changes the signed message and revokes the previously-issued URL /// for that one user, without rotating the global signing secret (which would /// invalidate every user's feed at once). The URL is otherwise permanent. pub fn generate_feed_url( host_url: &str, user_id: crate::db::UserId, version: i32, secret: &str, ) -> String { let sig = feed_signature(user_id, version, secret); format!("{}/feed/{}?v={}&sig={}", host_url, user_id, version, sig) } /// Verify a personal feed URL signature for a given `(user_id, version)`. /// /// The caller MUST additionally check that `version` equals the user's current /// `feed_key_version` — a valid signature for a stale version is a revoked URL. pub fn verify_feed_signature( user_id: crate::db::UserId, version: i32, signature: &str, secret: &str, ) -> bool { let expected = feed_signature(user_id, version, secret); 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 ── /// Extract the `sig=` value from a generated feed URL. fn sig_of(url: &str) -> &str { url.split("sig=").nth(1).unwrap() } #[test] fn feed_url_round_trip() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(url.contains(&user_id.to_string())); assert!(url.contains("v=0")); assert!(url.contains("sig=")); assert!(verify_feed_signature(user_id, 0, sig_of(&url), "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, 0, "secret"); assert!(!verify_feed_signature(user_id, 0, sig_of(&url), "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, 0, "secret"); assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret")); } #[test] fn feed_url_stale_version_rejected() { // A signature minted for version 0 must not verify against version 1 — // this is what makes "Regenerate feed URL" revoke the old link. let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret")); assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret")); } #[test] fn feed_signature_empty_string_rejected() { let user_id = crate::db::UserId::new(); assert!(!verify_feed_signature(user_id, 0, "", "secret")); } #[test] fn feed_signature_tampered_rejected() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); let sig = sig_of(&url); 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, 0, &tampered, "secret")); } }