Skip to main content

max / makenotwork

30.0 KB · 913 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 a single-use password reset token.
58 ///
59 /// Returns `(token, token_hash)`: the random `token` goes in the emailed URL,
60 /// the SHA-256 `token_hash` is stored in `password_reset_tokens` and matched on
61 /// use. Single-use (the row is consumed atomically on submit) replaces the old
62 /// replayable HMAC link. Mirrors [`generate_login_token`].
63 pub fn generate_password_reset_token() -> (String, String) {
64 generate_opaque_token()
65 }
66
67 /// Generate the password reset link URL for a freshly-minted token.
68 pub fn generate_reset_link_url(host_url: &str, token: &str) -> String {
69 format!("{host_url}/reset-password?token={token}")
70 }
71
72 /// Generate email verification URL
73 pub fn generate_verification_url(
74 host_url: &str,
75 user_id: UserId,
76 email: &str,
77 secret: &str,
78 ) -> String {
79 use hmac::{Hmac, KeyInit, Mac};
80 use sha2::Sha256;
81
82 let expires = chrono::Utc::now().timestamp() + constants::EMAIL_VERIFICATION_EXPIRY_SECS;
83 let message = format!("verify:{user_id}:{expires}:{email}");
84
85 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
86 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
87 .expect("HMAC-SHA256 accepts any key length");
88 mac.update(message.as_bytes());
89 let signature = hex::encode(mac.finalize().into_bytes());
90
91 format!("{host_url}/verify-email?user={user_id}&expires={expires}&sig={signature}")
92 }
93
94 /// Generate a one-time login token
95 /// Returns (token, token_hash) where token is sent to user and token_hash is stored in DB
96 pub fn generate_login_token() -> (String, String) {
97 generate_opaque_token()
98 }
99
100 /// Mint a random opaque token and its storage hash: `(token, sha256(token))`.
101 /// The 256-bit token is sent to the user; only the hash is persisted, so a DB
102 /// read can't reconstruct a usable link.
103 fn generate_opaque_token() -> (String, String) {
104 use sha2::{Digest, Sha256};
105
106 let mut token_bytes = [0u8; 32];
107 rand::Rng::fill_bytes(&mut rand::rng(), &mut token_bytes);
108 let token = hex::encode(token_bytes);
109
110 let mut hasher = Sha256::new();
111 hasher.update(token.as_bytes());
112 let token_hash = hex::encode(hasher.finalize());
113
114 (token, token_hash)
115 }
116
117 /// Hash a reset/login token the same way it is stored, for lookup.
118 pub fn hash_opaque_token(token: &str) -> String {
119 use sha2::{Digest, Sha256};
120 let mut hasher = Sha256::new();
121 hasher.update(token.as_bytes());
122 hex::encode(hasher.finalize())
123 }
124
125 pub fn generate_login_link_url(host_url: &str, token: &str) -> String {
126 format!("{host_url}/login-link?token={token}")
127 }
128
129 /// Verify a login token against the stored hash
130 pub fn verify_login_token(token: &str, stored_hash: &str) -> bool {
131 use sha2::{Digest, Sha256};
132
133 let mut hasher = Sha256::new();
134 hasher.update(token.as_bytes());
135 let computed_hash = hex::encode(hasher.finalize());
136
137 // Constant-time comparison via the shared helper (Sec-M3); it also handles the
138 // length-mismatch case, so no separate early-return length check is needed.
139 crate::helpers::constant_time_compare(&computed_hash, stored_hash)
140 }
141
142 /// Verify email verification signature
143 pub fn verify_email_signature(
144 user_id: UserId,
145 expires: i64,
146 email: &str,
147 signature: &str,
148 secret: &str,
149 ) -> bool {
150 use hmac::{Hmac, KeyInit, Mac};
151 use sha2::Sha256;
152
153 if expires < chrono::Utc::now().timestamp() {
154 return false;
155 }
156
157 let message = format!("verify:{user_id}:{expires}:{email}");
158
159 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
160 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
161 .expect("HMAC-SHA256 accepts any key length");
162 mac.update(message.as_bytes());
163
164 let expected = hex::encode(mac.finalize().into_bytes());
165 crate::helpers::constant_time_compare(&expected, signature)
166 }
167
168 /// Generate an HMAC-signed unsubscribe URL.
169 ///
170 /// The URL is permanent (no expiry), it changes a user preference or removes
171 /// a follow relationship, both of which are easily reversible.
172 ///
173 /// * `action`, the unsubscribe action to perform
174 /// * `target`, for `broadcast`: the creator's user ID to unfollow;
175 /// for preferences: same as `user_id`
176 pub fn generate_unsubscribe_url(
177 host_url: &str,
178 user_id: UserId,
179 action: UnsubscribeAction,
180 target: &str,
181 secret: &str,
182 ) -> String {
183 use hmac::{Hmac, KeyInit, Mac};
184 use sha2::Sha256;
185
186 let message = format!("unsub:{user_id}:{action}:{target}");
187 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
188 .expect("HMAC-SHA256 accepts any key length");
189 mac.update(message.as_bytes());
190 let signature = hex::encode(mac.finalize().into_bytes());
191
192 format!("{host_url}/unsubscribe?user={user_id}&action={action}&target={target}&sig={signature}")
193 }
194
195 /// Verify an unsubscribe URL signature.
196 pub fn verify_unsubscribe_signature(
197 user_id: UserId,
198 action: UnsubscribeAction,
199 target: &str,
200 signature: &str,
201 secret: &str,
202 ) -> bool {
203 use hmac::{Hmac, KeyInit, Mac};
204 use sha2::Sha256;
205
206 let message = format!("unsub:{user_id}:{action}:{target}");
207 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
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 crate::helpers::constant_time_compare(&expected, signature)
213 }
214
215 /// Generate an HMAC-signed unsubscribe URL keyed on an EMAIL rather than a user
216 /// id. Used for mailing-list subscribers imported by email who have no MNW
217 /// account, the user-id-keyed form can't represent them, so without this an
218 /// imported subscriber has no working unsubscribe link (a CAN-SPAM problem).
219 ///
220 /// The message is domain-separated (`unsub_email:` prefix) so it shares no
221 /// signing space with the user-id form. `email` is lowercased before signing so
222 /// the token matches the stored (lowercased) subscriber row.
223 pub fn generate_unsubscribe_url_for_email(
224 host_url: &str,
225 email: &str,
226 action: UnsubscribeAction,
227 target: &str,
228 secret: &str,
229 ) -> String {
230 use hmac::{Hmac, KeyInit, Mac};
231 use sha2::Sha256;
232
233 let email = email.to_lowercase();
234 let message = format!("unsub_email:{email}:{action}:{target}");
235 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
236 .expect("HMAC-SHA256 accepts any key length");
237 mac.update(message.as_bytes());
238 let signature = hex::encode(mac.finalize().into_bytes());
239
240 format!(
241 "{}/unsubscribe?email={}&action={}&target={}&sig={}",
242 host_url,
243 urlencoding::encode(&email),
244 action,
245 target,
246 signature
247 )
248 }
249
250 /// Verify an email-keyed unsubscribe URL signature.
251 pub fn verify_email_unsubscribe_signature(
252 email: &str,
253 action: UnsubscribeAction,
254 target: &str,
255 signature: &str,
256 secret: &str,
257 ) -> bool {
258 use hmac::{Hmac, KeyInit, Mac};
259 use sha2::Sha256;
260
261 let email = email.to_lowercase();
262 let message = format!("unsub_email:{email}:{action}:{target}");
263 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
264 .expect("HMAC-SHA256 accepts any key length");
265 mac.update(message.as_bytes());
266
267 let expected = hex::encode(mac.finalize().into_bytes());
268 crate::helpers::constant_time_compare(&expected, signature)
269 }
270
271 /// Generate account deletion URL
272 pub fn generate_deletion_url(host_url: &str, user_id: UserId, email: &str, secret: &str) -> String {
273 let expires = chrono::Utc::now().timestamp() + constants::ACCOUNT_DELETION_EXPIRY_SECS;
274 let sig = generate_deletion_signature(secret, user_id, expires, email);
275
276 format!("{host_url}/confirm-delete?user={user_id}&expires={expires}&sig={sig}")
277 }
278
279 /// Generate HMAC signature for account deletion
280 pub fn generate_deletion_signature(
281 secret: &str,
282 user_id: UserId,
283 expires: i64,
284 email: &str,
285 ) -> String {
286 use hmac::{Hmac, KeyInit, Mac};
287 use sha2::Sha256;
288
289 let message = format!("delete:{user_id}:{expires}:{email}");
290
291 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
292 // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
293 .expect("HMAC-SHA256 accepts any key length");
294 mac.update(message.as_bytes());
295 hex::encode(mac.finalize().into_bytes())
296 }
297
298 /// Generate a reply-to email address for an issue comment.
299 ///
300 /// Format: `issue+{issue_id}.{user_id}.{sig}@reply.makenot.work`
301 ///
302 /// The signature is 16 chars of base64url-encoded HMAC-SHA256 (96 bits) over the
303 /// issue and user IDs. Base64url packs 6 bits/char vs hex's 4, giving 96 bits of
304 /// security in the same space that hex would give 64. This is stateless, no DB
305 /// storage needed. The handler will parse and verify the address.
306 pub fn generate_issue_reply_address(
307 issue_id: crate::db::IssueId,
308 user_id: UserId,
309 secret: &str,
310 ) -> String {
311 use base64::engine::{Engine, general_purpose::URL_SAFE_NO_PAD};
312 use hmac::{Hmac, KeyInit, Mac};
313 use sha2::Sha256;
314
315 let message = format!("issue-reply:{issue_id}:{user_id}");
316 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
317 .expect("HMAC-SHA256 accepts any key length");
318 mac.update(message.as_bytes());
319 let hash = mac.finalize().into_bytes();
320 let sig = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16];
321
322 format!("issue+{issue_id}.{user_id}.{sig}@reply.makenot.work")
323 }
324
325 /// Parse and verify an issue reply address local part.
326 ///
327 /// Input: the part before `@`, e.g. `issue+{issue_id}.{user_id}.{sig}`
328 ///
329 /// Returns `Some((IssueId, UserId))` if the signature is valid, `None` otherwise.
330 pub fn parse_issue_reply_token(
331 local_part: &str,
332 secret: &str,
333 ) -> Option<(crate::db::IssueId, UserId)> {
334 use base64::engine::{Engine, general_purpose::URL_SAFE_NO_PAD};
335 use hmac::{Hmac, KeyInit, Mac};
336 use sha2::Sha256;
337
338 let payload = local_part.strip_prefix("issue+")?;
339 let mut parts = payload.splitn(3, '.');
340 let issue_id_str = parts.next()?;
341 let user_id_str = parts.next()?;
342 let sig = parts.next()?;
343
344 let issue_id: crate::db::IssueId = issue_id_str.parse().ok()?;
345 let user_id: UserId = user_id_str.parse().ok()?;
346
347 let message = format!("issue-reply:{issue_id}:{user_id}");
348 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
349 .expect("HMAC-SHA256 accepts any key length");
350 mac.update(message.as_bytes());
351 let hash = mac.finalize().into_bytes();
352 let expected = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16];
353
354 // Constant-time comparison via the shared helper (Sec-M3).
355 if !crate::helpers::constant_time_compare(expected, sig) {
356 return None;
357 }
358
359 Some((issue_id, user_id))
360 }
361
362 #[cfg(test)]
363 mod tests {
364 use super::*;
365
366 #[test]
367 fn password_reset_token_round_trip() {
368 let (token, token_hash) = generate_password_reset_token();
369 // Token and hash differ; both are 64 hex chars (32 bytes).
370 assert_ne!(token, token_hash);
371 assert_eq!(token.len(), 64);
372 assert_eq!(token_hash.len(), 64);
373 // Hashing the emitted token reproduces the stored hash (lookup path).
374 assert_eq!(hash_opaque_token(&token), token_hash);
375 }
376
377 #[test]
378 fn password_reset_token_unique_each_call() {
379 let (t1, h1) = generate_password_reset_token();
380 let (t2, h2) = generate_password_reset_token();
381 assert_ne!(t1, t2);
382 assert_ne!(h1, h2);
383 }
384
385 #[test]
386 fn reset_link_url_format() {
387 let url = generate_reset_link_url("https://makenot.work", "abc123");
388 assert_eq!(url, "https://makenot.work/reset-password?token=abc123");
389 }
390
391 #[test]
392 fn verification_url_round_trip() {
393 let host = "https://makenot.work";
394 let user_id = UserId::new();
395 let email = "user@example.com";
396 let secret = "verify-secret";
397
398 let url = generate_verification_url(host, user_id, email, secret);
399 assert!(url.contains("/verify-email"));
400 assert!(url.contains(&user_id.to_string()));
401
402 let parsed: url::Url = url.parse().unwrap();
403 let expires: i64 = parsed
404 .query_pairs()
405 .find(|(k, _)| k == "expires")
406 .unwrap()
407 .1
408 .parse()
409 .unwrap();
410 let sig = parsed
411 .query_pairs()
412 .find(|(k, _)| k == "sig")
413 .unwrap()
414 .1
415 .to_string();
416
417 assert!(verify_email_signature(
418 user_id, expires, email, &sig, secret
419 ));
420 }
421
422 #[test]
423 fn verification_rejects_wrong_email() {
424 let user_id = UserId::new();
425 let secret = "verify-secret";
426
427 let url =
428 generate_verification_url("https://example.com", user_id, "real@example.com", secret);
429 let parsed: url::Url = url.parse().unwrap();
430 let expires: i64 = parsed
431 .query_pairs()
432 .find(|(k, _)| k == "expires")
433 .unwrap()
434 .1
435 .parse()
436 .unwrap();
437 let sig = parsed
438 .query_pairs()
439 .find(|(k, _)| k == "sig")
440 .unwrap()
441 .1
442 .to_string();
443
444 assert!(!verify_email_signature(
445 user_id,
446 expires,
447 "attacker@evil.com",
448 &sig,
449 secret
450 ));
451 }
452
453 #[test]
454 fn verification_rejects_expired() {
455 let user_id = UserId::new();
456 let expired = chrono::Utc::now().timestamp() - 1;
457 assert!(!verify_email_signature(
458 user_id, expired, "a@b.com", "deadbeef", "secret"
459 ));
460 }
461
462 #[test]
463 fn login_token_round_trip() {
464 let (token, token_hash) = generate_login_token();
465
466 // Token and hash should be different
467 assert_ne!(token, token_hash);
468 // Both should be hex-encoded 32-byte values (64 hex chars)
469 assert_eq!(token.len(), 64);
470 assert_eq!(token_hash.len(), 64);
471
472 assert!(verify_login_token(&token, &token_hash));
473 }
474
475 #[test]
476 fn login_token_rejects_wrong_token() {
477 let (_token, token_hash) = generate_login_token();
478 assert!(!verify_login_token(
479 "0000000000000000000000000000000000000000000000000000000000000000",
480 &token_hash
481 ));
482 }
483
484 #[test]
485 fn login_token_unique_each_call() {
486 let (token1, _) = generate_login_token();
487 let (token2, _) = generate_login_token();
488 assert_ne!(token1, token2);
489 }
490
491 #[test]
492 fn login_link_url_format() {
493 let url = generate_login_link_url("https://makenot.work", "abc123");
494 assert_eq!(url, "https://makenot.work/login-link?token=abc123");
495 }
496
497 #[test]
498 fn deletion_url_round_trip() {
499 let user_id = UserId::new();
500 let email = "user@example.com";
501 let secret = "delete-secret";
502
503 let url = generate_deletion_url("https://makenot.work", user_id, email, secret);
504 assert!(url.contains("/confirm-delete"));
505 assert!(url.contains(&user_id.to_string()));
506
507 // Extract and verify the signature
508 let parsed: url::Url = url.parse().unwrap();
509 let expires: i64 = parsed
510 .query_pairs()
511 .find(|(k, _)| k == "expires")
512 .unwrap()
513 .1
514 .parse()
515 .unwrap();
516 let sig = parsed
517 .query_pairs()
518 .find(|(k, _)| k == "sig")
519 .unwrap()
520 .1
521 .to_string();
522
523 let expected_sig = generate_deletion_signature(secret, user_id, expires, email);
524 assert_eq!(sig, expected_sig);
525 }
526
527 #[test]
528 fn deletion_signature_rejects_wrong_secret() {
529 let user_id = UserId::new();
530 let expires = chrono::Utc::now().timestamp() + 3600;
531 let sig = generate_deletion_signature("real-secret", user_id, expires, "a@b.com");
532 let wrong = generate_deletion_signature("wrong-secret", user_id, expires, "a@b.com");
533 assert_ne!(sig, wrong);
534 }
535
536 #[test]
537 fn unsubscribe_url_round_trip() {
538 let user_id = UserId::new();
539 let url = generate_unsubscribe_url(
540 "https://makenot.work",
541 user_id,
542 UnsubscribeAction::Release,
543 &user_id.to_string(),
544 "secret",
545 );
546 assert!(url.contains("/unsubscribe"));
547 assert!(url.contains("action=release"));
548
549 let parsed: url::Url = url.parse().unwrap();
550 let sig = parsed
551 .query_pairs()
552 .find(|(k, _)| k == "sig")
553 .unwrap()
554 .1
555 .to_string();
556 assert!(verify_unsubscribe_signature(
557 user_id,
558 UnsubscribeAction::Release,
559 &user_id.to_string(),
560 &sig,
561 "secret"
562 ));
563 }
564
565 #[test]
566 fn email_unsubscribe_url_round_trip() {
567 let url = generate_unsubscribe_url_for_email(
568 "https://makenot.work",
569 "Fan@Example.com",
570 UnsubscribeAction::MailingList,
571 "list-123",
572 "secret",
573 );
574 assert!(url.contains("/unsubscribe"));
575 assert!(url.contains("action=mailing_list"));
576 assert!(url.contains("email="));
577
578 let parsed: url::Url = url.parse().unwrap();
579 let sig = parsed
580 .query_pairs()
581 .find(|(k, _)| k == "sig")
582 .unwrap()
583 .1
584 .to_string();
585 // Email is lowercased before signing, so verification is case-insensitive.
586 assert!(verify_email_unsubscribe_signature(
587 "fan@example.com",
588 UnsubscribeAction::MailingList,
589 "list-123",
590 &sig,
591 "secret"
592 ));
593 assert!(verify_email_unsubscribe_signature(
594 "FAN@EXAMPLE.COM",
595 UnsubscribeAction::MailingList,
596 "list-123",
597 &sig,
598 "secret"
599 ));
600 }
601
602 #[test]
603 fn email_unsubscribe_rejects_tampering() {
604 let url = generate_unsubscribe_url_for_email(
605 "https://makenot.work",
606 "fan@example.com",
607 UnsubscribeAction::MailingList,
608 "list-123",
609 "secret",
610 );
611 let parsed: url::Url = url.parse().unwrap();
612 let sig = parsed
613 .query_pairs()
614 .find(|(k, _)| k == "sig")
615 .unwrap()
616 .1
617 .to_string();
618 // A different email, list, or secret must not verify.
619 assert!(!verify_email_unsubscribe_signature(
620 "other@example.com",
621 UnsubscribeAction::MailingList,
622 "list-123",
623 &sig,
624 "secret"
625 ));
626 assert!(!verify_email_unsubscribe_signature(
627 "fan@example.com",
628 UnsubscribeAction::MailingList,
629 "list-999",
630 &sig,
631 "secret"
632 ));
633 assert!(!verify_email_unsubscribe_signature(
634 "fan@example.com",
635 UnsubscribeAction::MailingList,
636 "list-123",
637 &sig,
638 "wrong-secret"
639 ));
640 // The user-keyed verifier must not accept an email-keyed signature.
641 assert!(!verify_unsubscribe_signature(
642 UserId::new(),
643 UnsubscribeAction::MailingList,
644 "list-123",
645 &sig,
646 "secret"
647 ));
648 }
649
650 #[test]
651 fn unsubscribe_rejects_wrong_action() {
652 let user_id = UserId::new();
653 let url = generate_unsubscribe_url(
654 "https://makenot.work",
655 user_id,
656 UnsubscribeAction::Sale,
657 &user_id.to_string(),
658 "secret",
659 );
660 let parsed: url::Url = url.parse().unwrap();
661 let sig = parsed
662 .query_pairs()
663 .find(|(k, _)| k == "sig")
664 .unwrap()
665 .1
666 .to_string();
667 // Verify with different action should fail
668 assert!(!verify_unsubscribe_signature(
669 user_id,
670 UnsubscribeAction::Follower,
671 &user_id.to_string(),
672 &sig,
673 "secret"
674 ));
675 }
676
677 #[test]
678 fn unsubscribe_rejects_wrong_secret() {
679 let user_id = UserId::new();
680 let url = generate_unsubscribe_url(
681 "https://makenot.work",
682 user_id,
683 UnsubscribeAction::Login,
684 &user_id.to_string(),
685 "real-secret",
686 );
687 let parsed: url::Url = url.parse().unwrap();
688 let sig = parsed
689 .query_pairs()
690 .find(|(k, _)| k == "sig")
691 .unwrap()
692 .1
693 .to_string();
694 assert!(!verify_unsubscribe_signature(
695 user_id,
696 UnsubscribeAction::Login,
697 &user_id.to_string(),
698 &sig,
699 "wrong-secret"
700 ));
701 }
702
703 #[test]
704 fn unsubscribe_broadcast_with_target() {
705 let user_id = UserId::new();
706 let creator_id = UserId::new();
707 let url = generate_unsubscribe_url(
708 "https://makenot.work",
709 user_id,
710 UnsubscribeAction::Broadcast,
711 &creator_id.to_string(),
712 "secret",
713 );
714 let parsed: url::Url = url.parse().unwrap();
715 let sig = parsed
716 .query_pairs()
717 .find(|(k, _)| k == "sig")
718 .unwrap()
719 .1
720 .to_string();
721 assert!(verify_unsubscribe_signature(
722 user_id,
723 UnsubscribeAction::Broadcast,
724 &creator_id.to_string(),
725 &sig,
726 "secret"
727 ));
728 // Wrong target should fail
729 assert!(!verify_unsubscribe_signature(
730 user_id,
731 UnsubscribeAction::Broadcast,
732 &user_id.to_string(),
733 &sig,
734 "secret"
735 ));
736 }
737
738 #[test]
739 fn constant_time_compare_equal() {
740 use crate::helpers::constant_time_compare;
741 assert!(constant_time_compare("hello", "hello"));
742 assert!(constant_time_compare("", ""));
743 }
744
745 #[test]
746 fn constant_time_compare_not_equal() {
747 use crate::helpers::constant_time_compare;
748 assert!(!constant_time_compare("hello", "world"));
749 assert!(!constant_time_compare("hello", "hell"));
750 assert!(!constant_time_compare("short", "longer"));
751 }
752
753 // ── Issue reply token tests ──
754
755 #[test]
756 fn issue_reply_round_trip() {
757 let issue_id = crate::db::IssueId::new();
758 let user_id = UserId::new();
759 let secret = "reply-secret";
760
761 let addr = generate_issue_reply_address(issue_id, user_id, secret);
762 assert!(addr.ends_with("@reply.makenot.work"));
763 assert!(addr.starts_with("issue+"));
764
765 // Extract local part
766 let local = addr.split('@').next().unwrap();
767 let result = parse_issue_reply_token(local, secret);
768 assert!(result.is_some());
769 let (parsed_issue, parsed_user) = result.unwrap();
770 assert_eq!(parsed_issue, issue_id);
771 assert_eq!(parsed_user, user_id);
772 }
773
774 #[test]
775 fn issue_reply_wrong_secret_rejected() {
776 let issue_id = crate::db::IssueId::new();
777 let user_id = UserId::new();
778
779 let addr = generate_issue_reply_address(issue_id, user_id, "real-secret");
780 let local = addr.split('@').next().unwrap();
781 assert!(parse_issue_reply_token(local, "wrong-secret").is_none());
782 }
783
784 #[test]
785 fn issue_reply_malformed_input() {
786 let secret = "test-secret";
787 assert!(parse_issue_reply_token("garbage", secret).is_none());
788 assert!(parse_issue_reply_token("issue+", secret).is_none());
789 assert!(parse_issue_reply_token("issue+a.b", secret).is_none());
790 assert!(
791 parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none()
792 );
793 }
794
795 // ─────────────────────────────────────────────────────────────────────
796 // Expiry arithmetic tests, pin `now + EXPIRY` so cargo-mutants can't
797 // replace `+` with `*`/`-` without the test catching it. Each generator
798 // emits an `expires=` URL parameter; we assert the value is within a
799 // tight window of `now + EXPIRY`.
800 // ─────────────────────────────────────────────────────────────────────
801
802 /// Extract the `expires` query param from a URL emitted by a token generator.
803 fn extract_expires(url: &str) -> i64 {
804 let parsed: url::Url = url.parse().expect("valid URL");
805 parsed
806 .query_pairs()
807 .find(|(k, _)| k == "expires")
808 .expect("expires param")
809 .1
810 .parse()
811 .expect("expires is i64")
812 }
813
814 /// Assert `actual ∈ [now+expiry, now+expiry + slack]`. The slack covers the
815 /// few ms between calling `Utc::now()` inside the function and `Utc::now()`
816 /// here. Any mutation that flips the arithmetic (e.g. `+` → `*`) will
817 /// produce a value wildly outside this window.
818 fn assert_within_expiry_window(actual: i64, expiry_secs: i64) {
819 let now = chrono::Utc::now().timestamp();
820 let expected_min = now + expiry_secs - 1;
821 let expected_max = now + expiry_secs + 5;
822 assert!(
823 actual >= expected_min && actual <= expected_max,
824 "expires={actual} outside [{expected_min}, {expected_max}] (now={now}, expiry_secs={expiry_secs})"
825 );
826 }
827
828 #[test]
829 fn verification_url_expires_matches_constant() {
830 let url = generate_verification_url(
831 "https://example.com",
832 UserId::new(),
833 "user@example.com",
834 "secret",
835 );
836 assert_within_expiry_window(
837 extract_expires(&url),
838 constants::EMAIL_VERIFICATION_EXPIRY_SECS,
839 );
840 }
841
842 #[test]
843 fn deletion_url_expires_matches_constant() {
844 let url = generate_deletion_url(
845 "https://example.com",
846 UserId::new(),
847 "user@example.com",
848 "secret",
849 );
850 assert_within_expiry_window(
851 extract_expires(&url),
852 constants::ACCOUNT_DELETION_EXPIRY_SECS,
853 );
854 }
855
856 // ─────────────────────────────────────────────────────────────────────
857 // Expiry-comparison boundary tests for `verify_email_signature` and
858 // `verify_password_reset_signature`. Catches `<` → `==`/`<=` mutations on
859 // the `if expires < now { return false; }` guard.
860 // ─────────────────────────────────────────────────────────────────────
861
862 #[test]
863 fn verify_email_signature_rejects_already_expired() {
864 // Build a signed URL, then verify with an `expires` value 60s in the past.
865 // The signature won't match (since the message contains expires), but
866 // the early `expires < now` check should fire first and short-circuit.
867 let user_id = UserId::new();
868 let email = "test@example.com";
869 let secret = "secret";
870 let now = chrono::Utc::now().timestamp();
871
872 // Generate a sig for an EXPIRED timestamp.
873 use hmac::{Hmac, KeyInit, Mac};
874 use sha2::Sha256;
875 let expires_past = now - 60;
876 let message = format!("verify:{user_id}:{expires_past}:{email}");
877 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
878 mac.update(message.as_bytes());
879 let sig_past = hex::encode(mac.finalize().into_bytes());
880
881 // Sig is valid for the message, but expires < now → must reject.
882 assert!(
883 !verify_email_signature(user_id, expires_past, email, &sig_past, secret),
884 "must reject expired signature"
885 );
886 }
887
888 #[test]
889 fn verify_email_signature_accepts_just_in_future() {
890 // Inverse: a sig that's still valid (expires just in the future) must
891 // pass, catches `<` → `<=` (which would reject expires == now-1+1).
892 let user_id = UserId::new();
893 let email = "test@example.com";
894 let secret = "secret";
895 let expires_future = chrono::Utc::now().timestamp() + 3600;
896
897 use hmac::{Hmac, KeyInit, Mac};
898 use sha2::Sha256;
899 let message = format!("verify:{user_id}:{expires_future}:{email}");
900 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
901 mac.update(message.as_bytes());
902 let sig = hex::encode(mac.finalize().into_bytes());
903
904 assert!(verify_email_signature(
905 user_id,
906 expires_future,
907 email,
908 &sig,
909 secret
910 ));
911 }
912 }
913