Skip to main content

max / makenotwork

20.0 KB · 527 lines History Blame Raw
1 //! Cryptographic utilities: constant-time comparison, key generation, feed
2 //! signing, and secret encryption at rest.
3
4 use std::fmt::Write as _;
5
6 use crate::error::{AppError, Result};
7
8 /// Version-tagged prefix on an encrypted-at-rest TOTP secret. Its presence is
9 /// how [`decrypt_totp_secret`] distinguishes a ciphertext from a legacy
10 /// plaintext base32 seed during the dual-read migration window.
11 const TOTP_ENC_PREFIX: &str = "enc:v1:";
12
13 /// Derive a domain-separated 32-byte key for TOTP-secret encryption from the
14 /// global signing secret, using HMAC-SHA256 as a PRF. The label keeps this key
15 /// independent of every other use of the signing secret (feed signing,
16 /// backup-code HMAC, session tokens), so reuse in one context can't weaken
17 /// another.
18 fn totp_encryption_key(signing_secret: &str) -> [u8; 32] {
19 use hmac::{Hmac, KeyInit, Mac};
20 use sha2::Sha256;
21
22 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
23 .expect("HMAC-SHA256 accepts any key length");
24 mac.update(b"mnw-totp-secret-encryption-v1");
25 mac.finalize().into_bytes().into()
26 }
27
28 /// Encrypt a TOTP secret for storage at rest with ChaCha20-Poly1305 (AEAD).
29 ///
30 /// The on-disk form is `enc:v1:` + base64(`nonce(12) || ciphertext+tag`). A
31 /// fresh random nonce is drawn per call, so encrypting the same seed twice
32 /// yields distinct ciphertexts. A database read alone (snapshot, replica, SQL
33 /// injection elsewhere) no longer yields a usable second factor; the attacker
34 /// also needs `SIGNING_SECRET`.
35 pub fn encrypt_totp_secret(plaintext: &str, signing_secret: &str) -> String {
36 use base64::Engine;
37 use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
38
39 let key = totp_encryption_key(signing_secret);
40 let cipher = ChaCha20Poly1305::new((&key).into());
41
42 let mut nonce_bytes = [0u8; 12];
43 rand::Rng::fill_bytes(&mut rand::rng(), &mut nonce_bytes);
44 let nonce = Nonce::from(nonce_bytes);
45
46 let ciphertext = cipher
47 .encrypt(&nonce, plaintext.as_bytes())
48 // Encryption of an in-memory plaintext with a valid key/nonce cannot
49 // fail; the only error variant is for buffer-size issues we don't hit.
50 .expect("ChaCha20-Poly1305 encryption is infallible here");
51
52 let mut payload = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
53 payload.extend_from_slice(&nonce_bytes);
54 payload.extend_from_slice(&ciphertext);
55
56 format!(
57 "{TOTP_ENC_PREFIX}{}",
58 base64::engine::general_purpose::STANDARD.encode(payload)
59 )
60 }
61
62 /// Decrypt a stored TOTP secret produced by [`encrypt_totp_secret`].
63 ///
64 /// Every stored secret MUST carry the `enc:v1:` prefix. There is no legacy
65 /// plaintext fallback; a value without the prefix is rejected as malformed
66 /// (backwards compatibility with pre-encryption plaintext seeds was cut, so
67 /// any such user re-enrolls their authenticator). Also errors when a tagged
68 /// ciphertext fails to decode or authenticate (wrong key or tampering).
69 pub fn decrypt_totp_secret(stored: &str, signing_secret: &str) -> Result<String> {
70 use base64::Engine;
71 use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
72
73 let Some(b64) = stored.strip_prefix(TOTP_ENC_PREFIX) else {
74 return Err(AppError::Internal(anyhow::anyhow!(
75 "totp secret is not encrypted (missing {TOTP_ENC_PREFIX} prefix)"
76 )));
77 };
78
79 let payload = base64::engine::general_purpose::STANDARD
80 .decode(b64)
81 .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret base64 decode: {e}")))?;
82 if payload.len() < 12 {
83 return Err(AppError::Internal(anyhow::anyhow!(
84 "totp secret ciphertext too short"
85 )));
86 }
87 let (nonce_bytes, ciphertext) = payload.split_at(12);
88 let nonce_arr: [u8; 12] = nonce_bytes
89 .try_into()
90 .expect("split_at(12) on a >=12-byte payload yields exactly 12 bytes");
91
92 let key = totp_encryption_key(signing_secret);
93 let cipher = ChaCha20Poly1305::new((&key).into());
94 let plaintext = cipher
95 .decrypt(&Nonce::from(nonce_arr), ciphertext)
96 .map_err(|_| AppError::Internal(anyhow::anyhow!("totp secret decryption failed")))?;
97
98 String::from_utf8(plaintext)
99 .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret utf8: {e}")))
100 }
101
102 /// Constant-time byte comparison for tokens, MACs, and other fixed-shape
103 /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl)
104 /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256.
105 ///
106 /// Length mismatch short-circuits; leaking the length of fixed-format
107 /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets)
108 /// reveals nothing useful to an attacker, since the format already fixes
109 /// the length. Don't use this for variable-length sensitive payloads
110 /// where length is itself secret.
111 pub fn constant_time_compare(a: &str, b: &str) -> bool {
112 use subtle::ConstantTimeEq;
113 let a = a.as_bytes();
114 let b = b.as_bytes();
115 if a.len() != b.len() {
116 return false;
117 }
118 a.ct_eq(b).into()
119 }
120
121 /// Generate a license key code in word-word-word-word-word-word format.
122 ///
123 /// Six random words from the 2048-word list (~66 bits of entropy). Six was
124 /// chosen over five (~55 bits) after a birthday-collision review: at five
125 /// words, ~190M keys gives a coin-flip chance of collision; at six, the
126 /// equivalent threshold rises to ~6B keys, far past the lifetime cap of
127 /// any realistic license catalog. Returns a `KeyCode` via `from_trusted`;
128 /// the wordlist guarantees validity.
129 pub fn generate_key_code() -> crate::db::KeyCode {
130 use rand::RngExt;
131 let mut rng = rand::rng();
132 let words: Vec<&str> = (0..6)
133 .map(|_| {
134 let idx = rng.random_range(0..crate::wordlist::WORDLIST.len());
135 crate::wordlist::WORDLIST[idx]
136 })
137 .collect();
138 crate::db::KeyCode::from_trusted(words.join("-"))
139 }
140
141 /// Generate a git personal-access token. Returns `(plaintext, hash)`: the
142 /// `mnw_`-prefixed plaintext is shown to the user exactly once and never
143 /// stored; only the SHA-256 hex `hash` is persisted. The body is 32 CSPRNG
144 /// bytes (~256 bits) rendered as hex so it's safe in a Basic-auth password / URL.
145 pub fn generate_git_token() -> (String, String) {
146 let mut bytes = [0u8; 32];
147 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
148 let body: String = bytes.iter().fold(String::new(), |mut out, b| {
149 let _ = write!(out, "{b:02x}");
150 out
151 });
152 let plaintext = format!("mnw_{body}");
153 let hash = git_token_hash(&plaintext);
154 (plaintext, hash)
155 }
156
157 /// Lowercase SHA-256 hex digest of a string. The shared primitive behind the
158 /// "store a hash, never the plaintext" credential pattern so a DB read can't
159 /// recover a usable secret.
160 pub fn sha256_hex(input: &str) -> String {
161 use sha2::{Digest, Sha256};
162 Sha256::digest(input.as_bytes())
163 .iter()
164 .fold(String::new(), |mut out, b| {
165 let _ = write!(out, "{b:02x}");
166 out
167 })
168 }
169
170 /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
171 /// token and when verifying one on a request, so the stored hash is never the
172 /// plaintext and a DB read can't recover a usable credential.
173 pub fn git_token_hash(token: &str) -> String {
174 sha256_hex(token)
175 }
176
177 /// SHA-256 hex digest of an invite code. Stored in place of the plaintext so a
178 /// DB read yields no directly-usable invite (matches the reset/login/git-PAT
179 /// posture). Callers normalize the code (uppercase, dashes stripped) first.
180 pub fn invite_code_hash(code: &str) -> String {
181 sha256_hex(code)
182 }
183
184 /// Domain-separation prefix for the internal-API actor assertion HMAC.
185 const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1";
186
187 /// Mint a signed actor assertion binding `user_id` for the internal API, valid
188 /// until `expiry_unix`. Format: `{user_id}.{expiry}.{hmac_hex}`, HMAC-SHA256
189 /// over `internal-actor:v1:{user_id}:{expiry}` keyed by `signing_secret`.
190 ///
191 /// The server mints this during `ssh-key-lookup` (after authenticating the user
192 /// by SSH key) and the CLI forwards it on internal calls. Because the key is the
193 /// server-only `signing_secret` (never held by the CLI or embedded in the shared
194 /// `cli_service_token`), a leaked service token alone cannot forge an assertion
195 /// for another user, so it cannot act as an arbitrary user.
196 pub fn mint_internal_actor_token(
197 user_id: crate::db::UserId,
198 expiry_unix: i64,
199 signing_secret: &str,
200 ) -> String {
201 use hmac::{Hmac, KeyInit, Mac};
202 use sha2::Sha256;
203 let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry_unix}");
204 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
205 .expect("HMAC-SHA256 accepts any key length");
206 mac.update(message.as_bytes());
207 format!(
208 "{user_id}.{expiry_unix}.{}",
209 hex::encode(mac.finalize().into_bytes())
210 )
211 }
212
213 /// Verify an actor assertion and return the asserted `UserId` if the signature
214 /// is valid (constant-time) and the token has not expired at `now_unix`.
215 pub fn verify_internal_actor_token(
216 token: &str,
217 signing_secret: &str,
218 now_unix: i64,
219 ) -> Option<crate::db::UserId> {
220 use hmac::{Hmac, KeyInit, Mac};
221 use sha2::Sha256;
222
223 let (user_part, rest) = token.split_once('.')?;
224 let (expiry_part, sig_hex) = rest.split_once('.')?;
225 let user_id = crate::db::UserId::from_uuid(user_part.parse().ok()?);
226 let expiry: i64 = expiry_part.parse().ok()?;
227 if expiry <= now_unix {
228 return None;
229 }
230 let sig = hex::decode(sig_hex).ok()?;
231 let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry}");
232 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
233 .expect("HMAC-SHA256 accepts any key length");
234 mac.update(message.as_bytes());
235 mac.verify_slice(&sig).ok()?;
236 Some(user_id)
237 }
238
239 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
240 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
241 use hmac::{Hmac, KeyInit, Mac};
242 use sha2::Sha256;
243
244 let message = format!("feed:{user_id}:{version}");
245 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
246 .expect("HMAC-SHA256 accepts any key length");
247 mac.update(message.as_bytes());
248 hex::encode(mac.finalize().into_bytes())
249 }
250
251 /// Generate an HMAC-signed personal RSS feed URL for a user.
252 ///
253 /// The signature covers `feed:{user_id}:{version}`. `version` is the user's
254 /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL"
255 /// action) changes the signed message and revokes the previously-issued URL
256 /// for that one user, without rotating the global signing secret (which would
257 /// invalidate every user's feed at once). The URL is otherwise permanent.
258 pub fn generate_feed_url(
259 host_url: &str,
260 user_id: crate::db::UserId,
261 version: i32,
262 secret: &str,
263 ) -> String {
264 let sig = feed_signature(user_id, version, secret);
265 format!("{host_url}/feed/{user_id}?v={version}&sig={sig}")
266 }
267
268 /// Verify a personal feed URL signature for a given `(user_id, version)`.
269 ///
270 /// The caller MUST additionally check that `version` equals the user's current
271 /// `feed_key_version`; a valid signature for a stale version is a revoked URL.
272 pub fn verify_feed_signature(
273 user_id: crate::db::UserId,
274 version: i32,
275 signature: &str,
276 secret: &str,
277 ) -> bool {
278 let expected = feed_signature(user_id, version, secret);
279 constant_time_compare(&expected, signature)
280 }
281
282 #[cfg(test)]
283 mod tests {
284 use super::*;
285
286 // ── Internal-API actor assertions ──
287
288 #[test]
289 fn internal_actor_token_round_trips() {
290 let uid = crate::db::UserId::new();
291 let secret = "a-stable-signing-secret-at-least-32c";
292 let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
293 assert_eq!(verify_internal_actor_token(&tok, secret, 1_000), Some(uid));
294 }
295
296 #[test]
297 fn internal_actor_token_rejects_expired() {
298 let uid = crate::db::UserId::new();
299 let secret = "a-stable-signing-secret-at-least-32c";
300 let tok = mint_internal_actor_token(uid, 1_000, secret);
301 assert_eq!(verify_internal_actor_token(&tok, secret, 2_000), None);
302 }
303
304 #[test]
305 fn internal_actor_token_rejects_wrong_secret() {
306 let uid = crate::db::UserId::new();
307 let tok =
308 mint_internal_actor_token(uid, 10_000_000_000, "secret-one-that-is-long-enough!!");
309 assert_eq!(
310 verify_internal_actor_token(&tok, "secret-two-that-is-long-enough!!", 1_000),
311 None
312 );
313 }
314
315 #[test]
316 fn internal_actor_token_rejects_tampered_user() {
317 let uid = crate::db::UserId::new();
318 let other = crate::db::UserId::new();
319 let secret = "a-stable-signing-secret-at-least-32c";
320 let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
321 // Swap the user_id component; the signature no longer matches.
322 let rest = tok.split_once('.').unwrap().1;
323 let forged = format!("{other}.{rest}");
324 assert_eq!(verify_internal_actor_token(&forged, secret, 1_000), None);
325 }
326
327 // ── TOTP secret encryption at rest ──
328
329 /// Known-answer vector for the at-rest TOTP envelope, computed outside
330 /// RustCrypto (python `cryptography`'s ChaCha20-Poly1305, with the key
331 /// derived by an independent HMAC-SHA256). Every enrolled second factor in
332 /// the database is sealed this way, so the envelope must keep decrypting
333 /// byte-for-byte across cipher upgrades. The roundtrip tests below cannot
334 /// catch a changed envelope, since they re-encrypt with the same code.
335 #[test]
336 fn totp_secret_matches_independent_known_answer() {
337 const STORED: &str = "enc:v1:EBESExQVFhcYGRobIDLGTuOMhI+7NitFN57N5vREAFNm152FpA/N06cmy8o=";
338 let signing_secret = "test-signing-secret-for-known-answer";
339 assert_eq!(
340 decrypt_totp_secret(STORED, signing_secret).unwrap(),
341 "JBSWY3DPEHPK3PXP"
342 );
343 // A different signing secret must fail, or the vector would not show
344 // that the seed is actually bound to SIGNING_SECRET.
345 assert!(decrypt_totp_secret(STORED, "some-other-signing-secret").is_err());
346 }
347
348 #[test]
349 fn totp_secret_round_trips() {
350 let secret = "JBSWY3DPEHPK3PXP";
351 let key = "a-stable-signing-secret-at-least-32c";
352 let enc = encrypt_totp_secret(secret, key);
353 assert!(
354 enc.starts_with("enc:v1:"),
355 "ciphertext must be version-tagged"
356 );
357 assert_ne!(enc, secret, "ciphertext must not be the plaintext");
358 assert_eq!(decrypt_totp_secret(&enc, key).unwrap(), secret);
359 }
360
361 #[test]
362 fn totp_secret_nonce_is_random() {
363 let secret = "JBSWY3DPEHPK3PXP";
364 let key = "a-stable-signing-secret-at-least-32c";
365 // Same plaintext + key encrypted twice must differ (fresh nonce each time).
366 assert_ne!(
367 encrypt_totp_secret(secret, key),
368 encrypt_totp_secret(secret, key)
369 );
370 }
371
372 #[test]
373 fn totp_secret_wrong_key_fails_to_decrypt() {
374 let secret = "JBSWY3DPEHPK3PXP";
375 let enc = encrypt_totp_secret(secret, "a-stable-signing-secret-at-least-32c");
376 assert!(decrypt_totp_secret(&enc, "a-different-signing-secret-32-chars!").is_err());
377 }
378
379 #[test]
380 fn totp_secret_tampered_ciphertext_fails() {
381 let key = "a-stable-signing-secret-at-least-32c";
382 let enc = encrypt_totp_secret("JBSWY3DPEHPK3PXP", key);
383 // Flip a character in the base64 body; the AEAD tag must reject it.
384 let mut bytes: Vec<char> = enc.chars().collect();
385 let last = bytes.len() - 1;
386 bytes[last] = if bytes[last] == 'A' { 'B' } else { 'A' };
387 let tampered: String = bytes.into_iter().collect();
388 assert!(decrypt_totp_secret(&tampered, key).is_err());
389 }
390
391 #[test]
392 fn totp_secret_unprefixed_plaintext_is_rejected() {
393 // Backwards compat was cut: a bare (pre-encryption) plaintext seed has
394 // no `enc:v1:` prefix and must be rejected, not trusted.
395 let key = "a-stable-signing-secret-at-least-32c";
396 assert!(decrypt_totp_secret("JBSWY3DPEHPK3PXP", key).is_err());
397 }
398
399 // ── constant_time_compare ──
400
401 #[test]
402 fn compare_equal_strings() {
403 assert!(constant_time_compare("abc123", "abc123"));
404 }
405
406 #[test]
407 fn compare_different_strings() {
408 assert!(!constant_time_compare("abc123", "abc124"));
409 }
410
411 #[test]
412 fn compare_different_lengths() {
413 assert!(!constant_time_compare("short", "longer"));
414 }
415
416 #[test]
417 fn compare_empty_strings() {
418 assert!(constant_time_compare("", ""));
419 }
420
421 #[test]
422 fn adversarial_timing_safety() {
423 assert!(!constant_time_compare("a", "b"));
424 assert!(!constant_time_compare("a", "aa"));
425 assert!(!constant_time_compare("", "x"));
426 assert!(constant_time_compare("same", "same"));
427 }
428
429 // ── generate_key_code ──
430
431 #[test]
432 fn key_code_format() {
433 let code = generate_key_code();
434 let parts: Vec<&str> = code.split('-').collect();
435 assert_eq!(parts.len(), 6, "Key code should have 6 words");
436 for word in &parts {
437 assert!(
438 word.len() >= 3,
439 "Each word should be at least 3 chars: {word}"
440 );
441 assert!(
442 word.len() <= 6,
443 "Each word should be at most 6 chars: {word}"
444 );
445 assert!(
446 word.chars().all(|c| c.is_ascii_lowercase()),
447 "Words should be lowercase: {word}"
448 );
449 }
450 }
451
452 #[test]
453 fn key_code_uniqueness() {
454 let codes: std::collections::HashSet<crate::db::KeyCode> =
455 (0..100).map(|_| generate_key_code()).collect();
456 assert_eq!(
457 codes.len(),
458 100,
459 "100 generated key codes should all be unique"
460 );
461 }
462
463 // ── feed URL signing ──
464
465 /// Extract the `sig=` value from a generated feed URL.
466 fn sig_of(url: &str) -> &str {
467 url.split("sig=").nth(1).unwrap()
468 }
469
470 #[test]
471 fn feed_url_round_trip() {
472 let user_id = crate::db::UserId::new();
473 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
474 assert!(url.contains(&user_id.to_string()));
475 assert!(url.contains("v=0"));
476 assert!(url.contains("sig="));
477 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
478 }
479
480 #[test]
481 fn feed_url_wrong_secret_rejected() {
482 let user_id = crate::db::UserId::new();
483 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
484 assert!(!verify_feed_signature(
485 user_id,
486 0,
487 sig_of(&url),
488 "wrong-secret"
489 ));
490 }
491
492 #[test]
493 fn feed_url_wrong_user_rejected() {
494 let user_id = crate::db::UserId::new();
495 let other_id = crate::db::UserId::new();
496 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
497 assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret"));
498 }
499
500 #[test]
501 fn feed_url_stale_version_rejected() {
502 // A signature minted for version 0 must not verify against version 1;
503 // this is what makes "Regenerate feed URL" revoke the old link.
504 let user_id = crate::db::UserId::new();
505 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
506 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
507 assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret"));
508 }
509
510 #[test]
511 fn feed_signature_empty_string_rejected() {
512 let user_id = crate::db::UserId::new();
513 assert!(!verify_feed_signature(user_id, 0, "", "secret"));
514 }
515
516 #[test]
517 fn feed_signature_tampered_rejected() {
518 let user_id = crate::db::UserId::new();
519 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
520 let sig = sig_of(&url);
521 let mut tampered = sig.to_string();
522 let first = tampered.remove(0);
523 tampered.insert(0, if first == '0' { '1' } else { '0' });
524 assert!(!verify_feed_signature(user_id, 0, &tampered, "secret"));
525 }
526 }
527