Skip to main content

max / synckit

69.1 KB · 1916 lines History Blame Raw
1 //! Encryption engine: key derivation, wrapping, and per-entry encrypt/decrypt.
2 //!
3 //! Key hierarchy:
4 //! password + (app_id, user_id) → Argon2id → wrapping_key
5 //! wrapping_key encrypts/decrypts the random master_key
6 //! master_key encrypts/decrypts individual data entries
7 //!
8 //! All encryption uses XChaCha20-Poly1305 (192-bit nonces, safe for random generation).
9 //!
10 //! ## Why XChaCha20-Poly1305 over AES-GCM
11 //!
12 //! - 192-bit random nonces eliminate nonce-reuse risk (AES-GCM's 96-bit nonces are
13 //! unsafe for random generation at high volume due to birthday bound at ~2^32 messages).
14 //! - No hardware dependency: constant-time in software on all targets (AES-GCM needs
15 //! AES-NI for safe, fast operation, not guaranteed on all user devices).
16 //! - Widely audited: libsodium's default AEAD, used by Signal, WireGuard, and age.
17
18 use argon2::{Algorithm, Argon2, Params, Version};
19 use base64::{Engine, engine::general_purpose::STANDARD as B64};
20 use chacha20poly1305::{
21 XChaCha20Poly1305, XNonce,
22 aead::{Aead, KeyInit, Payload},
23 };
24 use rand::Rng;
25 use serde::{Deserialize, Serialize};
26 use unicode_normalization::UnicodeNormalization;
27 use zeroize::Zeroize;
28
29 use crate::error::{Result, SyncKitError};
30
31 /// Size of XChaCha20-Poly1305 nonce in bytes.
32 const NONCE_SIZE: usize = 24;
33 /// Size of the encryption key in bytes.
34 const KEY_SIZE: usize = 32;
35
36 /// Current envelope version.
37 const ENVELOPE_VERSION: u8 = 1;
38
39 /// Argon2id parameters: 64 MB memory, 3 iterations (OWASP interactive minimum).
40 const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB
41 const ARGON2_TIME_COST: u32 = 3;
42 const ARGON2_PARALLELISM: u32 = 1;
43
44 // Accepted range for Argon2 parameters read from an (untrusted, server-supplied)
45 // envelope. The floor rejects a maliciously weakened envelope (KDF downgrade); the
46 // ceiling rejects an inflated one whose allocation would OOM every syncing device.
47 // The pinned wrap constants above sit inside this range, so real envelopes unwrap.
48 const ARGON2_MEM_MIN_KB: u32 = 8 * 1024; // 8 MiB
49 // 256 MiB. The wrap cost is 64 MiB, so real envelopes sit comfortably inside the
50 // range. A 1 GiB ceiling let a hostile server demand a 1 GiB Argon2 allocation on
51 // every setup/change-password/rotate, an OOM (jetsam) kill lever on a 2-4 GiB
52 // mobile device. 256 MiB still permits a stronger-than-default envelope while
53 // staying well under the mobile memory budget.
54 const ARGON2_MEM_MAX_KB: u32 = 256 * 1024; // 256 MiB
55 const ARGON2_TIME_MAX: u32 = 16;
56 const ARGON2_PARALLELISM_MAX: u32 = 16;
57
58 fn default_argon_mem() -> u32 {
59 ARGON2_MEM_COST_KB
60 }
61 fn default_argon_time() -> u32 {
62 ARGON2_TIME_COST
63 }
64 fn default_argon_par() -> u32 {
65 ARGON2_PARALLELISM
66 }
67
68 /// Encrypted master key envelope stored on the server.
69 ///
70 /// The Argon2 cost parameters travel with the envelope (defaulting to the
71 /// current constants for v1 envelopes that predate them), so the work factor
72 /// can be raised later without a flag-day: an old envelope still re-derives
73 /// with the parameters it was wrapped under.
74 #[derive(Debug, Serialize, Deserialize)]
75 pub(crate) struct KeyEnvelope {
76 /// Envelope version (currently 1).
77 pub v: u8,
78 /// Argon2 salt (base64).
79 pub salt: String,
80 /// XChaCha20-Poly1305 nonce for the wrapping (base64).
81 pub nonce: String,
82 /// Encrypted master key (base64).
83 pub ciphertext: String,
84 /// Argon2 memory cost in KiB.
85 #[serde(default = "default_argon_mem")]
86 pub m: u32,
87 /// Argon2 time cost (iterations).
88 #[serde(default = "default_argon_time")]
89 pub t: u32,
90 /// Argon2 parallelism.
91 #[serde(default = "default_argon_par")]
92 pub p: u32,
93 }
94
95 /// Generate a random 256-bit master key.
96 pub fn generate_master_key() -> [u8; KEY_SIZE] {
97 let mut key = [0u8; KEY_SIZE];
98 rand::rng().fill_bytes(&mut key);
99 key
100 }
101
102 /// Maximum password length in bytes. Passwords longer than this are rejected
103 /// to prevent denial-of-service via extreme Argon2 input sizes.
104 const MAX_PASSWORD_BYTES: usize = 1024;
105
106 /// Normalize a password to NFC form and validate constraints.
107 ///
108 /// Returns the NFC-normalized password string. Rejects empty passwords
109 /// and passwords exceeding [`MAX_PASSWORD_BYTES`].
110 fn normalize_password(password: &str) -> Result<String> {
111 if password.is_empty() {
112 return Err(SyncKitError::Crypto("password must not be empty".into()));
113 }
114
115 let normalized: String = password.nfc().collect();
116
117 if normalized.len() > MAX_PASSWORD_BYTES {
118 return Err(SyncKitError::Crypto(format!(
119 "password exceeds maximum length of {MAX_PASSWORD_BYTES} bytes"
120 )));
121 }
122
123 Ok(normalized)
124 }
125
126 /// Derive a wrapping key from a password and salt using Argon2id.
127 ///
128 /// The password is NFC-normalized before derivation to ensure consistent
129 /// keys across platforms with different default Unicode normalization forms.
130 fn derive_wrapping_key(password: &str, salt: &[u8; 32]) -> Result<ZeroizeOnDrop> {
131 derive_wrapping_key_with_params(
132 password,
133 salt,
134 ARGON2_MEM_COST_KB,
135 ARGON2_TIME_COST,
136 ARGON2_PARALLELISM,
137 )
138 }
139
140 /// Derive a wrapping key with explicit Argon2 cost parameters (read from the
141 /// envelope on unwrap, so old envelopes re-derive under their original cost).
142 fn derive_wrapping_key_with_params(
143 password: &str,
144 salt: &[u8; 32],
145 mem_kb: u32,
146 time_cost: u32,
147 parallelism: u32,
148 ) -> Result<ZeroizeOnDrop> {
149 // Reject out-of-range parameters before the (expensive) allocation. An
150 // envelope's params are attacker-reachable; a clamp is pointless (wrong
151 // params derive a wrong key that fails the tag anyway), so fail closed.
152 if !(ARGON2_MEM_MIN_KB..=ARGON2_MEM_MAX_KB).contains(&mem_kb)
153 || !(1..=ARGON2_TIME_MAX).contains(&time_cost)
154 || !(1..=ARGON2_PARALLELISM_MAX).contains(&parallelism)
155 {
156 return Err(SyncKitError::InvalidEnvelope(
157 "Argon2 parameters outside the accepted range".into(),
158 ));
159 }
160
161 let normalized = normalize_password(password)?;
162
163 let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE))
164 .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
165
166 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
167
168 let mut wrapping_key = ZeroizeOnDrop([0u8; KEY_SIZE]);
169 let result = argon2
170 .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key.0)
171 .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}")));
172
173 // Zeroize the normalized password before returning (defense-in-depth).
174 let mut norm_bytes = normalized.into_bytes();
175 norm_bytes.zeroize();
176
177 result?;
178 Ok(wrapping_key)
179 }
180
181 /// Verify that a password correctly derives to the given master key by
182 /// attempting to unwrap the envelope with it.
183 ///
184 /// This is used by `change_password` to validate the old password before
185 /// allowing a re-wrap with the new password. The cached key may still be
186 /// used for the actual re-encryption, but the old password must be proven
187 /// correct first.
188 pub fn verify_password_against_envelope(
189 envelope_json: &str,
190 password: &str,
191 ) -> Result<[u8; KEY_SIZE]> {
192 unwrap_master_key(envelope_json, password)
193 }
194
195 /// Encrypt the master key with a password, producing a JSON envelope.
196 ///
197 /// Generates a random 32-byte salt for Argon2id key derivation and stores it
198 /// in the envelope. Each wrap operation uses a unique salt, preventing
199 /// precomputation attacks.
200 pub fn wrap_master_key(master_key: &[u8; KEY_SIZE], password: &str) -> Result<String> {
201 let mut salt = [0u8; 32];
202 rand::rng().fill_bytes(&mut salt);
203
204 let wrapping_key = derive_wrapping_key(password, &salt)?;
205
206 let mut nonce_bytes = [0u8; NONCE_SIZE];
207 rand::rng().fill_bytes(&mut nonce_bytes);
208
209 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
210 let nonce = XNonce::from(nonce_bytes);
211
212 let ciphertext = cipher
213 .encrypt(&nonce, master_key.as_ref())
214 .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?;
215
216 let envelope = KeyEnvelope {
217 v: ENVELOPE_VERSION,
218 salt: B64.encode(salt),
219 nonce: B64.encode(nonce_bytes),
220 ciphertext: B64.encode(ciphertext),
221 m: ARGON2_MEM_COST_KB,
222 t: ARGON2_TIME_COST,
223 p: ARGON2_PARALLELISM,
224 };
225
226 serde_json::to_string(&envelope).map_err(Into::into)
227 }
228
229 /// Decrypt the master key from a JSON envelope using a password.
230 ///
231 /// Reads the random salt from the envelope, derives the wrapping key via
232 /// Argon2id, then decrypts the master key.
233 pub fn unwrap_master_key(envelope_json: &str, password: &str) -> Result<[u8; KEY_SIZE]> {
234 let envelope: KeyEnvelope = serde_json::from_str(envelope_json)
235 .map_err(|e| SyncKitError::InvalidEnvelope(format!("JSON parse: {e}")))?;
236
237 if envelope.v != ENVELOPE_VERSION {
238 return Err(SyncKitError::InvalidEnvelope(format!(
239 "unsupported version {}",
240 envelope.v
241 )));
242 }
243
244 let salt_bytes = B64.decode(&envelope.salt)?;
245 let nonce_bytes = B64.decode(&envelope.nonce)?;
246 let ciphertext = B64.decode(&envelope.ciphertext)?;
247
248 if salt_bytes.len() != 32 {
249 return Err(SyncKitError::InvalidEnvelope("invalid salt length".into()));
250 }
251
252 if nonce_bytes.len() != NONCE_SIZE {
253 return Err(SyncKitError::InvalidEnvelope("invalid nonce length".into()));
254 }
255
256 let mut salt = [0u8; 32];
257 salt.copy_from_slice(&salt_bytes);
258 let wrapping_key =
259 derive_wrapping_key_with_params(password, &salt, envelope.m, envelope.t, envelope.p)?;
260
261 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
262 let nonce =
263 XNonce::try_from(&nonce_bytes[..]).expect("nonce length was validated as NONCE_SIZE above");
264
265 let mut plaintext = cipher
266 .decrypt(&nonce, ciphertext.as_ref())
267 .map_err(|_| SyncKitError::DecryptionFailed)?;
268
269 if plaintext.len() != KEY_SIZE {
270 plaintext.zeroize();
271 return Err(SyncKitError::InvalidEnvelope(
272 "decrypted key has wrong length".into(),
273 ));
274 }
275
276 let mut key = [0u8; KEY_SIZE];
277 key.copy_from_slice(&plaintext);
278 plaintext.zeroize();
279 Ok(key)
280 }
281
282 /// Wire-format tag marking a v2 payload: AEAD sealed with associated data
283 /// binding the ciphertext to its logical position. Legacy (untagged) payloads
284 /// were sealed with empty AAD. The tag is read *before* decryption, so the
285 /// reader always knows which AAD to apply, no structural guessing.
286 ///
287 /// `:` is not in the base64 alphabet, so a tagged base64 string is
288 /// unambiguous against a legacy one.
289 const WIRE_V2_TAG: &str = "sk2:";
290 /// Raw-bytes form of [`WIRE_V2_TAG`] for blob payloads.
291 const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:";
292
293 /// The binding context for an AEAD operation, *what* a ciphertext belongs to.
294 ///
295 /// The associated data is derived from this value, so a caller cannot pass
296 /// empty or mismatched AAD: the only way to seal or open an entry or a blob is
297 /// to name its logical position. That makes the untrusted-server placement
298 /// attack (relocating a valid ciphertext to a different row/table/address)
299 /// unrepresentable at the type level rather than a per-call-site discipline.
300 #[derive(Clone, Copy, Debug)]
301 pub enum AeadContext<'a> {
302 /// A personal sync change entry, bound to its `(table, row_id)` address.
303 Entry { table: &'a str, row_id: &'a str },
304 /// A group sync change entry, bound to its `(group_id, table, row_id)`
305 /// address. Sealed under the group's GCK (see [`crate::identity`]), not the
306 /// personal `master_key`. Folding `group_id` into the AAD makes a ciphertext
307 /// non-relocatable across groups as well as across tables and rows: a server
308 /// that moves a group-entry ciphertext into a different group's changelog
309 /// changes its AAD, so it fails to open.
310 GroupEntry {
311 group_id: &'a str,
312 table: &'a str,
313 row_id: &'a str,
314 },
315 /// A content-addressed blob, bound to its content hash.
316 Blob { hash: &'a str },
317 }
318
319 impl<'a> AeadContext<'a> {
320 /// Bind a personal change entry to its `(table, row_id)` address.
321 pub fn entry(table: &'a str, row_id: &'a str) -> Self {
322 AeadContext::Entry { table, row_id }
323 }
324
325 /// Bind a group change entry to its `(group_id, table, row_id)` address.
326 pub fn group_entry(group_id: &'a str, table: &'a str, row_id: &'a str) -> Self {
327 AeadContext::GroupEntry {
328 group_id,
329 table,
330 row_id,
331 }
332 }
333
334 /// Bind a blob to its content hash.
335 pub fn blob(hash: &'a str) -> Self {
336 AeadContext::Blob { hash }
337 }
338
339 /// The associated-data bytes for this context. A server that relocates a
340 /// ciphertext changes its context, hence its AAD, so decryption fails
341 /// closed, the AEAD tag no longer authenticates the moved bytes.
342 fn aad(&self) -> Result<Vec<u8>> {
343 match self {
344 AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id),
345 AeadContext::GroupEntry {
346 group_id,
347 table,
348 row_id,
349 } => aad_for_group_entry(group_id, table, row_id),
350 AeadContext::Blob { hash } => Ok(hash.as_bytes().to_vec()),
351 }
352 }
353 }
354
355 /// Canonical associated-data encoding binding an entry's ciphertext to its
356 /// `(table, row_id)` address.
357 ///
358 /// The encoding is `table || 0x1f || row_id`. This is injective as long as
359 /// neither field contains the `0x1f` unit separator, so we reject any field that
360 /// does, making `("a", "bc")` and `("ab", "c")` provably distinct rather than
361 /// distinct-by-assumption. Rejecting (vs length-prefixing) keeps the wire format
362 /// unchanged, so existing ciphertext still decrypts. Private: callers reach it
363 /// only through [`AeadContext::Entry`], which is the point of the seal.
364 fn aad_for_entry(table: &str, row_id: &str) -> Result<Vec<u8>> {
365 if table.as_bytes().contains(&0x1f) || row_id.as_bytes().contains(&0x1f) {
366 return Err(SyncKitError::Crypto(
367 "table or row_id contains the 0x1f AAD separator".into(),
368 ));
369 }
370 let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1);
371 aad.extend_from_slice(table.as_bytes());
372 aad.push(0x1f);
373 aad.extend_from_slice(row_id.as_bytes());
374 Ok(aad)
375 }
376
377 /// Canonical associated-data encoding binding a group entry's ciphertext to its
378 /// `(group_id, table, row_id)` address: `group_id || 0x1f || table || 0x1f ||
379 /// row_id`. Rejects any field containing `0x1f`, which keeps the encoding
380 /// injective (as with [`aad_for_entry`]). The three-field form always carries
381 /// exactly two separators where a personal [`aad_for_entry`] carries exactly one,
382 /// so a group-entry AAD can never coincide with a personal-entry AAD.
383 fn aad_for_group_entry(group_id: &str, table: &str, row_id: &str) -> Result<Vec<u8>> {
384 if group_id.as_bytes().contains(&0x1f)
385 || table.as_bytes().contains(&0x1f)
386 || row_id.as_bytes().contains(&0x1f)
387 {
388 return Err(SyncKitError::Crypto(
389 "group_id, table, or row_id contains the 0x1f AAD separator".into(),
390 ));
391 }
392 let mut aad = Vec::with_capacity(group_id.len() + table.len() + row_id.len() + 2);
393 aad.extend_from_slice(group_id.as_bytes());
394 aad.push(0x1f);
395 aad.extend_from_slice(table.as_bytes());
396 aad.push(0x1f);
397 aad.extend_from_slice(row_id.as_bytes());
398 Ok(aad)
399 }
400
401 /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`.
402 ///
403 /// `pub(crate)` so the group-key layer ([`crate::identity`]) can seal a Group
404 /// Content Key under a freshly derived (ECDH) key with explicit AAD, reusing the
405 /// one audited AEAD this crate ships rather than open-coding a second one.
406 pub(crate) fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
407 let mut nonce_bytes = [0u8; NONCE_SIZE];
408 rand::rng().fill_bytes(&mut nonce_bytes);
409
410 let cipher = XChaCha20Poly1305::new(master_key.into());
411 let nonce = XNonce::from(nonce_bytes);
412
413 let ciphertext = cipher
414 .encrypt(
415 &nonce,
416 Payload {
417 msg: plaintext,
418 aad,
419 },
420 )
421 .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
422
423 let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
424 blob.extend_from_slice(&nonce_bytes);
425 blob.extend_from_slice(&ciphertext);
426 Ok(blob)
427 }
428
429 /// AEAD open of a `nonce || ciphertext || tag` blob with the given AAD.
430 ///
431 /// `pub(crate)` counterpart to [`seal`]; see its note. Used by
432 /// [`crate::identity`] to open a Group Content Key grant.
433 pub(crate) fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
434 if blob.len() < NONCE_SIZE + 16 {
435 // Minimum: nonce + poly1305 tag (empty plaintext)
436 return Err(SyncKitError::Crypto("ciphertext too short".into()));
437 }
438 let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
439 let cipher = XChaCha20Poly1305::new(master_key.into());
440 let nonce = XNonce::try_from(nonce_bytes)
441 .expect("split_at(NONCE_SIZE) yields exactly NONCE_SIZE bytes");
442 cipher
443 .decrypt(
444 &nonce,
445 Payload {
446 msg: ciphertext,
447 aad,
448 },
449 )
450 .map_err(|_| SyncKitError::DecryptionFailed)
451 }
452
453 /// Encrypt a data entry with the master key (legacy, untagged, empty AAD).
454 /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). Prefer
455 /// [`encrypt_data_aad`] for new writes so the ciphertext is position-bound.
456 pub fn encrypt_data(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<String> {
457 Ok(B64.encode(seal(plaintext, master_key, &[])?))
458 }
459
460 /// Decrypt a legacy (untagged, empty-AAD) data entry.
461 /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
462 pub fn decrypt_data(encoded: &str, master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
463 open(&B64.decode(encoded)?, master_key, &[])
464 }
465
466 /// Encrypt a data entry, binding it to `ctx`, and emit the [`WIRE_V2_TAG`]-prefixed form.
467 pub fn encrypt_data_aad(
468 plaintext: &[u8],
469 master_key: &[u8; KEY_SIZE],
470 ctx: &AeadContext,
471 ) -> Result<String> {
472 Ok(format!(
473 "{WIRE_V2_TAG}{}",
474 B64.encode(seal(plaintext, master_key, &ctx.aad()?)?)
475 ))
476 }
477
478 /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged
479 /// payload is opened with `ctx`'s AAD; an untagged (legacy) payload is opened
480 /// with empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day.
481 pub fn decrypt_data_aad(
482 encoded: &str,
483 master_key: &[u8; KEY_SIZE],
484 ctx: &AeadContext,
485 ) -> Result<Vec<u8>> {
486 match encoded.strip_prefix(WIRE_V2_TAG) {
487 Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()?),
488 None => open(&B64.decode(encoded)?, master_key, &[]),
489 }
490 }
491
492 /// Encrypt raw bytes with the master key (legacy, untagged, empty AAD).
493 /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
494 pub fn encrypt_bytes(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
495 seal(plaintext, master_key, &[])
496 }
497
498 /// Decrypt legacy (untagged, empty-AAD) raw bytes.
499 pub fn decrypt_bytes(encrypted: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
500 open(encrypted, master_key, &[])
501 }
502
503 /// Encrypt raw bytes binding them to `ctx`, emitting the `sk2:`-prefixed raw
504 /// form. Used for blobs, where `ctx` is [`AeadContext::Blob`].
505 pub fn encrypt_bytes_aad(
506 plaintext: &[u8],
507 master_key: &[u8; KEY_SIZE],
508 ctx: &AeadContext,
509 ) -> Result<Vec<u8>> {
510 let sealed = seal(plaintext, master_key, &ctx.aad()?)?;
511 let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len());
512 out.extend_from_slice(WIRE_V2_TAG_BYTES);
513 out.extend_from_slice(&sealed);
514 Ok(out)
515 }
516
517 /// Decrypt raw bytes, auto-detecting the wire version. A `sk2:`-tagged blob is
518 /// opened with `ctx`'s AAD; an untagged (legacy) blob is opened with empty AAD.
519 pub fn decrypt_bytes_aad(
520 encrypted: &[u8],
521 master_key: &[u8; KEY_SIZE],
522 ctx: &AeadContext,
523 ) -> Result<Vec<u8>> {
524 match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) {
525 Some(rest) => open(rest, master_key, &ctx.aad()?),
526 None => open(encrypted, master_key, &[]),
527 }
528 }
529
530 /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag).
531 pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16;
532
533 // ── Chunked blob format (v3) ──
534 //
535 // A blob is split into fixed-size plaintext chunks, each sealed independently
536 // with AAD binding (content_hash, chunk_index, chunk_count). This lets a reader
537 // decrypt and verify one chunk at a time, bounding peak memory to a single
538 // chunk instead of the whole blob, while making chunk reorder, truncation,
539 // duplication, and cross-blob substitution all fail closed at the AEAD layer.
540 //
541 // Layout: `sk3:` | version(u8=3) | chunk_size(u32 LE) | total_len(u64 LE) | sealed_chunk*
542 // where each `sealed_chunk` is `nonce[24] || ciphertext || tag[16]`. A zero-length
543 // blob is encoded as a single empty sealed chunk so it is still authenticated.
544
545 /// Wire tag for the v3 chunked blob format.
546 const WIRE_V3_TAG_BYTES: &[u8] = b"sk3:";
547 /// Fixed header length after the tag: version(1) + chunk_size(4) + total_len(8).
548 const BLOB_V3_HEADER_LEN: usize = 1 + 4 + 8;
549 /// Plaintext bytes per blob chunk (1 MiB). Bounds per-chunk working memory.
550 pub const BLOB_CHUNK_SIZE: usize = 1024 * 1024;
551
552 /// AAD binding a blob chunk to its content hash and position. `chunk_count` is
553 /// included so dropping the final chunk (truncation) changes every chunk's AAD.
554 fn blob_chunk_aad(hash: &str, index: u32, chunk_count: u32) -> Vec<u8> {
555 let mut aad = Vec::with_capacity(hash.len() + 1 + 8);
556 aad.extend_from_slice(hash.as_bytes());
557 aad.push(0x1f);
558 aad.extend_from_slice(&index.to_le_bytes());
559 aad.extend_from_slice(&chunk_count.to_le_bytes());
560 aad
561 }
562
563 /// Number of v3 chunks a `total_len`-byte plaintext seals into, at this
564 /// client's [`BLOB_CHUNK_SIZE`].
565 ///
566 /// Pure arithmetic over the plaintext length, which is what lets a streaming
567 /// uploader lay out every part boundary before reading a byte of the file.
568 pub fn blob_chunk_count_for(total_len: usize) -> u32 {
569 blob_chunk_count(total_len, BLOB_CHUNK_SIZE)
570 }
571
572 /// Number of chunks for a `total_len` plaintext at `chunk_size` (min 1).
573 fn blob_chunk_count(total_len: usize, chunk_size: usize) -> u32 {
574 if total_len == 0 {
575 1
576 } else {
577 // Saturate rather than truncate. `total_len` is trusted (= `data.len()`,
578 // capped upstream) on the encrypt path but ATTACKER-CONTROLLED on the
579 // decrypt path (it comes from the v3 header), where a bare `as u32` would
580 // silently wrap an astronomical claim down to a small count. Saturating to
581 // u32::MAX keeps the value obviously-too-large so the caller's
582 // `total_len`-vs-input-length bound rejects the header instead, and a
583 // panic (debug_assert) here would itself be a crash DoS on that path.
584 total_len.div_ceil(chunk_size).min(u32::MAX as usize) as u32
585 }
586 }
587
588 /// Parsed v3 blob header.
589 #[derive(Clone, Copy, Debug)]
590 pub struct BlobHeader {
591 /// Plaintext bytes per chunk (last chunk may be shorter).
592 pub chunk_size: usize,
593 /// Total plaintext length.
594 pub total_len: usize,
595 /// Number of sealed chunks.
596 pub chunk_count: u32,
597 }
598
599 impl BlobHeader {
600 /// Sealed length of chunk `index` (`nonce + plaintext_slice + tag`).
601 pub fn sealed_chunk_len(&self, index: u32) -> usize {
602 let plain = if self.total_len == 0 {
603 0
604 } else if index == self.chunk_count - 1 {
605 self.total_len - self.chunk_size * index as usize
606 } else {
607 self.chunk_size
608 };
609 ENCRYPTION_OVERHEAD + plain
610 }
611 }
612
613 /// Whether `data` begins with the v3 chunked-blob tag.
614 pub fn is_chunked_blob(data: &[u8]) -> bool {
615 data.starts_with(WIRE_V3_TAG_BYTES)
616 }
617
618 /// Total wire overhead [`encrypt_blob_chunked`] adds for a `plaintext_len`-byte
619 /// blob: the tag + header, plus one nonce + tag per chunk.
620 pub fn chunked_blob_overhead(plaintext_len: usize) -> usize {
621 let chunk_count = blob_chunk_count(plaintext_len, BLOB_CHUNK_SIZE) as usize;
622 WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN + chunk_count * ENCRYPTION_OVERHEAD
623 }
624
625 /// Exact v3 ciphertext length for a `plaintext_len`-byte blob.
626 ///
627 /// Known from the plaintext length alone, so a multipart uploader can declare
628 /// the object size (and therefore sign an exact `Content-Length` per part)
629 /// before it has sealed anything.
630 pub fn blob_encrypted_len(plaintext_len: usize) -> usize {
631 plaintext_len + chunked_blob_overhead(plaintext_len)
632 }
633
634 /// The v3 tag + header that precedes the sealed-chunk stream for a
635 /// `total_len`-byte plaintext.
636 ///
637 /// Split out of [`encrypt_blob_chunked`] so a streaming uploader can emit the
638 /// header first and then seal chunks one at a time; the two paths produce
639 /// byte-identical output.
640 pub fn blob_header_bytes(total_len: usize) -> Vec<u8> {
641 let mut out = Vec::with_capacity(WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN);
642 out.extend_from_slice(WIRE_V3_TAG_BYTES);
643 out.push(3);
644 out.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
645 out.extend_from_slice(&(total_len as u64).to_le_bytes());
646 out
647 }
648
649 /// Seal one blob chunk, binding `(hash, index, chunk_count)` as AAD.
650 ///
651 /// The encrypt-side counterpart to [`decrypt_blob_chunk`]. `chunk` must be the
652 /// `index`-th [`BLOB_CHUNK_SIZE`] slice of the plaintext (the last may be
653 /// shorter), and `chunk_count` must be [`blob_chunk_count_for`] of the whole
654 /// plaintext, the AAD binds both, so a streaming caller that miscounts
655 /// produces a blob that fails to open rather than one that opens wrong.
656 pub fn seal_blob_chunk(
657 chunk: &[u8],
658 master_key: &[u8; KEY_SIZE],
659 hash: &str,
660 index: u32,
661 chunk_count: u32,
662 ) -> Result<Vec<u8>> {
663 seal(chunk, master_key, &blob_chunk_aad(hash, index, chunk_count))
664 }
665
666 /// Parse the v3 header, returning it plus the number of bytes consumed (tag +
667 /// header). The remaining input is the sealed-chunk stream.
668 pub fn parse_blob_header(data: &[u8]) -> Result<(BlobHeader, usize)> {
669 let body = data
670 .strip_prefix(WIRE_V3_TAG_BYTES)
671 .ok_or_else(|| SyncKitError::Crypto("not a v3 chunked blob".into()))?;
672 if body.len() < BLOB_V3_HEADER_LEN {
673 return Err(SyncKitError::Crypto("v3 blob header truncated".into()));
674 }
675 if body[0] != 3 {
676 return Err(SyncKitError::Crypto(format!(
677 "unknown blob format version {}; this client is too old to read it",
678 body[0]
679 )));
680 }
681 let chunk_size = u32::from_le_bytes(body[1..5].try_into().unwrap()) as usize;
682 let total_len = u64::from_le_bytes(body[5..13].try_into().unwrap()) as usize;
683 if chunk_size == 0 {
684 return Err(SyncKitError::Crypto("v3 blob chunk_size is zero".into()));
685 }
686 let chunk_count = blob_chunk_count(total_len, chunk_size);
687 let consumed = WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN;
688 Ok((
689 BlobHeader {
690 chunk_size,
691 total_len,
692 chunk_count,
693 },
694 consumed,
695 ))
696 }
697
698 /// Decrypt one sealed chunk, binding `(hash, index, chunk_count)`.
699 pub fn decrypt_blob_chunk(
700 sealed: &[u8],
701 master_key: &[u8; KEY_SIZE],
702 hash: &str,
703 index: u32,
704 chunk_count: u32,
705 ) -> Result<Vec<u8>> {
706 open(
707 sealed,
708 master_key,
709 &blob_chunk_aad(hash, index, chunk_count),
710 )
711 }
712
713 /// Encrypt a blob into the v3 chunked wire format (see the module note above).
714 pub fn encrypt_blob_chunked(
715 plaintext: &[u8],
716 master_key: &[u8; KEY_SIZE],
717 hash: &str,
718 ) -> Result<Vec<u8>> {
719 let total_len = plaintext.len();
720 let chunk_count = blob_chunk_count_for(total_len);
721 let mut out = Vec::with_capacity(blob_encrypted_len(total_len));
722 out.extend_from_slice(&blob_header_bytes(total_len));
723
724 if total_len == 0 {
725 out.extend_from_slice(&seal_blob_chunk(&[], master_key, hash, 0, 1)?);
726 return Ok(out);
727 }
728 for (i, chunk) in plaintext.chunks(BLOB_CHUNK_SIZE).enumerate() {
729 out.extend_from_slice(&seal_blob_chunk(
730 chunk,
731 master_key,
732 hash,
733 i as u32,
734 chunk_count,
735 )?);
736 }
737 Ok(out)
738 }
739
740 /// Decrypt a complete v3 chunked blob from one buffer. Used by the non-streaming
741 /// fallback and tests; the streaming download path parses chunks incrementally
742 /// via [`parse_blob_header`] + [`decrypt_blob_chunk`].
743 pub fn decrypt_blob_chunked(
744 encrypted: &[u8],
745 master_key: &[u8; KEY_SIZE],
746 hash: &str,
747 ) -> Result<Vec<u8>> {
748 let (header, consumed) = parse_blob_header(encrypted)?;
749 // `total_len` is an attacker-controlled u64 from the wire header. Plaintext can
750 // never exceed its own ciphertext, so reject any claim larger than the input
751 // before allocating, otherwise a header of `u64::MAX` aborts on the capacity
752 // request (a trivial DoS).
753 if header.total_len > encrypted.len() {
754 return Err(SyncKitError::Crypto(
755 "v3 blob total_len exceeds encrypted input".into(),
756 ));
757 }
758 let mut rest = &encrypted[consumed..];
759 let mut out = Vec::with_capacity(header.total_len);
760 for i in 0..header.chunk_count {
761 let len = header.sealed_chunk_len(i);
762 if rest.len() < len {
763 return Err(SyncKitError::Crypto("v3 blob truncated mid-chunk".into()));
764 }
765 let (sealed, tail) = rest.split_at(len);
766 out.extend_from_slice(&decrypt_blob_chunk(
767 sealed,
768 master_key,
769 hash,
770 i,
771 header.chunk_count,
772 )?);
773 rest = tail;
774 }
775 if !rest.is_empty() {
776 return Err(SyncKitError::Crypto("v3 blob has trailing bytes".into()));
777 }
778 Ok(out)
779 }
780
781 /// Overhead of a v2 (AAD-bound, `sk2:`-tagged) blob: the wire tag plus the
782 /// nonce + Poly1305 tag.
783 pub const ENCRYPTION_OVERHEAD_V2: usize = WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD;
784
785 /// Encrypt a JSON value (legacy, untagged, empty AAD). Prefer [`encrypt_json_aad`].
786 pub fn encrypt_json(
787 value: &serde_json::Value,
788 master_key: &[u8; KEY_SIZE],
789 ) -> Result<serde_json::Value> {
790 use zeroize::Zeroize;
791 let mut plaintext = serde_json::to_vec(value)?;
792 let encrypted = encrypt_data(&plaintext, master_key);
793 plaintext.zeroize();
794 Ok(serde_json::Value::String(encrypted?))
795 }
796
797 /// Decrypt a legacy (untagged, empty-AAD) JSON string from the `data` field.
798 pub fn decrypt_json(
799 encrypted_value: &serde_json::Value,
800 master_key: &[u8; KEY_SIZE],
801 ) -> Result<serde_json::Value> {
802 use zeroize::Zeroize;
803 let encoded = encrypted_value
804 .as_str()
805 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
806 let mut plaintext = decrypt_data(encoded, master_key)?;
807 let result = serde_json::from_slice(&plaintext);
808 plaintext.zeroize();
809 result.map_err(Into::into)
810 }
811
812 /// Encrypt a JSON value binding it to `ctx`, emitting the `sk2:`-tagged form.
813 pub fn encrypt_json_aad(
814 value: &serde_json::Value,
815 master_key: &[u8; KEY_SIZE],
816 ctx: &AeadContext,
817 ) -> Result<serde_json::Value> {
818 use zeroize::Zeroize;
819 let mut plaintext = serde_json::to_vec(value)?;
820 let encrypted = encrypt_data_aad(&plaintext, master_key, ctx);
821 plaintext.zeroize();
822 Ok(serde_json::Value::String(encrypted?))
823 }
824
825 /// Decrypt a JSON string from the `data` field, auto-detecting the wire
826 /// version (tagged → use `ctx`'s AAD, untagged → empty AAD).
827 pub fn decrypt_json_aad(
828 encrypted_value: &serde_json::Value,
829 master_key: &[u8; KEY_SIZE],
830 ctx: &AeadContext,
831 ) -> Result<serde_json::Value> {
832 use zeroize::Zeroize;
833 let encoded = encrypted_value
834 .as_str()
835 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
836
837 let mut plaintext = decrypt_data_aad(encoded, master_key, ctx)?;
838 let result = serde_json::from_slice(&plaintext);
839 plaintext.zeroize();
840 result.map_err(Into::into)
841 }
842
843 /// Zero out a key on drop using the `zeroize` crate.
844 pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]);
845
846 impl std::fmt::Debug for ZeroizeOnDrop {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 f.write_str("[REDACTED 32-byte key]")
849 }
850 }
851
852 impl Drop for ZeroizeOnDrop {
853 fn drop(&mut self) {
854 use zeroize::Zeroize;
855 self.0.zeroize();
856 }
857 }
858
859 impl std::ops::Deref for ZeroizeOnDrop {
860 type Target = [u8; KEY_SIZE];
861 fn deref(&self) -> &Self::Target {
862 &self.0
863 }
864 }
865
866 #[cfg(test)]
867 mod tests {
868 use super::*;
869
870 #[test]
871 fn master_key_generation_is_random() {
872 let k1 = generate_master_key();
873 let k2 = generate_master_key();
874 assert_ne!(k1, k2, "Two generated keys must differ");
875 assert_eq!(k1.len(), 32);
876 }
877
878 #[test]
879 fn wrapping_key_derivation_is_deterministic() {
880 let salt = [42u8; 32];
881 let k1 = derive_wrapping_key("password123", &salt).unwrap();
882 let k2 = derive_wrapping_key("password123", &salt).unwrap();
883 assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
884 }
885
886 #[test]
887 fn different_passwords_produce_different_keys() {
888 let salt = [42u8; 32];
889 let k1 = derive_wrapping_key("password1", &salt).unwrap();
890 let k2 = derive_wrapping_key("password2", &salt).unwrap();
891 assert_ne!(*k1, *k2);
892 }
893
894 #[test]
895 fn different_salts_produce_different_keys() {
896 let salt1 = [1u8; 32];
897 let salt2 = [2u8; 32];
898 let k1 = derive_wrapping_key("password", &salt1).unwrap();
899 let k2 = derive_wrapping_key("password", &salt2).unwrap();
900 assert_ne!(*k1, *k2);
901 }
902
903 // ── Password normalization (NFC/NFD) ──
904
905 #[test]
906 fn nfc_and_nfd_passwords_derive_same_key() {
907 // "e" + combining acute accent (NFD form of e-acute)
908 let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent
909 // Pre-composed e-acute (NFC form)
910 let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent
911
912 // Verify they are actually different byte sequences
913 assert_ne!(
914 nfd_password.as_bytes(),
915 nfc_password.as_bytes(),
916 "NFD and NFC should have different raw bytes"
917 );
918
919 let salt = [99u8; 32];
920 let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
921 let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
922 assert_eq!(
923 *k1, *k2,
924 "Same password in NFC and NFD forms must derive the same key"
925 );
926 }
927
928 #[test]
929 fn nfc_nfd_wrap_unwrap_roundtrip() {
930 let master_key = generate_master_key();
931 // Wrap with NFC form
932 let nfc_password = "caf\u{00e9}";
933 let envelope = wrap_master_key(&master_key, nfc_password).unwrap();
934
935 // Unwrap with NFD form
936 let nfd_password = "caf\u{0065}\u{0301}";
937 let recovered = unwrap_master_key(&envelope, nfd_password).unwrap();
938 assert_eq!(master_key, recovered);
939 }
940
941 #[test]
942 fn nfd_wrap_nfc_unwrap_roundtrip() {
943 let master_key = generate_master_key();
944 // Wrap with NFD form
945 let nfd_password = "caf\u{0065}\u{0301}";
946 let envelope = wrap_master_key(&master_key, nfd_password).unwrap();
947
948 // Unwrap with NFC form
949 let nfc_password = "caf\u{00e9}";
950 let recovered = unwrap_master_key(&envelope, nfc_password).unwrap();
951 assert_eq!(master_key, recovered);
952 }
953
954 #[test]
955 fn normalize_password_converts_to_nfc() {
956 let nfd = "caf\u{0065}\u{0301}";
957 let nfc = "caf\u{00e9}";
958 let normalized = normalize_password(nfd).unwrap();
959 assert_eq!(normalized, nfc);
960 }
961
962 // ── Empty password rejection ──
963
964 #[test]
965 fn empty_password_rejected_by_normalize() {
966 let result = normalize_password("");
967 assert!(result.is_err());
968 let msg = result.unwrap_err().to_string();
969 assert!(msg.contains("empty"), "Error should mention empty: {msg}");
970 }
971
972 #[test]
973 fn empty_password_rejected_by_derive() {
974 let salt = [0u8; 32];
975 let result = derive_wrapping_key("", &salt);
976 assert!(result.is_err());
977 }
978
979 #[test]
980 fn empty_password_rejected_by_wrap() {
981 let master_key = generate_master_key();
982 let result = wrap_master_key(&master_key, "");
983 assert!(result.is_err());
984 }
985
986 #[test]
987 fn empty_password_rejected_by_unwrap() {
988 let master_key = generate_master_key();
989 let envelope = wrap_master_key(&master_key, "valid").unwrap();
990 let result = unwrap_master_key(&envelope, "");
991 assert!(result.is_err());
992 }
993
994 // ── Password length limit ──
995
996 #[test]
997 fn very_long_password_rejected() {
998 let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1);
999 let result = normalize_password(&long_password);
1000 assert!(result.is_err());
1001 let msg = result.unwrap_err().to_string();
1002 assert!(
1003 msg.contains("maximum length"),
1004 "Error should mention max length: {msg}"
1005 );
1006 }
1007
1008 #[test]
1009 fn password_at_max_length_accepted() {
1010 let max_password = "a".repeat(MAX_PASSWORD_BYTES);
1011 let result = normalize_password(&max_password);
1012 assert!(result.is_ok());
1013 }
1014
1015 #[test]
1016 fn password_just_under_max_length_accepted() {
1017 let password = "a".repeat(MAX_PASSWORD_BYTES - 1);
1018 let result = normalize_password(&password);
1019 assert!(result.is_ok());
1020 }
1021
1022 // ── Salt reuse detection ──
1023
1024 #[test]
1025 fn two_wraps_use_different_salts() {
1026 let master_key = generate_master_key();
1027 let e1_json = wrap_master_key(&master_key, "pass").unwrap();
1028 let e2_json = wrap_master_key(&master_key, "pass").unwrap();
1029
1030 let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap();
1031 let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap();
1032
1033 assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt");
1034 assert_ne!(
1035 e1.nonce, e2.nonce,
1036 "Each wrap must use a unique random nonce"
1037 );
1038 }
1039
1040 // ── Key derivation determinism ──
1041
1042 #[test]
1043 fn key_derivation_deterministic_multiple_calls() {
1044 let salt = [77u8; 32];
1045 let password = "deterministic-test-password";
1046
1047 let k1 = derive_wrapping_key(password, &salt).unwrap();
1048 let k2 = derive_wrapping_key(password, &salt).unwrap();
1049 let k3 = derive_wrapping_key(password, &salt).unwrap();
1050
1051 assert_eq!(*k1, *k2);
1052 assert_eq!(*k2, *k3);
1053 }
1054
1055 // ── Key rotation: re-wrap with new password, old data still readable ──
1056
1057 #[test]
1058 fn key_rotation_preserves_data_access() {
1059 let master_key = generate_master_key();
1060 let plaintext = b"encrypted before password change";
1061
1062 // Encrypt data with the master key
1063 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
1064
1065 // Wrap master key with old password
1066 let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap();
1067
1068 // Simulate password change: unwrap with old, re-wrap with new
1069 let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap();
1070 assert_eq!(recovered_key, master_key);
1071
1072 let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap();
1073
1074 // Verify: unwrap with new password gives same key
1075 let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap();
1076 assert_eq!(key_from_new, master_key);
1077
1078 // Verify: old encrypted data can still be decrypted
1079 let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap();
1080 assert_eq!(decrypted, plaintext);
1081
1082 // Verify: old password no longer works on new envelope
1083 let result = unwrap_master_key(&new_envelope, "old-pass");
1084 assert!(result.is_err());
1085 }
1086
1087 // ── Encryption roundtrip with various data sizes ──
1088
1089 #[test]
1090 fn encrypt_decrypt_empty_data() {
1091 let master_key = generate_master_key();
1092 let encrypted = encrypt_data(b"", &master_key).unwrap();
1093 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1094 assert!(decrypted.is_empty());
1095 }
1096
1097 #[test]
1098 fn encrypt_decrypt_single_byte() {
1099 let master_key = generate_master_key();
1100 let encrypted = encrypt_data(&[42], &master_key).unwrap();
1101 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1102 assert_eq!(decrypted, vec![42]);
1103 }
1104
1105 #[test]
1106 fn encrypt_decrypt_large_payload() {
1107 let master_key = generate_master_key();
1108 // 1MB of data
1109 let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
1110 let encrypted = encrypt_data(&plaintext, &master_key).unwrap();
1111 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1112 assert_eq!(decrypted, plaintext);
1113 }
1114
1115 // ── Wrong key gives error, not garbage ──
1116
1117 #[test]
1118 fn wrong_key_gives_decryption_error_not_garbage() {
1119 let key1 = generate_master_key();
1120 let key2 = generate_master_key();
1121 let plaintext = b"this should fail cleanly with wrong key";
1122
1123 let encrypted = encrypt_data(plaintext, &key1).unwrap();
1124 let result = decrypt_data(&encrypted, &key2);
1125
1126 // Must be an error, not a successful decryption to garbage
1127 assert!(result.is_err());
1128 assert!(
1129 matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
1130 "Wrong key must produce DecryptionFailed, not garbage output"
1131 );
1132 }
1133
1134 #[test]
1135 fn wrong_key_bytes_gives_decryption_error_not_garbage() {
1136 let key1 = generate_master_key();
1137 let key2 = generate_master_key();
1138 let plaintext = b"binary data check";
1139
1140 let encrypted = encrypt_bytes(plaintext, &key1).unwrap();
1141 let result = decrypt_bytes(&encrypted, &key2);
1142
1143 assert!(result.is_err());
1144 assert!(matches!(
1145 result.unwrap_err(),
1146 SyncKitError::DecryptionFailed
1147 ));
1148 }
1149
1150 // ── JSON encryption edge cases ──
1151
1152 #[test]
1153 fn json_encrypt_decrypt_null() {
1154 let master_key = generate_master_key();
1155 let original = serde_json::Value::Null;
1156 let encrypted = encrypt_json(&original, &master_key).unwrap();
1157 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
1158 assert_eq!(decrypted, original);
1159 }
1160
1161 #[test]
1162 fn json_encrypt_decrypt_nested_object() {
1163 let master_key = generate_master_key();
1164 let original = serde_json::json!({
1165 "level1": {
1166 "level2": {
1167 "level3": [1, 2, 3],
1168 "flag": true
1169 }
1170 },
1171 "empty_array": [],
1172 "empty_object": {}
1173 });
1174
1175 let encrypted = encrypt_json(&original, &master_key).unwrap();
1176 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
1177 assert_eq!(decrypted, original);
1178 }
1179
1180 #[test]
1181 fn json_decrypt_with_wrong_key_fails() {
1182 let key1 = generate_master_key();
1183 let key2 = generate_master_key();
1184 let original = serde_json::json!({"secret": "data"});
1185
1186 let encrypted = encrypt_json(&original, &key1).unwrap();
1187 let result = decrypt_json(&encrypted, &key2);
1188 assert!(result.is_err());
1189 }
1190
1191 #[test]
1192 fn json_decrypt_non_string_value_fails() {
1193 let master_key = generate_master_key();
1194 let not_a_string = serde_json::json!(42);
1195 let result = decrypt_json(&not_a_string, &master_key);
1196 assert!(result.is_err());
1197 }
1198
1199 // ── Blob (bytes) edge cases ──
1200
1201 #[test]
1202 fn bytes_zero_byte_blob_roundtrip() {
1203 let master_key = generate_master_key();
1204 let empty: &[u8] = &[];
1205 let encrypted = encrypt_bytes(empty, &master_key).unwrap();
1206 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
1207 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1208 assert!(decrypted.is_empty());
1209 }
1210
1211 #[test]
1212 fn bytes_boundary_size_blob() {
1213 let master_key = generate_master_key();
1214 // Test at exactly the nonce size boundary
1215 let data = vec![0xAB; NONCE_SIZE];
1216 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
1217 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1218 assert_eq!(decrypted, data);
1219 }
1220
1221 #[test]
1222 fn bytes_1mb_blob_roundtrip() {
1223 let master_key = generate_master_key();
1224 let data: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
1225 let encrypted = encrypt_bytes(&data, &master_key).unwrap();
1226 assert_eq!(encrypted.len(), data.len() + ENCRYPTION_OVERHEAD);
1227 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1228 assert_eq!(decrypted, data);
1229 }
1230
1231 // ── Tampered ciphertext detection ──
1232
1233 #[test]
1234 fn tampered_ciphertext_detected() {
1235 let master_key = generate_master_key();
1236 let plaintext = b"integrity check";
1237 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
1238
1239 let mut blob = B64.decode(&encrypted).unwrap();
1240 // Flip a byte in the ciphertext portion (after the nonce)
1241 let idx = NONCE_SIZE + 1;
1242 blob[idx] ^= 0xFF;
1243 let tampered = B64.encode(&blob);
1244
1245 let result = decrypt_data(&tampered, &master_key);
1246 assert!(result.is_err());
1247 assert!(matches!(
1248 result.unwrap_err(),
1249 SyncKitError::DecryptionFailed
1250 ));
1251 }
1252
1253 #[test]
1254 fn tampered_nonce_detected() {
1255 let master_key = generate_master_key();
1256 let plaintext = b"nonce tamper check";
1257 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
1258
1259 let mut blob = B64.decode(&encrypted).unwrap();
1260 // Flip a byte in the nonce
1261 blob[0] ^= 0xFF;
1262 let tampered = B64.encode(&blob);
1263
1264 let result = decrypt_data(&tampered, &master_key);
1265 assert!(result.is_err());
1266 }
1267
1268 // ── Envelope validation edge cases ──
1269
1270 #[test]
1271 fn invalid_envelope_json_rejected() {
1272 let result = unwrap_master_key("not valid json at all", "pass");
1273 assert!(result.is_err());
1274 assert!(matches!(
1275 result.unwrap_err(),
1276 SyncKitError::InvalidEnvelope(_)
1277 ));
1278 }
1279
1280 #[test]
1281 fn envelope_with_wrong_salt_length_rejected() {
1282 let envelope = KeyEnvelope {
1283 v: ENVELOPE_VERSION,
1284 salt: B64.encode([0u8; 16]), // 16 bytes, should be 32
1285 nonce: B64.encode([0u8; NONCE_SIZE]),
1286 ciphertext: B64.encode([0u8; 48]),
1287 m: ARGON2_MEM_COST_KB,
1288 t: ARGON2_TIME_COST,
1289 p: ARGON2_PARALLELISM,
1290 };
1291 let json = serde_json::to_string(&envelope).unwrap();
1292
1293 let result = unwrap_master_key(&json, "pass");
1294 assert!(result.is_err());
1295 assert!(matches!(
1296 result.unwrap_err(),
1297 SyncKitError::InvalidEnvelope(_)
1298 ));
1299 }
1300
1301 #[test]
1302 fn envelope_with_wrong_nonce_length_rejected() {
1303 let envelope = KeyEnvelope {
1304 v: ENVELOPE_VERSION,
1305 salt: B64.encode([0u8; 32]),
1306 nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24
1307 ciphertext: B64.encode([0u8; 48]),
1308 m: ARGON2_MEM_COST_KB,
1309 t: ARGON2_TIME_COST,
1310 p: ARGON2_PARALLELISM,
1311 };
1312 let json = serde_json::to_string(&envelope).unwrap();
1313
1314 let result = unwrap_master_key(&json, "pass");
1315 assert!(result.is_err());
1316 assert!(matches!(
1317 result.unwrap_err(),
1318 SyncKitError::InvalidEnvelope(_)
1319 ));
1320 }
1321
1322 // ── verify_password_against_envelope ──
1323
1324 #[test]
1325 fn verify_password_correct() {
1326 let master_key = generate_master_key();
1327 let envelope = wrap_master_key(&master_key, "correct").unwrap();
1328 let result = verify_password_against_envelope(&envelope, "correct");
1329 assert!(result.is_ok());
1330 assert_eq!(result.unwrap(), master_key);
1331 }
1332
1333 #[test]
1334 fn verify_password_wrong() {
1335 let master_key = generate_master_key();
1336 let envelope = wrap_master_key(&master_key, "correct").unwrap();
1337 let result = verify_password_against_envelope(&envelope, "wrong");
1338 assert!(result.is_err());
1339 assert!(matches!(
1340 result.unwrap_err(),
1341 SyncKitError::DecryptionFailed
1342 ));
1343 }
1344
1345 #[test]
1346 fn wrap_unwrap_roundtrip() {
1347 let master_key = generate_master_key();
1348
1349 let envelope = wrap_master_key(&master_key, "mypassword").unwrap();
1350 let recovered = unwrap_master_key(&envelope, "mypassword").unwrap();
1351
1352 assert_eq!(master_key, recovered);
1353 }
1354
1355 #[test]
1356 fn wrap_uses_random_salt() {
1357 let master_key = generate_master_key();
1358 let e1 = wrap_master_key(&master_key, "pass").unwrap();
1359 let e2 = wrap_master_key(&master_key, "pass").unwrap();
1360
1361 // Different envelopes (random salt + random nonce)
1362 assert_ne!(e1, e2);
1363
1364 // Both decrypt correctly
1365 assert_eq!(unwrap_master_key(&e1, "pass").unwrap(), master_key);
1366 assert_eq!(unwrap_master_key(&e2, "pass").unwrap(), master_key);
1367 }
1368
1369 #[test]
1370 fn wrong_password_fails_unwrap() {
1371 let master_key = generate_master_key();
1372
1373 let envelope = wrap_master_key(&master_key, "correct").unwrap();
1374 let result = unwrap_master_key(&envelope, "wrong");
1375
1376 assert!(result.is_err());
1377 assert!(matches!(
1378 result.unwrap_err(),
1379 SyncKitError::DecryptionFailed
1380 ));
1381 }
1382
1383 #[test]
1384 fn data_encrypt_decrypt_roundtrip() {
1385 let master_key = generate_master_key();
1386 let plaintext = b"Hello, world! This is sensitive data.";
1387
1388 let encrypted = encrypt_data(plaintext, &master_key).unwrap();
1389 let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1390
1391 assert_eq!(decrypted, plaintext);
1392 }
1393
1394 #[test]
1395 fn same_plaintext_different_ciphertext() {
1396 let master_key = generate_master_key();
1397 let plaintext = b"same data";
1398
1399 let e1 = encrypt_data(plaintext, &master_key).unwrap();
1400 let e2 = encrypt_data(plaintext, &master_key).unwrap();
1401
1402 assert_ne!(e1, e2, "Random nonces must produce different ciphertext");
1403
1404 // But both decrypt to the same plaintext
1405 assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext);
1406 assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext);
1407 }
1408
1409 #[test]
1410 fn wrong_key_fails_decrypt() {
1411 let key1 = generate_master_key();
1412 let key2 = generate_master_key();
1413 let plaintext = b"secret";
1414
1415 let encrypted = encrypt_data(plaintext, &key1).unwrap();
1416 let result = decrypt_data(&encrypted, &key2);
1417
1418 assert!(result.is_err());
1419 assert!(matches!(
1420 result.unwrap_err(),
1421 SyncKitError::DecryptionFailed
1422 ));
1423 }
1424
1425 #[test]
1426 fn envelope_version_check() {
1427 let master_key = generate_master_key();
1428
1429 let envelope_json = wrap_master_key(&master_key, "pass").unwrap();
1430
1431 // Tamper with version
1432 let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap();
1433 envelope.v = 99;
1434 let tampered = serde_json::to_string(&envelope).unwrap();
1435
1436 let result = unwrap_master_key(&tampered, "pass");
1437 assert!(result.is_err());
1438 assert!(matches!(
1439 result.unwrap_err(),
1440 SyncKitError::InvalidEnvelope(_)
1441 ));
1442 }
1443
1444 #[test]
1445 fn truncated_ciphertext_rejected() {
1446 let master_key = generate_master_key();
1447 let encrypted = encrypt_data(b"data", &master_key).unwrap();
1448
1449 // Decode, truncate, re-encode
1450 let mut blob = B64.decode(&encrypted).unwrap();
1451 blob.truncate(10); // Way too short
1452 let truncated = B64.encode(&blob);
1453
1454 let result = decrypt_data(&truncated, &master_key);
1455 assert!(result.is_err());
1456 }
1457
1458 #[test]
1459 fn json_encrypt_decrypt_roundtrip() {
1460 let master_key = generate_master_key();
1461 let original = serde_json::json!({
1462 "title": "Buy milk",
1463 "priority": 3,
1464 "tags": ["groceries", "urgent"]
1465 });
1466
1467 let encrypted = encrypt_json(&original, &master_key).unwrap();
1468 assert!(encrypted.is_string(), "Encrypted JSON should be a string");
1469
1470 let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
1471 assert_eq!(decrypted, original);
1472 }
1473
1474 #[test]
1475 fn zeroize_on_drop() {
1476 let key = generate_master_key();
1477 let guarded = ZeroizeOnDrop(key);
1478 // Verify we can use it
1479 assert_eq!(guarded.len(), 32);
1480 // Drop happens automatically, we can't easily test memory zeroing
1481 // but we verify the API works without panic.
1482 drop(guarded);
1483 }
1484
1485 // ── encrypt_bytes / decrypt_bytes ──
1486
1487 #[test]
1488 fn bytes_encrypt_decrypt_roundtrip() {
1489 let master_key = generate_master_key();
1490 let plaintext = b"raw binary blob data \x00\x01\x02\xff";
1491
1492 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
1493 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1494
1495 assert_eq!(decrypted, plaintext);
1496 }
1497
1498 #[test]
1499 fn bytes_encrypt_has_correct_overhead() {
1500 let master_key = generate_master_key();
1501 let plaintext = vec![0u8; 1000];
1502
1503 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
1504 assert_eq!(encrypted.len(), plaintext.len() + ENCRYPTION_OVERHEAD);
1505 }
1506
1507 #[test]
1508 fn bytes_same_plaintext_different_ciphertext() {
1509 let master_key = generate_master_key();
1510 let plaintext = b"same data";
1511
1512 let e1 = encrypt_bytes(plaintext, &master_key).unwrap();
1513 let e2 = encrypt_bytes(plaintext, &master_key).unwrap();
1514
1515 assert_ne!(e1, e2);
1516 assert_eq!(decrypt_bytes(&e1, &master_key).unwrap(), plaintext);
1517 assert_eq!(decrypt_bytes(&e2, &master_key).unwrap(), plaintext);
1518 }
1519
1520 #[test]
1521 fn bytes_wrong_key_fails() {
1522 let key1 = generate_master_key();
1523 let key2 = generate_master_key();
1524
1525 let encrypted = encrypt_bytes(b"secret", &key1).unwrap();
1526 let result = decrypt_bytes(&encrypted, &key2);
1527
1528 assert!(result.is_err());
1529 assert!(matches!(
1530 result.unwrap_err(),
1531 SyncKitError::DecryptionFailed
1532 ));
1533 }
1534
1535 #[test]
1536 fn bytes_truncated_rejected() {
1537 let master_key = generate_master_key();
1538 let encrypted = encrypt_bytes(b"data", &master_key).unwrap();
1539
1540 let result = decrypt_bytes(&encrypted[..10], &master_key);
1541 assert!(result.is_err());
1542 }
1543
1544 #[test]
1545 fn bytes_empty_plaintext_roundtrip() {
1546 let master_key = generate_master_key();
1547 let plaintext = b"";
1548
1549 let encrypted = encrypt_bytes(plaintext, &master_key).unwrap();
1550 assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
1551
1552 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1553 assert_eq!(decrypted, plaintext);
1554 }
1555
1556 #[test]
1557 fn bytes_large_blob_roundtrip() {
1558 let master_key = generate_master_key();
1559 let plaintext: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect();
1560
1561 let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap();
1562 let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap();
1563
1564 assert_eq!(decrypted, plaintext);
1565 }
1566
1567 // ── AAD binding + wire version tag (v2) ──
1568
1569 #[test]
1570 fn aad_for_entry_is_injective_across_boundary() {
1571 // ("a","bc") and ("ab","c") must not collide, or a server could swap
1572 // table/row_id halves and keep the AAD constant.
1573 assert_ne!(
1574 aad_for_entry("a", "bc").unwrap(),
1575 aad_for_entry("ab", "c").unwrap()
1576 );
1577 assert_eq!(
1578 aad_for_entry("tasks", "r1").unwrap(),
1579 aad_for_entry("tasks", "r1").unwrap()
1580 );
1581 }
1582
1583 #[test]
1584 fn aad_for_entry_rejects_separator_byte() {
1585 // A field carrying the 0x1f separator would break injectivity, so it is
1586 // rejected rather than silently encoded.
1587 assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err());
1588 assert!(aad_for_entry("tasks", "r\u{1f}1").is_err());
1589 assert!(aad_for_entry("tasks", "r1").is_ok());
1590 }
1591
1592 #[test]
1593 fn argon2_params_out_of_range_are_rejected() {
1594 let salt = [7u8; 32];
1595 // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation.
1596 assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err());
1597 // Mobile-OOM fix: 512 MiB, valid under the old 1 GiB ceiling, is now
1598 // rejected (before any allocation) by the 256 MiB ceiling.
1599 assert!(derive_wrapping_key_with_params("pw", &salt, 512 * 1024, 3, 1).is_err());
1600 // Weakened memory (KDF downgrade) rejected.
1601 assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err());
1602 // Zero time / parallelism rejected.
1603 assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err());
1604 assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err());
1605 // The pinned production parameters are inside the accepted range.
1606 assert!(
1607 derive_wrapping_key_with_params(
1608 "pw",
1609 &salt,
1610 ARGON2_MEM_COST_KB,
1611 ARGON2_TIME_COST,
1612 ARGON2_PARALLELISM
1613 )
1614 .is_ok()
1615 );
1616 }
1617
1618 #[test]
1619 fn blob_total_len_exceeding_input_is_rejected_not_allocated() {
1620 let key = generate_master_key();
1621 // A v3 header claiming u64::MAX plaintext with no chunk bytes must be
1622 // rejected on the length bound, not attempt an astronomical allocation.
1623 let mut buf = Vec::new();
1624 buf.extend_from_slice(WIRE_V3_TAG_BYTES);
1625 buf.push(3);
1626 buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
1627 buf.extend_from_slice(&u64::MAX.to_le_bytes());
1628 assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err());
1629 }
1630
1631 #[test]
1632 fn v2_data_is_tagged_and_roundtrips() {
1633 let key = generate_master_key();
1634 let ctx = AeadContext::entry("tasks", "row-1");
1635 let wire = encrypt_data_aad(b"hello", &key, &ctx).unwrap();
1636 assert!(
1637 wire.starts_with("sk2:"),
1638 "v2 payload must carry the wire tag: {wire}"
1639 );
1640 let pt = decrypt_data_aad(&wire, &key, &ctx).unwrap();
1641 assert_eq!(pt, b"hello");
1642 }
1643
1644 #[test]
1645 fn v2_relocation_to_different_row_fails_closed() {
1646 // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1)
1647 // must not decrypt when the server presents it under (tasks,row-2).
1648 let key = generate_master_key();
1649 let sealed =
1650 encrypt_data_aad(b"secret", &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1651 let relocated = decrypt_data_aad(&sealed, &key, &AeadContext::entry("tasks", "row-2"));
1652 assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed)));
1653 let relocated_table =
1654 decrypt_data_aad(&sealed, &key, &AeadContext::entry("notes", "row-1"));
1655 assert!(matches!(
1656 relocated_table,
1657 Err(SyncKitError::DecryptionFailed)
1658 ));
1659 }
1660
1661 /// Known-answer vectors for the XChaCha20-Poly1305 envelope, computed
1662 /// outside RustCrypto: HChaCha20 implemented against the test vector in
1663 /// draft-irtf-cfrg-xchacha-03 §2.2.1, then the IETF ChaCha20-Poly1305 leg
1664 /// run through python `cryptography`. This is the ciphertext already on
1665 /// disk and on the wire, so it must decrypt byte-for-byte forever, a
1666 /// cipher-crate upgrade that silently changed the envelope would break
1667 /// every existing user, and the roundtrip tests above would not notice.
1668 ///
1669 /// key = 00..1f, nonce = 40..57, plaintext = the string asserted below.
1670 #[test]
1671 fn envelope_matches_independent_known_answer() {
1672 const V2_ENTRY: &str = "sk2:QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuJwl14RY250chudQ2lSbk6V";
1673 const LEGACY: &str = "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuKvdRcL/9EeoK9LSYyzSJY7";
1674 const PLAINTEXT: &[u8] = b"synckit envelope v2 known answer";
1675
1676 let mut key = [0u8; KEY_SIZE];
1677 for (i, b) in key.iter_mut().enumerate() {
1678 *b = u8::try_from(i).unwrap();
1679 }
1680
1681 let ctx = AeadContext::entry("notes", "row-1");
1682 assert_eq!(decrypt_data_aad(V2_ENTRY, &key, &ctx).unwrap(), PLAINTEXT);
1683 assert_eq!(decrypt_data(LEGACY, &key).unwrap(), PLAINTEXT);
1684
1685 // The AAD is genuinely bound: the same bytes under a different address
1686 // must fail, or the vector above would prove nothing about binding.
1687 assert!(matches!(
1688 decrypt_data_aad(V2_ENTRY, &key, &AeadContext::entry("notes", "row-2")),
1689 Err(SyncKitError::DecryptionFailed)
1690 ));
1691 }
1692
1693 #[test]
1694 fn legacy_untagged_still_decrypts_under_aad_reader() {
1695 // A v1 (untagged, empty-AAD) payload must keep decrypting through the
1696 // tag-aware reader, regardless of the context passed, no flag-day.
1697 let key = generate_master_key();
1698 let legacy = encrypt_data(b"old data", &key).unwrap();
1699 assert!(!legacy.starts_with("sk2:"));
1700 let pt = decrypt_data_aad(&legacy, &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1701 assert_eq!(pt, b"old data");
1702 }
1703
1704 #[test]
1705 fn v2_json_roundtrip_and_relocation_fails() {
1706 let key = generate_master_key();
1707 let value = serde_json::json!({"title": "Buy milk"});
1708 let ctx = AeadContext::entry("tasks", "row-1");
1709 let wire = encrypt_json_aad(&value, &key, &ctx).unwrap();
1710 assert!(wire.as_str().unwrap().starts_with("sk2:"));
1711 assert_eq!(decrypt_json_aad(&wire, &key, &ctx).unwrap(), value);
1712 let moved = decrypt_json_aad(&wire, &key, &AeadContext::entry("tasks", "row-2"));
1713 assert!(moved.is_err());
1714 }
1715
1716 #[test]
1717 fn v2_bytes_tagged_roundtrip_and_relocation_fails() {
1718 let key = generate_master_key();
1719 let ctx_a = AeadContext::blob("sha256-aaa");
1720 let ctx_b = AeadContext::blob("sha256-bbb");
1721 let blob = encrypt_bytes_aad(b"blob bytes", &key, &ctx_a).unwrap();
1722 assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag");
1723 assert_eq!(
1724 decrypt_bytes_aad(&blob, &key, &ctx_a).unwrap(),
1725 b"blob bytes"
1726 );
1727 // Substituting a different hash's context fails closed.
1728 assert!(matches!(
1729 decrypt_bytes_aad(&blob, &key, &ctx_b),
1730 Err(SyncKitError::DecryptionFailed)
1731 ));
1732 }
1733
1734 #[test]
1735 fn legacy_bytes_still_decrypt_under_aad_reader() {
1736 let key = generate_master_key();
1737 let legacy = encrypt_bytes(b"old blob", &key).unwrap();
1738 assert!(!legacy.starts_with(b"sk2:"));
1739 let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap();
1740 assert_eq!(pt, b"old blob");
1741 }
1742
1743 // ── chunked blob format (v3) ──
1744
1745 #[test]
1746 fn chunked_blob_roundtrips_across_chunk_boundaries() {
1747 let key = generate_master_key();
1748 // Spans three chunks (two full + a partial), exercising the boundary math.
1749 let plaintext: Vec<u8> = (0..(BLOB_CHUNK_SIZE * 2 + 123)).map(|i| i as u8).collect();
1750 let hash = "sha256-abc";
1751 let wire = encrypt_blob_chunked(&plaintext, &key, hash).unwrap();
1752 assert!(is_chunked_blob(&wire), "must carry the sk3: tag");
1753 let (header, _) = parse_blob_header(&wire).unwrap();
1754 assert_eq!(header.total_len, plaintext.len());
1755 assert_eq!(header.chunk_count, 3);
1756 assert_eq!(decrypt_blob_chunked(&wire, &key, hash).unwrap(), plaintext);
1757 }
1758
1759 #[test]
1760 fn chunked_blob_handles_empty_and_single_chunk() {
1761 let key = generate_master_key();
1762 for pt in [vec![], b"small blob".to_vec()] {
1763 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1764 assert_eq!(parse_blob_header(&wire).unwrap().0.chunk_count, 1);
1765 assert_eq!(decrypt_blob_chunked(&wire, &key, "h").unwrap(), pt);
1766 }
1767 }
1768
1769 #[test]
1770 fn streaming_seal_matches_whole_buffer_encrypt() {
1771 let key = generate_master_key();
1772 let hash = "sha256-stream";
1773 // Two full chunks plus a partial, and the empty blob, which the
1774 // streaming path has to special-case the same way (one empty chunk).
1775 for pt in [
1776 vec![],
1777 b"one small chunk".to_vec(),
1778 (0..(BLOB_CHUNK_SIZE * 2 + 7)).map(|i| i as u8).collect(),
1779 ] {
1780 let whole = encrypt_blob_chunked(&pt, &key, hash).unwrap();
1781
1782 // What a streaming uploader does: header first, then seal each
1783 // chunk as it reads it. Nonces are random, so the bytes differ,
1784 // the contract is the LENGTH (which every part boundary is signed
1785 // against) and that the result opens to the same plaintext.
1786 let chunk_count = blob_chunk_count_for(pt.len());
1787 let mut streamed = blob_header_bytes(pt.len());
1788 if pt.is_empty() {
1789 streamed.extend_from_slice(&seal_blob_chunk(&[], &key, hash, 0, 1).unwrap());
1790 } else {
1791 for (i, chunk) in pt.chunks(BLOB_CHUNK_SIZE).enumerate() {
1792 streamed.extend_from_slice(
1793 &seal_blob_chunk(chunk, &key, hash, i as u32, chunk_count).unwrap(),
1794 );
1795 }
1796 }
1797
1798 assert_eq!(streamed.len(), whole.len());
1799 assert_eq!(streamed.len(), blob_encrypted_len(pt.len()));
1800 assert_eq!(decrypt_blob_chunked(&streamed, &key, hash).unwrap(), pt);
1801 }
1802 }
1803
1804 #[test]
1805 fn blob_encrypted_len_predicts_the_wire_size() {
1806 let key = generate_master_key();
1807 // Boundary sizes: the prediction is what a multipart start declares, so
1808 // being off by one byte anywhere breaks a signed Content-Length.
1809 for len in [
1810 0,
1811 1,
1812 BLOB_CHUNK_SIZE - 1,
1813 BLOB_CHUNK_SIZE,
1814 BLOB_CHUNK_SIZE + 1,
1815 BLOB_CHUNK_SIZE * 3,
1816 ] {
1817 let pt = vec![7u8; len];
1818 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1819 assert_eq!(blob_encrypted_len(len), wire.len(), "len {len}");
1820 }
1821 }
1822
1823 #[test]
1824 fn chunked_blob_wrong_hash_fails_closed() {
1825 let key = generate_master_key();
1826 let pt = b"bound to its address".to_vec();
1827 let wire = encrypt_blob_chunked(&pt, &key, "hash-a").unwrap();
1828 // A different content hash changes every chunk's AAD -> open fails.
1829 assert!(matches!(
1830 decrypt_blob_chunked(&wire, &key, "hash-b"),
1831 Err(SyncKitError::DecryptionFailed)
1832 ));
1833 }
1834
1835 #[test]
1836 fn chunked_blob_tamper_and_truncation_fail_closed() {
1837 let key = generate_master_key();
1838 let pt: Vec<u8> = (0..(BLOB_CHUNK_SIZE + 50)).map(|i| i as u8).collect();
1839 let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
1840
1841 // Flip a ciphertext byte -> AEAD tag mismatch.
1842 let mut tampered = wire.clone();
1843 let last = tampered.len() - 1;
1844 tampered[last] ^= 0x01;
1845 assert!(decrypt_blob_chunked(&tampered, &key, "h").is_err());
1846
1847 // Drop the final chunk's bytes -> truncation detected.
1848 let header = parse_blob_header(&wire).unwrap().0;
1849 let truncated = &wire[..wire.len() - header.sealed_chunk_len(header.chunk_count - 1)];
1850 assert!(decrypt_blob_chunked(truncated, &key, "h").is_err());
1851 }
1852
1853 #[test]
1854 fn parse_blob_header_rejects_non_v3() {
1855 let key = generate_master_key();
1856 let v2 = encrypt_bytes_aad(b"x", &key, &AeadContext::blob("h")).unwrap();
1857 assert!(parse_blob_header(&v2).is_err());
1858 assert!(!is_chunked_blob(&v2));
1859 }
1860
1861 // ── Group-entry AAD binding ──
1862
1863 #[test]
1864 fn group_entry_roundtrips_under_gck() {
1865 let gck = generate_master_key();
1866 let ctx = AeadContext::group_entry("grp1", "tasks", "row-9");
1867 let wire = encrypt_data_aad(b"shared task", &gck, &ctx).unwrap();
1868 let out = decrypt_data_aad(&wire, &gck, &ctx).unwrap();
1869 assert_eq!(out, b"shared task");
1870 }
1871
1872 #[test]
1873 fn group_entry_relocated_to_another_group_fails() {
1874 let gck = generate_master_key();
1875 let wire = encrypt_data_aad(
1876 b"shared task",
1877 &gck,
1878 &AeadContext::group_entry("grp1", "tasks", "row-9"),
1879 )
1880 .unwrap();
1881 // Same GCK and same (table, row_id), but a different group_id: a server
1882 // that filed this ciphertext under another group cannot make it open.
1883 let moved = AeadContext::group_entry("grp2", "tasks", "row-9");
1884 assert!(matches!(
1885 decrypt_data_aad(&wire, &gck, &moved),
1886 Err(SyncKitError::DecryptionFailed)
1887 ));
1888 }
1889
1890 #[test]
1891 fn group_entry_and_personal_entry_aad_never_collide() {
1892 // A personal Entry and a GroupEntry with the same table/row_id must not
1893 // share associated data (the separator-count invariant), so a ciphertext
1894 // cannot cross the personal/group boundary.
1895 let personal = AeadContext::entry("tasks", "row-9");
1896 let group = AeadContext::group_entry("", "tasks", "row-9");
1897 assert_ne!(personal.aad().unwrap(), group.aad().unwrap());
1898
1899 let gck = generate_master_key();
1900 let wire = encrypt_data_aad(b"x", &gck, &group).unwrap();
1901 assert!(decrypt_data_aad(&wire, &gck, &personal).is_err());
1902 }
1903
1904 #[test]
1905 fn group_entry_rejects_separator_byte_in_any_field() {
1906 let gck = generate_master_key();
1907 for ctx in [
1908 AeadContext::group_entry("g\u{1f}x", "tasks", "r"),
1909 AeadContext::group_entry("g", "ta\u{1f}sks", "r"),
1910 AeadContext::group_entry("g", "tasks", "r\u{1f}ow"),
1911 ] {
1912 assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err());
1913 }
1914 }
1915 }
1916