//! 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 a single-use password reset token. /// /// Returns `(token, token_hash)`: the random `token` goes in the emailed URL, /// the SHA-256 `token_hash` is stored in `password_reset_tokens` and matched on /// use. Single-use (the row is consumed atomically on submit) replaces the old /// replayable HMAC link. Mirrors [`generate_login_token`]. pub fn generate_password_reset_token() -> (String, String) { generate_opaque_token() } /// Generate the password reset link URL for a freshly-minted token. pub fn generate_reset_link_url(host_url: &str, token: &str) -> String { format!("{host_url}/reset-password?token={token}") } /// Generate email verification URL pub fn generate_verification_url( host_url: &str, user_id: UserId, email: &str, secret: &str, ) -> String { use hmac::{Hmac, KeyInit, 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!("{host_url}/verify-email?user={user_id}&expires={expires}&sig={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) { generate_opaque_token() } /// Mint a random opaque token and its storage hash: `(token, sha256(token))`. /// The 256-bit token is sent to the user; only the hash is persisted, so a DB /// read can't reconstruct a usable link. fn generate_opaque_token() -> (String, String) { use sha2::{Digest, Sha256}; let mut token_bytes = [0u8; 32]; rand::Rng::fill_bytes(&mut rand::rng(), &mut token_bytes); let token = hex::encode(token_bytes); let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let token_hash = hex::encode(hasher.finalize()); (token, token_hash) } /// Hash a reset/login token the same way it is stored, for lookup. pub fn hash_opaque_token(token: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } pub fn generate_login_link_url(host_url: &str, token: &str) -> String { format!("{host_url}/login-link?token={token}") } /// Verify a login token against the stored hash pub fn verify_login_token(token: &str, stored_hash: &str) -> bool { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let computed_hash = hex::encode(hasher.finalize()); // Constant-time comparison via the shared helper (Sec-M3); it also handles the // length-mismatch case, so no separate early-return length check is needed. crate::helpers::constant_time_compare(&computed_hash, stored_hash) } /// Verify email verification signature pub fn verify_email_signature( user_id: UserId, expires: i64, email: &str, signature: &str, secret: &str, ) -> bool { use hmac::{Hmac, KeyInit, 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()); crate::helpers::constant_time_compare(&expected, signature) } /// 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, KeyInit, 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!("{host_url}/unsubscribe?user={user_id}&action={action}&target={target}&sig={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, KeyInit, 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()); crate::helpers::constant_time_compare(&expected, signature) } /// Generate an HMAC-signed unsubscribe URL keyed on an EMAIL rather than a user /// id. Used for mailing-list subscribers imported by email who have no MNW /// account, the user-id-keyed form can't represent them, so without this an /// imported subscriber has no working unsubscribe link (a CAN-SPAM problem). /// /// The message is domain-separated (`unsub_email:` prefix) so it shares no /// signing space with the user-id form. `email` is lowercased before signing so /// the token matches the stored (lowercased) subscriber row. pub fn generate_unsubscribe_url_for_email( host_url: &str, email: &str, action: UnsubscribeAction, target: &str, secret: &str, ) -> String { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let email = email.to_lowercase(); let message = format!("unsub_email:{email}:{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?email={}&action={}&target={}&sig={}", host_url, urlencoding::encode(&email), action, target, signature ) } /// Verify an email-keyed unsubscribe URL signature. pub fn verify_email_unsubscribe_signature( email: &str, action: UnsubscribeAction, target: &str, signature: &str, secret: &str, ) -> bool { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let email = email.to_lowercase(); let message = format!("unsub_email:{email}:{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()); crate::helpers::constant_time_compare(&expected, signature) } /// 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!("{host_url}/confirm-delete?user={user_id}&expires={expires}&sig={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, KeyInit, 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::{Engine, general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, KeyInit, 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+{issue_id}.{user_id}.{sig}@reply.makenot.work") } /// 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::{Engine, general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, KeyInit, 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 via the shared helper (Sec-M3). if !crate::helpers::constant_time_compare(expected, sig) { return None; } Some((issue_id, user_id)) } #[cfg(test)] mod tests { use super::*; #[test] fn password_reset_token_round_trip() { let (token, token_hash) = generate_password_reset_token(); // Token and hash differ; both are 64 hex chars (32 bytes). assert_ne!(token, token_hash); assert_eq!(token.len(), 64); assert_eq!(token_hash.len(), 64); // Hashing the emitted token reproduces the stored hash (lookup path). assert_eq!(hash_opaque_token(&token), token_hash); } #[test] fn password_reset_token_unique_each_call() { let (t1, h1) = generate_password_reset_token(); let (t2, h2) = generate_password_reset_token(); assert_ne!(t1, t2); assert_ne!(h1, h2); } #[test] fn reset_link_url_format() { let url = generate_reset_link_url("https://makenot.work", "abc123"); assert_eq!(url, "https://makenot.work/reset-password?token=abc123"); } #[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 email_unsubscribe_url_round_trip() { let url = generate_unsubscribe_url_for_email( "https://makenot.work", "Fan@Example.com", UnsubscribeAction::MailingList, "list-123", "secret", ); assert!(url.contains("/unsubscribe")); assert!(url.contains("action=mailing_list")); assert!(url.contains("email=")); let parsed: url::Url = url.parse().unwrap(); let sig = parsed .query_pairs() .find(|(k, _)| k == "sig") .unwrap() .1 .to_string(); // Email is lowercased before signing, so verification is case-insensitive. assert!(verify_email_unsubscribe_signature( "fan@example.com", UnsubscribeAction::MailingList, "list-123", &sig, "secret" )); assert!(verify_email_unsubscribe_signature( "FAN@EXAMPLE.COM", UnsubscribeAction::MailingList, "list-123", &sig, "secret" )); } #[test] fn email_unsubscribe_rejects_tampering() { let url = generate_unsubscribe_url_for_email( "https://makenot.work", "fan@example.com", UnsubscribeAction::MailingList, "list-123", "secret", ); let parsed: url::Url = url.parse().unwrap(); let sig = parsed .query_pairs() .find(|(k, _)| k == "sig") .unwrap() .1 .to_string(); // A different email, list, or secret must not verify. assert!(!verify_email_unsubscribe_signature( "other@example.com", UnsubscribeAction::MailingList, "list-123", &sig, "secret" )); assert!(!verify_email_unsubscribe_signature( "fan@example.com", UnsubscribeAction::MailingList, "list-999", &sig, "secret" )); assert!(!verify_email_unsubscribe_signature( "fan@example.com", UnsubscribeAction::MailingList, "list-123", &sig, "wrong-secret" )); // The user-keyed verifier must not accept an email-keyed signature. assert!(!verify_unsubscribe_signature( UserId::new(), UnsubscribeAction::MailingList, "list-123", &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 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, KeyInit, 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, KeyInit, 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 )); } }