blendtutor_core/
crypto.rs

1//! Password-based encryption for built sites: AES-256-GCM + PBKDF2.
2//!
3//! Pure (§2.1): no I/O, no filesystem. [`encrypt`] takes plaintext + password +
4//! an injected rng and returns an [`EncryptedPayload`]; [`decrypt`] takes a
5//! payload + password and returns the plaintext. The rng is injected so the
6//! whole transform is deterministic given the rng's output (testable, no global
7//! randomness source).
8//!
9//! The browser-side decryption uses the same algorithm via WebCrypto
10//! (`crypto.subtle.deriveKey` + `crypto.subtle.decrypt`), so a payload encrypted
11//! in Rust decrypts in the browser and vice versa (ADR-0012).
12
13use std::error::Error;
14use std::fmt;
15
16use aes_gcm::aead::{Aead, KeyInit};
17use aes_gcm::{Aes256Gcm, Key, Nonce};
18use base64::Engine;
19use pbkdf2::pbkdf2_hmac;
20use rand_core::{CryptoRng, RngCore};
21use sha2::Sha256;
22
23/// PBKDF2 iteration count — 600,000 with HMAC-SHA-256 (OWASP 2023 recommendation
24/// for PBKDF2-SHA256). The same literal appears in the decrypt shell's inline JS
25/// so the browser-side WebCrypto derivation matches.
26pub const PBKDF2_ITERATIONS: u32 = 600_000;
27
28/// Salt length in bytes — 128 bits, per NIST SP 800-132.
29const SALT_LEN: usize = 16;
30
31/// GCM nonce (IV) length in bytes — 96 bits, the GCM standard length
32/// (NIST SP 800-38D).
33const NONCE_LEN: usize = 12;
34
35/// AES-256 key length in bytes — 256 bits.
36const KEY_LEN: usize = 32;
37
38/// An encrypted payload: the ciphertext plus the salt and nonce needed to
39/// decrypt it (§1.2 — a represented state, not a bag of bytes).
40///
41/// The salt and nonce are generated fresh per encryption call (§1.3.1 — GCM
42/// nonce reuse is catastrophic), so two encryptions of the same plaintext with
43/// the same password yield different payloads.
44#[derive(Debug, Clone)]
45pub struct EncryptedPayload {
46    /// The AES-256-GCM ciphertext (includes the GCM authentication tag).
47    pub ciphertext: Vec<u8>,
48    /// The PBKDF2 salt — 16 random bytes, unique per encryption.
49    pub salt: [u8; SALT_LEN],
50    /// The GCM nonce — 12 random bytes, unique per encryption.
51    pub nonce: [u8; NONCE_LEN],
52}
53
54impl EncryptedPayload {
55    /// Encode the payload as base64 of `salt || nonce || ciphertext` — a single
56    /// self-contained string the browser fetch monkeypatch can decode and decrypt.
57    ///
58    /// The format is: first 16 bytes = salt, next 12 bytes = nonce, rest =
59    /// ciphertext (including GCM tag). This is NOT valid JSON, so
60    /// `serde_json::from_str` fails on it (the JSON-bypass guard).
61    pub fn to_base64(&self) -> String {
62        let mut bytes = Vec::with_capacity(SALT_LEN + NONCE_LEN + self.ciphertext.len());
63        bytes.extend_from_slice(&self.salt);
64        bytes.extend_from_slice(&self.nonce);
65        bytes.extend_from_slice(&self.ciphertext);
66        base64::engine::general_purpose::STANDARD.encode(&bytes)
67    }
68
69    /// Decode a base64 payload (`salt || nonce || ciphertext`) back into an
70    /// [`EncryptedPayload`].
71    pub fn from_base64(s: &str) -> Result<Self, CryptoError> {
72        let bytes = base64::engine::general_purpose::STANDARD
73            .decode(s)
74            .map_err(|_| CryptoError::InvalidPayload)?;
75        if bytes.len() < SALT_LEN + NONCE_LEN {
76            return Err(CryptoError::InvalidPayload);
77        }
78        let mut salt = [0u8; SALT_LEN];
79        let mut nonce = [0u8; NONCE_LEN];
80        salt.copy_from_slice(&bytes[..SALT_LEN]);
81        nonce.copy_from_slice(&bytes[SALT_LEN..SALT_LEN + NONCE_LEN]);
82        let ciphertext = bytes[SALT_LEN + NONCE_LEN..].to_vec();
83        Ok(EncryptedPayload {
84            ciphertext,
85            salt,
86            nonce,
87        })
88    }
89}
90
91/// Derive a 256-bit AES key from a password and salt via PBKDF2-HMAC-SHA256.
92fn derive_key(password: &str, salt: &[u8]) -> [u8; KEY_LEN] {
93    let mut key = [0u8; KEY_LEN];
94    pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, PBKDF2_ITERATIONS, &mut key);
95    key
96}
97
98/// Encrypt `plaintext` with `password`, using `rng` to generate a fresh salt
99/// and nonce (§2.1 — pure w.r.t. rng: the rng is the only source of
100/// nondeterminism).
101///
102/// Returns an [`EncryptedPayload`] carrying the ciphertext, salt, and nonce.
103/// Two calls with the same plaintext and password yield different payloads
104/// (different salt and nonce), so GCM nonce reuse is impossible.
105pub fn encrypt(
106    plaintext: &str,
107    password: &str,
108    rng: &mut (impl RngCore + CryptoRng),
109) -> EncryptedPayload {
110    let mut salt = [0u8; SALT_LEN];
111    let mut nonce = [0u8; NONCE_LEN];
112    rng.fill_bytes(&mut salt);
113    rng.fill_bytes(&mut nonce);
114
115    let key = derive_key(password, &salt);
116    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
117    let ciphertext = cipher
118        .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes())
119        .expect("AES-GCM encryption is infallible for valid inputs");
120
121    EncryptedPayload {
122        ciphertext,
123        salt,
124        nonce,
125    }
126}
127
128/// Decrypt an [`EncryptedPayload`] with `password`.
129///
130/// Returns `Err(CryptoError::DecryptionFailed)` if the password is wrong or the
131/// payload is corrupt — GCM authentication catches both (the tag fails to verify).
132pub fn decrypt(payload: &EncryptedPayload, password: &str) -> Result<String, CryptoError> {
133    let key = derive_key(password, &payload.salt);
134    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
135    let plaintext = cipher
136        .decrypt(
137            Nonce::from_slice(&payload.nonce),
138            payload.ciphertext.as_ref(),
139        )
140        .map_err(|_| CryptoError::DecryptionFailed)?;
141    String::from_utf8(plaintext).map_err(|_| CryptoError::DecryptionFailed)
142}
143
144/// Why decryption failed — the password was wrong, the payload is corrupt, or
145/// the base64 encoding is malformed.
146#[derive(Debug)]
147pub enum CryptoError {
148    /// The GCM authentication tag did not verify — wrong password or corrupt
149    /// ciphertext. GCM does not distinguish the two (by design).
150    DecryptionFailed,
151    /// The base64 payload is malformed or too short to contain salt + nonce.
152    InvalidPayload,
153}
154
155impl fmt::Display for CryptoError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            CryptoError::DecryptionFailed => {
159                write!(f, "decryption failed: wrong password or corrupt data")
160            }
161            CryptoError::InvalidPayload => {
162                write!(f, "invalid encrypted payload: malformed or too short")
163            }
164        }
165    }
166}
167
168impl Error for CryptoError {}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use rand_core::OsRng;
174
175    #[test]
176    fn roundtrip_encrypt_decrypt_recovers_plaintext() {
177        let mut rng = OsRng;
178        let payload = encrypt("hello world", "secret", &mut rng);
179        let plaintext = decrypt(&payload, "secret").expect("correct password decrypts");
180        assert_eq!(plaintext, "hello world");
181    }
182
183    #[test]
184    fn wrong_password_fails_decryption() {
185        let mut rng = OsRng;
186        let payload = encrypt("hello world", "secret", &mut rng);
187        assert!(matches!(
188            decrypt(&payload, "wrong"),
189            Err(CryptoError::DecryptionFailed)
190        ));
191    }
192
193    #[test]
194    fn two_encryptions_yield_different_salts() {
195        let mut rng = OsRng;
196        let a = encrypt("same plaintext", "same password", &mut rng);
197        let b = encrypt("same plaintext", "same password", &mut rng);
198        assert_ne!(a.salt, b.salt, "two calls must yield different salts");
199    }
200
201    #[test]
202    fn two_encryptions_yield_different_nonces() {
203        let mut rng = OsRng;
204        let a = encrypt("same plaintext", "same password", &mut rng);
205        let b = encrypt("same plaintext", "same password", &mut rng);
206        assert_ne!(
207            a.nonce, b.nonce,
208            "two calls must yield different nonces (GCM catastrophic-failure guard)"
209        );
210    }
211
212    #[test]
213    fn nonce_is_twelve_bytes_not_all_zeros() {
214        let mut rng = OsRng;
215        let payload = encrypt("test", "pw", &mut rng);
216        assert_eq!(payload.nonce.len(), 12, "IV must decode to 12 bytes");
217        assert!(
218            payload.nonce.iter().any(|&b| b != 0),
219            "IV must not be all zeros"
220        );
221    }
222
223    #[test]
224    fn base64_roundtrip_preserves_payload() {
225        let mut rng = OsRng;
226        let payload = encrypt("base64 test", "pw", &mut rng);
227        let encoded = payload.to_base64();
228        let decoded = EncryptedPayload::from_base64(&encoded).expect("decodes");
229        assert_eq!(decoded.salt, payload.salt);
230        assert_eq!(decoded.nonce, payload.nonce);
231        assert_eq!(decoded.ciphertext, payload.ciphertext);
232        let plaintext = decrypt(&decoded, "pw").expect("roundtrip decrypts");
233        assert_eq!(plaintext, "base64 test");
234    }
235
236    #[test]
237    fn from_base64_rejects_short_input() {
238        assert!(matches!(
239            EncryptedPayload::from_base64("dG9vIHNob3J0"),
240            Err(CryptoError::InvalidPayload)
241        ));
242    }
243
244    /// A key derived from file A's salt cannot decrypt file B's ciphertext,
245    /// even when both files share the same password. This is the crypto-level
246    /// root cause of the fetch-monkeypatch bug: each content file gets its own
247    /// fresh salt from `encrypt`, so the browser must derive a per-file key
248    /// using THAT file's salt — not reuse a key derived from index.html's salt.
249    #[test]
250    fn per_file_salt_prevents_cross_file_decryption() {
251        let mut rng = OsRng;
252        let payload_a = encrypt("index page html", "shared-pw", &mut rng);
253        let payload_b = encrypt("lesson json content", "shared-pw", &mut rng);
254
255        // Sanity: each file has its own salt.
256        assert_ne!(
257            payload_a.salt, payload_b.salt,
258            "two encrypt calls must yield different salts"
259        );
260
261        // Construct a Frankenstein payload: B's ciphertext + B's nonce, but
262        // A's salt. This simulates the bug — deriving a key from A's salt
263        // (index.html) and trying to decrypt B (a lesson JSON).
264        let frankenstein = EncryptedPayload {
265            ciphertext: payload_b.ciphertext.clone(),
266            salt: payload_a.salt, // wrong salt!
267            nonce: payload_b.nonce,
268        };
269        assert!(
270            decrypt(&frankenstein, "shared-pw").is_err(),
271            "a key derived from file A's salt must NOT decrypt file B's ciphertext \
272             — the browser fetch monkeypatch must derive a per-file key"
273        );
274
275        // Control: B decrypts fine with its own salt (correct password).
276        assert!(
277            decrypt(&payload_b, "shared-pw").is_ok(),
278            "file B must decrypt with its own salt + correct password"
279        );
280    }
281
282    #[test]
283    fn from_base64_accepts_payload_with_exactly_salt_plus_nonce_length() {
284        // A payload with exactly SALT_LEN + NONCE_LEN bytes (28) has a valid
285        // salt and nonce but zero-length ciphertext. from_base64 must accept it
286        // (the length check is `<`, not `<=` or `==`) — the empty ciphertext is
287        // structurally valid; decryption would fail, but parsing succeeds.
288        //
289        // Kills the mutant that replaces `<` with `==`: the mutant rejects
290        // 28-byte payloads (== SALT_LEN + NONCE_LEN), while the original accepts
291        // them (>= SALT_LEN + NONCE_LEN).
292        let mut bytes = vec![0u8; SALT_LEN + NONCE_LEN];
293        bytes.iter_mut().enumerate().for_each(|(i, b)| *b = i as u8);
294        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
295        let payload = EncryptedPayload::from_base64(&encoded)
296            .expect("a payload with exactly SALT_LEN + NONCE_LEN bytes is accepted");
297        assert_eq!(payload.salt.len(), SALT_LEN);
298        assert_eq!(payload.nonce.len(), NONCE_LEN);
299        assert!(
300            payload.ciphertext.is_empty(),
301            "ciphertext is empty (zero-length payload)"
302        );
303    }
304
305    #[test]
306    fn crypto_error_display_contains_expected_text() {
307        // Each CryptoError variant's Display output must contain its expected
308        // message text. Kills the mutant that replaces the fmt body with
309        // Ok(Default::default()) (empty string).
310        let decryption_failed = CryptoError::DecryptionFailed.to_string();
311        assert!(
312            decryption_failed.contains("decryption failed"),
313            "DecryptionFailed Display must mention 'decryption failed', got: {decryption_failed}"
314        );
315        assert!(
316            !decryption_failed.is_empty(),
317            "DecryptionFailed Display must not be empty"
318        );
319
320        let invalid_payload = CryptoError::InvalidPayload.to_string();
321        assert!(
322            invalid_payload.contains("invalid encrypted payload"),
323            "InvalidPayload Display must mention 'invalid encrypted payload', got: {invalid_payload}"
324        );
325        assert!(
326            !invalid_payload.is_empty(),
327            "InvalidPayload Display must not be empty"
328        );
329    }
330}