Skip to main content

max / makenotwork

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