//! Cryptographic utilities: constant-time comparison, key generation, feed //! signing, and secret encryption at rest. use std::fmt::Write as _; use crate::error::{AppError, Result}; /// Version-tagged prefix on an encrypted-at-rest TOTP secret. Its presence is /// how [`decrypt_totp_secret`] distinguishes a ciphertext from a legacy /// plaintext base32 seed during the dual-read migration window. const TOTP_ENC_PREFIX: &str = "enc:v1:"; /// Derive a domain-separated 32-byte key for TOTP-secret encryption from the /// global signing secret, using HMAC-SHA256 as a PRF. The label keeps this key /// independent of every other use of the signing secret (feed signing, /// backup-code HMAC, session tokens), so reuse in one context can't weaken /// another. fn totp_encryption_key(signing_secret: &str) -> [u8; 32] { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let mut mac = Hmac::::new_from_slice(signing_secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(b"mnw-totp-secret-encryption-v1"); mac.finalize().into_bytes().into() } /// Encrypt a TOTP secret for storage at rest with ChaCha20-Poly1305 (AEAD). /// /// The on-disk form is `enc:v1:` + base64(`nonce(12) || ciphertext+tag`). A /// fresh random nonce is drawn per call, so encrypting the same seed twice /// yields distinct ciphertexts. A database read alone (snapshot, replica, SQL /// injection elsewhere) no longer yields a usable second factor; the attacker /// also needs `SIGNING_SECRET`. pub fn encrypt_totp_secret(plaintext: &str, signing_secret: &str) -> String { use base64::Engine; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead}; let key = totp_encryption_key(signing_secret); let cipher = ChaCha20Poly1305::new((&key).into()); let mut nonce_bytes = [0u8; 12]; rand::Rng::fill_bytes(&mut rand::rng(), &mut nonce_bytes); let nonce = Nonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) // Encryption of an in-memory plaintext with a valid key/nonce cannot // fail; the only error variant is for buffer-size issues we don't hit. .expect("ChaCha20-Poly1305 encryption is infallible here"); let mut payload = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); payload.extend_from_slice(&nonce_bytes); payload.extend_from_slice(&ciphertext); format!( "{TOTP_ENC_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(payload) ) } /// Decrypt a stored TOTP secret produced by [`encrypt_totp_secret`]. /// /// Every stored secret MUST carry the `enc:v1:` prefix. There is no legacy /// plaintext fallback; a value without the prefix is rejected as malformed /// (backwards compatibility with pre-encryption plaintext seeds was cut, so /// any such user re-enrolls their authenticator). Also errors when a tagged /// ciphertext fails to decode or authenticate (wrong key or tampering). pub fn decrypt_totp_secret(stored: &str, signing_secret: &str) -> Result { use base64::Engine; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead}; let Some(b64) = stored.strip_prefix(TOTP_ENC_PREFIX) else { return Err(AppError::Internal(anyhow::anyhow!( "totp secret is not encrypted (missing {TOTP_ENC_PREFIX} prefix)" ))); }; let payload = base64::engine::general_purpose::STANDARD .decode(b64) .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret base64 decode: {e}")))?; if payload.len() < 12 { return Err(AppError::Internal(anyhow::anyhow!( "totp secret ciphertext too short" ))); } let (nonce_bytes, ciphertext) = payload.split_at(12); let nonce_arr: [u8; 12] = nonce_bytes .try_into() .expect("split_at(12) on a >=12-byte payload yields exactly 12 bytes"); let key = totp_encryption_key(signing_secret); let cipher = ChaCha20Poly1305::new((&key).into()); let plaintext = cipher .decrypt(&Nonce::from(nonce_arr), ciphertext) .map_err(|_| AppError::Internal(anyhow::anyhow!("totp secret decryption failed")))?; String::from_utf8(plaintext) .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret utf8: {e}"))) } /// Constant-time byte comparison for tokens, MACs, and other fixed-shape /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl) /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256. /// /// Length mismatch short-circuits; leaking the length of fixed-format /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets) /// reveals nothing useful to an attacker, since the format already fixes /// the length. Don't use this for variable-length sensitive payloads /// where length is itself secret. pub fn constant_time_compare(a: &str, b: &str) -> bool { use subtle::ConstantTimeEq; let a = a.as_bytes(); let b = b.as_bytes(); if a.len() != b.len() { return false; } a.ct_eq(b).into() } /// Generate a license key code in word-word-word-word-word-word format. /// /// Six random words from the 2048-word list (~66 bits of entropy). Six was /// chosen over five (~55 bits) after a birthday-collision review: at five /// words, ~190M keys gives a coin-flip chance of collision; at six, the /// equivalent threshold rises to ~6B keys, far past the lifetime cap of /// any realistic license catalog. Returns a `KeyCode` via `from_trusted`; /// the wordlist guarantees validity. pub fn generate_key_code() -> crate::db::KeyCode { use rand::RngExt; let mut rng = rand::rng(); let words: Vec<&str> = (0..6) .map(|_| { let idx = rng.random_range(0..crate::wordlist::WORDLIST.len()); crate::wordlist::WORDLIST[idx] }) .collect(); crate::db::KeyCode::from_trusted(words.join("-")) } /// Generate a git personal-access token. Returns `(plaintext, hash)`: the /// `mnw_`-prefixed plaintext is shown to the user exactly once and never /// stored; only the SHA-256 hex `hash` is persisted. The body is 32 CSPRNG /// bytes (~256 bits) rendered as hex so it's safe in a Basic-auth password / URL. pub fn generate_git_token() -> (String, String) { let mut bytes = [0u8; 32]; rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes); let body: String = bytes.iter().fold(String::new(), |mut out, b| { let _ = write!(out, "{b:02x}"); out }); let plaintext = format!("mnw_{body}"); let hash = git_token_hash(&plaintext); (plaintext, hash) } /// Lowercase SHA-256 hex digest of a string. The shared primitive behind the /// "store a hash, never the plaintext" credential pattern so a DB read can't /// recover a usable secret. pub fn sha256_hex(input: &str) -> String { use sha2::{Digest, Sha256}; Sha256::digest(input.as_bytes()) .iter() .fold(String::new(), |mut out, b| { let _ = write!(out, "{b:02x}"); out }) } /// SHA-256 hex digest of a git token's plaintext. Used both when minting a /// token and when verifying one on a request, so the stored hash is never the /// plaintext and a DB read can't recover a usable credential. pub fn git_token_hash(token: &str) -> String { sha256_hex(token) } /// SHA-256 hex digest of an invite code. Stored in place of the plaintext so a /// DB read yields no directly-usable invite (matches the reset/login/git-PAT /// posture). Callers normalize the code (uppercase, dashes stripped) first. pub fn invite_code_hash(code: &str) -> String { sha256_hex(code) } /// Domain-separation prefix for the internal-API actor assertion HMAC. const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1"; /// Mint a signed actor assertion binding `user_id` for the internal API, valid /// until `expiry_unix`. Format: `{user_id}.{expiry}.{hmac_hex}`, HMAC-SHA256 /// over `internal-actor:v1:{user_id}:{expiry}` keyed by `signing_secret`. /// /// The server mints this during `ssh-key-lookup` (after authenticating the user /// by SSH key) and the CLI forwards it on internal calls. Because the key is the /// server-only `signing_secret` (never held by the CLI or embedded in the shared /// `cli_service_token`), a leaked service token alone cannot forge an assertion /// for another user, so it cannot act as an arbitrary user. pub fn mint_internal_actor_token( user_id: crate::db::UserId, expiry_unix: i64, signing_secret: &str, ) -> String { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry_unix}"); let mut mac = Hmac::::new_from_slice(signing_secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); format!( "{user_id}.{expiry_unix}.{}", hex::encode(mac.finalize().into_bytes()) ) } /// Verify an actor assertion and return the asserted `UserId` if the signature /// is valid (constant-time) and the token has not expired at `now_unix`. pub fn verify_internal_actor_token( token: &str, signing_secret: &str, now_unix: i64, ) -> Option { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let (user_part, rest) = token.split_once('.')?; let (expiry_part, sig_hex) = rest.split_once('.')?; let user_id = crate::db::UserId::from_uuid(user_part.parse().ok()?); let expiry: i64 = expiry_part.parse().ok()?; if expiry <= now_unix { return None; } let sig = hex::decode(sig_hex).ok()?; let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry}"); let mut mac = Hmac::::new_from_slice(signing_secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); mac.verify_slice(&sig).ok()?; Some(user_id) } /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`. fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let message = format!("feed:{user_id}:{version}"); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Generate an HMAC-signed personal RSS feed URL for a user. /// /// The signature covers `feed:{user_id}:{version}`. `version` is the user's /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL" /// action) changes the signed message and revokes the previously-issued URL /// for that one user, without rotating the global signing secret (which would /// invalidate every user's feed at once). The URL is otherwise permanent. pub fn generate_feed_url( host_url: &str, user_id: crate::db::UserId, version: i32, secret: &str, ) -> String { let sig = feed_signature(user_id, version, secret); format!("{host_url}/feed/{user_id}?v={version}&sig={sig}") } /// Verify a personal feed URL signature for a given `(user_id, version)`. /// /// The caller MUST additionally check that `version` equals the user's current /// `feed_key_version`; a valid signature for a stale version is a revoked URL. pub fn verify_feed_signature( user_id: crate::db::UserId, version: i32, signature: &str, secret: &str, ) -> bool { let expected = feed_signature(user_id, version, secret); constant_time_compare(&expected, signature) } #[cfg(test)] mod tests { use super::*; // ── Internal-API actor assertions ── #[test] fn internal_actor_token_round_trips() { let uid = crate::db::UserId::new(); let secret = "a-stable-signing-secret-at-least-32c"; let tok = mint_internal_actor_token(uid, 10_000_000_000, secret); assert_eq!(verify_internal_actor_token(&tok, secret, 1_000), Some(uid)); } #[test] fn internal_actor_token_rejects_expired() { let uid = crate::db::UserId::new(); let secret = "a-stable-signing-secret-at-least-32c"; let tok = mint_internal_actor_token(uid, 1_000, secret); assert_eq!(verify_internal_actor_token(&tok, secret, 2_000), None); } #[test] fn internal_actor_token_rejects_wrong_secret() { let uid = crate::db::UserId::new(); let tok = mint_internal_actor_token(uid, 10_000_000_000, "secret-one-that-is-long-enough!!"); assert_eq!( verify_internal_actor_token(&tok, "secret-two-that-is-long-enough!!", 1_000), None ); } #[test] fn internal_actor_token_rejects_tampered_user() { let uid = crate::db::UserId::new(); let other = crate::db::UserId::new(); let secret = "a-stable-signing-secret-at-least-32c"; let tok = mint_internal_actor_token(uid, 10_000_000_000, secret); // Swap the user_id component; the signature no longer matches. let rest = tok.split_once('.').unwrap().1; let forged = format!("{other}.{rest}"); assert_eq!(verify_internal_actor_token(&forged, secret, 1_000), None); } // ── TOTP secret encryption at rest ── /// Known-answer vector for the at-rest TOTP envelope, computed outside /// RustCrypto (python `cryptography`'s ChaCha20-Poly1305, with the key /// derived by an independent HMAC-SHA256). Every enrolled second factor in /// the database is sealed this way, so the envelope must keep decrypting /// byte-for-byte across cipher upgrades. The roundtrip tests below cannot /// catch a changed envelope, since they re-encrypt with the same code. #[test] fn totp_secret_matches_independent_known_answer() { const STORED: &str = "enc:v1:EBESExQVFhcYGRobIDLGTuOMhI+7NitFN57N5vREAFNm152FpA/N06cmy8o="; let signing_secret = "test-signing-secret-for-known-answer"; assert_eq!( decrypt_totp_secret(STORED, signing_secret).unwrap(), "JBSWY3DPEHPK3PXP" ); // A different signing secret must fail, or the vector would not show // that the seed is actually bound to SIGNING_SECRET. assert!(decrypt_totp_secret(STORED, "some-other-signing-secret").is_err()); } #[test] fn totp_secret_round_trips() { let secret = "JBSWY3DPEHPK3PXP"; let key = "a-stable-signing-secret-at-least-32c"; let enc = encrypt_totp_secret(secret, key); assert!( enc.starts_with("enc:v1:"), "ciphertext must be version-tagged" ); assert_ne!(enc, secret, "ciphertext must not be the plaintext"); assert_eq!(decrypt_totp_secret(&enc, key).unwrap(), secret); } #[test] fn totp_secret_nonce_is_random() { let secret = "JBSWY3DPEHPK3PXP"; let key = "a-stable-signing-secret-at-least-32c"; // Same plaintext + key encrypted twice must differ (fresh nonce each time). assert_ne!( encrypt_totp_secret(secret, key), encrypt_totp_secret(secret, key) ); } #[test] fn totp_secret_wrong_key_fails_to_decrypt() { let secret = "JBSWY3DPEHPK3PXP"; let enc = encrypt_totp_secret(secret, "a-stable-signing-secret-at-least-32c"); assert!(decrypt_totp_secret(&enc, "a-different-signing-secret-32-chars!").is_err()); } #[test] fn totp_secret_tampered_ciphertext_fails() { let key = "a-stable-signing-secret-at-least-32c"; let enc = encrypt_totp_secret("JBSWY3DPEHPK3PXP", key); // Flip a character in the base64 body; the AEAD tag must reject it. let mut bytes: Vec = enc.chars().collect(); let last = bytes.len() - 1; bytes[last] = if bytes[last] == 'A' { 'B' } else { 'A' }; let tampered: String = bytes.into_iter().collect(); assert!(decrypt_totp_secret(&tampered, key).is_err()); } #[test] fn totp_secret_unprefixed_plaintext_is_rejected() { // Backwards compat was cut: a bare (pre-encryption) plaintext seed has // no `enc:v1:` prefix and must be rejected, not trusted. let key = "a-stable-signing-secret-at-least-32c"; assert!(decrypt_totp_secret("JBSWY3DPEHPK3PXP", key).is_err()); } // ── constant_time_compare ── #[test] fn compare_equal_strings() { assert!(constant_time_compare("abc123", "abc123")); } #[test] fn compare_different_strings() { assert!(!constant_time_compare("abc123", "abc124")); } #[test] fn compare_different_lengths() { assert!(!constant_time_compare("short", "longer")); } #[test] fn compare_empty_strings() { assert!(constant_time_compare("", "")); } #[test] fn adversarial_timing_safety() { assert!(!constant_time_compare("a", "b")); assert!(!constant_time_compare("a", "aa")); assert!(!constant_time_compare("", "x")); assert!(constant_time_compare("same", "same")); } // ── generate_key_code ── #[test] fn key_code_format() { let code = generate_key_code(); let parts: Vec<&str> = code.split('-').collect(); assert_eq!(parts.len(), 6, "Key code should have 6 words"); for word in &parts { assert!( word.len() >= 3, "Each word should be at least 3 chars: {word}" ); assert!( word.len() <= 6, "Each word should be at most 6 chars: {word}" ); assert!( word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {word}" ); } } #[test] fn key_code_uniqueness() { let codes: std::collections::HashSet = (0..100).map(|_| generate_key_code()).collect(); assert_eq!( codes.len(), 100, "100 generated key codes should all be unique" ); } // ── feed URL signing ── /// Extract the `sig=` value from a generated feed URL. fn sig_of(url: &str) -> &str { url.split("sig=").nth(1).unwrap() } #[test] fn feed_url_round_trip() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(url.contains(&user_id.to_string())); assert!(url.contains("v=0")); assert!(url.contains("sig=")); assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret")); } #[test] fn feed_url_wrong_secret_rejected() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(!verify_feed_signature( user_id, 0, sig_of(&url), "wrong-secret" )); } #[test] fn feed_url_wrong_user_rejected() { let user_id = crate::db::UserId::new(); let other_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret")); } #[test] fn feed_url_stale_version_rejected() { // A signature minted for version 0 must not verify against version 1; // this is what makes "Regenerate feed URL" revoke the old link. let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret")); assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret")); } #[test] fn feed_signature_empty_string_rejected() { let user_id = crate::db::UserId::new(); assert!(!verify_feed_signature(user_id, 0, "", "secret")); } #[test] fn feed_signature_tampered_rejected() { let user_id = crate::db::UserId::new(); let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); let sig = sig_of(&url); let mut tampered = sig.to_string(); let first = tampered.remove(0); tampered.insert(0, if first == '0' { '1' } else { '0' }); assert!(!verify_feed_signature(user_id, 0, &tampered, "secret")); } }