1use 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
23pub const PBKDF2_ITERATIONS: u32 = 600_000;
27
28const SALT_LEN: usize = 16;
30
31const NONCE_LEN: usize = 12;
34
35const KEY_LEN: usize = 32;
37
38#[derive(Debug, Clone)]
45pub struct EncryptedPayload {
46 pub ciphertext: Vec<u8>,
48 pub salt: [u8; SALT_LEN],
50 pub nonce: [u8; NONCE_LEN],
52}
53
54impl EncryptedPayload {
55 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 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
91fn 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
98pub 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
128pub 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#[derive(Debug)]
147pub enum CryptoError {
148 DecryptionFailed,
151 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 #[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 assert_ne!(
257 payload_a.salt, payload_b.salt,
258 "two encrypt calls must yield different salts"
259 );
260
261 let frankenstein = EncryptedPayload {
265 ciphertext: payload_b.ciphertext.clone(),
266 salt: payload_a.salt, 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 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 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 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}