//! Encryption engine: key derivation, wrapping, and per-entry encrypt/decrypt. //! //! Key hierarchy: //! password + (app_id, user_id) → Argon2id → wrapping_key //! wrapping_key encrypts/decrypts the random master_key //! master_key encrypts/decrypts individual data entries //! //! All encryption uses XChaCha20-Poly1305 (192-bit nonces, safe for random generation). //! //! ## Why XChaCha20-Poly1305 over AES-GCM //! //! - 192-bit random nonces eliminate nonce-reuse risk (AES-GCM's 96-bit nonces are //! unsafe for random generation at high volume due to birthday bound at ~2^32 messages). //! - No hardware dependency: constant-time in software on all targets (AES-GCM needs //! AES-NI for safe, fast operation — not guaranteed on all user devices). //! - Widely audited: libsodium's default AEAD, used by Signal, WireGuard, and age. use argon2::{Argon2, Algorithm, Version, Params}; use base64::{engine::general_purpose::STANDARD as B64, Engine}; use chacha20poly1305::{ aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, }; use rand::RngCore; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroize; use crate::error::{Result, SyncKitError}; /// Size of XChaCha20-Poly1305 nonce in bytes. const NONCE_SIZE: usize = 24; /// Size of the encryption key in bytes. const KEY_SIZE: usize = 32; /// Current envelope version. const ENVELOPE_VERSION: u8 = 1; /// Argon2id parameters: 64 MB memory, 3 iterations (OWASP interactive minimum). const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB const ARGON2_TIME_COST: u32 = 3; const ARGON2_PARALLELISM: u32 = 1; /// Encrypted master key envelope stored on the server. #[derive(Debug, Serialize, Deserialize)] pub(crate) struct KeyEnvelope { /// Envelope version (currently 1). pub v: u8, /// Argon2 salt (base64). pub salt: String, /// XChaCha20-Poly1305 nonce for the wrapping (base64). pub nonce: String, /// Encrypted master key (base64). pub ciphertext: String, } /// Generate a random 256-bit master key. pub fn generate_master_key() -> [u8; KEY_SIZE] { let mut key = [0u8; KEY_SIZE]; rand::rng().fill_bytes(&mut key); key } /// Maximum password length in bytes. Passwords longer than this are rejected /// to prevent denial-of-service via extreme Argon2 input sizes. const MAX_PASSWORD_BYTES: usize = 1024; /// Normalize a password to NFC form and validate constraints. /// /// Returns the NFC-normalized password string. Rejects empty passwords /// and passwords exceeding [`MAX_PASSWORD_BYTES`]. fn normalize_password(password: &str) -> Result { if password.is_empty() { return Err(SyncKitError::Crypto("password must not be empty".into())); } let normalized: String = password.nfc().collect(); if normalized.len() > MAX_PASSWORD_BYTES { return Err(SyncKitError::Crypto(format!( "password exceeds maximum length of {MAX_PASSWORD_BYTES} bytes" ))); } Ok(normalized) } /// Derive a wrapping key from a password and salt using Argon2id. /// /// The password is NFC-normalized before derivation to ensure consistent /// keys across platforms with different default Unicode normalization forms. fn derive_wrapping_key( password: &str, salt: &[u8; 32], ) -> Result { let normalized = normalize_password(password)?; let params = Params::new( ARGON2_MEM_COST_KB, ARGON2_TIME_COST, ARGON2_PARALLELISM, Some(KEY_SIZE), ) .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut wrapping_key = ZeroizeOnDrop([0u8; KEY_SIZE]); let result = argon2 .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key.0) .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}"))); // Zeroize the normalized password before returning (defense-in-depth). let mut norm_bytes = normalized.into_bytes(); norm_bytes.zeroize(); result?; Ok(wrapping_key) } /// Verify that a password correctly derives to the given master key by /// attempting to unwrap the envelope with it. /// /// This is used by `change_password` to validate the old password before /// allowing a re-wrap with the new password. The cached key may still be /// used for the actual re-encryption, but the old password must be proven /// correct first. pub fn verify_password_against_envelope( envelope_json: &str, password: &str, ) -> Result<[u8; KEY_SIZE]> { unwrap_master_key(envelope_json, password) } /// Encrypt the master key with a password, producing a JSON envelope. /// /// Generates a random 32-byte salt for Argon2id key derivation and stores it /// in the envelope. Each wrap operation uses a unique salt, preventing /// precomputation attacks. pub fn wrap_master_key( master_key: &[u8; KEY_SIZE], password: &str, ) -> Result { let mut salt = [0u8; 32]; rand::rng().fill_bytes(&mut salt); let wrapping_key = derive_wrapping_key(password, &salt)?; let mut nonce_bytes = [0u8; NONCE_SIZE]; rand::rng().fill_bytes(&mut nonce_bytes); let cipher = XChaCha20Poly1305::new((&*wrapping_key).into()); let nonce = XNonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, master_key.as_ref()) .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?; let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode(salt), nonce: B64.encode(nonce_bytes), ciphertext: B64.encode(ciphertext), }; serde_json::to_string(&envelope).map_err(Into::into) } /// Decrypt the master key from a JSON envelope using a password. /// /// Reads the random salt from the envelope, derives the wrapping key via /// Argon2id, then decrypts the master key. pub fn unwrap_master_key( envelope_json: &str, password: &str, ) -> Result<[u8; KEY_SIZE]> { let envelope: KeyEnvelope = serde_json::from_str(envelope_json).map_err(|e| { SyncKitError::InvalidEnvelope(format!("JSON parse: {e}")) })?; if envelope.v != ENVELOPE_VERSION { return Err(SyncKitError::InvalidEnvelope(format!( "unsupported version {}", envelope.v ))); } let salt_bytes = B64.decode(&envelope.salt)?; let nonce_bytes = B64.decode(&envelope.nonce)?; let ciphertext = B64.decode(&envelope.ciphertext)?; if salt_bytes.len() != 32 { return Err(SyncKitError::InvalidEnvelope( "invalid salt length".into(), )); } if nonce_bytes.len() != NONCE_SIZE { return Err(SyncKitError::InvalidEnvelope( "invalid nonce length".into(), )); } let mut salt = [0u8; 32]; salt.copy_from_slice(&salt_bytes); let wrapping_key = derive_wrapping_key(password, &salt)?; let cipher = XChaCha20Poly1305::new((&*wrapping_key).into()); let nonce = XNonce::from_slice(&nonce_bytes); let mut plaintext = cipher .decrypt(nonce, ciphertext.as_ref()) .map_err(|_| SyncKitError::DecryptionFailed)?; if plaintext.len() != KEY_SIZE { plaintext.zeroize(); return Err(SyncKitError::InvalidEnvelope( "decrypted key has wrong length".into(), )); } let mut key = [0u8; KEY_SIZE]; key.copy_from_slice(&plaintext); plaintext.zeroize(); Ok(key) } /// Encrypt a data entry with the master key. /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). pub fn encrypt_data( plaintext: &[u8], master_key: &[u8; KEY_SIZE], ) -> Result { let mut nonce_bytes = [0u8; NONCE_SIZE]; rand::rng().fill_bytes(&mut nonce_bytes); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext) .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?; // Wire format: nonce || ciphertext (which includes poly1305 tag) let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); blob.extend_from_slice(&nonce_bytes); blob.extend_from_slice(&ciphertext); Ok(B64.encode(blob)) } /// Decrypt a data entry with the master key. /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]). pub fn decrypt_data( encoded: &str, master_key: &[u8; KEY_SIZE], ) -> Result> { let blob = B64.decode(encoded)?; if blob.len() < NONCE_SIZE + 16 { // Minimum: nonce + poly1305 tag (empty plaintext) return Err(SyncKitError::Crypto( "ciphertext too short".into(), )); } let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::from_slice(nonce_bytes); cipher .decrypt(nonce, ciphertext) .map_err(|_| SyncKitError::DecryptionFailed) } /// Encrypt raw bytes with the master key. /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64). /// Use this for blob data where base64 overhead is undesirable. pub fn encrypt_bytes( plaintext: &[u8], master_key: &[u8; KEY_SIZE], ) -> Result> { let mut nonce_bytes = [0u8; NONCE_SIZE]; rand::rng().fill_bytes(&mut nonce_bytes); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext) .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?; let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); blob.extend_from_slice(&nonce_bytes); blob.extend_from_slice(&ciphertext); Ok(blob) } /// Decrypt raw bytes with the master key. /// Input is `nonce[24] || ciphertext || poly1305_tag[16]`. pub fn decrypt_bytes( encrypted: &[u8], master_key: &[u8; KEY_SIZE], ) -> Result> { if encrypted.len() < NONCE_SIZE + 16 { return Err(SyncKitError::Crypto( "ciphertext too short".into(), )); } let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::from_slice(nonce_bytes); cipher .decrypt(nonce, ciphertext) .map_err(|_| SyncKitError::DecryptionFailed) } /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag). pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16; /// Encrypt a JSON value, returning a JSON string suitable for the `data` field. pub fn encrypt_json( value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ) -> Result { use zeroize::Zeroize; let mut plaintext = serde_json::to_vec(value)?; let encrypted = encrypt_data(&plaintext, master_key); plaintext.zeroize(); Ok(serde_json::Value::String(encrypted?)) } /// Decrypt a JSON string from the `data` field back into the original value. pub fn decrypt_json( encrypted_value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ) -> Result { use zeroize::Zeroize; let encoded = encrypted_value .as_str() .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?; let mut plaintext = decrypt_data(encoded, master_key)?; let result = serde_json::from_slice(&plaintext); plaintext.zeroize(); result.map_err(Into::into) } /// Zero out a key on drop using the `zeroize` crate. pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]); impl std::fmt::Debug for ZeroizeOnDrop { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("[REDACTED 32-byte key]") } } impl Drop for ZeroizeOnDrop { fn drop(&mut self) { use zeroize::Zeroize; self.0.zeroize(); } } impl std::ops::Deref for ZeroizeOnDrop { type Target = [u8; KEY_SIZE]; fn deref(&self) -> &Self::Target { &self.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn master_key_generation_is_random() { let k1 = generate_master_key(); let k2 = generate_master_key(); assert_ne!(k1, k2, "Two generated keys must differ"); assert_eq!(k1.len(), 32); } #[test] fn wrapping_key_derivation_is_deterministic() { let salt = [42u8; 32]; let k1 = derive_wrapping_key("password123", &salt).unwrap(); let k2 = derive_wrapping_key("password123", &salt).unwrap(); assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key"); } #[test] fn different_passwords_produce_different_keys() { let salt = [42u8; 32]; let k1 = derive_wrapping_key("password1", &salt).unwrap(); let k2 = derive_wrapping_key("password2", &salt).unwrap(); assert_ne!(*k1, *k2); } #[test] fn different_salts_produce_different_keys() { let salt1 = [1u8; 32]; let salt2 = [2u8; 32]; let k1 = derive_wrapping_key("password", &salt1).unwrap(); let k2 = derive_wrapping_key("password", &salt2).unwrap(); assert_ne!(*k1, *k2); } // ── Password normalization (NFC/NFD) ── #[test] fn nfc_and_nfd_passwords_derive_same_key() { // "e" + combining acute accent (NFD form of e-acute) let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent // Pre-composed e-acute (NFC form) let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent // Verify they are actually different byte sequences assert_ne!( nfd_password.as_bytes(), nfc_password.as_bytes(), "NFD and NFC should have different raw bytes" ); let salt = [99u8; 32]; let k1 = derive_wrapping_key(nfd_password, &salt).unwrap(); let k2 = derive_wrapping_key(nfc_password, &salt).unwrap(); assert_eq!( *k1, *k2, "Same password in NFC and NFD forms must derive the same key" ); } #[test] fn nfc_nfd_wrap_unwrap_roundtrip() { let master_key = generate_master_key(); // Wrap with NFC form let nfc_password = "caf\u{00e9}"; let envelope = wrap_master_key(&master_key, nfc_password).unwrap(); // Unwrap with NFD form let nfd_password = "caf\u{0065}\u{0301}"; let recovered = unwrap_master_key(&envelope, nfd_password).unwrap(); assert_eq!(master_key, recovered); } #[test] fn nfd_wrap_nfc_unwrap_roundtrip() { let master_key = generate_master_key(); // Wrap with NFD form let nfd_password = "caf\u{0065}\u{0301}"; let envelope = wrap_master_key(&master_key, nfd_password).unwrap(); // Unwrap with NFC form let nfc_password = "caf\u{00e9}"; let recovered = unwrap_master_key(&envelope, nfc_password).unwrap(); assert_eq!(master_key, recovered); } #[test] fn normalize_password_converts_to_nfc() { let nfd = "caf\u{0065}\u{0301}"; let nfc = "caf\u{00e9}"; let normalized = normalize_password(nfd).unwrap(); assert_eq!(normalized, nfc); } // ── Empty password rejection ── #[test] fn empty_password_rejected_by_normalize() { let result = normalize_password(""); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(msg.contains("empty"), "Error should mention empty: {msg}"); } #[test] fn empty_password_rejected_by_derive() { let salt = [0u8; 32]; let result = derive_wrapping_key("", &salt); assert!(result.is_err()); } #[test] fn empty_password_rejected_by_wrap() { let master_key = generate_master_key(); let result = wrap_master_key(&master_key, ""); assert!(result.is_err()); } #[test] fn empty_password_rejected_by_unwrap() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "valid").unwrap(); let result = unwrap_master_key(&envelope, ""); assert!(result.is_err()); } // ── Password length limit ── #[test] fn very_long_password_rejected() { let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1); let result = normalize_password(&long_password); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( msg.contains("maximum length"), "Error should mention max length: {msg}" ); } #[test] fn password_at_max_length_accepted() { let max_password = "a".repeat(MAX_PASSWORD_BYTES); let result = normalize_password(&max_password); assert!(result.is_ok()); } #[test] fn password_just_under_max_length_accepted() { let password = "a".repeat(MAX_PASSWORD_BYTES - 1); let result = normalize_password(&password); assert!(result.is_ok()); } // ── Salt reuse detection ── #[test] fn two_wraps_use_different_salts() { let master_key = generate_master_key(); let e1_json = wrap_master_key(&master_key, "pass").unwrap(); let e2_json = wrap_master_key(&master_key, "pass").unwrap(); let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap(); let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap(); assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt"); assert_ne!(e1.nonce, e2.nonce, "Each wrap must use a unique random nonce"); } // ── Key derivation determinism ── #[test] fn key_derivation_deterministic_multiple_calls() { let salt = [77u8; 32]; let password = "deterministic-test-password"; let k1 = derive_wrapping_key(password, &salt).unwrap(); let k2 = derive_wrapping_key(password, &salt).unwrap(); let k3 = derive_wrapping_key(password, &salt).unwrap(); assert_eq!(*k1, *k2); assert_eq!(*k2, *k3); } // ── Key rotation: re-wrap with new password, old data still readable ── #[test] fn key_rotation_preserves_data_access() { let master_key = generate_master_key(); let plaintext = b"encrypted before password change"; // Encrypt data with the master key let encrypted = encrypt_data(plaintext, &master_key).unwrap(); // Wrap master key with old password let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap(); // Simulate password change: unwrap with old, re-wrap with new let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap(); assert_eq!(recovered_key, master_key); let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap(); // Verify: unwrap with new password gives same key let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap(); assert_eq!(key_from_new, master_key); // Verify: old encrypted data can still be decrypted let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap(); assert_eq!(decrypted, plaintext); // Verify: old password no longer works on new envelope let result = unwrap_master_key(&new_envelope, "old-pass"); assert!(result.is_err()); } // ── Encryption roundtrip with various data sizes ── #[test] fn encrypt_decrypt_empty_data() { let master_key = generate_master_key(); let encrypted = encrypt_data(b"", &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert!(decrypted.is_empty()); } #[test] fn encrypt_decrypt_single_byte() { let master_key = generate_master_key(); let encrypted = encrypt_data(&[42], &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, vec![42]); } #[test] fn encrypt_decrypt_large_payload() { let master_key = generate_master_key(); // 1MB of data let plaintext: Vec = (0..1_000_000).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_data(&plaintext, &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } // ── Wrong key gives error, not garbage ── #[test] fn wrong_key_gives_decryption_error_not_garbage() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"this should fail cleanly with wrong key"; let encrypted = encrypt_data(plaintext, &key1).unwrap(); let result = decrypt_data(&encrypted, &key2); // Must be an error, not a successful decryption to garbage assert!(result.is_err()); assert!( matches!(result.unwrap_err(), SyncKitError::DecryptionFailed), "Wrong key must produce DecryptionFailed, not garbage output" ); } #[test] fn wrong_key_bytes_gives_decryption_error_not_garbage() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"binary data check"; let encrypted = encrypt_bytes(plaintext, &key1).unwrap(); let result = decrypt_bytes(&encrypted, &key2); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } // ── JSON encryption edge cases ── #[test] fn json_encrypt_decrypt_null() { let master_key = generate_master_key(); let original = serde_json::Value::Null; let encrypted = encrypt_json(&original, &master_key).unwrap(); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn json_encrypt_decrypt_nested_object() { let master_key = generate_master_key(); let original = serde_json::json!({ "level1": { "level2": { "level3": [1, 2, 3], "flag": true } }, "empty_array": [], "empty_object": {} }); let encrypted = encrypt_json(&original, &master_key).unwrap(); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn json_decrypt_with_wrong_key_fails() { let key1 = generate_master_key(); let key2 = generate_master_key(); let original = serde_json::json!({"secret": "data"}); let encrypted = encrypt_json(&original, &key1).unwrap(); let result = decrypt_json(&encrypted, &key2); assert!(result.is_err()); } #[test] fn json_decrypt_non_string_value_fails() { let master_key = generate_master_key(); let not_a_string = serde_json::json!(42); let result = decrypt_json(¬_a_string, &master_key); assert!(result.is_err()); } // ── Blob (bytes) edge cases ── #[test] fn bytes_zero_byte_blob_roundtrip() { let master_key = generate_master_key(); let empty: &[u8] = &[]; let encrypted = encrypt_bytes(empty, &master_key).unwrap(); assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert!(decrypted.is_empty()); } #[test] fn bytes_boundary_size_blob() { let master_key = generate_master_key(); // Test at exactly the nonce size boundary let data = vec![0xAB; NONCE_SIZE]; let encrypted = encrypt_bytes(&data, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, data); } #[test] fn bytes_1mb_blob_roundtrip() { let master_key = generate_master_key(); let data: Vec = (0..1_048_576).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_bytes(&data, &master_key).unwrap(); assert_eq!(encrypted.len(), data.len() + ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, data); } // ── Tampered ciphertext detection ── #[test] fn tampered_ciphertext_detected() { let master_key = generate_master_key(); let plaintext = b"integrity check"; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let mut blob = B64.decode(&encrypted).unwrap(); // Flip a byte in the ciphertext portion (after the nonce) let idx = NONCE_SIZE + 1; blob[idx] ^= 0xFF; let tampered = B64.encode(&blob); let result = decrypt_data(&tampered, &master_key); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } #[test] fn tampered_nonce_detected() { let master_key = generate_master_key(); let plaintext = b"nonce tamper check"; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let mut blob = B64.decode(&encrypted).unwrap(); // Flip a byte in the nonce blob[0] ^= 0xFF; let tampered = B64.encode(&blob); let result = decrypt_data(&tampered, &master_key); assert!(result.is_err()); } // ── Envelope validation edge cases ── #[test] fn invalid_envelope_json_rejected() { let result = unwrap_master_key("not valid json at all", "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn envelope_with_wrong_salt_length_rejected() { let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode([0u8; 16]), // 16 bytes, should be 32 nonce: B64.encode([0u8; NONCE_SIZE]), ciphertext: B64.encode([0u8; 48]), }; let json = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&json, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn envelope_with_wrong_nonce_length_rejected() { let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode([0u8; 32]), nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24 ciphertext: B64.encode([0u8; 48]), }; let json = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&json, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } // ── verify_password_against_envelope ── #[test] fn verify_password_correct() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = verify_password_against_envelope(&envelope, "correct"); assert!(result.is_ok()); assert_eq!(result.unwrap(), master_key); } #[test] fn verify_password_wrong() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = verify_password_against_envelope(&envelope, "wrong"); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } #[test] fn wrap_unwrap_roundtrip() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "mypassword").unwrap(); let recovered = unwrap_master_key(&envelope, "mypassword").unwrap(); assert_eq!(master_key, recovered); } #[test] fn wrap_uses_random_salt() { let master_key = generate_master_key(); let e1 = wrap_master_key(&master_key, "pass").unwrap(); let e2 = wrap_master_key(&master_key, "pass").unwrap(); // Different envelopes (random salt + random nonce) assert_ne!(e1, e2); // Both decrypt correctly assert_eq!(unwrap_master_key(&e1, "pass").unwrap(), master_key); assert_eq!(unwrap_master_key(&e2, "pass").unwrap(), master_key); } #[test] fn wrong_password_fails_unwrap() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = unwrap_master_key(&envelope, "wrong"); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } #[test] fn data_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let plaintext = b"Hello, world! This is sensitive data."; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn same_plaintext_different_ciphertext() { let master_key = generate_master_key(); let plaintext = b"same data"; let e1 = encrypt_data(plaintext, &master_key).unwrap(); let e2 = encrypt_data(plaintext, &master_key).unwrap(); assert_ne!(e1, e2, "Random nonces must produce different ciphertext"); // But both decrypt to the same plaintext assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext); assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext); } #[test] fn wrong_key_fails_decrypt() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"secret"; let encrypted = encrypt_data(plaintext, &key1).unwrap(); let result = decrypt_data(&encrypted, &key2); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } #[test] fn envelope_version_check() { let master_key = generate_master_key(); let envelope_json = wrap_master_key(&master_key, "pass").unwrap(); // Tamper with version let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap(); envelope.v = 99; let tampered = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&tampered, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn truncated_ciphertext_rejected() { let master_key = generate_master_key(); let encrypted = encrypt_data(b"data", &master_key).unwrap(); // Decode, truncate, re-encode let mut blob = B64.decode(&encrypted).unwrap(); blob.truncate(10); // Way too short let truncated = B64.encode(&blob); let result = decrypt_data(&truncated, &master_key); assert!(result.is_err()); } #[test] fn json_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let original = serde_json::json!({ "title": "Buy milk", "priority": 3, "tags": ["groceries", "urgent"] }); let encrypted = encrypt_json(&original, &master_key).unwrap(); assert!(encrypted.is_string(), "Encrypted JSON should be a string"); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn zeroize_on_drop() { let key = generate_master_key(); let guarded = ZeroizeOnDrop(key); // Verify we can use it assert_eq!(guarded.len(), 32); // Drop happens automatically — we can't easily test memory zeroing // but we verify the API works without panic. drop(guarded); } // ── encrypt_bytes / decrypt_bytes ── #[test] fn bytes_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let plaintext = b"raw binary blob data \x00\x01\x02\xff"; let encrypted = encrypt_bytes(plaintext, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn bytes_encrypt_has_correct_overhead() { let master_key = generate_master_key(); let plaintext = vec![0u8; 1000]; let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap(); assert_eq!(encrypted.len(), plaintext.len() + ENCRYPTION_OVERHEAD); } #[test] fn bytes_same_plaintext_different_ciphertext() { let master_key = generate_master_key(); let plaintext = b"same data"; let e1 = encrypt_bytes(plaintext, &master_key).unwrap(); let e2 = encrypt_bytes(plaintext, &master_key).unwrap(); assert_ne!(e1, e2); assert_eq!(decrypt_bytes(&e1, &master_key).unwrap(), plaintext); assert_eq!(decrypt_bytes(&e2, &master_key).unwrap(), plaintext); } #[test] fn bytes_wrong_key_fails() { let key1 = generate_master_key(); let key2 = generate_master_key(); let encrypted = encrypt_bytes(b"secret", &key1).unwrap(); let result = decrypt_bytes(&encrypted, &key2); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); } #[test] fn bytes_truncated_rejected() { let master_key = generate_master_key(); let encrypted = encrypt_bytes(b"data", &master_key).unwrap(); let result = decrypt_bytes(&encrypted[..10], &master_key); assert!(result.is_err()); } #[test] fn bytes_empty_plaintext_roundtrip() { let master_key = generate_master_key(); let plaintext = b""; let encrypted = encrypt_bytes(plaintext, &master_key).unwrap(); assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn bytes_large_blob_roundtrip() { let master_key = generate_master_key(); let plaintext: Vec = (0..100_000).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } }