Skip to main content

max / makenotwork

6.1 KB · 173 lines History Blame Raw
1 //! Cryptographic utilities: constant-time comparison, key generation, feed signing.
2
3 /// Constant-time byte comparison for tokens, MACs, and other fixed-shape
4 /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl)
5 /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256.
6 ///
7 /// Length mismatch short-circuits — leaking the length of fixed-format
8 /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets)
9 /// reveals nothing useful to an attacker, since the format already fixes
10 /// the length. Don't use this for variable-length sensitive payloads
11 /// where length is itself secret.
12 pub fn constant_time_compare(a: &str, b: &str) -> bool {
13 use subtle::ConstantTimeEq;
14 let a = a.as_bytes();
15 let b = b.as_bytes();
16 if a.len() != b.len() {
17 return false;
18 }
19 a.ct_eq(b).into()
20 }
21
22 /// Generate a license key code in word-word-word-word-word-word format.
23 ///
24 /// Six random words from the 2048-word list (~66 bits of entropy). Six was
25 /// chosen over five (~55 bits) after a birthday-collision review: at five
26 /// words, ~190M keys gives a coin-flip chance of collision; at six, the
27 /// equivalent threshold rises to ~6B keys — far past the lifetime cap of
28 /// any realistic license catalog. Returns a `KeyCode` via `from_trusted` —
29 /// the wordlist guarantees validity.
30 pub fn generate_key_code() -> crate::db::KeyCode {
31 use rand::Rng;
32 let mut rng = rand::rng();
33 let words: Vec<&str> = (0..6)
34 .map(|_| {
35 let idx = rng.random_range(0..crate::wordlist::WORDLIST.len());
36 crate::wordlist::WORDLIST[idx]
37 })
38 .collect();
39 crate::db::KeyCode::from_trusted(words.join("-"))
40 }
41
42 /// Generate an HMAC-signed personal RSS feed URL for a user.
43 ///
44 /// The URL is permanent (no expiry) and tied to the signing secret.
45 /// If the secret rotates, old URLs become invalid.
46 pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String {
47 use hmac::{Hmac, Mac};
48 use sha2::Sha256;
49
50 let message = format!("feed:{}", user_id);
51 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
52 .expect("HMAC-SHA256 accepts any key length");
53 mac.update(message.as_bytes());
54 let sig = hex::encode(mac.finalize().into_bytes());
55
56 format!("{}/feed/{}?sig={}", host_url, user_id, sig)
57 }
58
59 /// Verify a personal feed URL signature.
60 pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool {
61 use hmac::{Hmac, Mac};
62 use sha2::Sha256;
63
64 let message = format!("feed:{}", user_id);
65 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
66 .expect("HMAC-SHA256 accepts any key length");
67 mac.update(message.as_bytes());
68 let expected = hex::encode(mac.finalize().into_bytes());
69
70 constant_time_compare(&expected, signature)
71 }
72
73 #[cfg(test)]
74 mod tests {
75 use super::*;
76
77 // ── constant_time_compare ──
78
79 #[test]
80 fn compare_equal_strings() {
81 assert!(constant_time_compare("abc123", "abc123"));
82 }
83
84 #[test]
85 fn compare_different_strings() {
86 assert!(!constant_time_compare("abc123", "abc124"));
87 }
88
89 #[test]
90 fn compare_different_lengths() {
91 assert!(!constant_time_compare("short", "longer"));
92 }
93
94 #[test]
95 fn compare_empty_strings() {
96 assert!(constant_time_compare("", ""));
97 }
98
99 #[test]
100 fn adversarial_timing_safety() {
101 assert!(!constant_time_compare("a", "b"));
102 assert!(!constant_time_compare("a", "aa"));
103 assert!(!constant_time_compare("", "x"));
104 assert!(constant_time_compare("same", "same"));
105 }
106
107 // ── generate_key_code ──
108
109 #[test]
110 fn key_code_format() {
111 let code = generate_key_code();
112 let parts: Vec<&str> = code.split('-').collect();
113 assert_eq!(parts.len(), 6, "Key code should have 6 words");
114 for word in &parts {
115 assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word);
116 assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word);
117 assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word);
118 }
119 }
120
121 #[test]
122 fn key_code_uniqueness() {
123 let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect();
124 assert_eq!(codes.len(), 100, "100 generated key codes should all be unique");
125 }
126
127 // ── feed URL signing ──
128
129 #[test]
130 fn feed_url_round_trip() {
131 let user_id = crate::db::UserId::new();
132 let url = generate_feed_url("https://makenot.work", user_id, "secret");
133 assert!(url.contains(&user_id.to_string()));
134 assert!(url.contains("sig="));
135 let sig = url.split("sig=").nth(1).unwrap();
136 assert!(verify_feed_signature(user_id, sig, "secret"));
137 }
138
139 #[test]
140 fn feed_url_wrong_secret_rejected() {
141 let user_id = crate::db::UserId::new();
142 let url = generate_feed_url("https://makenot.work", user_id, "secret");
143 let sig = url.split("sig=").nth(1).unwrap();
144 assert!(!verify_feed_signature(user_id, sig, "wrong-secret"));
145 }
146
147 #[test]
148 fn feed_url_wrong_user_rejected() {
149 let user_id = crate::db::UserId::new();
150 let other_id = crate::db::UserId::new();
151 let url = generate_feed_url("https://makenot.work", user_id, "secret");
152 let sig = url.split("sig=").nth(1).unwrap();
153 assert!(!verify_feed_signature(other_id, sig, "secret"));
154 }
155
156 #[test]
157 fn feed_signature_empty_string_rejected() {
158 let user_id = crate::db::UserId::new();
159 assert!(!verify_feed_signature(user_id, "", "secret"));
160 }
161
162 #[test]
163 fn feed_signature_tampered_rejected() {
164 let user_id = crate::db::UserId::new();
165 let url = generate_feed_url("https://makenot.work", user_id, "secret");
166 let sig = url.split("sig=").nth(1).unwrap();
167 let mut tampered = sig.to_string();
168 let first = tampered.remove(0);
169 tampered.insert(0, if first == '0' { '1' } else { '0' });
170 assert!(!verify_feed_signature(user_id, &tampered, "secret"));
171 }
172 }
173