Skip to main content

max / synckit

46.4 KB · 1356 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4
5 /// Properties of the sealing layer.
6 ///
7 /// Encryption is a round-trip for every input, not for the handful of
8 /// payload shapes the examples below happen to use. See wiki
9 /// `testing-posture`, Phase 2.
10 mod properties {
11 use super::*;
12 use proptest::prelude::*;
13
14 proptest! {
15 /// `decrypt(encrypt(m, k), k) == m`, including for the empty
16 /// message and for inputs that straddle the chunking boundary.
17 #[test]
18 fn encryption_round_trips(
19 plaintext in prop::collection::vec(any::<u8>(), 0..4096),
20 ) {
21 let key = generate_master_key();
22 let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
23 let opened = decrypt_bytes(&sealed, &key).expect("decrypt");
24 prop_assert_eq!(opened, plaintext);
25 }
26
27 /// A wrong key must be an error rather than garbage plaintext,
28 /// which is what makes the AEAD tag load-bearing instead of
29 /// decorative.
30 #[test]
31 fn decryption_under_the_wrong_key_fails(
32 plaintext in prop::collection::vec(any::<u8>(), 0..1024),
33 ) {
34 let key = generate_master_key();
35 let other = generate_master_key();
36 prop_assume!(key != other);
37 let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
38 prop_assert!(
39 decrypt_bytes(&sealed, &other).is_err(),
40 "a wrong key produced a result instead of an error"
41 );
42 }
43
44 /// Sealing the same bytes twice under one key must not repeat the
45 /// ciphertext. A reused nonce is the classic AEAD break, and
46 /// nothing asserted the nonce actually varies.
47 #[test]
48 fn sealing_twice_does_not_repeat_ciphertext(
49 plaintext in prop::collection::vec(any::<u8>(), 1..512),
50 ) {
51 let key = generate_master_key();
52 let a = encrypt_bytes(&plaintext, &key).expect("encrypt");
53 let b = encrypt_bytes(&plaintext, &key).expect("encrypt");
54 prop_assert_ne!(a, b, "the same plaintext sealed to identical bytes twice");
55 }
56 }
57 }
58
59 /// Differential relations over the chunked-blob format.
60 ///
61 /// Three implementations describe one layout: `encrypt_blob_chunked`
62 /// produces it, `blob_encrypted_len`/`sealed_chunk_len`/
63 /// `blob_chunk_count_for` predict it before a byte is sealed, and
64 /// `parse_blob_header` + `decrypt_blob_chunk` read it back one chunk at a
65 /// time. Relating them needs no expected-value table, which is what makes
66 /// these cheap (Chen et al. 1998; McKeeman 1998).
67 ///
68 /// Note on what is NOT asserted: the multipart and one-shot paths do not
69 /// produce identical ciphertext and cannot, because every chunk is sealed
70 /// under a fresh nonce (see `sealing_twice_does_not_repeat_ciphertext`
71 /// above). The relation that holds, and the one the uploader depends on, is
72 /// that the predicted layout equals the produced layout.
73 ///
74 /// See wiki `testing-posture`, Phase 2.
75 mod blob_relations {
76 use super::*;
77 use proptest::prelude::*;
78
79 /// Lengths that land either side of a chunk boundary, using a small
80 /// stand-in for the 1 MiB production chunk so a case is cheap to run.
81 /// The boundary arithmetic is what these relations are about, and it is
82 /// the same arithmetic at any chunk size.
83 fn plaintext() -> impl Strategy<Value = Vec<u8>> {
84 prop_oneof![
85 1 => Just(Vec::new()),
86 4 => prop::collection::vec(any::<u8>(), 1..4096),
87 ]
88 }
89
90 proptest! {
91 /// The uploader signs an exact `Content-Length` per part before it
92 /// has sealed anything, so a predicted length that disagrees with
93 /// the produced one is a broken upload rather than a wrong number.
94 #[test]
95 fn the_predicted_length_equals_the_produced_length(plaintext in plaintext()) {
96 let key = generate_master_key();
97 let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
98 prop_assert_eq!(
99 sealed.len(),
100 blob_encrypted_len(plaintext.len()),
101 "blob_encrypted_len disagrees with encrypt_blob_chunked for {} bytes",
102 plaintext.len()
103 );
104 }
105
106 /// The per-chunk lengths must add up the same way, since the
107 /// uploader slices parts by them. Checked against the header the
108 /// encoder actually wrote rather than against the predictor's own
109 /// idea of it.
110 #[test]
111 fn the_predicted_chunk_layout_equals_the_produced_one(plaintext in plaintext()) {
112 let key = generate_master_key();
113 let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
114 let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
115
116 prop_assert_eq!(
117 header.chunk_count,
118 blob_chunk_count_for(plaintext.len()),
119 "header chunk count disagrees with the predictor"
120 );
121 let summed: usize = (0..header.chunk_count)
122 .map(|i| header.sealed_chunk_len(i))
123 .sum();
124 prop_assert_eq!(
125 consumed + summed,
126 sealed.len(),
127 "the per-chunk lengths do not tile the sealed body"
128 );
129 }
130
131 /// The two decode paths are two implementations of one format: the
132 /// buffered fallback and the streaming reader the download path
133 /// actually uses. They must agree on every input, or a blob opens
134 /// one way in a test and another way in the app.
135 #[test]
136 fn streaming_and_buffered_decode_agree(plaintext in plaintext()) {
137 let key = generate_master_key();
138 let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
139
140 let buffered = decrypt_blob_chunked(&sealed, &key, "h").expect("buffered decrypt");
141
142 let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
143 let mut streamed = Vec::new();
144 let mut offset = consumed;
145 for i in 0..header.chunk_count {
146 let len = header.sealed_chunk_len(i);
147 let chunk = &sealed[offset..offset + len];
148 streamed.extend_from_slice(
149 &decrypt_blob_chunk(chunk, &key, "h", i, header.chunk_count)
150 .expect("chunk decrypt"),
151 );
152 offset += len;
153 }
154
155 prop_assert_eq!(&buffered, &plaintext, "buffered decode lost the plaintext");
156 prop_assert_eq!(
157 &streamed, &plaintext,
158 "streaming decode disagreed with the plaintext"
159 );
160 prop_assert_eq!(offset, sealed.len(), "streaming decode left bytes unread");
161 }
162 }
163 }
164
165 #[test]
166 fn master_key_generation_is_random() {
167 let k1 = generate_master_key();
168 let k2 = generate_master_key();
169 assert_ne!(k1, k2, "Two generated keys must differ");
170 assert_eq!(k1.len(), 32);
171 }
172
173 #[test]
174 fn wrapping_key_derivation_is_deterministic() {
175 let salt = [42u8; 32];
176 let k1 = derive_wrapping_key("password123", &salt).unwrap();
177 let k2 = derive_wrapping_key("password123", &salt).unwrap();
178 assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
179 }
180
181 #[test]
182 fn different_passwords_produce_different_keys() {
183 let salt = [42u8; 32];
184 let k1 = derive_wrapping_key("password1", &salt).unwrap();
185 let k2 = derive_wrapping_key("password2", &salt).unwrap();
186 assert_ne!(*k1, *k2);
187 }
188
189 #[test]
190 fn different_salts_produce_different_keys() {
191 let salt1 = [1u8; 32];
192 let salt2 = [2u8; 32];
193 let k1 = derive_wrapping_key("password", &salt1).unwrap();
194 let k2 = derive_wrapping_key("password", &salt2).unwrap();
195 assert_ne!(*k1, *k2);
196 }
197
198 // ── Password normalization (NFC/NFD) ──
199
200 #[test]
201 fn nfc_and_nfd_passwords_derive_same_key() {
202 // "e" + combining acute accent (NFD form of e-acute)
203 let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent
204 // Pre-composed e-acute (NFC form)
205 let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent
206
207 // Verify they are actually different byte sequences
208 assert_ne!(
209 nfd_password.as_bytes(),
210 nfc_password.as_bytes(),
211 "NFD and NFC should have different raw bytes"
212 );
213
214 let salt = [99u8; 32];
215 let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
216 let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
217 assert_eq!(
218 *k1, *k2,
219 "Same password in NFC and NFD forms must derive the same key"
220 );
221 }
222
223 #[test]
224 fn nfc_nfd_wrap_unwrap_roundtrip() {
225 let master_key = generate_master_key();
226 // Wrap with NFC form
227 let nfc_password = "caf\u{00e9}";
228 let envelope = wrap_master_key(&master_key, nfc_password).unwrap();
229
230 // Unwrap with NFD form
231 let nfd_password = "caf\u{0065}\u{0301}";
232 let recovered = unwrap_master_key(&envelope, nfd_password).unwrap();
233 assert_eq!(master_key, recovered);
234 }
235
236 #[test]
237 fn nfd_wrap_nfc_unwrap_roundtrip() {
238 let master_key = generate_master_key();
239 // Wrap with NFD form
240 let nfd_password = "caf\u{0065}\u{0301}";
241 let envelope = wrap_master_key(&master_key, nfd_password).unwrap();
242
243 // Unwrap with NFC form
244 let nfc_password = "caf\u{00e9}";
245 let recovered = unwrap_master_key(&envelope, nfc_password).unwrap();
246 assert_eq!(master_key, recovered);
247 }
248
249 #[test]
250 fn normalize_password_converts_to_nfc() {
251 let nfd = "caf\u{0065}\u{0301}";
252 let nfc = "caf\u{00e9}";
253 let normalized = normalize_password(nfd).unwrap();
254 assert_eq!(normalized, nfc);
255 }
256
257 // ── Empty password rejection ──
258
259 #[test]
260 fn empty_password_rejected_by_normalize() {
261 let result = normalize_password("");
262 assert!(result.is_err());
263 let msg = result.unwrap_err().to_string();
264 assert!(msg.contains("empty"), "Error should mention empty: {msg}");
265 }
266
267 #[test]
268 fn empty_password_rejected_by_derive() {
269 let salt = [0u8; 32];
270 let result = derive_wrapping_key("", &salt);
271 assert!(result.is_err());
272 }
273
274 #[test]
275 fn empty_password_rejected_by_wrap() {
276 let master_key = generate_master_key();
277 let result = wrap_master_key(&master_key, "");
278 assert!(result.is_err());
279 }
280
281 #[test]
282 fn empty_password_rejected_by_unwrap() {
283 let master_key = generate_master_key();
284 let envelope = wrap_master_key(&master_key, "valid").unwrap();
285 let result = unwrap_master_key(&envelope, "");
286 assert!(result.is_err());
287 }
288
289 // ── Password length limit ──
290
291 #[test]
292 fn very_long_password_rejected() {
293 let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1);
294 let result = normalize_password(&long_password);
295 assert!(result.is_err());
296 let msg = result.unwrap_err().to_string();
297 assert!(
298 msg.contains("maximum length"),
299 "Error should mention max length: {msg}"
300 );
301 }
302
303 #[test]
304 fn password_at_max_length_accepted() {
305 let max_password = "a".repeat(MAX_PASSWORD_BYTES);
306 let result = normalize_password(&max_password);
307 assert!(result.is_ok());
308 }
309
310 #[test]
311 fn password_just_under_max_length_accepted() {
312 let password = "a".repeat(MAX_PASSWORD_BYTES - 1);
313 let result = normalize_password(&password);
314 assert!(result.is_ok());
315 }
316
317 // ── Salt reuse detection ──
318
319 #[test]
320 fn two_wraps_use_different_salts() {
321 let master_key = generate_master_key();
322 let e1_json = wrap_master_key(&master_key, "pass").unwrap();
323 let e2_json = wrap_master_key(&master_key, "pass").unwrap();
324
325 let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap();
326 let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap();
327
328 assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt");
329 assert_ne!(
330 e1.nonce, e2.nonce,
331 "Each wrap must use a unique random nonce"
332 );
333 }
334
335 // ── Key derivation determinism ──
336
337 #[test]
338 fn key_derivation_deterministic_multiple_calls() {
339 let salt = [77u8; 32];
340 let password = "deterministic-test-password";
341
342 let k1 = derive_wrapping_key(password, &salt).unwrap();
343 let k2 = derive_wrapping_key(password, &salt).unwrap();
344 let k3 = derive_wrapping_key(password, &salt).unwrap();
345
346 assert_eq!(*k1, *k2);
347 assert_eq!(*k2, *k3);
348 }
349
350 // ── Key rotation: re-wrap with new password, old data still readable ──
351
352 #[test]
353 fn key_rotation_preserves_data_access() {
354 let master_key = generate_master_key();
355 let plaintext = b"encrypted before password change";
356
357 // Encrypt data with the master key
358 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
359
360 // Wrap master key with old password
361 let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap();
362
363 // Simulate password change: unwrap with old, re-wrap with new
364 let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap();
365 assert_eq!(recovered_key, master_key);
366
367 let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap();
368
369 // Verify: unwrap with new password gives same key
370 let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap();
371 assert_eq!(key_from_new, master_key);
372
373 // Verify: old encrypted data can still be decrypted
374 let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap();
375 assert_eq!(decrypted, plaintext);
376
377 // Verify: old password no longer works on new envelope
378 let result = unwrap_master_key(&new_envelope, "old-pass");
379 assert!(result.is_err());
380 }
381
382 // ── Encryption roundtrip with various data sizes ──
383
384 #[test]
385 fn encrypt_decrypt_empty_data() {
386 let master_key = generate_master_key();
387 let encrypted = encrypt_data(b"", &master_key).unwrap();
388 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
389 assert!(decrypted.is_empty());
390 }
391
392 #[test]
393 fn encrypt_decrypt_single_byte() {
394 let master_key = generate_master_key();
395 let encrypted = encrypt_data(&[42], &master_key).unwrap();
396 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
397 assert_eq!(decrypted, vec![42]);
398 }
399
400 #[test]
401 fn encrypt_decrypt_large_payload() {
402 let master_key = generate_master_key();
403 // 1MB of data
404 let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
405 let encrypted = encrypt_data(&plaintext, &master_key).unwrap();
406 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
407 assert_eq!(decrypted, plaintext);
408 }
409
410 // ── Wrong key gives error, not garbage ──
411
412 #[test]
413 fn wrong_key_gives_decryption_error_not_garbage() {
414 let key1 = generate_master_key();
415 let key2 = generate_master_key();
416 let plaintext = b"this should fail cleanly with wrong key";
417
418 let encrypted = encrypt_data(plaintext, &key1).unwrap();
419 let result = decrypt_data(&encrypted, &key2);
420
421 // Must be an error, not a successful decryption to garbage
422 assert!(result.is_err());
423 assert!(
424 matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
425 "Wrong key must produce DecryptionFailed, not garbage output"
426 );
427 }
428
429 #[test]
430 fn wrong_key_bytes_gives_decryption_error_not_garbage() {
431 let key1 = generate_master_key();
432 let key2 = generate_master_key();
433 let plaintext = b"binary data check";
434
435 let encrypted = encrypt_bytes(plaintext, &key1).unwrap();
436 let result = decrypt_bytes(&encrypted, &key2);
437
438 assert!(result.is_err());
439 assert!(matches!(
440 result.unwrap_err(),
441 SyncKitError::DecryptionFailed
442 ));
443 }
444
445 // ── JSON encryption edge cases ──
446
447 #[test]
448 fn json_encrypt_decrypt_null() {
449 let master_key = generate_master_key();
450 let original = serde_json::Value::Null;
451 let encrypted = encrypt_json(&original, &master_key).unwrap();
452 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
453 assert_eq!(decrypted, original);
454 }
455
456 #[test]
457 fn json_encrypt_decrypt_nested_object() {
458 let master_key = generate_master_key();
459 let original = serde_json::json!({
460 "level1": {
461 "level2": {
462 "level3": [1, 2, 3],
463 "flag": true
464 }
465 },
466 "empty_array": [],
467 "empty_object": {}
468 });
469
470 let encrypted = encrypt_json(&original, &master_key).unwrap();
471 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
472 assert_eq!(decrypted, original);
473 }
474
475 #[test]
476 fn json_decrypt_with_wrong_key_fails() {
477 let key1 = generate_master_key();
478 let key2 = generate_master_key();
479 let original = serde_json::json!({"secret": "data"});
480
481 let encrypted = encrypt_json(&original, &key1).unwrap();
482 let result = decrypt_json(&encrypted, &key2);
483 assert!(result.is_err());
484 }
485
486 #[test]
487 fn json_decrypt_non_string_value_fails() {
488 let master_key = generate_master_key();
489 let not_a_string = serde_json::json!(42);
490 let result = decrypt_json(&not_a_string, &master_key);
491 assert!(result.is_err());
492 }
493
494 // ── Blob (bytes) edge cases ──
495
496 #[test]
497 fn bytes_zero_byte_blob_roundtrip() {
498 let master_key = generate_master_key();
499 let empty: &[u8] = &[];
500 let encrypted = encrypt_bytes(empty, &master_key).unwrap();
501 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
502 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
503 assert!(decrypted.is_empty());
504 }
505
506 #[test]
507 fn bytes_boundary_size_blob() {
508 let master_key = generate_master_key();
509 // Test at exactly the nonce size boundary
510 let data = vec![0xAB; NONCE_SIZE];
511 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
512 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
513 assert_eq!(decrypted, data);
514 }
515
516 #[test]
517 fn bytes_1mb_blob_roundtrip() {
518 let master_key = generate_master_key();
519 let data: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
520 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
521 assert_eq!(encrypted.len(), data.len() + ENCRYPTION_OVERHEAD);
522 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
523 assert_eq!(decrypted, data);
524 }
525
526 // ── Tampered ciphertext detection ──
527
528 #[test]
529 fn tampered_ciphertext_detected() {
530 let master_key = generate_master_key();
531 let plaintext = b"integrity check";
532 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
533
534 let mut blob = B64.decode(&encrypted).unwrap();
535 // Flip a byte in the ciphertext portion (after the nonce)
536 let idx = NONCE_SIZE + 1;
537 blob[idx] ^= 0xFF;
538 let tampered = B64.encode(&blob);
539
540 let result = decrypt_data(&tampered, &master_key);
541 assert!(result.is_err());
542 assert!(matches!(
543 result.unwrap_err(),
544 SyncKitError::DecryptionFailed
545 ));
546 }
547
548 #[test]
549 fn tampered_nonce_detected() {
550 let master_key = generate_master_key();
551 let plaintext = b"nonce tamper check";
552 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
553
554 let mut blob = B64.decode(&encrypted).unwrap();
555 // Flip a byte in the nonce
556 blob[0] ^= 0xFF;
557 let tampered = B64.encode(&blob);
558
559 let result = decrypt_data(&tampered, &master_key);
560 assert!(result.is_err());
561 }
562
563 // ── Envelope validation edge cases ──
564
565 #[test]
566 fn invalid_envelope_json_rejected() {
567 let result = unwrap_master_key("not valid json at all", "pass");
568 assert!(result.is_err());
569 assert!(matches!(
570 result.unwrap_err(),
571 SyncKitError::InvalidEnvelope(_)
572 ));
573 }
574
575 #[test]
576 fn envelope_with_wrong_salt_length_rejected() {
577 let envelope = KeyEnvelope {
578 v: ENVELOPE_VERSION,
579 salt: B64.encode([0u8; 16]), // 16 bytes, should be 32
580 nonce: B64.encode([0u8; NONCE_SIZE]),
581 ciphertext: B64.encode([0u8; 48]),
582 m: ARGON2_MEM_COST_KB,
583 t: ARGON2_TIME_COST,
584 p: ARGON2_PARALLELISM,
585 };
586 let json = serde_json::to_string(&envelope).unwrap();
587
588 let result = unwrap_master_key(&json, "pass");
589 assert!(result.is_err());
590 assert!(matches!(
591 result.unwrap_err(),
592 SyncKitError::InvalidEnvelope(_)
593 ));
594 }
595
596 #[test]
597 fn envelope_with_wrong_nonce_length_rejected() {
598 let envelope = KeyEnvelope {
599 v: ENVELOPE_VERSION,
600 salt: B64.encode([0u8; 32]),
601 nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24
602 ciphertext: B64.encode([0u8; 48]),
603 m: ARGON2_MEM_COST_KB,
604 t: ARGON2_TIME_COST,
605 p: ARGON2_PARALLELISM,
606 };
607 let json = serde_json::to_string(&envelope).unwrap();
608
609 let result = unwrap_master_key(&json, "pass");
610 assert!(result.is_err());
611 assert!(matches!(
612 result.unwrap_err(),
613 SyncKitError::InvalidEnvelope(_)
614 ));
615 }
616
617 // ── verify_password_against_envelope ──
618
619 #[test]
620 fn verify_password_correct() {
621 let master_key = generate_master_key();
622 let envelope = wrap_master_key(&master_key, "correct").unwrap();
623 let result = verify_password_against_envelope(&envelope, "correct");
624 assert!(result.is_ok());
625 assert_eq!(result.unwrap(), master_key);
626 }
627
628 #[test]
629 fn verify_password_wrong() {
630 let master_key = generate_master_key();
631 let envelope = wrap_master_key(&master_key, "correct").unwrap();
632 let result = verify_password_against_envelope(&envelope, "wrong");
633 assert!(result.is_err());
634 assert!(matches!(
635 result.unwrap_err(),
636 SyncKitError::DecryptionFailed
637 ));
638 }
639
640 #[test]
641 fn wrap_unwrap_roundtrip() {
642 let master_key = generate_master_key();
643
644 let envelope = wrap_master_key(&master_key, "mypassword").unwrap();
645 let recovered = unwrap_master_key(&envelope, "mypassword").unwrap();
646
647 assert_eq!(master_key, recovered);
648 }
649
650 #[test]
651 fn wrap_uses_random_salt() {
652 let master_key = generate_master_key();
653 let e1 = wrap_master_key(&master_key, "pass").unwrap();
654 let e2 = wrap_master_key(&master_key, "pass").unwrap();
655
656 // Different envelopes (random salt + random nonce)
657 assert_ne!(e1, e2);
658
659 // Both decrypt correctly
660 assert_eq!(unwrap_master_key(&e1, "pass").unwrap(), master_key);
661 assert_eq!(unwrap_master_key(&e2, "pass").unwrap(), master_key);
662 }
663
664 #[test]
665 fn wrong_password_fails_unwrap() {
666 let master_key = generate_master_key();
667
668 let envelope = wrap_master_key(&master_key, "correct").unwrap();
669 let result = unwrap_master_key(&envelope, "wrong");
670
671 assert!(result.is_err());
672 assert!(matches!(
673 result.unwrap_err(),
674 SyncKitError::DecryptionFailed
675 ));
676 }
677
678 #[test]
679 fn data_encrypt_decrypt_roundtrip() {
680 let master_key = generate_master_key();
681 let plaintext = b"Hello, world! This is sensitive data.";
682
683 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
684 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
685
686 assert_eq!(decrypted, plaintext);
687 }
688
689 #[test]
690 fn same_plaintext_different_ciphertext() {
691 let master_key = generate_master_key();
692 let plaintext = b"same data";
693
694 let e1 = encrypt_data(plaintext, &master_key).unwrap();
695 let e2 = encrypt_data(plaintext, &master_key).unwrap();
696
697 assert_ne!(e1, e2, "Random nonces must produce different ciphertext");
698
699 // But both decrypt to the same plaintext
700 assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext);
701 assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext);
702 }
703
704 #[test]
705 fn wrong_key_fails_decrypt() {
706 let key1 = generate_master_key();
707 let key2 = generate_master_key();
708 let plaintext = b"secret";
709
710 let encrypted = encrypt_data(plaintext, &key1).unwrap();
711 let result = decrypt_data(&encrypted, &key2);
712
713 assert!(result.is_err());
714 assert!(matches!(
715 result.unwrap_err(),
716 SyncKitError::DecryptionFailed
717 ));
718 }
719
720 #[test]
721 fn envelope_version_check() {
722 let master_key = generate_master_key();
723
724 let envelope_json = wrap_master_key(&master_key, "pass").unwrap();
725
726 // Tamper with version
727 let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap();
728 envelope.v = 99;
729 let tampered = serde_json::to_string(&envelope).unwrap();
730
731 let result = unwrap_master_key(&tampered, "pass");
732 assert!(result.is_err());
733 assert!(matches!(
734 result.unwrap_err(),
735 SyncKitError::InvalidEnvelope(_)
736 ));
737 }
738
739 #[test]
740 fn truncated_ciphertext_rejected() {
741 let master_key = generate_master_key();
742 let encrypted = encrypt_data(b"data", &master_key).unwrap();
743
744 // Decode, truncate, re-encode
745 let mut blob = B64.decode(&encrypted).unwrap();
746 blob.truncate(10); // Way too short
747 let truncated = B64.encode(&blob);
748
749 let result = decrypt_data(&truncated, &master_key);
750 assert!(result.is_err());
751 }
752
753 #[test]
754 fn json_encrypt_decrypt_roundtrip() {
755 let master_key = generate_master_key();
756 let original = serde_json::json!({
757 "title": "Buy milk",
758 "priority": 3,
759 "tags": ["groceries", "urgent"]
760 });
761
762 let encrypted = encrypt_json(&original, &master_key).unwrap();
763 assert!(encrypted.is_string(), "Encrypted JSON should be a string");
764
765 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
766 assert_eq!(decrypted, original);
767 }
768
769 #[test]
770 fn zeroize_on_drop() {
771 let key = generate_master_key();
772 let guarded = ZeroizeOnDrop(key);
773 // Verify we can use it
774 assert_eq!(guarded.len(), 32);
775 // Drop happens automatically, we can't easily test memory zeroing
776 // but we verify the API works without panic.
777 drop(guarded);
778 }
779
780 // ── encrypt_bytes / decrypt_bytes ──
781
782 #[test]
783 fn bytes_encrypt_decrypt_roundtrip() {
784 let master_key = generate_master_key();
785 let plaintext = b"raw binary blob data \x00\x01\x02\xff";
786
787 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
788 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
789
790 assert_eq!(decrypted, plaintext);
791 }
792
793 #[test]
794 fn bytes_encrypt_has_correct_overhead() {
795 let master_key = generate_master_key();
796 let plaintext = vec![0u8; 1000];
797
798 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
799 assert_eq!(encrypted.len(), plaintext.len() + ENCRYPTION_OVERHEAD);
800 }
801
802 #[test]
803 fn bytes_same_plaintext_different_ciphertext() {
804 let master_key = generate_master_key();
805 let plaintext = b"same data";
806
807 let e1 = encrypt_bytes(plaintext, &master_key).unwrap();
808 let e2 = encrypt_bytes(plaintext, &master_key).unwrap();
809
810 assert_ne!(e1, e2);
811 assert_eq!(decrypt_bytes(&e1, &master_key).unwrap(), plaintext);
812 assert_eq!(decrypt_bytes(&e2, &master_key).unwrap(), plaintext);
813 }
814
815 #[test]
816 fn bytes_wrong_key_fails() {
817 let key1 = generate_master_key();
818 let key2 = generate_master_key();
819
820 let encrypted = encrypt_bytes(b"secret", &key1).unwrap();
821 let result = decrypt_bytes(&encrypted, &key2);
822
823 assert!(result.is_err());
824 assert!(matches!(
825 result.unwrap_err(),
826 SyncKitError::DecryptionFailed
827 ));
828 }
829
830 #[test]
831 fn bytes_truncated_rejected() {
832 let master_key = generate_master_key();
833 let encrypted = encrypt_bytes(b"data", &master_key).unwrap();
834
835 let result = decrypt_bytes(&encrypted[..10], &master_key);
836 assert!(result.is_err());
837 }
838
839 #[test]
840 fn bytes_empty_plaintext_roundtrip() {
841 let master_key = generate_master_key();
842 let plaintext = b"";
843
844 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
845 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
846
847 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
848 assert_eq!(decrypted, plaintext);
849 }
850
851 #[test]
852 fn bytes_large_blob_roundtrip() {
853 let master_key = generate_master_key();
854 let plaintext: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect();
855
856 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
857 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
858
859 assert_eq!(decrypted, plaintext);
860 }
861
862 // ── AAD binding + wire version tag (v2) ──
863
864 #[test]
865 fn aad_for_entry_is_injective_across_boundary() {
866 // ("a","bc") and ("ab","c") must not collide, or a server could swap
867 // table/row_id halves and keep the AAD constant.
868 assert_ne!(
869 aad_for_entry("a", "bc").unwrap(),
870 aad_for_entry("ab", "c").unwrap()
871 );
872 assert_eq!(
873 aad_for_entry("tasks", "r1").unwrap(),
874 aad_for_entry("tasks", "r1").unwrap()
875 );
876 }
877
878 #[test]
879 fn aad_for_entry_rejects_separator_byte() {
880 // A field carrying the 0x1f separator would break injectivity, so it is
881 // rejected rather than silently encoded.
882 assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err());
883 assert!(aad_for_entry("tasks", "r\u{1f}1").is_err());
884 assert!(aad_for_entry("tasks", "r1").is_ok());
885 }
886
887 #[test]
888 fn argon2_params_out_of_range_are_rejected() {
889 let salt = [7u8; 32];
890 // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation.
891 assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err());
892 // 512 MiB is rejected (before any allocation) by the 256 MiB ceiling.
893 assert!(derive_wrapping_key_with_params("pw", &salt, 512 * 1024, 3, 1).is_err());
894 // Weakened memory (KDF downgrade) rejected.
895 assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err());
896 // Zero time / parallelism rejected.
897 assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err());
898 assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err());
899 // Inflated parallelism rejected at the ceiling (16), not only at zero.
900 assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 17).is_err());
901 // The pinned production parameters are inside the accepted range.
902 assert!(
903 derive_wrapping_key_with_params(
904 "pw",
905 &salt,
906 ARGON2_MEM_COST_KB,
907 ARGON2_TIME_COST,
908 ARGON2_PARALLELISM
909 )
910 .is_ok()
911 );
912 }
913
914 /// The serde defaults are what a v1 envelope predating the cost fields
915 /// re-derives under, so they are wire format. Pinned against literals: an
916 /// assertion against the constants the functions return would hold however
917 /// the constants moved.
918 #[test]
919 fn argon2_serde_defaults_are_the_pinned_costs() {
920 assert_eq!(default_argon_mem(), 65_536);
921 assert_eq!(default_argon_time(), 3);
922 assert_eq!(default_argon_par(), 1);
923 }
924
925 #[test]
926 fn blob_total_len_exceeding_input_is_rejected_not_allocated() {
927 let key = generate_master_key();
928 // A v3 header claiming u64::MAX plaintext with no chunk bytes must be
929 // rejected on the length bound, not attempt an astronomical allocation.
930 let mut buf = Vec::new();
931 buf.extend_from_slice(WIRE_V3_TAG_BYTES);
932 buf.push(3);
933 buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
934 buf.extend_from_slice(&u64::MAX.to_le_bytes());
935 assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err());
936 }
937
938 /// The header-length guard is an exact bound: one byte short of the header
939 /// must fail, and exactly the header (or more) must parse.
940 #[test]
941 fn v3_header_length_bound_is_exact() {
942 fn body_of(len: usize) -> Vec<u8> {
943 let mut buf = Vec::from(WIRE_V3_TAG_BYTES);
944 let mut body = vec![0u8; len];
945 if !body.is_empty() {
946 body[0] = 3;
947 }
948 // chunk_size must be nonzero for the header to be accepted.
949 if len >= 5 {
950 body[1..5].copy_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
951 }
952 buf.extend_from_slice(&body);
953 buf
954 }
955
956 let short = body_of(BLOB_V3_HEADER_LEN - 1);
957 assert!(
958 parse_blob_header(&short).is_err(),
959 "a body one byte short of the header must be rejected"
960 );
961
962 let exact = body_of(BLOB_V3_HEADER_LEN);
963 let (header, consumed) = parse_blob_header(&exact).expect("exact header parses");
964 assert_eq!(consumed, WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN);
965 assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE);
966 assert_eq!(header.total_len, 0);
967
968 let long = body_of(BLOB_V3_HEADER_LEN + 1);
969 let (header, consumed) = parse_blob_header(&long).expect("header plus a stream byte parses");
970 assert_eq!(
971 consumed,
972 WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN,
973 "the trailing byte belongs to the chunk stream, not the header"
974 );
975 assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE);
976 }
977
978 /// Losing the last byte of the final sealed chunk is a clean error, not a
979 /// slice panic.
980 #[test]
981 fn v3_blob_truncated_one_byte_into_final_chunk_errs() {
982 let key = generate_master_key();
983 let plaintext = vec![7u8; 4096];
984 let sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap();
985 let truncated = &sealed[..sealed.len() - 1];
986 assert!(
987 matches!(
988 decrypt_blob_chunked(truncated, &key, "hash"),
989 Err(SyncKitError::Crypto(_))
990 ),
991 "a one-byte truncation must report a truncated blob"
992 );
993 }
994
995 #[test]
996 fn v2_data_is_tagged_and_roundtrips() {
997 let key = generate_master_key();
998 let ctx = AeadContext::entry("tasks", "row-1");
999 let wire = encrypt_data_aad(b"hello", &key, &ctx).unwrap();
1000 assert!(
1001 wire.starts_with("sk2:"),
1002 "v2 payload must carry the wire tag: {wire}"
1003 );
1004 let pt = decrypt_data_aad(&wire, &key, &ctx).unwrap();
1005 assert_eq!(pt, b"hello");
1006 }
1007
1008 #[test]
1009 fn v2_relocation_to_different_row_fails_closed() {
1010 // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1)
1011 // must not decrypt when the server presents it under (tasks,row-2).
1012 let key = generate_master_key();
1013 let sealed = encrypt_data_aad(b"secret", &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1014 let relocated = decrypt_data_aad(&sealed, &key, &AeadContext::entry("tasks", "row-2"));
1015 assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed)));
1016 let relocated_table = decrypt_data_aad(&sealed, &key, &AeadContext::entry("notes", "row-1"));
1017 assert!(matches!(
1018 relocated_table,
1019 Err(SyncKitError::DecryptionFailed)
1020 ));
1021 }
1022
1023 /// Known-answer vectors for the XChaCha20-Poly1305 envelope, computed
1024 /// outside RustCrypto: HChaCha20 implemented against the test vector in
1025 /// draft-irtf-cfrg-xchacha-03 §2.2.1, then the IETF ChaCha20-Poly1305 leg
1026 /// run through python `cryptography`. This is the ciphertext already on
1027 /// disk and on the wire, so it must decrypt byte-for-byte forever, a
1028 /// cipher-crate upgrade that silently changed the envelope would break
1029 /// every existing user, and the roundtrip tests above would not notice.
1030 ///
1031 /// key = 00..1f, nonce = 40..57, plaintext = the string asserted below.
1032 #[test]
1033 fn envelope_matches_independent_known_answer() {
1034 const V2_ENTRY: &str = "sk2:QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuJwl14RY250chudQ2lSbk6V";
1035 const LEGACY: &str = "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuKvdRcL/9EeoK9LSYyzSJY7";
1036 const PLAINTEXT: &[u8] = b"synckit envelope v2 known answer";
1037
1038 let mut key = [0u8; KEY_SIZE];
1039 for (i, b) in key.iter_mut().enumerate() {
1040 *b = u8::try_from(i).unwrap();
1041 }
1042
1043 let ctx = AeadContext::entry("notes", "row-1");
1044 assert_eq!(decrypt_data_aad(V2_ENTRY, &key, &ctx).unwrap(), PLAINTEXT);
1045 assert_eq!(decrypt_data(LEGACY, &key).unwrap(), PLAINTEXT);
1046
1047 // The AAD is genuinely bound: the same bytes under a different address
1048 // must fail, or the vector above would prove nothing about binding.
1049 assert!(matches!(
1050 decrypt_data_aad(V2_ENTRY, &key, &AeadContext::entry("notes", "row-2")),
1051 Err(SyncKitError::DecryptionFailed)
1052 ));
1053 }
1054
1055 #[test]
1056 fn legacy_untagged_still_decrypts_under_aad_reader() {
1057 // A v1 (untagged, empty-AAD) payload must keep decrypting through the
1058 // tag-aware reader, regardless of the context passed, no flag-day.
1059 let key = generate_master_key();
1060 let legacy = encrypt_data(b"old data", &key).unwrap();
1061 assert!(!legacy.starts_with("sk2:"));
1062 let pt = decrypt_data_aad(&legacy, &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1063 assert_eq!(pt, b"old data");
1064 }
1065
1066 #[test]
1067 fn v2_json_roundtrip_and_relocation_fails() {
1068 let key = generate_master_key();
1069 let value = serde_json::json!({"title": "Buy milk"});
1070 let ctx = AeadContext::entry("tasks", "row-1");
1071 let wire = encrypt_json_aad(&value, &key, &ctx).unwrap();
1072 assert!(wire.as_str().unwrap().starts_with("sk2:"));
1073 assert_eq!(decrypt_json_aad(&wire, &key, &ctx).unwrap(), value);
1074 let moved = decrypt_json_aad(&wire, &key, &AeadContext::entry("tasks", "row-2"));
1075 assert!(moved.is_err());
1076 }
1077
1078 #[test]
1079 fn v2_bytes_tagged_roundtrip_and_relocation_fails() {
1080 let key = generate_master_key();
1081 let ctx_a = AeadContext::blob("sha256-aaa");
1082 let ctx_b = AeadContext::blob("sha256-bbb");
1083 let blob = encrypt_bytes_aad(b"blob bytes", &key, &ctx_a).unwrap();
1084 assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag");
1085 assert_eq!(
1086 decrypt_bytes_aad(&blob, &key, &ctx_a).unwrap(),
1087 b"blob bytes"
1088 );
1089 // Substituting a different hash's context fails closed.
1090 assert!(matches!(
1091 decrypt_bytes_aad(&blob, &key, &ctx_b),
1092 Err(SyncKitError::DecryptionFailed)
1093 ));
1094 }
1095
1096 #[test]
1097 fn legacy_bytes_still_decrypt_under_aad_reader() {
1098 let key = generate_master_key();
1099 let legacy = encrypt_bytes(b"old blob", &key).unwrap();
1100 assert!(!legacy.starts_with(b"sk2:"));
1101 let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap();
1102 assert_eq!(pt, b"old blob");
1103 }
1104
1105 // ── chunked blob format (v3) ──
1106
1107 #[test]
1108 fn chunked_blob_roundtrips_across_chunk_boundaries() {
1109 let key = generate_master_key();
1110 // Spans three chunks (two full + a partial), exercising the boundary math.
1111 let plaintext: Vec<u8> = (0..(BLOB_CHUNK_SIZE * 2 + 123)).map(|i| i as u8).collect();
1112 let hash = "sha256-abc";
1113 let wire = encrypt_blob_chunked(&plaintext, &key, hash).unwrap();
1114 assert!(is_chunked_blob(&wire), "must carry the sk3: tag");
1115 let (header, _) = parse_blob_header(&wire).unwrap();
1116 assert_eq!(header.total_len, plaintext.len());
1117 assert_eq!(header.chunk_count, 3);
1118 assert_eq!(decrypt_blob_chunked(&wire, &key, hash).unwrap(), plaintext);
1119 }
1120
1121 #[test]
1122 fn chunked_blob_handles_empty_and_single_chunk() {
1123 let key = generate_master_key();
1124 for pt in [vec![], b"small blob".to_vec()] {
1125 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1126 assert_eq!(parse_blob_header(&wire).unwrap().0.chunk_count, 1);
1127 assert_eq!(decrypt_blob_chunked(&wire, &key, "h").unwrap(), pt);
1128 }
1129 }
1130
1131 #[test]
1132 fn streaming_seal_matches_whole_buffer_encrypt() {
1133 let key = generate_master_key();
1134 let hash = "sha256-stream";
1135 // Two full chunks plus a partial, and the empty blob, which the
1136 // streaming path has to special-case the same way (one empty chunk).
1137 for pt in [
1138 vec![],
1139 b"one small chunk".to_vec(),
1140 (0..(BLOB_CHUNK_SIZE * 2 + 7)).map(|i| i as u8).collect(),
1141 ] {
1142 let whole = encrypt_blob_chunked(&pt, &key, hash).unwrap();
1143
1144 // What a streaming uploader does: header first, then seal each
1145 // chunk as it reads it. Nonces are random, so the bytes differ,
1146 // the contract is the LENGTH (which every part boundary is signed
1147 // against) and that the result opens to the same plaintext.
1148 let chunk_count = blob_chunk_count_for(pt.len());
1149 let mut streamed = blob_header_bytes(pt.len());
1150 if pt.is_empty() {
1151 streamed.extend_from_slice(&seal_blob_chunk(&[], &key, hash, 0, 1).unwrap());
1152 } else {
1153 for (i, chunk) in pt.chunks(BLOB_CHUNK_SIZE).enumerate() {
1154 streamed.extend_from_slice(
1155 &seal_blob_chunk(chunk, &key, hash, i as u32, chunk_count).unwrap(),
1156 );
1157 }
1158 }
1159
1160 assert_eq!(streamed.len(), whole.len());
1161 assert_eq!(streamed.len(), blob_encrypted_len(pt.len()));
1162 assert_eq!(decrypt_blob_chunked(&streamed, &key, hash).unwrap(), pt);
1163 }
1164 }
1165
1166 #[test]
1167 fn blob_encrypted_len_predicts_the_wire_size() {
1168 let key = generate_master_key();
1169 // Boundary sizes: the prediction is what a multipart start declares, so
1170 // being off by one byte anywhere breaks a signed Content-Length.
1171 for len in [
1172 0,
1173 1,
1174 BLOB_CHUNK_SIZE - 1,
1175 BLOB_CHUNK_SIZE,
1176 BLOB_CHUNK_SIZE + 1,
1177 BLOB_CHUNK_SIZE * 3,
1178 ] {
1179 let pt = vec![7u8; len];
1180 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1181 assert_eq!(blob_encrypted_len(len), wire.len(), "len {len}");
1182 }
1183 }
1184
1185 #[test]
1186 fn chunked_blob_wrong_hash_fails_closed() {
1187 let key = generate_master_key();
1188 let pt = b"bound to its address".to_vec();
1189 let wire = encrypt_blob_chunked(&pt, &key, "hash-a").unwrap();
1190 // A different content hash changes every chunk's AAD -> open fails.
1191 assert!(matches!(
1192 decrypt_blob_chunked(&wire, &key, "hash-b"),
1193 Err(SyncKitError::DecryptionFailed)
1194 ));
1195 }
1196
1197 #[test]
1198 fn chunked_blob_tamper_and_truncation_fail_closed() {
1199 let key = generate_master_key();
1200 let pt: Vec<u8> = (0..(BLOB_CHUNK_SIZE + 50)).map(|i| i as u8).collect();
1201 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1202
1203 // Flip a ciphertext byte -> AEAD tag mismatch.
1204 let mut tampered = wire.clone();
1205 let last = tampered.len() - 1;
1206 tampered[last] ^= 0x01;
1207 assert!(decrypt_blob_chunked(&tampered, &key, "h").is_err());
1208
1209 // Drop the final chunk's bytes -> truncation detected.
1210 let header = parse_blob_header(&wire).unwrap().0;
1211 let truncated = &wire[..wire.len() - header.sealed_chunk_len(header.chunk_count - 1)];
1212 assert!(decrypt_blob_chunked(truncated, &key, "h").is_err());
1213 }
1214
1215 #[test]
1216 fn parse_blob_header_rejects_non_v3() {
1217 let key = generate_master_key();
1218 let v2 = encrypt_bytes_aad(b"x", &key, &AeadContext::blob("h")).unwrap();
1219 assert!(parse_blob_header(&v2).is_err());
1220 assert!(!is_chunked_blob(&v2));
1221 }
1222
1223 // ── Group-entry AAD binding ──
1224
1225 #[test]
1226 fn group_entry_roundtrips_under_gck() {
1227 let gck = generate_master_key();
1228 let ctx = AeadContext::group_entry("grp1", "tasks", "row-9");
1229 let wire = encrypt_data_aad(b"shared task", &gck, &ctx).unwrap();
1230 let out = decrypt_data_aad(&wire, &gck, &ctx).unwrap();
1231 assert_eq!(out, b"shared task");
1232 }
1233
1234 #[test]
1235 fn group_entry_relocated_to_another_group_fails() {
1236 let gck = generate_master_key();
1237 let wire = encrypt_data_aad(
1238 b"shared task",
1239 &gck,
1240 &AeadContext::group_entry("grp1", "tasks", "row-9"),
1241 )
1242 .unwrap();
1243 // Same GCK and same (table, row_id), but a different group_id: a server
1244 // that filed this ciphertext under another group cannot make it open.
1245 let moved = AeadContext::group_entry("grp2", "tasks", "row-9");
1246 assert!(matches!(
1247 decrypt_data_aad(&wire, &gck, &moved),
1248 Err(SyncKitError::DecryptionFailed)
1249 ));
1250 }
1251
1252 #[test]
1253 fn group_entry_and_personal_entry_aad_never_collide() {
1254 // A personal Entry and a GroupEntry with the same table/row_id must not
1255 // share associated data (the separator-count invariant), so a ciphertext
1256 // cannot cross the personal/group boundary.
1257 let personal = AeadContext::entry("tasks", "row-9");
1258 let group = AeadContext::group_entry("", "tasks", "row-9");
1259 assert_ne!(personal.aad().unwrap(), group.aad().unwrap());
1260
1261 let gck = generate_master_key();
1262 let wire = encrypt_data_aad(b"x", &gck, &group).unwrap();
1263 assert!(decrypt_data_aad(&wire, &gck, &personal).is_err());
1264 }
1265
1266 #[test]
1267 fn group_entry_rejects_separator_byte_in_any_field() {
1268 let gck = generate_master_key();
1269 for ctx in [
1270 AeadContext::group_entry("g\u{1f}x", "tasks", "r"),
1271 AeadContext::group_entry("g", "ta\u{1f}sks", "r"),
1272 AeadContext::group_entry("g", "tasks", "r\u{1f}ow"),
1273 ] {
1274 assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err());
1275 }
1276 }
1277
1278 /// The `total_len > encrypted.len()` bound is a strict inequality, and the
1279 /// equality case has to reach the chunk stream. A header claiming exactly
1280 /// the ciphertext length is not an over-claim on the allocation bound (it
1281 /// allocates no more than the input already occupies), so it must be
1282 /// rejected further down, where the sealed-chunk arithmetic finds the
1283 /// stream too short. Relaxing the bound to `>=` would short-circuit here
1284 /// and report the wrong reason, which is what this pins.
1285 #[test]
1286 fn v3_total_len_equal_to_input_is_rejected_by_the_chunk_stream_not_the_bound() {
1287 let key = generate_master_key();
1288 let plaintext = vec![9u8; 100];
1289 let mut sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap();
1290 // 100 plaintext + tag(4) + header(13) + nonce/tag(40) = 157.
1291 assert_eq!(sealed.len(), 157, "v3 layout for a single 100-byte chunk");
1292
1293 // total_len lives at body[5..13], i.e. absolute [9..17].
1294 let equal = sealed.len() as u64;
1295 sealed[9..17].copy_from_slice(&equal.to_le_bytes());
1296
1297 let err = decrypt_blob_chunked(&sealed, &key, "hash")
1298 .expect_err("a 157-byte plaintext claim cannot be satisfied by 140 stream bytes");
1299 let SyncKitError::Crypto(msg) = &err else {
1300 panic!("expected a crypto error, got {err:?}");
1301 };
1302 assert!(
1303 msg.contains("truncated mid-chunk"),
1304 "equality must fall through the length bound and fail on the stream: {msg}"
1305 );
1306 assert!(
1307 !msg.contains("exceeds encrypted input"),
1308 "total_len == encrypted.len() is not an over-claim: {msg}"
1309 );
1310
1311 // One byte past the bound is the over-claim, and must be caught here.
1312 let over = sealed.len() as u64 + 1;
1313 sealed[9..17].copy_from_slice(&over.to_le_bytes());
1314 let err = decrypt_blob_chunked(&sealed, &key, "hash")
1315 .expect_err("a claim larger than the input is rejected");
1316 let SyncKitError::Crypto(msg) = &err else {
1317 panic!("expected a crypto error, got {err:?}");
1318 };
1319 assert!(
1320 msg.contains("exceeds encrypted input"),
1321 "one byte over the input length is the allocation bound: {msg}"
1322 );
1323 }
1324
1325 /// v2 wire overhead is a sum of the tag and the AEAD overhead, pinned to
1326 /// the number rather than recomputed from the same expression: an assertion
1327 /// written as `WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD` would hold
1328 /// however that expression was mutated.
1329 #[test]
1330 fn v2_wire_overhead_is_forty_four_bytes() {
1331 assert_eq!(ENCRYPTION_OVERHEAD_V2, 44);
1332 // And the constant describes what the encoder actually emits.
1333 let key = generate_master_key();
1334 let ctx = AeadContext::blob("abc");
1335 let wire = encrypt_bytes_aad(&[3u8; 70], &key, &ctx).unwrap();
1336 assert_eq!(wire.len(), 70 + 44);
1337 }
1338
1339 /// The Argon2 memory floor is 8192 KiB exactly. Both sides of the bound are
1340 /// asserted: 8191 is a downgrade and must be refused, 8192 must derive.
1341 /// The pre-existing test used 8 KiB, which any plausible floor rejects.
1342 #[test]
1343 fn argon2_memory_floor_admits_8192_kib_and_refuses_8191() {
1344 let salt = [7u8; 32];
1345 let err = derive_wrapping_key_with_params("pw", &salt, 8191, 1, 1)
1346 .expect_err("one KiB under the floor is a KDF downgrade");
1347 assert!(
1348 matches!(err, SyncKitError::InvalidEnvelope(_)),
1349 "out-of-range params are an envelope error, got {err:?}"
1350 );
1351 assert!(
1352 derive_wrapping_key_with_params("pw", &salt, 8192, 1, 1).is_ok(),
1353 "the floor itself is inside the accepted range"
1354 );
1355 }
1356