Skip to main content

max / makenotwork

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