Skip to main content

max / makenotwork

34.1 KB · 1050 lines History Blame Raw
1 //! Encryption engine: key derivation, wrapping, and per-entry encrypt/decrypt.
2 //!
3 //! Key hierarchy:
4 //! password + (app_id, user_id) → Argon2id → wrapping_key
5 //! wrapping_key encrypts/decrypts the random master_key
6 //! master_key encrypts/decrypts individual data entries
7 //!
8 //! All encryption uses XChaCha20-Poly1305 (192-bit nonces, safe for random generation).
9 //!
10 //! ## Why XChaCha20-Poly1305 over AES-GCM
11 //!
12 //! - 192-bit random nonces eliminate nonce-reuse risk (AES-GCM's 96-bit nonces are
13 //! unsafe for random generation at high volume due to birthday bound at ~2^32 messages).
14 //! - No hardware dependency: constant-time in software on all targets (AES-GCM needs
15 //! AES-NI for safe, fast operation — not guaranteed on all user devices).
16 //! - Widely audited: libsodium's default AEAD, used by Signal, WireGuard, and age.
17
18 use argon2::{Argon2, Algorithm, Version, Params};
19 use base64::{engine::general_purpose::STANDARD as B64, Engine};
20 use chacha20poly1305::{
21 aead::{Aead, KeyInit},
22 XChaCha20Poly1305, XNonce,
23 };
24 use rand::RngCore;
25 use serde::{Deserialize, Serialize};
26 use unicode_normalization::UnicodeNormalization;
27 use zeroize::Zeroize;
28
29 use crate::error::{Result, SyncKitError};
30
31 /// Size of XChaCha20-Poly1305 nonce in bytes.
32 const NONCE_SIZE: usize = 24;
33 /// Size of the encryption key in bytes.
34 const KEY_SIZE: usize = 32;
35
36 /// Current envelope version.
37 const ENVELOPE_VERSION: u8 = 1;
38
39 /// Argon2id parameters: 64 MB memory, 3 iterations (OWASP interactive minimum).
40 const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB
41 const ARGON2_TIME_COST: u32 = 3;
42 const ARGON2_PARALLELISM: u32 = 1;
43
44 /// Encrypted master key envelope stored on the server.
45 #[derive(Debug, Serialize, Deserialize)]
46 pub(crate) struct KeyEnvelope {
47 /// Envelope version (currently 1).
48 pub v: u8,
49 /// Argon2 salt (base64).
50 pub salt: String,
51 /// XChaCha20-Poly1305 nonce for the wrapping (base64).
52 pub nonce: String,
53 /// Encrypted master key (base64).
54 pub ciphertext: String,
55 }
56
57 /// Generate a random 256-bit master key.
58 pub fn generate_master_key() -> [u8; KEY_SIZE] {
59 let mut key = [0u8; KEY_SIZE];
60 rand::rng().fill_bytes(&mut key);
61 key
62 }
63
64 /// Maximum password length in bytes. Passwords longer than this are rejected
65 /// to prevent denial-of-service via extreme Argon2 input sizes.
66 const MAX_PASSWORD_BYTES: usize = 1024;
67
68 /// Normalize a password to NFC form and validate constraints.
69 ///
70 /// Returns the NFC-normalized password string. Rejects empty passwords
71 /// and passwords exceeding [`MAX_PASSWORD_BYTES`].
72 fn normalize_password(password: &str) -> Result<String> {
73 if password.is_empty() {
74 return Err(SyncKitError::Crypto("password must not be empty".into()));
75 }
76
77 let normalized: String = password.nfc().collect();
78
79 if normalized.len() > MAX_PASSWORD_BYTES {
80 return Err(SyncKitError::Crypto(format!(
81 "password exceeds maximum length of {MAX_PASSWORD_BYTES} bytes"
82 )));
83 }
84
85 Ok(normalized)
86 }
87
88 /// Derive a wrapping key from a password and salt using Argon2id.
89 ///
90 /// The password is NFC-normalized before derivation to ensure consistent
91 /// keys across platforms with different default Unicode normalization forms.
92 fn derive_wrapping_key(
93 password: &str,
94 salt: &[u8; 32],
95 ) -> Result<ZeroizeOnDrop> {
96 let normalized = normalize_password(password)?;
97
98 let params = Params::new(
99 ARGON2_MEM_COST_KB,
100 ARGON2_TIME_COST,
101 ARGON2_PARALLELISM,
102 Some(KEY_SIZE),
103 )
104 .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
105
106 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
107
108 let mut wrapping_key = ZeroizeOnDrop([0u8; KEY_SIZE]);
109 let result = argon2
110 .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key.0)
111 .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}")));
112
113 // Zeroize the normalized password before returning (defense-in-depth).
114 let mut norm_bytes = normalized.into_bytes();
115 norm_bytes.zeroize();
116
117 result?;
118 Ok(wrapping_key)
119 }
120
121 /// Verify that a password correctly derives to the given master key by
122 /// attempting to unwrap the envelope with it.
123 ///
124 /// This is used by `change_password` to validate the old password before
125 /// allowing a re-wrap with the new password. The cached key may still be
126 /// used for the actual re-encryption, but the old password must be proven
127 /// correct first.
128 pub fn verify_password_against_envelope(
129 envelope_json: &str,
130 password: &str,
131 ) -> Result<[u8; KEY_SIZE]> {
132 unwrap_master_key(envelope_json, password)
133 }
134
135 /// Encrypt the master key with a password, producing a JSON envelope.
136 ///
137 /// Generates a random 32-byte salt for Argon2id key derivation and stores it
138 /// in the envelope. Each wrap operation uses a unique salt, preventing
139 /// precomputation attacks.
140 pub fn wrap_master_key(
141 master_key: &[u8; KEY_SIZE],
142 password: &str,
143 ) -> Result<String> {
144 let mut salt = [0u8; 32];
145 rand::rng().fill_bytes(&mut salt);
146
147 let wrapping_key = derive_wrapping_key(password, &salt)?;
148
149 let mut nonce_bytes = [0u8; NONCE_SIZE];
150 rand::rng().fill_bytes(&mut nonce_bytes);
151
152 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
153 let nonce = XNonce::from_slice(&nonce_bytes);
154
155 let ciphertext = cipher
156 .encrypt(nonce, master_key.as_ref())
157 .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?;
158
159 let envelope = KeyEnvelope {
160 v: ENVELOPE_VERSION,
161 salt: B64.encode(salt),
162 nonce: B64.encode(nonce_bytes),
163 ciphertext: B64.encode(ciphertext),
164 };
165
166 serde_json::to_string(&envelope).map_err(Into::into)
167 }
168
169 /// Decrypt the master key from a JSON envelope using a password.
170 ///
171 /// Reads the random salt from the envelope, derives the wrapping key via
172 /// Argon2id, then decrypts the master key.
173 pub fn unwrap_master_key(
174 envelope_json: &str,
175 password: &str,
176 ) -> Result<[u8; KEY_SIZE]> {
177 let envelope: KeyEnvelope =
178 serde_json::from_str(envelope_json).map_err(|e| {
179 SyncKitError::InvalidEnvelope(format!("JSON parse: {e}"))
180 })?;
181
182 if envelope.v != ENVELOPE_VERSION {
183 return Err(SyncKitError::InvalidEnvelope(format!(
184 "unsupported version {}",
185 envelope.v
186 )));
187 }
188
189 let salt_bytes = B64.decode(&envelope.salt)?;
190 let nonce_bytes = B64.decode(&envelope.nonce)?;
191 let ciphertext = B64.decode(&envelope.ciphertext)?;
192
193 if salt_bytes.len() != 32 {
194 return Err(SyncKitError::InvalidEnvelope(
195 "invalid salt length".into(),
196 ));
197 }
198
199 if nonce_bytes.len() != NONCE_SIZE {
200 return Err(SyncKitError::InvalidEnvelope(
201 "invalid nonce length".into(),
202 ));
203 }
204
205 let mut salt = [0u8; 32];
206 salt.copy_from_slice(&salt_bytes);
207 let wrapping_key = derive_wrapping_key(password, &salt)?;
208
209 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
210 let nonce = XNonce::from_slice(&nonce_bytes);
211
212 let mut plaintext = cipher
213 .decrypt(nonce, ciphertext.as_ref())
214 .map_err(|_| SyncKitError::DecryptionFailed)?;
215
216 if plaintext.len() != KEY_SIZE {
217 plaintext.zeroize();
218 return Err(SyncKitError::InvalidEnvelope(
219 "decrypted key has wrong length".into(),
220 ));
221 }
222
223 let mut key = [0u8; KEY_SIZE];
224 key.copy_from_slice(&plaintext);
225 plaintext.zeroize();
226 Ok(key)
227 }
228
229 /// Encrypt a data entry with the master key.
230 /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]).
231 pub fn encrypt_data(
232 plaintext: &[u8],
233 master_key: &[u8; KEY_SIZE],
234 ) -> Result<String> {
235 let mut nonce_bytes = [0u8; NONCE_SIZE];
236 rand::rng().fill_bytes(&mut nonce_bytes);
237
238 let cipher = XChaCha20Poly1305::new(master_key.into());
239 let nonce = XNonce::from_slice(&nonce_bytes);
240
241 let ciphertext = cipher
242 .encrypt(nonce, plaintext)
243 .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
244
245 // Wire format: nonce || ciphertext (which includes poly1305 tag)
246 let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
247 blob.extend_from_slice(&nonce_bytes);
248 blob.extend_from_slice(&ciphertext);
249
250 Ok(B64.encode(blob))
251 }
252
253 /// Decrypt a data entry with the master key.
254 /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
255 pub fn decrypt_data(
256 encoded: &str,
257 master_key: &[u8; KEY_SIZE],
258 ) -> Result<Vec<u8>> {
259 let blob = B64.decode(encoded)?;
260
261 if blob.len() < NONCE_SIZE + 16 {
262 // Minimum: nonce + poly1305 tag (empty plaintext)
263 return Err(SyncKitError::Crypto(
264 "ciphertext too short".into(),
265 ));
266 }
267
268 let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
269 let cipher = XChaCha20Poly1305::new(master_key.into());
270 let nonce = XNonce::from_slice(nonce_bytes);
271
272 cipher
273 .decrypt(nonce, ciphertext)
274 .map_err(|_| SyncKitError::DecryptionFailed)
275 }
276
277 /// Encrypt raw bytes with the master key.
278 /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
279 /// Use this for blob data where base64 overhead is undesirable.
280 pub fn encrypt_bytes(
281 plaintext: &[u8],
282 master_key: &[u8; KEY_SIZE],
283 ) -> Result<Vec<u8>> {
284 let mut nonce_bytes = [0u8; NONCE_SIZE];
285 rand::rng().fill_bytes(&mut nonce_bytes);
286
287 let cipher = XChaCha20Poly1305::new(master_key.into());
288 let nonce = XNonce::from_slice(&nonce_bytes);
289
290 let ciphertext = cipher
291 .encrypt(nonce, plaintext)
292 .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
293
294 let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
295 blob.extend_from_slice(&nonce_bytes);
296 blob.extend_from_slice(&ciphertext);
297
298 Ok(blob)
299 }
300
301 /// Decrypt raw bytes with the master key.
302 /// Input is `nonce[24] || ciphertext || poly1305_tag[16]`.
303 pub fn decrypt_bytes(
304 encrypted: &[u8],
305 master_key: &[u8; KEY_SIZE],
306 ) -> Result<Vec<u8>> {
307 if encrypted.len() < NONCE_SIZE + 16 {
308 return Err(SyncKitError::Crypto(
309 "ciphertext too short".into(),
310 ));
311 }
312
313 let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
314 let cipher = XChaCha20Poly1305::new(master_key.into());
315 let nonce = XNonce::from_slice(nonce_bytes);
316
317 cipher
318 .decrypt(nonce, ciphertext)
319 .map_err(|_| SyncKitError::DecryptionFailed)
320 }
321
322 /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag).
323 pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16;
324
325 /// Encrypt a JSON value, returning a JSON string suitable for the `data` field.
326 pub fn encrypt_json(
327 value: &serde_json::Value,
328 master_key: &[u8; KEY_SIZE],
329 ) -> Result<serde_json::Value> {
330 use zeroize::Zeroize;
331 let mut plaintext = serde_json::to_vec(value)?;
332 let encrypted = encrypt_data(&plaintext, master_key);
333 plaintext.zeroize();
334 Ok(serde_json::Value::String(encrypted?))
335 }
336
337 /// Decrypt a JSON string from the `data` field back into the original value.
338 pub fn decrypt_json(
339 encrypted_value: &serde_json::Value,
340 master_key: &[u8; KEY_SIZE],
341 ) -> Result<serde_json::Value> {
342 use zeroize::Zeroize;
343 let encoded = encrypted_value
344 .as_str()
345 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
346
347 let mut plaintext = decrypt_data(encoded, master_key)?;
348 let result = serde_json::from_slice(&plaintext);
349 plaintext.zeroize();
350 result.map_err(Into::into)
351 }
352
353 /// Zero out a key on drop using the `zeroize` crate.
354 pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]);
355
356 impl std::fmt::Debug for ZeroizeOnDrop {
357 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 f.write_str("[REDACTED 32-byte key]")
359 }
360 }
361
362 impl Drop for ZeroizeOnDrop {
363 fn drop(&mut self) {
364 use zeroize::Zeroize;
365 self.0.zeroize();
366 }
367 }
368
369 impl std::ops::Deref for ZeroizeOnDrop {
370 type Target = [u8; KEY_SIZE];
371 fn deref(&self) -> &Self::Target {
372 &self.0
373 }
374 }
375
376 #[cfg(test)]
377 mod tests {
378 use super::*;
379
380 #[test]
381 fn master_key_generation_is_random() {
382 let k1 = generate_master_key();
383 let k2 = generate_master_key();
384 assert_ne!(k1, k2, "Two generated keys must differ");
385 assert_eq!(k1.len(), 32);
386 }
387
388 #[test]
389 fn wrapping_key_derivation_is_deterministic() {
390 let salt = [42u8; 32];
391 let k1 = derive_wrapping_key("password123", &salt).unwrap();
392 let k2 = derive_wrapping_key("password123", &salt).unwrap();
393 assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
394 }
395
396 #[test]
397 fn different_passwords_produce_different_keys() {
398 let salt = [42u8; 32];
399 let k1 = derive_wrapping_key("password1", &salt).unwrap();
400 let k2 = derive_wrapping_key("password2", &salt).unwrap();
401 assert_ne!(*k1, *k2);
402 }
403
404 #[test]
405 fn different_salts_produce_different_keys() {
406 let salt1 = [1u8; 32];
407 let salt2 = [2u8; 32];
408 let k1 = derive_wrapping_key("password", &salt1).unwrap();
409 let k2 = derive_wrapping_key("password", &salt2).unwrap();
410 assert_ne!(*k1, *k2);
411 }
412
413 // ── Password normalization (NFC/NFD) ──
414
415 #[test]
416 fn nfc_and_nfd_passwords_derive_same_key() {
417 // "e" + combining acute accent (NFD form of e-acute)
418 let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent
419 // Pre-composed e-acute (NFC form)
420 let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent
421
422 // Verify they are actually different byte sequences
423 assert_ne!(
424 nfd_password.as_bytes(),
425 nfc_password.as_bytes(),
426 "NFD and NFC should have different raw bytes"
427 );
428
429 let salt = [99u8; 32];
430 let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
431 let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
432 assert_eq!(
433 *k1, *k2,
434 "Same password in NFC and NFD forms must derive the same key"
435 );
436 }
437
438 #[test]
439 fn nfc_nfd_wrap_unwrap_roundtrip() {
440 let master_key = generate_master_key();
441 // Wrap with NFC form
442 let nfc_password = "caf\u{00e9}";
443 let envelope = wrap_master_key(&master_key, nfc_password).unwrap();
444
445 // Unwrap with NFD form
446 let nfd_password = "caf\u{0065}\u{0301}";
447 let recovered = unwrap_master_key(&envelope, nfd_password).unwrap();
448 assert_eq!(master_key, recovered);
449 }
450
451 #[test]
452 fn nfd_wrap_nfc_unwrap_roundtrip() {
453 let master_key = generate_master_key();
454 // Wrap with NFD form
455 let nfd_password = "caf\u{0065}\u{0301}";
456 let envelope = wrap_master_key(&master_key, nfd_password).unwrap();
457
458 // Unwrap with NFC form
459 let nfc_password = "caf\u{00e9}";
460 let recovered = unwrap_master_key(&envelope, nfc_password).unwrap();
461 assert_eq!(master_key, recovered);
462 }
463
464 #[test]
465 fn normalize_password_converts_to_nfc() {
466 let nfd = "caf\u{0065}\u{0301}";
467 let nfc = "caf\u{00e9}";
468 let normalized = normalize_password(nfd).unwrap();
469 assert_eq!(normalized, nfc);
470 }
471
472 // ── Empty password rejection ──
473
474 #[test]
475 fn empty_password_rejected_by_normalize() {
476 let result = normalize_password("");
477 assert!(result.is_err());
478 let msg = result.unwrap_err().to_string();
479 assert!(msg.contains("empty"), "Error should mention empty: {msg}");
480 }
481
482 #[test]
483 fn empty_password_rejected_by_derive() {
484 let salt = [0u8; 32];
485 let result = derive_wrapping_key("", &salt);
486 assert!(result.is_err());
487 }
488
489 #[test]
490 fn empty_password_rejected_by_wrap() {
491 let master_key = generate_master_key();
492 let result = wrap_master_key(&master_key, "");
493 assert!(result.is_err());
494 }
495
496 #[test]
497 fn empty_password_rejected_by_unwrap() {
498 let master_key = generate_master_key();
499 let envelope = wrap_master_key(&master_key, "valid").unwrap();
500 let result = unwrap_master_key(&envelope, "");
501 assert!(result.is_err());
502 }
503
504 // ── Password length limit ──
505
506 #[test]
507 fn very_long_password_rejected() {
508 let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1);
509 let result = normalize_password(&long_password);
510 assert!(result.is_err());
511 let msg = result.unwrap_err().to_string();
512 assert!(
513 msg.contains("maximum length"),
514 "Error should mention max length: {msg}"
515 );
516 }
517
518 #[test]
519 fn password_at_max_length_accepted() {
520 let max_password = "a".repeat(MAX_PASSWORD_BYTES);
521 let result = normalize_password(&max_password);
522 assert!(result.is_ok());
523 }
524
525 #[test]
526 fn password_just_under_max_length_accepted() {
527 let password = "a".repeat(MAX_PASSWORD_BYTES - 1);
528 let result = normalize_password(&password);
529 assert!(result.is_ok());
530 }
531
532 // ── Salt reuse detection ──
533
534 #[test]
535 fn two_wraps_use_different_salts() {
536 let master_key = generate_master_key();
537 let e1_json = wrap_master_key(&master_key, "pass").unwrap();
538 let e2_json = wrap_master_key(&master_key, "pass").unwrap();
539
540 let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap();
541 let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap();
542
543 assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt");
544 assert_ne!(e1.nonce, e2.nonce, "Each wrap must use a unique random nonce");
545 }
546
547 // ── Key derivation determinism ──
548
549 #[test]
550 fn key_derivation_deterministic_multiple_calls() {
551 let salt = [77u8; 32];
552 let password = "deterministic-test-password";
553
554 let k1 = derive_wrapping_key(password, &salt).unwrap();
555 let k2 = derive_wrapping_key(password, &salt).unwrap();
556 let k3 = derive_wrapping_key(password, &salt).unwrap();
557
558 assert_eq!(*k1, *k2);
559 assert_eq!(*k2, *k3);
560 }
561
562 // ── Key rotation: re-wrap with new password, old data still readable ──
563
564 #[test]
565 fn key_rotation_preserves_data_access() {
566 let master_key = generate_master_key();
567 let plaintext = b"encrypted before password change";
568
569 // Encrypt data with the master key
570 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
571
572 // Wrap master key with old password
573 let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap();
574
575 // Simulate password change: unwrap with old, re-wrap with new
576 let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap();
577 assert_eq!(recovered_key, master_key);
578
579 let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap();
580
581 // Verify: unwrap with new password gives same key
582 let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap();
583 assert_eq!(key_from_new, master_key);
584
585 // Verify: old encrypted data can still be decrypted
586 let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap();
587 assert_eq!(decrypted, plaintext);
588
589 // Verify: old password no longer works on new envelope
590 let result = unwrap_master_key(&new_envelope, "old-pass");
591 assert!(result.is_err());
592 }
593
594 // ── Encryption roundtrip with various data sizes ──
595
596 #[test]
597 fn encrypt_decrypt_empty_data() {
598 let master_key = generate_master_key();
599 let encrypted = encrypt_data(b"", &master_key).unwrap();
600 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
601 assert!(decrypted.is_empty());
602 }
603
604 #[test]
605 fn encrypt_decrypt_single_byte() {
606 let master_key = generate_master_key();
607 let encrypted = encrypt_data(&[42], &master_key).unwrap();
608 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
609 assert_eq!(decrypted, vec![42]);
610 }
611
612 #[test]
613 fn encrypt_decrypt_large_payload() {
614 let master_key = generate_master_key();
615 // 1MB of data
616 let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
617 let encrypted = encrypt_data(&plaintext, &master_key).unwrap();
618 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
619 assert_eq!(decrypted, plaintext);
620 }
621
622 // ── Wrong key gives error, not garbage ──
623
624 #[test]
625 fn wrong_key_gives_decryption_error_not_garbage() {
626 let key1 = generate_master_key();
627 let key2 = generate_master_key();
628 let plaintext = b"this should fail cleanly with wrong key";
629
630 let encrypted = encrypt_data(plaintext, &key1).unwrap();
631 let result = decrypt_data(&encrypted, &key2);
632
633 // Must be an error, not a successful decryption to garbage
634 assert!(result.is_err());
635 assert!(
636 matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
637 "Wrong key must produce DecryptionFailed, not garbage output"
638 );
639 }
640
641 #[test]
642 fn wrong_key_bytes_gives_decryption_error_not_garbage() {
643 let key1 = generate_master_key();
644 let key2 = generate_master_key();
645 let plaintext = b"binary data check";
646
647 let encrypted = encrypt_bytes(plaintext, &key1).unwrap();
648 let result = decrypt_bytes(&encrypted, &key2);
649
650 assert!(result.is_err());
651 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
652 }
653
654 // ── JSON encryption edge cases ──
655
656 #[test]
657 fn json_encrypt_decrypt_null() {
658 let master_key = generate_master_key();
659 let original = serde_json::Value::Null;
660 let encrypted = encrypt_json(&original, &master_key).unwrap();
661 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
662 assert_eq!(decrypted, original);
663 }
664
665 #[test]
666 fn json_encrypt_decrypt_nested_object() {
667 let master_key = generate_master_key();
668 let original = serde_json::json!({
669 "level1": {
670 "level2": {
671 "level3": [1, 2, 3],
672 "flag": true
673 }
674 },
675 "empty_array": [],
676 "empty_object": {}
677 });
678
679 let encrypted = encrypt_json(&original, &master_key).unwrap();
680 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
681 assert_eq!(decrypted, original);
682 }
683
684 #[test]
685 fn json_decrypt_with_wrong_key_fails() {
686 let key1 = generate_master_key();
687 let key2 = generate_master_key();
688 let original = serde_json::json!({"secret": "data"});
689
690 let encrypted = encrypt_json(&original, &key1).unwrap();
691 let result = decrypt_json(&encrypted, &key2);
692 assert!(result.is_err());
693 }
694
695 #[test]
696 fn json_decrypt_non_string_value_fails() {
697 let master_key = generate_master_key();
698 let not_a_string = serde_json::json!(42);
699 let result = decrypt_json(&not_a_string, &master_key);
700 assert!(result.is_err());
701 }
702
703 // ── Blob (bytes) edge cases ──
704
705 #[test]
706 fn bytes_zero_byte_blob_roundtrip() {
707 let master_key = generate_master_key();
708 let empty: &[u8] = &[];
709 let encrypted = encrypt_bytes(empty, &master_key).unwrap();
710 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
711 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
712 assert!(decrypted.is_empty());
713 }
714
715 #[test]
716 fn bytes_boundary_size_blob() {
717 let master_key = generate_master_key();
718 // Test at exactly the nonce size boundary
719 let data = vec![0xAB; NONCE_SIZE];
720 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
721 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
722 assert_eq!(decrypted, data);
723 }
724
725 #[test]
726 fn bytes_1mb_blob_roundtrip() {
727 let master_key = generate_master_key();
728 let data: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
729 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
730 assert_eq!(encrypted.len(), data.len() + ENCRYPTION_OVERHEAD);
731 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
732 assert_eq!(decrypted, data);
733 }
734
735 // ── Tampered ciphertext detection ──
736
737 #[test]
738 fn tampered_ciphertext_detected() {
739 let master_key = generate_master_key();
740 let plaintext = b"integrity check";
741 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
742
743 let mut blob = B64.decode(&encrypted).unwrap();
744 // Flip a byte in the ciphertext portion (after the nonce)
745 let idx = NONCE_SIZE + 1;
746 blob[idx] ^= 0xFF;
747 let tampered = B64.encode(&blob);
748
749 let result = decrypt_data(&tampered, &master_key);
750 assert!(result.is_err());
751 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
752 }
753
754 #[test]
755 fn tampered_nonce_detected() {
756 let master_key = generate_master_key();
757 let plaintext = b"nonce tamper check";
758 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
759
760 let mut blob = B64.decode(&encrypted).unwrap();
761 // Flip a byte in the nonce
762 blob[0] ^= 0xFF;
763 let tampered = B64.encode(&blob);
764
765 let result = decrypt_data(&tampered, &master_key);
766 assert!(result.is_err());
767 }
768
769 // ── Envelope validation edge cases ──
770
771 #[test]
772 fn invalid_envelope_json_rejected() {
773 let result = unwrap_master_key("not valid json at all", "pass");
774 assert!(result.is_err());
775 assert!(matches!(
776 result.unwrap_err(),
777 SyncKitError::InvalidEnvelope(_)
778 ));
779 }
780
781 #[test]
782 fn envelope_with_wrong_salt_length_rejected() {
783 let envelope = KeyEnvelope {
784 v: ENVELOPE_VERSION,
785 salt: B64.encode([0u8; 16]), // 16 bytes, should be 32
786 nonce: B64.encode([0u8; NONCE_SIZE]),
787 ciphertext: B64.encode([0u8; 48]),
788 };
789 let json = serde_json::to_string(&envelope).unwrap();
790
791 let result = unwrap_master_key(&json, "pass");
792 assert!(result.is_err());
793 assert!(matches!(
794 result.unwrap_err(),
795 SyncKitError::InvalidEnvelope(_)
796 ));
797 }
798
799 #[test]
800 fn envelope_with_wrong_nonce_length_rejected() {
801 let envelope = KeyEnvelope {
802 v: ENVELOPE_VERSION,
803 salt: B64.encode([0u8; 32]),
804 nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24
805 ciphertext: B64.encode([0u8; 48]),
806 };
807 let json = serde_json::to_string(&envelope).unwrap();
808
809 let result = unwrap_master_key(&json, "pass");
810 assert!(result.is_err());
811 assert!(matches!(
812 result.unwrap_err(),
813 SyncKitError::InvalidEnvelope(_)
814 ));
815 }
816
817 // ── verify_password_against_envelope ──
818
819 #[test]
820 fn verify_password_correct() {
821 let master_key = generate_master_key();
822 let envelope = wrap_master_key(&master_key, "correct").unwrap();
823 let result = verify_password_against_envelope(&envelope, "correct");
824 assert!(result.is_ok());
825 assert_eq!(result.unwrap(), master_key);
826 }
827
828 #[test]
829 fn verify_password_wrong() {
830 let master_key = generate_master_key();
831 let envelope = wrap_master_key(&master_key, "correct").unwrap();
832 let result = verify_password_against_envelope(&envelope, "wrong");
833 assert!(result.is_err());
834 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
835 }
836
837 #[test]
838 fn wrap_unwrap_roundtrip() {
839 let master_key = generate_master_key();
840
841 let envelope = wrap_master_key(&master_key, "mypassword").unwrap();
842 let recovered = unwrap_master_key(&envelope, "mypassword").unwrap();
843
844 assert_eq!(master_key, recovered);
845 }
846
847 #[test]
848 fn wrap_uses_random_salt() {
849 let master_key = generate_master_key();
850 let e1 = wrap_master_key(&master_key, "pass").unwrap();
851 let e2 = wrap_master_key(&master_key, "pass").unwrap();
852
853 // Different envelopes (random salt + random nonce)
854 assert_ne!(e1, e2);
855
856 // Both decrypt correctly
857 assert_eq!(unwrap_master_key(&e1, "pass").unwrap(), master_key);
858 assert_eq!(unwrap_master_key(&e2, "pass").unwrap(), master_key);
859 }
860
861 #[test]
862 fn wrong_password_fails_unwrap() {
863 let master_key = generate_master_key();
864
865 let envelope = wrap_master_key(&master_key, "correct").unwrap();
866 let result = unwrap_master_key(&envelope, "wrong");
867
868 assert!(result.is_err());
869 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
870 }
871
872 #[test]
873 fn data_encrypt_decrypt_roundtrip() {
874 let master_key = generate_master_key();
875 let plaintext = b"Hello, world! This is sensitive data.";
876
877 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
878 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
879
880 assert_eq!(decrypted, plaintext);
881 }
882
883 #[test]
884 fn same_plaintext_different_ciphertext() {
885 let master_key = generate_master_key();
886 let plaintext = b"same data";
887
888 let e1 = encrypt_data(plaintext, &master_key).unwrap();
889 let e2 = encrypt_data(plaintext, &master_key).unwrap();
890
891 assert_ne!(e1, e2, "Random nonces must produce different ciphertext");
892
893 // But both decrypt to the same plaintext
894 assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext);
895 assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext);
896 }
897
898 #[test]
899 fn wrong_key_fails_decrypt() {
900 let key1 = generate_master_key();
901 let key2 = generate_master_key();
902 let plaintext = b"secret";
903
904 let encrypted = encrypt_data(plaintext, &key1).unwrap();
905 let result = decrypt_data(&encrypted, &key2);
906
907 assert!(result.is_err());
908 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
909 }
910
911 #[test]
912 fn envelope_version_check() {
913 let master_key = generate_master_key();
914
915 let envelope_json = wrap_master_key(&master_key, "pass").unwrap();
916
917 // Tamper with version
918 let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap();
919 envelope.v = 99;
920 let tampered = serde_json::to_string(&envelope).unwrap();
921
922 let result = unwrap_master_key(&tampered, "pass");
923 assert!(result.is_err());
924 assert!(matches!(
925 result.unwrap_err(),
926 SyncKitError::InvalidEnvelope(_)
927 ));
928 }
929
930 #[test]
931 fn truncated_ciphertext_rejected() {
932 let master_key = generate_master_key();
933 let encrypted = encrypt_data(b"data", &master_key).unwrap();
934
935 // Decode, truncate, re-encode
936 let mut blob = B64.decode(&encrypted).unwrap();
937 blob.truncate(10); // Way too short
938 let truncated = B64.encode(&blob);
939
940 let result = decrypt_data(&truncated, &master_key);
941 assert!(result.is_err());
942 }
943
944 #[test]
945 fn json_encrypt_decrypt_roundtrip() {
946 let master_key = generate_master_key();
947 let original = serde_json::json!({
948 "title": "Buy milk",
949 "priority": 3,
950 "tags": ["groceries", "urgent"]
951 });
952
953 let encrypted = encrypt_json(&original, &master_key).unwrap();
954 assert!(encrypted.is_string(), "Encrypted JSON should be a string");
955
956 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
957 assert_eq!(decrypted, original);
958 }
959
960 #[test]
961 fn zeroize_on_drop() {
962 let key = generate_master_key();
963 let guarded = ZeroizeOnDrop(key);
964 // Verify we can use it
965 assert_eq!(guarded.len(), 32);
966 // Drop happens automatically — we can't easily test memory zeroing
967 // but we verify the API works without panic.
968 drop(guarded);
969 }
970
971 // ── encrypt_bytes / decrypt_bytes ──
972
973 #[test]
974 fn bytes_encrypt_decrypt_roundtrip() {
975 let master_key = generate_master_key();
976 let plaintext = b"raw binary blob data \x00\x01\x02\xff";
977
978 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
979 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
980
981 assert_eq!(decrypted, plaintext);
982 }
983
984 #[test]
985 fn bytes_encrypt_has_correct_overhead() {
986 let master_key = generate_master_key();
987 let plaintext = vec![0u8; 1000];
988
989 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
990 assert_eq!(encrypted.len(), plaintext.len() + ENCRYPTION_OVERHEAD);
991 }
992
993 #[test]
994 fn bytes_same_plaintext_different_ciphertext() {
995 let master_key = generate_master_key();
996 let plaintext = b"same data";
997
998 let e1 = encrypt_bytes(plaintext, &master_key).unwrap();
999 let e2 = encrypt_bytes(plaintext, &master_key).unwrap();
1000
1001 assert_ne!(e1, e2);
1002 assert_eq!(decrypt_bytes(&e1, &master_key).unwrap(), plaintext);
1003 assert_eq!(decrypt_bytes(&e2, &master_key).unwrap(), plaintext);
1004 }
1005
1006 #[test]
1007 fn bytes_wrong_key_fails() {
1008 let key1 = generate_master_key();
1009 let key2 = generate_master_key();
1010
1011 let encrypted = encrypt_bytes(b"secret", &key1).unwrap();
1012 let result = decrypt_bytes(&encrypted, &key2);
1013
1014 assert!(result.is_err());
1015 assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed));
1016 }
1017
1018 #[test]
1019 fn bytes_truncated_rejected() {
1020 let master_key = generate_master_key();
1021 let encrypted = encrypt_bytes(b"data", &master_key).unwrap();
1022
1023 let result = decrypt_bytes(&encrypted[..10], &master_key);
1024 assert!(result.is_err());
1025 }
1026
1027 #[test]
1028 fn bytes_empty_plaintext_roundtrip() {
1029 let master_key = generate_master_key();
1030 let plaintext = b"";
1031
1032 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
1033 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
1034
1035 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1036 assert_eq!(decrypted, plaintext);
1037 }
1038
1039 #[test]
1040 fn bytes_large_blob_roundtrip() {
1041 let master_key = generate_master_key();
1042 let plaintext: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect();
1043
1044 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
1045 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1046
1047 assert_eq!(decrypted, plaintext);
1048 }
1049 }
1050