//! Tests for [`super`]. use super::*; /// Properties of the sealing layer. /// /// Encryption is a round-trip for every input, not for the handful of /// payload shapes the examples below happen to use. See wiki /// `testing-posture`, Phase 2. mod properties { use super::*; use proptest::prelude::*; proptest! { /// `decrypt(encrypt(m, k), k) == m`, including for the empty /// message and for inputs that straddle the chunking boundary. #[test] fn encryption_round_trips( plaintext in prop::collection::vec(any::(), 0..4096), ) { let key = generate_master_key(); let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt"); let opened = decrypt_bytes(&sealed, &key).expect("decrypt"); prop_assert_eq!(opened, plaintext); } /// A wrong key must be an error rather than garbage plaintext, /// which is what makes the AEAD tag load-bearing instead of /// decorative. #[test] fn decryption_under_the_wrong_key_fails( plaintext in prop::collection::vec(any::(), 0..1024), ) { let key = generate_master_key(); let other = generate_master_key(); prop_assume!(key != other); let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt"); prop_assert!( decrypt_bytes(&sealed, &other).is_err(), "a wrong key produced a result instead of an error" ); } /// Sealing the same bytes twice under one key must not repeat the /// ciphertext. A reused nonce is the classic AEAD break, and /// nothing asserted the nonce actually varies. #[test] fn sealing_twice_does_not_repeat_ciphertext( plaintext in prop::collection::vec(any::(), 1..512), ) { let key = generate_master_key(); let a = encrypt_bytes(&plaintext, &key).expect("encrypt"); let b = encrypt_bytes(&plaintext, &key).expect("encrypt"); prop_assert_ne!(a, b, "the same plaintext sealed to identical bytes twice"); } } } /// Differential relations over the chunked-blob format. /// /// Three implementations describe one layout: `encrypt_blob_chunked` /// produces it, `blob_encrypted_len`/`sealed_chunk_len`/ /// `blob_chunk_count_for` predict it before a byte is sealed, and /// `parse_blob_header` + `decrypt_blob_chunk` read it back one chunk at a /// time. Relating them needs no expected-value table, which is what makes /// these cheap (Chen et al. 1998; McKeeman 1998). /// /// Note on what is NOT asserted: the multipart and one-shot paths do not /// produce identical ciphertext and cannot, because every chunk is sealed /// under a fresh nonce (see `sealing_twice_does_not_repeat_ciphertext` /// above). The relation that holds, and the one the uploader depends on, is /// that the predicted layout equals the produced layout. /// /// See wiki `testing-posture`, Phase 2. mod blob_relations { use super::*; use proptest::prelude::*; /// Lengths that land either side of a chunk boundary, using a small /// stand-in for the 1 MiB production chunk so a case is cheap to run. /// The boundary arithmetic is what these relations are about, and it is /// the same arithmetic at any chunk size. fn plaintext() -> impl Strategy> { prop_oneof![ 1 => Just(Vec::new()), 4 => prop::collection::vec(any::(), 1..4096), ] } proptest! { /// The uploader signs an exact `Content-Length` per part before it /// has sealed anything, so a predicted length that disagrees with /// the produced one is a broken upload rather than a wrong number. #[test] fn the_predicted_length_equals_the_produced_length(plaintext in plaintext()) { let key = generate_master_key(); let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt"); prop_assert_eq!( sealed.len(), blob_encrypted_len(plaintext.len()), "blob_encrypted_len disagrees with encrypt_blob_chunked for {} bytes", plaintext.len() ); } /// The per-chunk lengths must add up the same way, since the /// uploader slices parts by them. Checked against the header the /// encoder actually wrote rather than against the predictor's own /// idea of it. #[test] fn the_predicted_chunk_layout_equals_the_produced_one(plaintext in plaintext()) { let key = generate_master_key(); let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt"); let (header, consumed) = parse_blob_header(&sealed).expect("parse header"); prop_assert_eq!( header.chunk_count, blob_chunk_count_for(plaintext.len()), "header chunk count disagrees with the predictor" ); let summed: usize = (0..header.chunk_count) .map(|i| header.sealed_chunk_len(i)) .sum(); prop_assert_eq!( consumed + summed, sealed.len(), "the per-chunk lengths do not tile the sealed body" ); } /// The two decode paths are two implementations of one format: the /// buffered fallback and the streaming reader the download path /// actually uses. They must agree on every input, or a blob opens /// one way in a test and another way in the app. #[test] fn streaming_and_buffered_decode_agree(plaintext in plaintext()) { let key = generate_master_key(); let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt"); let buffered = decrypt_blob_chunked(&sealed, &key, "h").expect("buffered decrypt"); let (header, consumed) = parse_blob_header(&sealed).expect("parse header"); let mut streamed = Vec::new(); let mut offset = consumed; for i in 0..header.chunk_count { let len = header.sealed_chunk_len(i); let chunk = &sealed[offset..offset + len]; streamed.extend_from_slice( &decrypt_blob_chunk(chunk, &key, "h", i, header.chunk_count) .expect("chunk decrypt"), ); offset += len; } prop_assert_eq!(&buffered, &plaintext, "buffered decode lost the plaintext"); prop_assert_eq!( &streamed, &plaintext, "streaming decode disagreed with the plaintext" ); prop_assert_eq!(offset, sealed.len(), "streaming decode left bytes unread"); } } } #[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]), m: ARGON2_MEM_COST_KB, t: ARGON2_TIME_COST, p: ARGON2_PARALLELISM, }; 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]), m: ARGON2_MEM_COST_KB, t: ARGON2_TIME_COST, p: ARGON2_PARALLELISM, }; 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); } // ── AAD binding + wire version tag (v2) ── #[test] fn aad_for_entry_is_injective_across_boundary() { // ("a","bc") and ("ab","c") must not collide, or a server could swap // table/row_id halves and keep the AAD constant. assert_ne!( aad_for_entry("a", "bc").unwrap(), aad_for_entry("ab", "c").unwrap() ); assert_eq!( aad_for_entry("tasks", "r1").unwrap(), aad_for_entry("tasks", "r1").unwrap() ); } #[test] fn aad_for_entry_rejects_separator_byte() { // A field carrying the 0x1f separator would break injectivity, so it is // rejected rather than silently encoded. assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err()); assert!(aad_for_entry("tasks", "r\u{1f}1").is_err()); assert!(aad_for_entry("tasks", "r1").is_ok()); } #[test] fn argon2_params_out_of_range_are_rejected() { let salt = [7u8; 32]; // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation. assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err()); // 512 MiB is rejected (before any allocation) by the 256 MiB ceiling. assert!(derive_wrapping_key_with_params("pw", &salt, 512 * 1024, 3, 1).is_err()); // Weakened memory (KDF downgrade) rejected. assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err()); // Zero time / parallelism rejected. assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err()); assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err()); // Inflated parallelism rejected at the ceiling (16), not only at zero. assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 17).is_err()); // The pinned production parameters are inside the accepted range. assert!( derive_wrapping_key_with_params( "pw", &salt, ARGON2_MEM_COST_KB, ARGON2_TIME_COST, ARGON2_PARALLELISM ) .is_ok() ); } /// The serde defaults are what a v1 envelope predating the cost fields /// re-derives under, so they are wire format. Pinned against literals: an /// assertion against the constants the functions return would hold however /// the constants moved. #[test] fn argon2_serde_defaults_are_the_pinned_costs() { assert_eq!(default_argon_mem(), 65_536); assert_eq!(default_argon_time(), 3); assert_eq!(default_argon_par(), 1); } #[test] fn blob_total_len_exceeding_input_is_rejected_not_allocated() { let key = generate_master_key(); // A v3 header claiming u64::MAX plaintext with no chunk bytes must be // rejected on the length bound, not attempt an astronomical allocation. let mut buf = Vec::new(); buf.extend_from_slice(WIRE_V3_TAG_BYTES); buf.push(3); buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes()); buf.extend_from_slice(&u64::MAX.to_le_bytes()); assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err()); } /// The header-length guard is an exact bound: one byte short of the header /// must fail, and exactly the header (or more) must parse. #[test] fn v3_header_length_bound_is_exact() { fn body_of(len: usize) -> Vec { let mut buf = Vec::from(WIRE_V3_TAG_BYTES); let mut body = vec![0u8; len]; if !body.is_empty() { body[0] = 3; } // chunk_size must be nonzero for the header to be accepted. if len >= 5 { body[1..5].copy_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes()); } buf.extend_from_slice(&body); buf } let short = body_of(BLOB_V3_HEADER_LEN - 1); assert!( parse_blob_header(&short).is_err(), "a body one byte short of the header must be rejected" ); let exact = body_of(BLOB_V3_HEADER_LEN); let (header, consumed) = parse_blob_header(&exact).expect("exact header parses"); assert_eq!(consumed, WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN); assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE); assert_eq!(header.total_len, 0); let long = body_of(BLOB_V3_HEADER_LEN + 1); let (header, consumed) = parse_blob_header(&long).expect("header plus a stream byte parses"); assert_eq!( consumed, WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN, "the trailing byte belongs to the chunk stream, not the header" ); assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE); } /// Losing the last byte of the final sealed chunk is a clean error, not a /// slice panic. #[test] fn v3_blob_truncated_one_byte_into_final_chunk_errs() { let key = generate_master_key(); let plaintext = vec![7u8; 4096]; let sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap(); let truncated = &sealed[..sealed.len() - 1]; assert!( matches!( decrypt_blob_chunked(truncated, &key, "hash"), Err(SyncKitError::Crypto(_)) ), "a one-byte truncation must report a truncated blob" ); } #[test] fn v2_data_is_tagged_and_roundtrips() { let key = generate_master_key(); let ctx = AeadContext::entry("tasks", "row-1"); let wire = encrypt_data_aad(b"hello", &key, &ctx).unwrap(); assert!( wire.starts_with("sk2:"), "v2 payload must carry the wire tag: {wire}" ); let pt = decrypt_data_aad(&wire, &key, &ctx).unwrap(); assert_eq!(pt, b"hello"); } #[test] fn v2_relocation_to_different_row_fails_closed() { // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1) // must not decrypt when the server presents it under (tasks,row-2). let key = generate_master_key(); let sealed = encrypt_data_aad(b"secret", &key, &AeadContext::entry("tasks", "row-1")).unwrap(); let relocated = decrypt_data_aad(&sealed, &key, &AeadContext::entry("tasks", "row-2")); assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed))); let relocated_table = decrypt_data_aad(&sealed, &key, &AeadContext::entry("notes", "row-1")); assert!(matches!( relocated_table, Err(SyncKitError::DecryptionFailed) )); } /// Known-answer vectors for the XChaCha20-Poly1305 envelope, computed /// outside RustCrypto: HChaCha20 implemented against the test vector in /// draft-irtf-cfrg-xchacha-03 §2.2.1, then the IETF ChaCha20-Poly1305 leg /// run through python `cryptography`. This is the ciphertext already on /// disk and on the wire, so it must decrypt byte-for-byte forever, a /// cipher-crate upgrade that silently changed the envelope would break /// every existing user, and the roundtrip tests above would not notice. /// /// key = 00..1f, nonce = 40..57, plaintext = the string asserted below. #[test] fn envelope_matches_independent_known_answer() { const V2_ENTRY: &str = "sk2:QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuJwl14RY250chudQ2lSbk6V"; const LEGACY: &str = "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuKvdRcL/9EeoK9LSYyzSJY7"; const PLAINTEXT: &[u8] = b"synckit envelope v2 known answer"; let mut key = [0u8; KEY_SIZE]; for (i, b) in key.iter_mut().enumerate() { *b = u8::try_from(i).unwrap(); } let ctx = AeadContext::entry("notes", "row-1"); assert_eq!(decrypt_data_aad(V2_ENTRY, &key, &ctx).unwrap(), PLAINTEXT); assert_eq!(decrypt_data(LEGACY, &key).unwrap(), PLAINTEXT); // The AAD is genuinely bound: the same bytes under a different address // must fail, or the vector above would prove nothing about binding. assert!(matches!( decrypt_data_aad(V2_ENTRY, &key, &AeadContext::entry("notes", "row-2")), Err(SyncKitError::DecryptionFailed) )); } #[test] fn legacy_untagged_still_decrypts_under_aad_reader() { // A v1 (untagged, empty-AAD) payload must keep decrypting through the // tag-aware reader, regardless of the context passed, no flag-day. let key = generate_master_key(); let legacy = encrypt_data(b"old data", &key).unwrap(); assert!(!legacy.starts_with("sk2:")); let pt = decrypt_data_aad(&legacy, &key, &AeadContext::entry("tasks", "row-1")).unwrap(); assert_eq!(pt, b"old data"); } #[test] fn v2_json_roundtrip_and_relocation_fails() { let key = generate_master_key(); let value = serde_json::json!({"title": "Buy milk"}); let ctx = AeadContext::entry("tasks", "row-1"); let wire = encrypt_json_aad(&value, &key, &ctx).unwrap(); assert!(wire.as_str().unwrap().starts_with("sk2:")); assert_eq!(decrypt_json_aad(&wire, &key, &ctx).unwrap(), value); let moved = decrypt_json_aad(&wire, &key, &AeadContext::entry("tasks", "row-2")); assert!(moved.is_err()); } #[test] fn v2_bytes_tagged_roundtrip_and_relocation_fails() { let key = generate_master_key(); let ctx_a = AeadContext::blob("sha256-aaa"); let ctx_b = AeadContext::blob("sha256-bbb"); let blob = encrypt_bytes_aad(b"blob bytes", &key, &ctx_a).unwrap(); assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag"); assert_eq!( decrypt_bytes_aad(&blob, &key, &ctx_a).unwrap(), b"blob bytes" ); // Substituting a different hash's context fails closed. assert!(matches!( decrypt_bytes_aad(&blob, &key, &ctx_b), Err(SyncKitError::DecryptionFailed) )); } #[test] fn legacy_bytes_still_decrypt_under_aad_reader() { let key = generate_master_key(); let legacy = encrypt_bytes(b"old blob", &key).unwrap(); assert!(!legacy.starts_with(b"sk2:")); let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap(); assert_eq!(pt, b"old blob"); } // ── chunked blob format (v3) ── #[test] fn chunked_blob_roundtrips_across_chunk_boundaries() { let key = generate_master_key(); // Spans three chunks (two full + a partial), exercising the boundary math. let plaintext: Vec = (0..(BLOB_CHUNK_SIZE * 2 + 123)).map(|i| i as u8).collect(); let hash = "sha256-abc"; let wire = encrypt_blob_chunked(&plaintext, &key, hash).unwrap(); assert!(is_chunked_blob(&wire), "must carry the sk3: tag"); let (header, _) = parse_blob_header(&wire).unwrap(); assert_eq!(header.total_len, plaintext.len()); assert_eq!(header.chunk_count, 3); assert_eq!(decrypt_blob_chunked(&wire, &key, hash).unwrap(), plaintext); } #[test] fn chunked_blob_handles_empty_and_single_chunk() { let key = generate_master_key(); for pt in [vec![], b"small blob".to_vec()] { let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); assert_eq!(parse_blob_header(&wire).unwrap().0.chunk_count, 1); assert_eq!(decrypt_blob_chunked(&wire, &key, "h").unwrap(), pt); } } #[test] fn streaming_seal_matches_whole_buffer_encrypt() { let key = generate_master_key(); let hash = "sha256-stream"; // Two full chunks plus a partial, and the empty blob, which the // streaming path has to special-case the same way (one empty chunk). for pt in [ vec![], b"one small chunk".to_vec(), (0..(BLOB_CHUNK_SIZE * 2 + 7)).map(|i| i as u8).collect(), ] { let whole = encrypt_blob_chunked(&pt, &key, hash).unwrap(); // What a streaming uploader does: header first, then seal each // chunk as it reads it. Nonces are random, so the bytes differ, // the contract is the LENGTH (which every part boundary is signed // against) and that the result opens to the same plaintext. let chunk_count = blob_chunk_count_for(pt.len()); let mut streamed = blob_header_bytes(pt.len()); if pt.is_empty() { streamed.extend_from_slice(&seal_blob_chunk(&[], &key, hash, 0, 1).unwrap()); } else { for (i, chunk) in pt.chunks(BLOB_CHUNK_SIZE).enumerate() { streamed.extend_from_slice( &seal_blob_chunk(chunk, &key, hash, i as u32, chunk_count).unwrap(), ); } } assert_eq!(streamed.len(), whole.len()); assert_eq!(streamed.len(), blob_encrypted_len(pt.len())); assert_eq!(decrypt_blob_chunked(&streamed, &key, hash).unwrap(), pt); } } #[test] fn blob_encrypted_len_predicts_the_wire_size() { let key = generate_master_key(); // Boundary sizes: the prediction is what a multipart start declares, so // being off by one byte anywhere breaks a signed Content-Length. for len in [ 0, 1, BLOB_CHUNK_SIZE - 1, BLOB_CHUNK_SIZE, BLOB_CHUNK_SIZE + 1, BLOB_CHUNK_SIZE * 3, ] { let pt = vec![7u8; len]; let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); assert_eq!(blob_encrypted_len(len), wire.len(), "len {len}"); } } #[test] fn chunked_blob_wrong_hash_fails_closed() { let key = generate_master_key(); let pt = b"bound to its address".to_vec(); let wire = encrypt_blob_chunked(&pt, &key, "hash-a").unwrap(); // A different content hash changes every chunk's AAD -> open fails. assert!(matches!( decrypt_blob_chunked(&wire, &key, "hash-b"), Err(SyncKitError::DecryptionFailed) )); } #[test] fn chunked_blob_tamper_and_truncation_fail_closed() { let key = generate_master_key(); let pt: Vec = (0..(BLOB_CHUNK_SIZE + 50)).map(|i| i as u8).collect(); let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); // Flip a ciphertext byte -> AEAD tag mismatch. let mut tampered = wire.clone(); let last = tampered.len() - 1; tampered[last] ^= 0x01; assert!(decrypt_blob_chunked(&tampered, &key, "h").is_err()); // Drop the final chunk's bytes -> truncation detected. let header = parse_blob_header(&wire).unwrap().0; let truncated = &wire[..wire.len() - header.sealed_chunk_len(header.chunk_count - 1)]; assert!(decrypt_blob_chunked(truncated, &key, "h").is_err()); } #[test] fn parse_blob_header_rejects_non_v3() { let key = generate_master_key(); let v2 = encrypt_bytes_aad(b"x", &key, &AeadContext::blob("h")).unwrap(); assert!(parse_blob_header(&v2).is_err()); assert!(!is_chunked_blob(&v2)); } // ── Group-entry AAD binding ── #[test] fn group_entry_roundtrips_under_gck() { let gck = generate_master_key(); let ctx = AeadContext::group_entry("grp1", "tasks", "row-9"); let wire = encrypt_data_aad(b"shared task", &gck, &ctx).unwrap(); let out = decrypt_data_aad(&wire, &gck, &ctx).unwrap(); assert_eq!(out, b"shared task"); } #[test] fn group_entry_relocated_to_another_group_fails() { let gck = generate_master_key(); let wire = encrypt_data_aad( b"shared task", &gck, &AeadContext::group_entry("grp1", "tasks", "row-9"), ) .unwrap(); // Same GCK and same (table, row_id), but a different group_id: a server // that filed this ciphertext under another group cannot make it open. let moved = AeadContext::group_entry("grp2", "tasks", "row-9"); assert!(matches!( decrypt_data_aad(&wire, &gck, &moved), Err(SyncKitError::DecryptionFailed) )); } #[test] fn group_entry_and_personal_entry_aad_never_collide() { // A personal Entry and a GroupEntry with the same table/row_id must not // share associated data (the separator-count invariant), so a ciphertext // cannot cross the personal/group boundary. let personal = AeadContext::entry("tasks", "row-9"); let group = AeadContext::group_entry("", "tasks", "row-9"); assert_ne!(personal.aad().unwrap(), group.aad().unwrap()); let gck = generate_master_key(); let wire = encrypt_data_aad(b"x", &gck, &group).unwrap(); assert!(decrypt_data_aad(&wire, &gck, &personal).is_err()); } #[test] fn group_entry_rejects_separator_byte_in_any_field() { let gck = generate_master_key(); for ctx in [ AeadContext::group_entry("g\u{1f}x", "tasks", "r"), AeadContext::group_entry("g", "ta\u{1f}sks", "r"), AeadContext::group_entry("g", "tasks", "r\u{1f}ow"), ] { assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err()); } } /// The `total_len > encrypted.len()` bound is a strict inequality, and the /// equality case has to reach the chunk stream. A header claiming exactly /// the ciphertext length is not an over-claim on the allocation bound (it /// allocates no more than the input already occupies), so it must be /// rejected further down, where the sealed-chunk arithmetic finds the /// stream too short. Relaxing the bound to `>=` would short-circuit here /// and report the wrong reason, which is what this pins. #[test] fn v3_total_len_equal_to_input_is_rejected_by_the_chunk_stream_not_the_bound() { let key = generate_master_key(); let plaintext = vec![9u8; 100]; let mut sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap(); // 100 plaintext + tag(4) + header(13) + nonce/tag(40) = 157. assert_eq!(sealed.len(), 157, "v3 layout for a single 100-byte chunk"); // total_len lives at body[5..13], i.e. absolute [9..17]. let equal = sealed.len() as u64; sealed[9..17].copy_from_slice(&equal.to_le_bytes()); let err = decrypt_blob_chunked(&sealed, &key, "hash") .expect_err("a 157-byte plaintext claim cannot be satisfied by 140 stream bytes"); let SyncKitError::Crypto(msg) = &err else { panic!("expected a crypto error, got {err:?}"); }; assert!( msg.contains("truncated mid-chunk"), "equality must fall through the length bound and fail on the stream: {msg}" ); assert!( !msg.contains("exceeds encrypted input"), "total_len == encrypted.len() is not an over-claim: {msg}" ); // One byte past the bound is the over-claim, and must be caught here. let over = sealed.len() as u64 + 1; sealed[9..17].copy_from_slice(&over.to_le_bytes()); let err = decrypt_blob_chunked(&sealed, &key, "hash") .expect_err("a claim larger than the input is rejected"); let SyncKitError::Crypto(msg) = &err else { panic!("expected a crypto error, got {err:?}"); }; assert!( msg.contains("exceeds encrypted input"), "one byte over the input length is the allocation bound: {msg}" ); } /// v2 wire overhead is a sum of the tag and the AEAD overhead, pinned to /// the number rather than recomputed from the same expression: an assertion /// written as `WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD` would hold /// however that expression was mutated. #[test] fn v2_wire_overhead_is_forty_four_bytes() { assert_eq!(ENCRYPTION_OVERHEAD_V2, 44); // And the constant describes what the encoder actually emits. let key = generate_master_key(); let ctx = AeadContext::blob("abc"); let wire = encrypt_bytes_aad(&[3u8; 70], &key, &ctx).unwrap(); assert_eq!(wire.len(), 70 + 44); } /// The Argon2 memory floor is 8192 KiB exactly. Both sides of the bound are /// asserted: 8191 is a downgrade and must be refused, 8192 must derive. /// The pre-existing test used 8 KiB, which any plausible floor rejects. #[test] fn argon2_memory_floor_admits_8192_kib_and_refuses_8191() { let salt = [7u8; 32]; let err = derive_wrapping_key_with_params("pw", &salt, 8191, 1, 1) .expect_err("one KiB under the floor is a KDF downgrade"); assert!( matches!(err, SyncKitError::InvalidEnvelope(_)), "out-of-range params are an envelope error, got {err:?}" ); assert!( derive_wrapping_key_with_params("pw", &salt, 8192, 1, 1).is_ok(), "the floor itself is inside the accepted range" ); }