//! HMAC-signed URL generation and verification for email actions. use crate::constants; use crate::db::UserId; /// Valid actions for email unsubscribe links. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum UnsubscribeAction { Broadcast, Release, Sale, Follower, Login, Issue, Status, MailingList, NotifyTip, } impl std::fmt::Display for UnsubscribeAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { Self::Broadcast => "broadcast", Self::Release => "release", Self::Sale => "sale", Self::Follower => "follower", Self::Login => "login", Self::Issue => "issue", Self::Status => "status", Self::MailingList => "mailing_list", Self::NotifyTip => "notify_tip", }; f.write_str(s) } } impl std::str::FromStr for UnsubscribeAction { type Err = String; fn from_str(s: &str) -> std::result::Result { match s { "broadcast" => Ok(Self::Broadcast), "release" => Ok(Self::Release), "sale" => Ok(Self::Sale), "follower" => Ok(Self::Follower), "login" => Ok(Self::Login), "issue" => Ok(Self::Issue), "status" => Ok(Self::Status), "mailing_list" => Ok(Self::MailingList), "notify_tip" => Ok(Self::NotifyTip), other => Err(format!("invalid UnsubscribeAction: {other}")), } } } /// Generate HMAC-signed password reset URL pub fn generate_password_reset_url( host_url: &str, user_id: UserId, password_hash: &str, secret: &str, ) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let expires = chrono::Utc::now().timestamp() + constants::PASSWORD_RESET_EXPIRY_SECS; // Use full password hash to bind token to current password // This invalidates the link if password changes let message = format!("reset:{}:{}:{}", user_id, expires, password_hash); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); format!( "{}/reset-password?user={}&expires={}&sig={}", host_url, user_id, expires, signature ) } /// Verify HMAC-signed password reset URL pub fn verify_password_reset_signature( user_id: UserId, expires: i64, password_hash: &str, signature: &str, secret: &str, ) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; // Check expiration if expires < chrono::Utc::now().timestamp() { return false; } // Use full password hash to match generation let message = format!("reset:{}:{}:{}", user_id, expires, password_hash); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); // Constant-time comparison let expected = hex::encode(mac.finalize().into_bytes()); if expected.len() != signature.len() { return false; } let mut result = 0u8; for (a, b) in expected.bytes().zip(signature.bytes()) { result |= a ^ b; } result == 0 } /// Generate email verification URL pub fn generate_verification_url( host_url: &str, user_id: UserId, email: &str, secret: &str, ) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let expires = chrono::Utc::now().timestamp() + constants::EMAIL_VERIFICATION_EXPIRY_SECS; let message = format!("verify:{}:{}:{}", user_id, expires, email); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); format!( "{}/verify-email?user={}&expires={}&sig={}", host_url, user_id, expires, signature ) } /// Generate a one-time login token /// Returns (token, token_hash) where token is sent to user and token_hash is stored in DB pub fn generate_login_token() -> (String, String) { use sha2::{Sha256, Digest}; // Generate random token let mut token_bytes = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut token_bytes); let token = hex::encode(token_bytes); // Hash the token for storage let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let token_hash = hex::encode(hasher.finalize()); (token, token_hash) } /// Generate login link URL pub fn generate_login_link_url(host_url: &str, token: &str) -> String { format!("{}/login-link?token={}", host_url, token) } /// Verify a login token against the stored hash pub fn verify_login_token(token: &str, stored_hash: &str) -> bool { use sha2::{Sha256, Digest}; let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let computed_hash = hex::encode(hasher.finalize()); // Constant-time comparison if computed_hash.len() != stored_hash.len() { return false; } let mut result = 0u8; for (a, b) in computed_hash.bytes().zip(stored_hash.bytes()) { result |= a ^ b; } result == 0 } /// Verify email verification signature pub fn verify_email_signature( user_id: UserId, expires: i64, email: &str, signature: &str, secret: &str, ) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; if expires < chrono::Utc::now().timestamp() { return false; } let message = format!("verify:{}:{}:{}", user_id, expires, email); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes()); if expected.len() != signature.len() { return false; } let mut result = 0u8; for (a, b) in expected.bytes().zip(signature.bytes()) { result |= a ^ b; } result == 0 } /// Generate an HMAC-signed unsubscribe URL. /// /// The URL is permanent (no expiry) — it changes a user preference or removes /// a follow relationship, both of which are easily reversible. /// /// * `action` — the unsubscribe action to perform /// * `target` — for `broadcast`: the creator's user ID to unfollow; /// for preferences: same as `user_id` pub fn generate_unsubscribe_url( host_url: &str, user_id: UserId, action: UnsubscribeAction, target: &str, secret: &str, ) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("unsub:{}:{}:{}", user_id, action, target); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); format!( "{}/unsubscribe?user={}&action={}&target={}&sig={}", host_url, user_id, action, target, signature ) } /// Verify an unsubscribe URL signature. pub fn verify_unsubscribe_signature( user_id: UserId, action: UnsubscribeAction, target: &str, signature: &str, secret: &str, ) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("unsub:{}:{}:{}", user_id, action, target); 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 comparison if expected.len() != signature.len() { return false; } let mut result = 0u8; for (a, b) in expected.bytes().zip(signature.bytes()) { result |= a ^ b; } result == 0 } /// Generate account deletion URL pub fn generate_deletion_url( host_url: &str, user_id: UserId, email: &str, secret: &str, ) -> String { let expires = chrono::Utc::now().timestamp() + constants::ACCOUNT_DELETION_EXPIRY_SECS; let sig = generate_deletion_signature(secret, user_id, expires, email); format!( "{}/confirm-delete?user={}&expires={}&sig={}", host_url, user_id, expires, sig ) } /// Generate HMAC signature for account deletion pub fn generate_deletion_signature( secret: &str, user_id: UserId, expires: i64, email: &str, ) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("delete:{}:{}:{}", user_id, expires, email); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Generate a reply-to email address for an issue comment. /// /// Format: `issue+{issue_id}.{user_id}.{sig}@reply.makenot.work` /// /// The signature is 16 chars of base64url-encoded HMAC-SHA256 (96 bits) over the /// issue and user IDs. Base64url packs 6 bits/char vs hex's 4, giving 96 bits of /// security in the same space that hex would give 64. This is stateless — no DB /// storage needed. The handler will parse and verify the address. pub fn generate_issue_reply_address( issue_id: crate::db::IssueId, user_id: UserId, secret: &str, ) -> String { use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("issue-reply:{}:{}", issue_id, 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 hash = mac.finalize().into_bytes(); let sig = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16]; format!("issue+{}.{}.{}@reply.makenot.work", issue_id, user_id, sig) } /// Parse and verify an issue reply address local part. /// /// Input: the part before `@`, e.g. `issue+{issue_id}.{user_id}.{sig}` /// /// Returns `Some((IssueId, UserId))` if the signature is valid, `None` otherwise. pub fn parse_issue_reply_token( local_part: &str, secret: &str, ) -> Option<(crate::db::IssueId, UserId)> { use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; let payload = local_part.strip_prefix("issue+")?; let mut parts = payload.splitn(3, '.'); let issue_id_str = parts.next()?; let user_id_str = parts.next()?; let sig = parts.next()?; let issue_id: crate::db::IssueId = issue_id_str.parse().ok()?; let user_id: UserId = user_id_str.parse().ok()?; let message = format!("issue-reply:{}:{}", issue_id, 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 hash = mac.finalize().into_bytes(); let expected = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16]; // Constant-time comparison if expected.len() != sig.len() { return None; } let mut result = 0u8; for (a, b) in expected.bytes().zip(sig.bytes()) { result |= a ^ b; } if result != 0 { return None; } Some((issue_id, user_id)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_password_reset_url_generation_and_verification() { let host = "https://makenot.work"; let user_id = UserId::new(); let password_hash = "argon2$abc123def456789012345678901234567890"; let secret = "test-secret-key"; let url = generate_password_reset_url(host, user_id, password_hash, secret); assert!(url.contains("/reset-password")); assert!(url.contains(&user_id.to_string())); // Extract params let url_parsed: url::Url = url.parse().unwrap(); let expires: i64 = url_parsed .query_pairs() .find(|(k, _)| k == "expires") .unwrap() .1 .parse() .unwrap(); let sig = url_parsed .query_pairs() .find(|(k, _)| k == "sig") .unwrap() .1 .to_string(); assert!(verify_password_reset_signature( user_id, expires, password_hash, &sig, secret )); } #[test] fn password_reset_rejects_wrong_secret() { let user_id = UserId::new(); let hash = "argon2$abc123"; let secret = "real-secret"; let url = generate_password_reset_url("https://example.com", user_id, hash, secret); let parsed: url::Url = url.parse().unwrap(); let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(!verify_password_reset_signature(user_id, expires, hash, &sig, "wrong-secret")); } #[test] fn password_reset_rejects_expired_token() { let user_id = UserId::new(); let hash = "argon2$abc123"; let secret = "test-secret"; // Use an already-expired timestamp let expired = chrono::Utc::now().timestamp() - 1; assert!(!verify_password_reset_signature(user_id, expired, hash, "deadbeef", secret)); } #[test] fn password_reset_rejects_wrong_password_hash() { let user_id = UserId::new(); let secret = "test-secret"; let url = generate_password_reset_url("https://example.com", user_id, "hash-v1", secret); let parsed: url::Url = url.parse().unwrap(); let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); // Signature was bound to "hash-v1", should fail with "hash-v2" (password changed) assert!(!verify_password_reset_signature(user_id, expires, "hash-v2", &sig, secret)); } #[test] fn verification_url_round_trip() { let host = "https://makenot.work"; let user_id = UserId::new(); let email = "user@example.com"; let secret = "verify-secret"; let url = generate_verification_url(host, user_id, email, secret); assert!(url.contains("/verify-email")); assert!(url.contains(&user_id.to_string())); let parsed: url::Url = url.parse().unwrap(); let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(verify_email_signature(user_id, expires, email, &sig, secret)); } #[test] fn verification_rejects_wrong_email() { let user_id = UserId::new(); let secret = "verify-secret"; let url = generate_verification_url("https://example.com", user_id, "real@example.com", secret); let parsed: url::Url = url.parse().unwrap(); let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(!verify_email_signature(user_id, expires, "attacker@evil.com", &sig, secret)); } #[test] fn verification_rejects_expired() { let user_id = UserId::new(); let expired = chrono::Utc::now().timestamp() - 1; assert!(!verify_email_signature(user_id, expired, "a@b.com", "deadbeef", "secret")); } #[test] fn login_token_round_trip() { let (token, token_hash) = generate_login_token(); // Token and hash should be different assert_ne!(token, token_hash); // Both should be hex-encoded 32-byte values (64 hex chars) assert_eq!(token.len(), 64); assert_eq!(token_hash.len(), 64); assert!(verify_login_token(&token, &token_hash)); } #[test] fn login_token_rejects_wrong_token() { let (_token, token_hash) = generate_login_token(); assert!(!verify_login_token("0000000000000000000000000000000000000000000000000000000000000000", &token_hash)); } #[test] fn login_token_unique_each_call() { let (token1, _) = generate_login_token(); let (token2, _) = generate_login_token(); assert_ne!(token1, token2); } #[test] fn login_link_url_format() { let url = generate_login_link_url("https://makenot.work", "abc123"); assert_eq!(url, "https://makenot.work/login-link?token=abc123"); } #[test] fn deletion_url_round_trip() { let user_id = UserId::new(); let email = "user@example.com"; let secret = "delete-secret"; let url = generate_deletion_url("https://makenot.work", user_id, email, secret); assert!(url.contains("/confirm-delete")); assert!(url.contains(&user_id.to_string())); // Extract and verify the signature let parsed: url::Url = url.parse().unwrap(); let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); let expected_sig = generate_deletion_signature(secret, user_id, expires, email); assert_eq!(sig, expected_sig); } #[test] fn deletion_signature_rejects_wrong_secret() { let user_id = UserId::new(); let expires = chrono::Utc::now().timestamp() + 3600; let sig = generate_deletion_signature("real-secret", user_id, expires, "a@b.com"); let wrong = generate_deletion_signature("wrong-secret", user_id, expires, "a@b.com"); assert_ne!(sig, wrong); } #[test] fn unsubscribe_url_round_trip() { let user_id = UserId::new(); let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Release, &user_id.to_string(), "secret"); assert!(url.contains("/unsubscribe")); assert!(url.contains("action=release")); let parsed: url::Url = url.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(verify_unsubscribe_signature(user_id, UnsubscribeAction::Release, &user_id.to_string(), &sig, "secret")); } #[test] fn unsubscribe_rejects_wrong_action() { let user_id = UserId::new(); let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Sale, &user_id.to_string(), "secret"); let parsed: url::Url = url.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); // Verify with different action should fail assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Follower, &user_id.to_string(), &sig, "secret")); } #[test] fn unsubscribe_rejects_wrong_secret() { let user_id = UserId::new(); let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Login, &user_id.to_string(), "real-secret"); let parsed: url::Url = url.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Login, &user_id.to_string(), &sig, "wrong-secret")); } #[test] fn unsubscribe_broadcast_with_target() { let user_id = UserId::new(); let creator_id = UserId::new(); let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Broadcast, &creator_id.to_string(), "secret"); let parsed: url::Url = url.parse().unwrap(); let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); assert!(verify_unsubscribe_signature(user_id, UnsubscribeAction::Broadcast, &creator_id.to_string(), &sig, "secret")); // Wrong target should fail assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Broadcast, &user_id.to_string(), &sig, "secret")); } #[test] fn constant_time_compare_equal() { use crate::helpers::constant_time_compare; assert!(constant_time_compare("hello", "hello")); assert!(constant_time_compare("", "")); } #[test] fn constant_time_compare_not_equal() { use crate::helpers::constant_time_compare; assert!(!constant_time_compare("hello", "world")); assert!(!constant_time_compare("hello", "hell")); assert!(!constant_time_compare("short", "longer")); } // ── Issue reply token tests ── #[test] fn issue_reply_round_trip() { let issue_id = crate::db::IssueId::new(); let user_id = UserId::new(); let secret = "reply-secret"; let addr = generate_issue_reply_address(issue_id, user_id, secret); assert!(addr.ends_with("@reply.makenot.work")); assert!(addr.starts_with("issue+")); // Extract local part let local = addr.split('@').next().unwrap(); let result = parse_issue_reply_token(local, secret); assert!(result.is_some()); let (parsed_issue, parsed_user) = result.unwrap(); assert_eq!(parsed_issue, issue_id); assert_eq!(parsed_user, user_id); } #[test] fn issue_reply_wrong_secret_rejected() { let issue_id = crate::db::IssueId::new(); let user_id = UserId::new(); let addr = generate_issue_reply_address(issue_id, user_id, "real-secret"); let local = addr.split('@').next().unwrap(); assert!(parse_issue_reply_token(local, "wrong-secret").is_none()); } #[test] fn issue_reply_malformed_input() { let secret = "test-secret"; assert!(parse_issue_reply_token("garbage", secret).is_none()); assert!(parse_issue_reply_token("issue+", secret).is_none()); assert!(parse_issue_reply_token("issue+a.b", secret).is_none()); assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none()); } // ───────────────────────────────────────────────────────────────────── // Expiry arithmetic tests — pin `now + EXPIRY` so cargo-mutants can't // replace `+` with `*`/`-` without the test catching it. Each generator // emits an `expires=` URL parameter; we assert the value is within a // tight window of `now + EXPIRY`. // ───────────────────────────────────────────────────────────────────── /// Extract the `expires` query param from a URL emitted by a token generator. fn extract_expires(url: &str) -> i64 { let parsed: url::Url = url.parse().expect("valid URL"); parsed .query_pairs() .find(|(k, _)| k == "expires") .expect("expires param") .1 .parse() .expect("expires is i64") } /// Assert `actual ∈ [now+expiry, now+expiry + slack]`. The slack covers the /// few ms between calling `Utc::now()` inside the function and `Utc::now()` /// here. Any mutation that flips the arithmetic (e.g. `+` → `*`) will /// produce a value wildly outside this window. fn assert_within_expiry_window(actual: i64, expiry_secs: i64) { let now = chrono::Utc::now().timestamp(); let expected_min = now + expiry_secs - 1; let expected_max = now + expiry_secs + 5; assert!( actual >= expected_min && actual <= expected_max, "expires={actual} outside [{expected_min}, {expected_max}] (now={now}, expiry_secs={expiry_secs})" ); } #[test] fn password_reset_url_expires_matches_constant() { let url = generate_password_reset_url( "https://example.com", UserId::new(), "argon2$dummy", "secret", ); assert_within_expiry_window(extract_expires(&url), constants::PASSWORD_RESET_EXPIRY_SECS); } #[test] fn verification_url_expires_matches_constant() { let url = generate_verification_url( "https://example.com", UserId::new(), "user@example.com", "secret", ); assert_within_expiry_window(extract_expires(&url), constants::EMAIL_VERIFICATION_EXPIRY_SECS); } #[test] fn deletion_url_expires_matches_constant() { let url = generate_deletion_url( "https://example.com", UserId::new(), "user@example.com", "secret", ); assert_within_expiry_window(extract_expires(&url), constants::ACCOUNT_DELETION_EXPIRY_SECS); } // ───────────────────────────────────────────────────────────────────── // Expiry-comparison boundary tests for `verify_email_signature` and // `verify_password_reset_signature`. Catches `<` → `==`/`<=` mutations on // the `if expires < now { return false; }` guard. // ───────────────────────────────────────────────────────────────────── #[test] fn verify_email_signature_rejects_already_expired() { // Build a signed URL, then verify with an `expires` value 60s in the past. // The signature won't match (since the message contains expires) — but // the early `expires < now` check should fire first and short-circuit. let user_id = UserId::new(); let email = "test@example.com"; let secret = "secret"; let now = chrono::Utc::now().timestamp(); // Generate a sig for an EXPIRED timestamp. use hmac::{Hmac, Mac}; use sha2::Sha256; let expires_past = now - 60; let message = format!("verify:{}:{}:{}", user_id, expires_past, email); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(message.as_bytes()); let sig_past = hex::encode(mac.finalize().into_bytes()); // Sig is valid for the message, but expires < now → must reject. assert!( !verify_email_signature(user_id, expires_past, email, &sig_past, secret), "must reject expired signature" ); } #[test] fn verify_email_signature_accepts_just_in_future() { // Inverse: a sig that's still valid (expires just in the future) must // pass — catches `<` → `<=` (which would reject expires == now-1+1). let user_id = UserId::new(); let email = "test@example.com"; let secret = "secret"; let expires_future = chrono::Utc::now().timestamp() + 3600; use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("verify:{}:{}:{}", user_id, expires_future, email); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(message.as_bytes()); let sig = hex::encode(mac.finalize().into_bytes()); assert!(verify_email_signature(user_id, expires_future, email, &sig, secret)); } #[test] fn verify_password_reset_signature_rejects_expired() { // Same boundary check for password reset path (L96 in this file). let user_id = UserId::new(); let password_hash = "argon2$dummy"; let secret = "secret"; let expires_past = chrono::Utc::now().timestamp() - 60; use hmac::{Hmac, Mac}; use sha2::Sha256; let message = format!("reset:{}:{}:{}", user_id, expires_past, password_hash); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(message.as_bytes()); let sig_past = hex::encode(mac.finalize().into_bytes()); assert!( !verify_password_reset_signature(user_id, expires_past, password_hash, &sig_past, secret), "must reject expired password reset signature" ); } }