Skip to main content

max / makenotwork

28.4 KB · 801 lines History Blame Raw
1 //! HMAC-signed URL generation and verification for email actions.
2
3 use crate::constants;
4 use crate::db::UserId;
5
6 /// Valid actions for email unsubscribe links.
7 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8 #[serde(rename_all = "snake_case")]
9 pub enum UnsubscribeAction {
10 Broadcast,
11 Release,
12 Sale,
13 Follower,
14 Login,
15 Issue,
16 Status,
17 MailingList,
18 NotifyTip,
19 }
20
21 impl std::fmt::Display for UnsubscribeAction {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 let s = match self {
24 Self::Broadcast => "broadcast",
25 Self::Release => "release",
26 Self::Sale => "sale",
27 Self::Follower => "follower",
28 Self::Login => "login",
29 Self::Issue => "issue",
30 Self::Status => "status",
31 Self::MailingList => "mailing_list",
32 Self::NotifyTip => "notify_tip",
33 };
34 f.write_str(s)
35 }
36 }
37
38 impl std::str::FromStr for UnsubscribeAction {
39 type Err = String;
40
41 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
42 match s {
43 "broadcast" => Ok(Self::Broadcast),
44 "release" => Ok(Self::Release),
45 "sale" => Ok(Self::Sale),
46 "follower" => Ok(Self::Follower),
47 "login" => Ok(Self::Login),
48 "issue" => Ok(Self::Issue),
49 "status" => Ok(Self::Status),
50 "mailing_list" => Ok(Self::MailingList),
51 "notify_tip" => Ok(Self::NotifyTip),
52 other => Err(format!("invalid UnsubscribeAction: {other}")),
53 }
54 }
55 }
56
57 /// Generate HMAC-signed password reset URL
58 pub fn generate_password_reset_url(
59 host_url: &str,
60 user_id: UserId,
61 password_hash: &str,
62 secret: &str,
63 ) -> String {
64 use hmac::{Hmac, Mac};
65 use sha2::Sha256;
66
67 let expires = chrono::Utc::now().timestamp() + constants::PASSWORD_RESET_EXPIRY_SECS;
68 // Use full password hash to bind token to current password
69 // This invalidates the link if password changes
70 let message = format!("reset:{}:{}:{}", user_id, expires, password_hash);
71
72 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
73 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
74 .expect("HMAC-SHA256 accepts any key length");
75 mac.update(message.as_bytes());
76 let signature = hex::encode(mac.finalize().into_bytes());
77
78 format!(
79 "{}/reset-password?user={}&expires={}&sig={}",
80 host_url, user_id, expires, signature
81 )
82 }
83
84 /// Verify HMAC-signed password reset URL
85 pub fn verify_password_reset_signature(
86 user_id: UserId,
87 expires: i64,
88 password_hash: &str,
89 signature: &str,
90 secret: &str,
91 ) -> bool {
92 use hmac::{Hmac, Mac};
93 use sha2::Sha256;
94
95 // Check expiration
96 if expires < chrono::Utc::now().timestamp() {
97 return false;
98 }
99
100 // Use full password hash to match generation
101 let message = format!("reset:{}:{}:{}", user_id, expires, password_hash);
102
103 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
104 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
105 .expect("HMAC-SHA256 accepts any key length");
106 mac.update(message.as_bytes());
107
108 // Constant-time comparison
109 let expected = hex::encode(mac.finalize().into_bytes());
110 if expected.len() != signature.len() {
111 return false;
112 }
113
114 let mut result = 0u8;
115 for (a, b) in expected.bytes().zip(signature.bytes()) {
116 result |= a ^ b;
117 }
118 result == 0
119 }
120
121 /// Generate email verification URL
122 pub fn generate_verification_url(
123 host_url: &str,
124 user_id: UserId,
125 email: &str,
126 secret: &str,
127 ) -> String {
128 use hmac::{Hmac, Mac};
129 use sha2::Sha256;
130
131 let expires = chrono::Utc::now().timestamp() + constants::EMAIL_VERIFICATION_EXPIRY_SECS;
132 let message = format!("verify:{}:{}:{}", user_id, expires, email);
133
134 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
135 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
136 .expect("HMAC-SHA256 accepts any key length");
137 mac.update(message.as_bytes());
138 let signature = hex::encode(mac.finalize().into_bytes());
139
140 format!(
141 "{}/verify-email?user={}&expires={}&sig={}",
142 host_url, user_id, expires, signature
143 )
144 }
145
146 /// Generate a one-time login token
147 /// Returns (token, token_hash) where token is sent to user and token_hash is stored in DB
148 pub fn generate_login_token() -> (String, String) {
149 use sha2::{Sha256, Digest};
150
151 // Generate random token
152 let mut token_bytes = [0u8; 32];
153 rand::RngCore::fill_bytes(&mut rand::rng(), &mut token_bytes);
154 let token = hex::encode(token_bytes);
155
156 // Hash the token for storage
157 let mut hasher = Sha256::new();
158 hasher.update(token.as_bytes());
159 let token_hash = hex::encode(hasher.finalize());
160
161 (token, token_hash)
162 }
163
164 /// Generate login link URL
165 pub fn generate_login_link_url(host_url: &str, token: &str) -> String {
166 format!("{}/login-link?token={}", host_url, token)
167 }
168
169 /// Verify a login token against the stored hash
170 pub fn verify_login_token(token: &str, stored_hash: &str) -> bool {
171 use sha2::{Sha256, Digest};
172
173 let mut hasher = Sha256::new();
174 hasher.update(token.as_bytes());
175 let computed_hash = hex::encode(hasher.finalize());
176
177 // Constant-time comparison
178 if computed_hash.len() != stored_hash.len() {
179 return false;
180 }
181
182 let mut result = 0u8;
183 for (a, b) in computed_hash.bytes().zip(stored_hash.bytes()) {
184 result |= a ^ b;
185 }
186 result == 0
187 }
188
189 /// Verify email verification signature
190 pub fn verify_email_signature(
191 user_id: UserId,
192 expires: i64,
193 email: &str,
194 signature: &str,
195 secret: &str,
196 ) -> bool {
197 use hmac::{Hmac, Mac};
198 use sha2::Sha256;
199
200 if expires < chrono::Utc::now().timestamp() {
201 return false;
202 }
203
204 let message = format!("verify:{}:{}:{}", user_id, expires, email);
205
206 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
207 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
208 .expect("HMAC-SHA256 accepts any key length");
209 mac.update(message.as_bytes());
210
211 let expected = hex::encode(mac.finalize().into_bytes());
212 if expected.len() != signature.len() {
213 return false;
214 }
215
216 let mut result = 0u8;
217 for (a, b) in expected.bytes().zip(signature.bytes()) {
218 result |= a ^ b;
219 }
220 result == 0
221 }
222
223 /// Generate an HMAC-signed unsubscribe URL.
224 ///
225 /// The URL is permanent (no expiry) — it changes a user preference or removes
226 /// a follow relationship, both of which are easily reversible.
227 ///
228 /// * `action` — the unsubscribe action to perform
229 /// * `target` — for `broadcast`: the creator's user ID to unfollow;
230 /// for preferences: same as `user_id`
231 pub fn generate_unsubscribe_url(
232 host_url: &str,
233 user_id: UserId,
234 action: UnsubscribeAction,
235 target: &str,
236 secret: &str,
237 ) -> String {
238 use hmac::{Hmac, Mac};
239 use sha2::Sha256;
240
241 let message = format!("unsub:{}:{}:{}", user_id, action, target);
242 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
243 .expect("HMAC-SHA256 accepts any key length");
244 mac.update(message.as_bytes());
245 let signature = hex::encode(mac.finalize().into_bytes());
246
247 format!(
248 "{}/unsubscribe?user={}&action={}&target={}&sig={}",
249 host_url, user_id, action, target, signature
250 )
251 }
252
253 /// Verify an unsubscribe URL signature.
254 pub fn verify_unsubscribe_signature(
255 user_id: UserId,
256 action: UnsubscribeAction,
257 target: &str,
258 signature: &str,
259 secret: &str,
260 ) -> bool {
261 use hmac::{Hmac, Mac};
262 use sha2::Sha256;
263
264 let message = format!("unsub:{}:{}:{}", user_id, action, target);
265 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
266 .expect("HMAC-SHA256 accepts any key length");
267 mac.update(message.as_bytes());
268
269 let expected = hex::encode(mac.finalize().into_bytes());
270
271 // Constant-time comparison
272 if expected.len() != signature.len() {
273 return false;
274 }
275 let mut result = 0u8;
276 for (a, b) in expected.bytes().zip(signature.bytes()) {
277 result |= a ^ b;
278 }
279 result == 0
280 }
281
282 /// Generate account deletion URL
283 pub fn generate_deletion_url(
284 host_url: &str,
285 user_id: UserId,
286 email: &str,
287 secret: &str,
288 ) -> String {
289 let expires = chrono::Utc::now().timestamp() + constants::ACCOUNT_DELETION_EXPIRY_SECS;
290 let sig = generate_deletion_signature(secret, user_id, expires, email);
291
292 format!(
293 "{}/confirm-delete?user={}&expires={}&sig={}",
294 host_url, user_id, expires, sig
295 )
296 }
297
298 /// Generate HMAC signature for account deletion
299 pub fn generate_deletion_signature(
300 secret: &str,
301 user_id: UserId,
302 expires: i64,
303 email: &str,
304 ) -> String {
305 use hmac::{Hmac, Mac};
306 use sha2::Sha256;
307
308 let message = format!("delete:{}:{}:{}", user_id, expires, email);
309
310 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
311 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
312 .expect("HMAC-SHA256 accepts any key length");
313 mac.update(message.as_bytes());
314 hex::encode(mac.finalize().into_bytes())
315 }
316
317 /// Generate a reply-to email address for an issue comment.
318 ///
319 /// Format: `issue+{issue_id}.{user_id}.{sig}@reply.makenot.work`
320 ///
321 /// The signature is 16 chars of base64url-encoded HMAC-SHA256 (96 bits) over the
322 /// issue and user IDs. Base64url packs 6 bits/char vs hex's 4, giving 96 bits of
323 /// security in the same space that hex would give 64. This is stateless — no DB
324 /// storage needed. The handler will parse and verify the address.
325 pub fn generate_issue_reply_address(
326 issue_id: crate::db::IssueId,
327 user_id: UserId,
328 secret: &str,
329 ) -> String {
330 use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
331 use hmac::{Hmac, Mac};
332 use sha2::Sha256;
333
334 let message = format!("issue-reply:{}:{}", issue_id, user_id);
335 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
336 .expect("HMAC-SHA256 accepts any key length");
337 mac.update(message.as_bytes());
338 let hash = mac.finalize().into_bytes();
339 let sig = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16];
340
341 format!("issue+{}.{}.{}@reply.makenot.work", issue_id, user_id, sig)
342 }
343
344 /// Parse and verify an issue reply address local part.
345 ///
346 /// Input: the part before `@`, e.g. `issue+{issue_id}.{user_id}.{sig}`
347 ///
348 /// Returns `Some((IssueId, UserId))` if the signature is valid, `None` otherwise.
349 pub fn parse_issue_reply_token(
350 local_part: &str,
351 secret: &str,
352 ) -> Option<(crate::db::IssueId, UserId)> {
353 use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
354 use hmac::{Hmac, Mac};
355 use sha2::Sha256;
356
357 let payload = local_part.strip_prefix("issue+")?;
358 let mut parts = payload.splitn(3, '.');
359 let issue_id_str = parts.next()?;
360 let user_id_str = parts.next()?;
361 let sig = parts.next()?;
362
363 let issue_id: crate::db::IssueId = issue_id_str.parse().ok()?;
364 let user_id: UserId = user_id_str.parse().ok()?;
365
366 let message = format!("issue-reply:{}:{}", issue_id, user_id);
367 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
368 .expect("HMAC-SHA256 accepts any key length");
369 mac.update(message.as_bytes());
370 let hash = mac.finalize().into_bytes();
371 let expected = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16];
372
373 // Constant-time comparison
374 if expected.len() != sig.len() {
375 return None;
376 }
377 let mut result = 0u8;
378 for (a, b) in expected.bytes().zip(sig.bytes()) {
379 result |= a ^ b;
380 }
381 if result != 0 {
382 return None;
383 }
384
385 Some((issue_id, user_id))
386 }
387
388 #[cfg(test)]
389 mod tests {
390 use super::*;
391
392 #[test]
393 fn test_password_reset_url_generation_and_verification() {
394 let host = "https://makenot.work";
395 let user_id = UserId::new();
396 let password_hash = "argon2$abc123def456789012345678901234567890";
397 let secret = "test-secret-key";
398
399 let url = generate_password_reset_url(host, user_id, password_hash, secret);
400 assert!(url.contains("/reset-password"));
401 assert!(url.contains(&user_id.to_string()));
402
403 // Extract params
404 let url_parsed: url::Url = url.parse().unwrap();
405 let expires: i64 = url_parsed
406 .query_pairs()
407 .find(|(k, _)| k == "expires")
408 .unwrap()
409 .1
410 .parse()
411 .unwrap();
412 let sig = url_parsed
413 .query_pairs()
414 .find(|(k, _)| k == "sig")
415 .unwrap()
416 .1
417 .to_string();
418
419 assert!(verify_password_reset_signature(
420 user_id,
421 expires,
422 password_hash,
423 &sig,
424 secret
425 ));
426 }
427
428 #[test]
429 fn password_reset_rejects_wrong_secret() {
430 let user_id = UserId::new();
431 let hash = "argon2$abc123";
432 let secret = "real-secret";
433
434 let url = generate_password_reset_url("https://example.com", user_id, hash, secret);
435 let parsed: url::Url = url.parse().unwrap();
436 let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
437 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
438
439 assert!(!verify_password_reset_signature(user_id, expires, hash, &sig, "wrong-secret"));
440 }
441
442 #[test]
443 fn password_reset_rejects_expired_token() {
444 let user_id = UserId::new();
445 let hash = "argon2$abc123";
446 let secret = "test-secret";
447
448 // Use an already-expired timestamp
449 let expired = chrono::Utc::now().timestamp() - 1;
450 assert!(!verify_password_reset_signature(user_id, expired, hash, "deadbeef", secret));
451 }
452
453 #[test]
454 fn password_reset_rejects_wrong_password_hash() {
455 let user_id = UserId::new();
456 let secret = "test-secret";
457
458 let url = generate_password_reset_url("https://example.com", user_id, "hash-v1", secret);
459 let parsed: url::Url = url.parse().unwrap();
460 let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
461 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
462
463 // Signature was bound to "hash-v1", should fail with "hash-v2" (password changed)
464 assert!(!verify_password_reset_signature(user_id, expires, "hash-v2", &sig, secret));
465 }
466
467 #[test]
468 fn verification_url_round_trip() {
469 let host = "https://makenot.work";
470 let user_id = UserId::new();
471 let email = "user@example.com";
472 let secret = "verify-secret";
473
474 let url = generate_verification_url(host, user_id, email, secret);
475 assert!(url.contains("/verify-email"));
476 assert!(url.contains(&user_id.to_string()));
477
478 let parsed: url::Url = url.parse().unwrap();
479 let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
480 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
481
482 assert!(verify_email_signature(user_id, expires, email, &sig, secret));
483 }
484
485 #[test]
486 fn verification_rejects_wrong_email() {
487 let user_id = UserId::new();
488 let secret = "verify-secret";
489
490 let url = generate_verification_url("https://example.com", user_id, "real@example.com", secret);
491 let parsed: url::Url = url.parse().unwrap();
492 let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
493 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
494
495 assert!(!verify_email_signature(user_id, expires, "attacker@evil.com", &sig, secret));
496 }
497
498 #[test]
499 fn verification_rejects_expired() {
500 let user_id = UserId::new();
501 let expired = chrono::Utc::now().timestamp() - 1;
502 assert!(!verify_email_signature(user_id, expired, "a@b.com", "deadbeef", "secret"));
503 }
504
505 #[test]
506 fn login_token_round_trip() {
507 let (token, token_hash) = generate_login_token();
508
509 // Token and hash should be different
510 assert_ne!(token, token_hash);
511 // Both should be hex-encoded 32-byte values (64 hex chars)
512 assert_eq!(token.len(), 64);
513 assert_eq!(token_hash.len(), 64);
514
515 assert!(verify_login_token(&token, &token_hash));
516 }
517
518 #[test]
519 fn login_token_rejects_wrong_token() {
520 let (_token, token_hash) = generate_login_token();
521 assert!(!verify_login_token("0000000000000000000000000000000000000000000000000000000000000000", &token_hash));
522 }
523
524 #[test]
525 fn login_token_unique_each_call() {
526 let (token1, _) = generate_login_token();
527 let (token2, _) = generate_login_token();
528 assert_ne!(token1, token2);
529 }
530
531 #[test]
532 fn login_link_url_format() {
533 let url = generate_login_link_url("https://makenot.work", "abc123");
534 assert_eq!(url, "https://makenot.work/login-link?token=abc123");
535 }
536
537 #[test]
538 fn deletion_url_round_trip() {
539 let user_id = UserId::new();
540 let email = "user@example.com";
541 let secret = "delete-secret";
542
543 let url = generate_deletion_url("https://makenot.work", user_id, email, secret);
544 assert!(url.contains("/confirm-delete"));
545 assert!(url.contains(&user_id.to_string()));
546
547 // Extract and verify the signature
548 let parsed: url::Url = url.parse().unwrap();
549 let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
550 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
551
552 let expected_sig = generate_deletion_signature(secret, user_id, expires, email);
553 assert_eq!(sig, expected_sig);
554 }
555
556 #[test]
557 fn deletion_signature_rejects_wrong_secret() {
558 let user_id = UserId::new();
559 let expires = chrono::Utc::now().timestamp() + 3600;
560 let sig = generate_deletion_signature("real-secret", user_id, expires, "a@b.com");
561 let wrong = generate_deletion_signature("wrong-secret", user_id, expires, "a@b.com");
562 assert_ne!(sig, wrong);
563 }
564
565 #[test]
566 fn unsubscribe_url_round_trip() {
567 let user_id = UserId::new();
568 let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Release, &user_id.to_string(), "secret");
569 assert!(url.contains("/unsubscribe"));
570 assert!(url.contains("action=release"));
571
572 let parsed: url::Url = url.parse().unwrap();
573 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
574 assert!(verify_unsubscribe_signature(user_id, UnsubscribeAction::Release, &user_id.to_string(), &sig, "secret"));
575 }
576
577 #[test]
578 fn unsubscribe_rejects_wrong_action() {
579 let user_id = UserId::new();
580 let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Sale, &user_id.to_string(), "secret");
581 let parsed: url::Url = url.parse().unwrap();
582 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
583 // Verify with different action should fail
584 assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Follower, &user_id.to_string(), &sig, "secret"));
585 }
586
587 #[test]
588 fn unsubscribe_rejects_wrong_secret() {
589 let user_id = UserId::new();
590 let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Login, &user_id.to_string(), "real-secret");
591 let parsed: url::Url = url.parse().unwrap();
592 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
593 assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Login, &user_id.to_string(), &sig, "wrong-secret"));
594 }
595
596 #[test]
597 fn unsubscribe_broadcast_with_target() {
598 let user_id = UserId::new();
599 let creator_id = UserId::new();
600 let url = generate_unsubscribe_url("https://makenot.work", user_id, UnsubscribeAction::Broadcast, &creator_id.to_string(), "secret");
601 let parsed: url::Url = url.parse().unwrap();
602 let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
603 assert!(verify_unsubscribe_signature(user_id, UnsubscribeAction::Broadcast, &creator_id.to_string(), &sig, "secret"));
604 // Wrong target should fail
605 assert!(!verify_unsubscribe_signature(user_id, UnsubscribeAction::Broadcast, &user_id.to_string(), &sig, "secret"));
606 }
607
608 #[test]
609 fn constant_time_compare_equal() {
610 use crate::helpers::constant_time_compare;
611 assert!(constant_time_compare("hello", "hello"));
612 assert!(constant_time_compare("", ""));
613 }
614
615 #[test]
616 fn constant_time_compare_not_equal() {
617 use crate::helpers::constant_time_compare;
618 assert!(!constant_time_compare("hello", "world"));
619 assert!(!constant_time_compare("hello", "hell"));
620 assert!(!constant_time_compare("short", "longer"));
621 }
622
623 // ── Issue reply token tests ──
624
625 #[test]
626 fn issue_reply_round_trip() {
627 let issue_id = crate::db::IssueId::new();
628 let user_id = UserId::new();
629 let secret = "reply-secret";
630
631 let addr = generate_issue_reply_address(issue_id, user_id, secret);
632 assert!(addr.ends_with("@reply.makenot.work"));
633 assert!(addr.starts_with("issue+"));
634
635 // Extract local part
636 let local = addr.split('@').next().unwrap();
637 let result = parse_issue_reply_token(local, secret);
638 assert!(result.is_some());
639 let (parsed_issue, parsed_user) = result.unwrap();
640 assert_eq!(parsed_issue, issue_id);
641 assert_eq!(parsed_user, user_id);
642 }
643
644 #[test]
645 fn issue_reply_wrong_secret_rejected() {
646 let issue_id = crate::db::IssueId::new();
647 let user_id = UserId::new();
648
649 let addr = generate_issue_reply_address(issue_id, user_id, "real-secret");
650 let local = addr.split('@').next().unwrap();
651 assert!(parse_issue_reply_token(local, "wrong-secret").is_none());
652 }
653
654 #[test]
655 fn issue_reply_malformed_input() {
656 let secret = "test-secret";
657 assert!(parse_issue_reply_token("garbage", secret).is_none());
658 assert!(parse_issue_reply_token("issue+", secret).is_none());
659 assert!(parse_issue_reply_token("issue+a.b", secret).is_none());
660 assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none());
661 }
662
663 // ─────────────────────────────────────────────────────────────────────
664 // Expiry arithmetic tests — pin `now + EXPIRY` so cargo-mutants can't
665 // replace `+` with `*`/`-` without the test catching it. Each generator
666 // emits an `expires=` URL parameter; we assert the value is within a
667 // tight window of `now + EXPIRY`.
668 // ─────────────────────────────────────────────────────────────────────
669
670 /// Extract the `expires` query param from a URL emitted by a token generator.
671 fn extract_expires(url: &str) -> i64 {
672 let parsed: url::Url = url.parse().expect("valid URL");
673 parsed
674 .query_pairs()
675 .find(|(k, _)| k == "expires")
676 .expect("expires param")
677 .1
678 .parse()
679 .expect("expires is i64")
680 }
681
682 /// Assert `actual ∈ [now+expiry, now+expiry + slack]`. The slack covers the
683 /// few ms between calling `Utc::now()` inside the function and `Utc::now()`
684 /// here. Any mutation that flips the arithmetic (e.g. `+` → `*`) will
685 /// produce a value wildly outside this window.
686 fn assert_within_expiry_window(actual: i64, expiry_secs: i64) {
687 let now = chrono::Utc::now().timestamp();
688 let expected_min = now + expiry_secs - 1;
689 let expected_max = now + expiry_secs + 5;
690 assert!(
691 actual >= expected_min && actual <= expected_max,
692 "expires={actual} outside [{expected_min}, {expected_max}] (now={now}, expiry_secs={expiry_secs})"
693 );
694 }
695
696 #[test]
697 fn password_reset_url_expires_matches_constant() {
698 let url = generate_password_reset_url(
699 "https://example.com",
700 UserId::new(),
701 "argon2$dummy",
702 "secret",
703 );
704 assert_within_expiry_window(extract_expires(&url), constants::PASSWORD_RESET_EXPIRY_SECS);
705 }
706
707 #[test]
708 fn verification_url_expires_matches_constant() {
709 let url = generate_verification_url(
710 "https://example.com",
711 UserId::new(),
712 "user@example.com",
713 "secret",
714 );
715 assert_within_expiry_window(extract_expires(&url), constants::EMAIL_VERIFICATION_EXPIRY_SECS);
716 }
717
718 #[test]
719 fn deletion_url_expires_matches_constant() {
720 let url = generate_deletion_url(
721 "https://example.com",
722 UserId::new(),
723 "user@example.com",
724 "secret",
725 );
726 assert_within_expiry_window(extract_expires(&url), constants::ACCOUNT_DELETION_EXPIRY_SECS);
727 }
728
729 // ─────────────────────────────────────────────────────────────────────
730 // Expiry-comparison boundary tests for `verify_email_signature` and
731 // `verify_password_reset_signature`. Catches `<` → `==`/`<=` mutations on
732 // the `if expires < now { return false; }` guard.
733 // ─────────────────────────────────────────────────────────────────────
734
735 #[test]
736 fn verify_email_signature_rejects_already_expired() {
737 // Build a signed URL, then verify with an `expires` value 60s in the past.
738 // The signature won't match (since the message contains expires) — but
739 // the early `expires < now` check should fire first and short-circuit.
740 let user_id = UserId::new();
741 let email = "test@example.com";
742 let secret = "secret";
743 let now = chrono::Utc::now().timestamp();
744
745 // Generate a sig for an EXPIRED timestamp.
746 use hmac::{Hmac, Mac};
747 use sha2::Sha256;
748 let expires_past = now - 60;
749 let message = format!("verify:{}:{}:{}", user_id, expires_past, email);
750 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
751 mac.update(message.as_bytes());
752 let sig_past = hex::encode(mac.finalize().into_bytes());
753
754 // Sig is valid for the message, but expires < now → must reject.
755 assert!(
756 !verify_email_signature(user_id, expires_past, email, &sig_past, secret),
757 "must reject expired signature"
758 );
759 }
760
761 #[test]
762 fn verify_email_signature_accepts_just_in_future() {
763 // Inverse: a sig that's still valid (expires just in the future) must
764 // pass — catches `<` → `<=` (which would reject expires == now-1+1).
765 let user_id = UserId::new();
766 let email = "test@example.com";
767 let secret = "secret";
768 let expires_future = chrono::Utc::now().timestamp() + 3600;
769
770 use hmac::{Hmac, Mac};
771 use sha2::Sha256;
772 let message = format!("verify:{}:{}:{}", user_id, expires_future, email);
773 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
774 mac.update(message.as_bytes());
775 let sig = hex::encode(mac.finalize().into_bytes());
776
777 assert!(verify_email_signature(user_id, expires_future, email, &sig, secret));
778 }
779
780 #[test]
781 fn verify_password_reset_signature_rejects_expired() {
782 // Same boundary check for password reset path (L96 in this file).
783 let user_id = UserId::new();
784 let password_hash = "argon2$dummy";
785 let secret = "secret";
786 let expires_past = chrono::Utc::now().timestamp() - 60;
787
788 use hmac::{Hmac, Mac};
789 use sha2::Sha256;
790 let message = format!("reset:{}:{}:{}", user_id, expires_past, password_hash);
791 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
792 mac.update(message.as_bytes());
793 let sig_past = hex::encode(mac.finalize().into_bytes());
794
795 assert!(
796 !verify_password_reset_signature(user_id, expires_past, password_hash, &sig_past, secret),
797 "must reject expired password reset signature"
798 );
799 }
800 }
801