Skip to main content

max / makenotwork

7.1 KB · 198 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 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
43 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
44 use hmac::{Hmac, Mac};
45 use sha2::Sha256;
46
47 let message = format!("feed:{user_id}:{version}");
48 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
49 .expect("HMAC-SHA256 accepts any key length");
50 mac.update(message.as_bytes());
51 hex::encode(mac.finalize().into_bytes())
52 }
53
54 /// Generate an HMAC-signed personal RSS feed URL for a user.
55 ///
56 /// The signature covers `feed:{user_id}:{version}`. `version` is the user's
57 /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL"
58 /// action) changes the signed message and revokes the previously-issued URL
59 /// for that one user, without rotating the global signing secret (which would
60 /// invalidate every user's feed at once). The URL is otherwise permanent.
61 pub fn generate_feed_url(
62 host_url: &str,
63 user_id: crate::db::UserId,
64 version: i32,
65 secret: &str,
66 ) -> String {
67 let sig = feed_signature(user_id, version, secret);
68 format!("{}/feed/{}?v={}&sig={}", host_url, user_id, version, sig)
69 }
70
71 /// Verify a personal feed URL signature for a given `(user_id, version)`.
72 ///
73 /// The caller MUST additionally check that `version` equals the user's current
74 /// `feed_key_version` — a valid signature for a stale version is a revoked URL.
75 pub fn verify_feed_signature(
76 user_id: crate::db::UserId,
77 version: i32,
78 signature: &str,
79 secret: &str,
80 ) -> bool {
81 let expected = feed_signature(user_id, version, secret);
82 constant_time_compare(&expected, signature)
83 }
84
85 #[cfg(test)]
86 mod tests {
87 use super::*;
88
89 // ── constant_time_compare ──
90
91 #[test]
92 fn compare_equal_strings() {
93 assert!(constant_time_compare("abc123", "abc123"));
94 }
95
96 #[test]
97 fn compare_different_strings() {
98 assert!(!constant_time_compare("abc123", "abc124"));
99 }
100
101 #[test]
102 fn compare_different_lengths() {
103 assert!(!constant_time_compare("short", "longer"));
104 }
105
106 #[test]
107 fn compare_empty_strings() {
108 assert!(constant_time_compare("", ""));
109 }
110
111 #[test]
112 fn adversarial_timing_safety() {
113 assert!(!constant_time_compare("a", "b"));
114 assert!(!constant_time_compare("a", "aa"));
115 assert!(!constant_time_compare("", "x"));
116 assert!(constant_time_compare("same", "same"));
117 }
118
119 // ── generate_key_code ──
120
121 #[test]
122 fn key_code_format() {
123 let code = generate_key_code();
124 let parts: Vec<&str> = code.split('-').collect();
125 assert_eq!(parts.len(), 6, "Key code should have 6 words");
126 for word in &parts {
127 assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word);
128 assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word);
129 assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word);
130 }
131 }
132
133 #[test]
134 fn key_code_uniqueness() {
135 let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect();
136 assert_eq!(codes.len(), 100, "100 generated key codes should all be unique");
137 }
138
139 // ── feed URL signing ──
140
141 /// Extract the `sig=` value from a generated feed URL.
142 fn sig_of(url: &str) -> &str {
143 url.split("sig=").nth(1).unwrap()
144 }
145
146 #[test]
147 fn feed_url_round_trip() {
148 let user_id = crate::db::UserId::new();
149 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
150 assert!(url.contains(&user_id.to_string()));
151 assert!(url.contains("v=0"));
152 assert!(url.contains("sig="));
153 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
154 }
155
156 #[test]
157 fn feed_url_wrong_secret_rejected() {
158 let user_id = crate::db::UserId::new();
159 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
160 assert!(!verify_feed_signature(user_id, 0, sig_of(&url), "wrong-secret"));
161 }
162
163 #[test]
164 fn feed_url_wrong_user_rejected() {
165 let user_id = crate::db::UserId::new();
166 let other_id = crate::db::UserId::new();
167 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
168 assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret"));
169 }
170
171 #[test]
172 fn feed_url_stale_version_rejected() {
173 // A signature minted for version 0 must not verify against version 1 —
174 // this is what makes "Regenerate feed URL" revoke the old link.
175 let user_id = crate::db::UserId::new();
176 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
177 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
178 assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret"));
179 }
180
181 #[test]
182 fn feed_signature_empty_string_rejected() {
183 let user_id = crate::db::UserId::new();
184 assert!(!verify_feed_signature(user_id, 0, "", "secret"));
185 }
186
187 #[test]
188 fn feed_signature_tampered_rejected() {
189 let user_id = crate::db::UserId::new();
190 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
191 let sig = sig_of(&url);
192 let mut tampered = sig.to_string();
193 let first = tampered.remove(0);
194 tampered.insert(0, if first == '0' { '1' } else { '0' });
195 assert!(!verify_feed_signature(user_id, 0, &tampered, "secret"));
196 }
197 }
198