//! Encryption engine: key derivation, wrapping, and per-entry encrypt/decrypt. //! //! Key hierarchy: //! password + (app_id, user_id) → Argon2id → wrapping_key //! wrapping_key encrypts/decrypts the random master_key //! master_key encrypts/decrypts individual data entries //! //! All encryption uses XChaCha20-Poly1305 (192-bit nonces, safe for random generation). //! //! ## Why XChaCha20-Poly1305 over AES-GCM //! //! - 192-bit random nonces eliminate nonce-reuse risk (AES-GCM's 96-bit nonces are //! unsafe for random generation at high volume due to birthday bound at ~2^32 messages). //! - No hardware dependency: constant-time in software on all targets (AES-GCM needs //! AES-NI for safe, fast operation, not guaranteed on all user devices). //! - Widely audited: libsodium's default AEAD, used by Signal, WireGuard, and age. use argon2::{Algorithm, Argon2, Params, Version}; use base64::{Engine, engine::general_purpose::STANDARD as B64}; use chacha20poly1305::{ XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, Payload}, }; use rand::Rng; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroize; use crate::error::{Result, SyncKitError}; /// Size of XChaCha20-Poly1305 nonce in bytes. const NONCE_SIZE: usize = 24; /// Size of the encryption key in bytes. const KEY_SIZE: usize = 32; /// Current envelope version. const ENVELOPE_VERSION: u8 = 1; /// Argon2id parameters: 64 MB memory, 3 iterations (OWASP interactive minimum). const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB const ARGON2_TIME_COST: u32 = 3; const ARGON2_PARALLELISM: u32 = 1; // Accepted range for Argon2 parameters read from an (untrusted, server-supplied) // envelope. The floor rejects a maliciously weakened envelope (KDF downgrade); the // ceiling rejects an inflated one whose allocation would OOM every syncing device. // The pinned wrap constants above sit inside this range, so real envelopes unwrap. const ARGON2_MEM_MIN_KB: u32 = 8 * 1024; // 8 MiB // 256 MiB. The wrap cost is 64 MiB, so real envelopes sit comfortably inside the // range. A 1 GiB ceiling let a hostile server demand a 1 GiB Argon2 allocation on // every setup/change-password/rotate, an OOM (jetsam) kill lever on a 2-4 GiB // mobile device. 256 MiB still permits a stronger-than-default envelope while // staying well under the mobile memory budget. const ARGON2_MEM_MAX_KB: u32 = 256 * 1024; // 256 MiB const ARGON2_TIME_MAX: u32 = 16; const ARGON2_PARALLELISM_MAX: u32 = 16; fn default_argon_mem() -> u32 { ARGON2_MEM_COST_KB } fn default_argon_time() -> u32 { ARGON2_TIME_COST } fn default_argon_par() -> u32 { ARGON2_PARALLELISM } /// Encrypted master key envelope stored on the server. /// /// The Argon2 cost parameters travel with the envelope (defaulting to the /// current constants for v1 envelopes that predate them), so the work factor /// can be raised later without a flag-day: an old envelope still re-derives /// with the parameters it was wrapped under. #[derive(Debug, Serialize, Deserialize)] pub(crate) struct KeyEnvelope { /// Envelope version (currently 1). pub v: u8, /// Argon2 salt (base64). pub salt: String, /// XChaCha20-Poly1305 nonce for the wrapping (base64). pub nonce: String, /// Encrypted master key (base64). pub ciphertext: String, /// Argon2 memory cost in KiB. #[serde(default = "default_argon_mem")] pub m: u32, /// Argon2 time cost (iterations). #[serde(default = "default_argon_time")] pub t: u32, /// Argon2 parallelism. #[serde(default = "default_argon_par")] pub p: u32, } /// Generate a random 256-bit master key. pub fn generate_master_key() -> [u8; KEY_SIZE] { let mut key = [0u8; KEY_SIZE]; rand::rng().fill_bytes(&mut key); key } /// Maximum password length in bytes. Passwords longer than this are rejected /// to prevent denial-of-service via extreme Argon2 input sizes. const MAX_PASSWORD_BYTES: usize = 1024; /// Normalize a password to NFC form and validate constraints. /// /// Returns the NFC-normalized password string. Rejects empty passwords /// and passwords exceeding [`MAX_PASSWORD_BYTES`]. fn normalize_password(password: &str) -> Result { if password.is_empty() { return Err(SyncKitError::Crypto("password must not be empty".into())); } let normalized: String = password.nfc().collect(); if normalized.len() > MAX_PASSWORD_BYTES { return Err(SyncKitError::Crypto(format!( "password exceeds maximum length of {MAX_PASSWORD_BYTES} bytes" ))); } Ok(normalized) } /// Derive a wrapping key from a password and salt using Argon2id. /// /// The password is NFC-normalized before derivation to ensure consistent /// keys across platforms with different default Unicode normalization forms. fn derive_wrapping_key(password: &str, salt: &[u8; 32]) -> Result { derive_wrapping_key_with_params( password, salt, ARGON2_MEM_COST_KB, ARGON2_TIME_COST, ARGON2_PARALLELISM, ) } /// Derive a wrapping key with explicit Argon2 cost parameters (read from the /// envelope on unwrap, so old envelopes re-derive under their original cost). fn derive_wrapping_key_with_params( password: &str, salt: &[u8; 32], mem_kb: u32, time_cost: u32, parallelism: u32, ) -> Result { // Reject out-of-range parameters before the (expensive) allocation. An // envelope's params are attacker-reachable; a clamp is pointless (wrong // params derive a wrong key that fails the tag anyway), so fail closed. if !(ARGON2_MEM_MIN_KB..=ARGON2_MEM_MAX_KB).contains(&mem_kb) || !(1..=ARGON2_TIME_MAX).contains(&time_cost) || !(1..=ARGON2_PARALLELISM_MAX).contains(¶llelism) { return Err(SyncKitError::InvalidEnvelope( "Argon2 parameters outside the accepted range".into(), )); } let normalized = normalize_password(password)?; let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE)) .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut wrapping_key = ZeroizeOnDrop([0u8; KEY_SIZE]); let result = argon2 .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key.0) .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}"))); // Zeroize the normalized password before returning (defense-in-depth). let mut norm_bytes = normalized.into_bytes(); norm_bytes.zeroize(); result?; Ok(wrapping_key) } /// Verify that a password correctly derives to the given master key by /// attempting to unwrap the envelope with it. /// /// This is used by `change_password` to validate the old password before /// allowing a re-wrap with the new password. The cached key may still be /// used for the actual re-encryption, but the old password must be proven /// correct first. pub fn verify_password_against_envelope( envelope_json: &str, password: &str, ) -> Result<[u8; KEY_SIZE]> { unwrap_master_key(envelope_json, password) } /// Encrypt the master key with a password, producing a JSON envelope. /// /// Generates a random 32-byte salt for Argon2id key derivation and stores it /// in the envelope. Each wrap operation uses a unique salt, preventing /// precomputation attacks. pub fn wrap_master_key(master_key: &[u8; KEY_SIZE], password: &str) -> Result { let mut salt = [0u8; 32]; rand::rng().fill_bytes(&mut salt); let wrapping_key = derive_wrapping_key(password, &salt)?; let mut nonce_bytes = [0u8; NONCE_SIZE]; rand::rng().fill_bytes(&mut nonce_bytes); let cipher = XChaCha20Poly1305::new((&*wrapping_key).into()); let nonce = XNonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, master_key.as_ref()) .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?; let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode(salt), nonce: B64.encode(nonce_bytes), ciphertext: B64.encode(ciphertext), m: ARGON2_MEM_COST_KB, t: ARGON2_TIME_COST, p: ARGON2_PARALLELISM, }; serde_json::to_string(&envelope).map_err(Into::into) } /// Decrypt the master key from a JSON envelope using a password. /// /// Reads the random salt from the envelope, derives the wrapping key via /// Argon2id, then decrypts the master key. pub fn unwrap_master_key(envelope_json: &str, password: &str) -> Result<[u8; KEY_SIZE]> { let envelope: KeyEnvelope = serde_json::from_str(envelope_json) .map_err(|e| SyncKitError::InvalidEnvelope(format!("JSON parse: {e}")))?; if envelope.v != ENVELOPE_VERSION { return Err(SyncKitError::InvalidEnvelope(format!( "unsupported version {}", envelope.v ))); } let salt_bytes = B64.decode(&envelope.salt)?; let nonce_bytes = B64.decode(&envelope.nonce)?; let ciphertext = B64.decode(&envelope.ciphertext)?; if salt_bytes.len() != 32 { return Err(SyncKitError::InvalidEnvelope("invalid salt length".into())); } if nonce_bytes.len() != NONCE_SIZE { return Err(SyncKitError::InvalidEnvelope("invalid nonce length".into())); } let mut salt = [0u8; 32]; salt.copy_from_slice(&salt_bytes); let wrapping_key = derive_wrapping_key_with_params(password, &salt, envelope.m, envelope.t, envelope.p)?; let cipher = XChaCha20Poly1305::new((&*wrapping_key).into()); let nonce = XNonce::try_from(&nonce_bytes[..]).expect("nonce length was validated as NONCE_SIZE above"); let mut plaintext = cipher .decrypt(&nonce, ciphertext.as_ref()) .map_err(|_| SyncKitError::DecryptionFailed)?; if plaintext.len() != KEY_SIZE { plaintext.zeroize(); return Err(SyncKitError::InvalidEnvelope( "decrypted key has wrong length".into(), )); } let mut key = [0u8; KEY_SIZE]; key.copy_from_slice(&plaintext); plaintext.zeroize(); Ok(key) } /// Wire-format tag marking a v2 payload: AEAD sealed with associated data /// binding the ciphertext to its logical position. Legacy (untagged) payloads /// were sealed with empty AAD. The tag is read *before* decryption, so the /// reader always knows which AAD to apply, no structural guessing. /// /// `:` is not in the base64 alphabet, so a tagged base64 string is /// unambiguous against a legacy one. const WIRE_V2_TAG: &str = "sk2:"; /// Raw-bytes form of [`WIRE_V2_TAG`] for blob payloads. const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:"; /// The binding context for an AEAD operation, *what* a ciphertext belongs to. /// /// The associated data is derived from this value, so a caller cannot pass /// empty or mismatched AAD: the only way to seal or open an entry or a blob is /// to name its logical position. That makes the untrusted-server placement /// attack (relocating a valid ciphertext to a different row/table/address) /// unrepresentable at the type level rather than a per-call-site discipline. #[derive(Clone, Copy, Debug)] pub enum AeadContext<'a> { /// A personal sync change entry, bound to its `(table, row_id)` address. Entry { /// Table the row belongs to. table: &'a str, /// Row id within the table. row_id: &'a str, }, /// A group sync change entry, bound to its `(group_id, table, row_id)` /// address. Sealed under the group's GCK (see [`crate::identity`]), not the /// personal `master_key`. Folding `group_id` into the AAD makes a ciphertext /// non-relocatable across groups as well as across tables and rows: a server /// that moves a group-entry ciphertext into a different group's changelog /// changes its AAD, so it fails to open. GroupEntry { /// Group whose GCK sealed the entry. group_id: &'a str, /// Table the row belongs to. table: &'a str, /// Row id within the table. row_id: &'a str, }, /// A content-addressed blob, bound to its content hash. Blob { /// Content hash the blob is addressed by. hash: &'a str, }, } impl<'a> AeadContext<'a> { /// Bind a personal change entry to its `(table, row_id)` address. pub fn entry(table: &'a str, row_id: &'a str) -> Self { AeadContext::Entry { table, row_id } } /// Bind a group change entry to its `(group_id, table, row_id)` address. pub fn group_entry(group_id: &'a str, table: &'a str, row_id: &'a str) -> Self { AeadContext::GroupEntry { group_id, table, row_id, } } /// Bind a blob to its content hash. pub fn blob(hash: &'a str) -> Self { AeadContext::Blob { hash } } /// The associated-data bytes for this context. A server that relocates a /// ciphertext changes its context, hence its AAD, so decryption fails /// closed, the AEAD tag no longer authenticates the moved bytes. fn aad(&self) -> Result> { match self { AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id), AeadContext::GroupEntry { group_id, table, row_id, } => aad_for_group_entry(group_id, table, row_id), AeadContext::Blob { hash } => Ok(hash.as_bytes().to_vec()), } } } /// Canonical associated-data encoding binding an entry's ciphertext to its /// `(table, row_id)` address. /// /// The encoding is `table || 0x1f || row_id`. This is injective as long as /// neither field contains the `0x1f` unit separator, so we reject any field that /// does, making `("a", "bc")` and `("ab", "c")` provably distinct rather than /// distinct-by-assumption. Rejecting (vs length-prefixing) keeps the wire format /// unchanged, so existing ciphertext still decrypts. Private: callers reach it /// only through [`AeadContext::Entry`], which is the point of the seal. fn aad_for_entry(table: &str, row_id: &str) -> Result> { if table.as_bytes().contains(&0x1f) || row_id.as_bytes().contains(&0x1f) { return Err(SyncKitError::Crypto( "table or row_id contains the 0x1f AAD separator".into(), )); } let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1); aad.extend_from_slice(table.as_bytes()); aad.push(0x1f); aad.extend_from_slice(row_id.as_bytes()); Ok(aad) } /// Canonical associated-data encoding binding a group entry's ciphertext to its /// `(group_id, table, row_id)` address: `group_id || 0x1f || table || 0x1f || /// row_id`. Rejects any field containing `0x1f`, which keeps the encoding /// injective (as with [`aad_for_entry`]). The three-field form always carries /// exactly two separators where a personal [`aad_for_entry`] carries exactly one, /// so a group-entry AAD can never coincide with a personal-entry AAD. fn aad_for_group_entry(group_id: &str, table: &str, row_id: &str) -> Result> { if group_id.as_bytes().contains(&0x1f) || table.as_bytes().contains(&0x1f) || row_id.as_bytes().contains(&0x1f) { return Err(SyncKitError::Crypto( "group_id, table, or row_id contains the 0x1f AAD separator".into(), )); } let mut aad = Vec::with_capacity(group_id.len() + table.len() + row_id.len() + 2); aad.extend_from_slice(group_id.as_bytes()); aad.push(0x1f); aad.extend_from_slice(table.as_bytes()); aad.push(0x1f); aad.extend_from_slice(row_id.as_bytes()); Ok(aad) } /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`. /// /// `pub(crate)` so the group-key layer ([`crate::identity`]) can seal a Group /// Content Key under a freshly derived (ECDH) key with explicit AAD, reusing the /// one audited AEAD this crate ships rather than open-coding a second one. pub(crate) fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result> { let mut nonce_bytes = [0u8; NONCE_SIZE]; rand::rng().fill_bytes(&mut nonce_bytes); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::from(nonce_bytes); let ciphertext = cipher .encrypt( &nonce, Payload { msg: plaintext, aad, }, ) .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?; let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); blob.extend_from_slice(&nonce_bytes); blob.extend_from_slice(&ciphertext); Ok(blob) } /// AEAD open of a `nonce || ciphertext || tag` blob with the given AAD. /// /// `pub(crate)` counterpart to [`seal`]; see its note. Used by /// [`crate::identity`] to open a Group Content Key grant. pub(crate) fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result> { if blob.len() < NONCE_SIZE + 16 { // Minimum: nonce + poly1305 tag (empty plaintext) return Err(SyncKitError::Crypto("ciphertext too short".into())); } let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE); let cipher = XChaCha20Poly1305::new(master_key.into()); let nonce = XNonce::try_from(nonce_bytes) .expect("split_at(NONCE_SIZE) yields exactly NONCE_SIZE bytes"); cipher .decrypt( &nonce, Payload { msg: ciphertext, aad, }, ) .map_err(|_| SyncKitError::DecryptionFailed) } /// Encrypt a data entry with the master key (legacy, untagged, empty AAD). /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). Prefer /// [`encrypt_data_aad`] for new writes so the ciphertext is position-bound. pub fn encrypt_data(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result { Ok(B64.encode(seal(plaintext, master_key, &[])?)) } /// Decrypt a legacy (untagged, empty-AAD) data entry. /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]). pub fn decrypt_data(encoded: &str, master_key: &[u8; KEY_SIZE]) -> Result> { open(&B64.decode(encoded)?, master_key, &[]) } /// Encrypt a data entry, binding it to `ctx`, and emit the [`WIRE_V2_TAG`]-prefixed form. pub fn encrypt_data_aad( plaintext: &[u8], master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result { Ok(format!( "{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad()?)?) )) } /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged /// payload is opened with `ctx`'s AAD; an untagged (legacy) payload is opened /// with empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day. pub fn decrypt_data_aad( encoded: &str, master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result> { match encoded.strip_prefix(WIRE_V2_TAG) { Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()?), None => open(&B64.decode(encoded)?, master_key, &[]), } } /// Encrypt raw bytes with the master key (legacy, untagged, empty AAD). /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64). pub fn encrypt_bytes(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result> { seal(plaintext, master_key, &[]) } /// Decrypt legacy (untagged, empty-AAD) raw bytes. pub fn decrypt_bytes(encrypted: &[u8], master_key: &[u8; KEY_SIZE]) -> Result> { open(encrypted, master_key, &[]) } /// Encrypt raw bytes binding them to `ctx`, emitting the `sk2:`-prefixed raw /// form. Used for blobs, where `ctx` is [`AeadContext::Blob`]. pub fn encrypt_bytes_aad( plaintext: &[u8], master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result> { let sealed = seal(plaintext, master_key, &ctx.aad()?)?; let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len()); out.extend_from_slice(WIRE_V2_TAG_BYTES); out.extend_from_slice(&sealed); Ok(out) } /// Decrypt raw bytes, auto-detecting the wire version. A `sk2:`-tagged blob is /// opened with `ctx`'s AAD; an untagged (legacy) blob is opened with empty AAD. pub fn decrypt_bytes_aad( encrypted: &[u8], master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result> { match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) { Some(rest) => open(rest, master_key, &ctx.aad()?), None => open(encrypted, master_key, &[]), } } /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag). pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16; // ── Chunked blob format (v3) ── // // A blob is split into fixed-size plaintext chunks, each sealed independently // with AAD binding (content_hash, chunk_index, chunk_count). This lets a reader // decrypt and verify one chunk at a time, bounding peak memory to a single // chunk instead of the whole blob, while making chunk reorder, truncation, // duplication, and cross-blob substitution all fail closed at the AEAD layer. // // Layout: `sk3:` | version(u8=3) | chunk_size(u32 LE) | total_len(u64 LE) | sealed_chunk* // where each `sealed_chunk` is `nonce[24] || ciphertext || tag[16]`. A zero-length // blob is encoded as a single empty sealed chunk so it is still authenticated. /// Wire tag for the v3 chunked blob format. const WIRE_V3_TAG_BYTES: &[u8] = b"sk3:"; /// Fixed header length after the tag: version(1) + chunk_size(4) + total_len(8). const BLOB_V3_HEADER_LEN: usize = 1 + 4 + 8; /// Plaintext bytes per blob chunk (1 MiB). Bounds per-chunk working memory. pub const BLOB_CHUNK_SIZE: usize = 1024 * 1024; /// AAD binding a blob chunk to its content hash and position. `chunk_count` is /// included so dropping the final chunk (truncation) changes every chunk's AAD. fn blob_chunk_aad(hash: &str, index: u32, chunk_count: u32) -> Vec { let mut aad = Vec::with_capacity(hash.len() + 1 + 8); aad.extend_from_slice(hash.as_bytes()); aad.push(0x1f); aad.extend_from_slice(&index.to_le_bytes()); aad.extend_from_slice(&chunk_count.to_le_bytes()); aad } /// Number of v3 chunks a `total_len`-byte plaintext seals into, at this /// client's [`BLOB_CHUNK_SIZE`]. /// /// Pure arithmetic over the plaintext length, which is what lets a streaming /// uploader lay out every part boundary before reading a byte of the file. pub fn blob_chunk_count_for(total_len: usize) -> u32 { blob_chunk_count(total_len, BLOB_CHUNK_SIZE) } /// Number of chunks for a `total_len` plaintext at `chunk_size` (min 1). fn blob_chunk_count(total_len: usize, chunk_size: usize) -> u32 { if total_len == 0 { 1 } else { // Saturate rather than truncate. `total_len` is trusted (= `data.len()`, // capped upstream) on the encrypt path but ATTACKER-CONTROLLED on the // decrypt path (it comes from the v3 header), where a bare `as u32` would // silently wrap an astronomical claim down to a small count. Saturating to // u32::MAX keeps the value obviously-too-large so the caller's // `total_len`-vs-input-length bound rejects the header instead, and a // panic (debug_assert) here would itself be a crash DoS on that path. total_len.div_ceil(chunk_size).min(u32::MAX as usize) as u32 } } /// Parsed v3 blob header. #[derive(Clone, Copy, Debug)] pub struct BlobHeader { /// Plaintext bytes per chunk (last chunk may be shorter). pub chunk_size: usize, /// Total plaintext length. pub total_len: usize, /// Number of sealed chunks. pub chunk_count: u32, } impl BlobHeader { /// Sealed length of chunk `index` (`nonce + plaintext_slice + tag`). pub fn sealed_chunk_len(&self, index: u32) -> usize { let plain = if self.total_len == 0 { 0 } else if index == self.chunk_count - 1 { self.total_len - self.chunk_size * index as usize } else { self.chunk_size }; ENCRYPTION_OVERHEAD + plain } } /// Whether `data` begins with the v3 chunked-blob tag. pub fn is_chunked_blob(data: &[u8]) -> bool { data.starts_with(WIRE_V3_TAG_BYTES) } /// Total wire overhead [`encrypt_blob_chunked`] adds for a `plaintext_len`-byte /// blob: the tag + header, plus one nonce + tag per chunk. pub fn chunked_blob_overhead(plaintext_len: usize) -> usize { let chunk_count = blob_chunk_count(plaintext_len, BLOB_CHUNK_SIZE) as usize; WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN + chunk_count * ENCRYPTION_OVERHEAD } /// Exact v3 ciphertext length for a `plaintext_len`-byte blob. /// /// Known from the plaintext length alone, so a multipart uploader can declare /// the object size (and therefore sign an exact `Content-Length` per part) /// before it has sealed anything. pub fn blob_encrypted_len(plaintext_len: usize) -> usize { plaintext_len + chunked_blob_overhead(plaintext_len) } /// The v3 tag + header that precedes the sealed-chunk stream for a /// `total_len`-byte plaintext. /// /// Split out of [`encrypt_blob_chunked`] so a streaming uploader can emit the /// header first and then seal chunks one at a time; the two paths produce /// byte-identical output. pub fn blob_header_bytes(total_len: usize) -> Vec { let mut out = Vec::with_capacity(WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN); out.extend_from_slice(WIRE_V3_TAG_BYTES); out.push(3); out.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes()); out.extend_from_slice(&(total_len as u64).to_le_bytes()); out } /// Seal one blob chunk, binding `(hash, index, chunk_count)` as AAD. /// /// The encrypt-side counterpart to [`decrypt_blob_chunk`]. `chunk` must be the /// `index`-th [`BLOB_CHUNK_SIZE`] slice of the plaintext (the last may be /// shorter), and `chunk_count` must be [`blob_chunk_count_for`] of the whole /// plaintext, the AAD binds both, so a streaming caller that miscounts /// produces a blob that fails to open rather than one that opens wrong. pub fn seal_blob_chunk( chunk: &[u8], master_key: &[u8; KEY_SIZE], hash: &str, index: u32, chunk_count: u32, ) -> Result> { seal(chunk, master_key, &blob_chunk_aad(hash, index, chunk_count)) } /// Parse the v3 header, returning it plus the number of bytes consumed (tag + /// header). The remaining input is the sealed-chunk stream. pub fn parse_blob_header(data: &[u8]) -> Result<(BlobHeader, usize)> { let body = data .strip_prefix(WIRE_V3_TAG_BYTES) .ok_or_else(|| SyncKitError::Crypto("not a v3 chunked blob".into()))?; if body.len() < BLOB_V3_HEADER_LEN { return Err(SyncKitError::Crypto("v3 blob header truncated".into())); } if body[0] != 3 { return Err(SyncKitError::Crypto(format!( "unknown blob format version {}; this client is too old to read it", body[0] ))); } let chunk_size = u32::from_le_bytes(body[1..5].try_into().unwrap()) as usize; let total_len = u64::from_le_bytes(body[5..13].try_into().unwrap()) as usize; if chunk_size == 0 { return Err(SyncKitError::Crypto("v3 blob chunk_size is zero".into())); } let chunk_count = blob_chunk_count(total_len, chunk_size); let consumed = WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN; Ok(( BlobHeader { chunk_size, total_len, chunk_count, }, consumed, )) } /// Decrypt one sealed chunk, binding `(hash, index, chunk_count)`. pub fn decrypt_blob_chunk( sealed: &[u8], master_key: &[u8; KEY_SIZE], hash: &str, index: u32, chunk_count: u32, ) -> Result> { open( sealed, master_key, &blob_chunk_aad(hash, index, chunk_count), ) } /// Encrypt a blob into the v3 chunked wire format (see the module note above). pub fn encrypt_blob_chunked( plaintext: &[u8], master_key: &[u8; KEY_SIZE], hash: &str, ) -> Result> { let total_len = plaintext.len(); let chunk_count = blob_chunk_count_for(total_len); let mut out = Vec::with_capacity(blob_encrypted_len(total_len)); out.extend_from_slice(&blob_header_bytes(total_len)); if total_len == 0 { out.extend_from_slice(&seal_blob_chunk(&[], master_key, hash, 0, 1)?); return Ok(out); } for (i, chunk) in plaintext.chunks(BLOB_CHUNK_SIZE).enumerate() { out.extend_from_slice(&seal_blob_chunk( chunk, master_key, hash, i as u32, chunk_count, )?); } Ok(out) } /// Decrypt a complete v3 chunked blob from one buffer. Used by the non-streaming /// fallback and tests; the streaming download path parses chunks incrementally /// via [`parse_blob_header`] + [`decrypt_blob_chunk`]. pub fn decrypt_blob_chunked( encrypted: &[u8], master_key: &[u8; KEY_SIZE], hash: &str, ) -> Result> { let (header, consumed) = parse_blob_header(encrypted)?; // `total_len` is an attacker-controlled u64 from the wire header. Plaintext can // never exceed its own ciphertext, so reject any claim larger than the input // before allocating, otherwise a header of `u64::MAX` aborts on the capacity // request (a trivial DoS). if header.total_len > encrypted.len() { return Err(SyncKitError::Crypto( "v3 blob total_len exceeds encrypted input".into(), )); } let mut rest = &encrypted[consumed..]; let mut out = Vec::with_capacity(header.total_len); for i in 0..header.chunk_count { let len = header.sealed_chunk_len(i); if rest.len() < len { return Err(SyncKitError::Crypto("v3 blob truncated mid-chunk".into())); } let (sealed, tail) = rest.split_at(len); out.extend_from_slice(&decrypt_blob_chunk( sealed, master_key, hash, i, header.chunk_count, )?); rest = tail; } if !rest.is_empty() { return Err(SyncKitError::Crypto("v3 blob has trailing bytes".into())); } Ok(out) } /// Overhead of a v2 (AAD-bound, `sk2:`-tagged) blob: the wire tag plus the /// nonce + Poly1305 tag. pub const ENCRYPTION_OVERHEAD_V2: usize = WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD; /// Encrypt a JSON value (legacy, untagged, empty AAD). Prefer [`encrypt_json_aad`]. pub fn encrypt_json( value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ) -> Result { use zeroize::Zeroize; let mut plaintext = serde_json::to_vec(value)?; let encrypted = encrypt_data(&plaintext, master_key); plaintext.zeroize(); Ok(serde_json::Value::String(encrypted?)) } /// Decrypt a legacy (untagged, empty-AAD) JSON string from the `data` field. pub fn decrypt_json( encrypted_value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ) -> Result { use zeroize::Zeroize; let encoded = encrypted_value .as_str() .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?; let mut plaintext = decrypt_data(encoded, master_key)?; let result = serde_json::from_slice(&plaintext); plaintext.zeroize(); result.map_err(Into::into) } /// Encrypt a JSON value binding it to `ctx`, emitting the `sk2:`-tagged form. pub fn encrypt_json_aad( value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result { use zeroize::Zeroize; let mut plaintext = serde_json::to_vec(value)?; let encrypted = encrypt_data_aad(&plaintext, master_key, ctx); plaintext.zeroize(); Ok(serde_json::Value::String(encrypted?)) } /// Decrypt a JSON string from the `data` field, auto-detecting the wire /// version (tagged → use `ctx`'s AAD, untagged → empty AAD). pub fn decrypt_json_aad( encrypted_value: &serde_json::Value, master_key: &[u8; KEY_SIZE], ctx: &AeadContext, ) -> Result { use zeroize::Zeroize; let encoded = encrypted_value .as_str() .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?; let mut plaintext = decrypt_data_aad(encoded, master_key, ctx)?; let result = serde_json::from_slice(&plaintext); plaintext.zeroize(); result.map_err(Into::into) } /// Zero out a key on drop using the `zeroize` crate. pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]); impl std::fmt::Debug for ZeroizeOnDrop { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("[REDACTED 32-byte key]") } } impl Drop for ZeroizeOnDrop { fn drop(&mut self) { use zeroize::Zeroize; self.0.zeroize(); } } impl std::ops::Deref for ZeroizeOnDrop { type Target = [u8; KEY_SIZE]; fn deref(&self) -> &Self::Target { &self.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn master_key_generation_is_random() { let k1 = generate_master_key(); let k2 = generate_master_key(); assert_ne!(k1, k2, "Two generated keys must differ"); assert_eq!(k1.len(), 32); } #[test] fn wrapping_key_derivation_is_deterministic() { let salt = [42u8; 32]; let k1 = derive_wrapping_key("password123", &salt).unwrap(); let k2 = derive_wrapping_key("password123", &salt).unwrap(); assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key"); } #[test] fn different_passwords_produce_different_keys() { let salt = [42u8; 32]; let k1 = derive_wrapping_key("password1", &salt).unwrap(); let k2 = derive_wrapping_key("password2", &salt).unwrap(); assert_ne!(*k1, *k2); } #[test] fn different_salts_produce_different_keys() { let salt1 = [1u8; 32]; let salt2 = [2u8; 32]; let k1 = derive_wrapping_key("password", &salt1).unwrap(); let k2 = derive_wrapping_key("password", &salt2).unwrap(); assert_ne!(*k1, *k2); } // ── Password normalization (NFC/NFD) ── #[test] fn nfc_and_nfd_passwords_derive_same_key() { // "e" + combining acute accent (NFD form of e-acute) let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent // Pre-composed e-acute (NFC form) let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent // Verify they are actually different byte sequences assert_ne!( nfd_password.as_bytes(), nfc_password.as_bytes(), "NFD and NFC should have different raw bytes" ); let salt = [99u8; 32]; let k1 = derive_wrapping_key(nfd_password, &salt).unwrap(); let k2 = derive_wrapping_key(nfc_password, &salt).unwrap(); assert_eq!( *k1, *k2, "Same password in NFC and NFD forms must derive the same key" ); } #[test] fn nfc_nfd_wrap_unwrap_roundtrip() { let master_key = generate_master_key(); // Wrap with NFC form let nfc_password = "caf\u{00e9}"; let envelope = wrap_master_key(&master_key, nfc_password).unwrap(); // Unwrap with NFD form let nfd_password = "caf\u{0065}\u{0301}"; let recovered = unwrap_master_key(&envelope, nfd_password).unwrap(); assert_eq!(master_key, recovered); } #[test] fn nfd_wrap_nfc_unwrap_roundtrip() { let master_key = generate_master_key(); // Wrap with NFD form let nfd_password = "caf\u{0065}\u{0301}"; let envelope = wrap_master_key(&master_key, nfd_password).unwrap(); // Unwrap with NFC form let nfc_password = "caf\u{00e9}"; let recovered = unwrap_master_key(&envelope, nfc_password).unwrap(); assert_eq!(master_key, recovered); } #[test] fn normalize_password_converts_to_nfc() { let nfd = "caf\u{0065}\u{0301}"; let nfc = "caf\u{00e9}"; let normalized = normalize_password(nfd).unwrap(); assert_eq!(normalized, nfc); } // ── Empty password rejection ── #[test] fn empty_password_rejected_by_normalize() { let result = normalize_password(""); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(msg.contains("empty"), "Error should mention empty: {msg}"); } #[test] fn empty_password_rejected_by_derive() { let salt = [0u8; 32]; let result = derive_wrapping_key("", &salt); assert!(result.is_err()); } #[test] fn empty_password_rejected_by_wrap() { let master_key = generate_master_key(); let result = wrap_master_key(&master_key, ""); assert!(result.is_err()); } #[test] fn empty_password_rejected_by_unwrap() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "valid").unwrap(); let result = unwrap_master_key(&envelope, ""); assert!(result.is_err()); } // ── Password length limit ── #[test] fn very_long_password_rejected() { let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1); let result = normalize_password(&long_password); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( msg.contains("maximum length"), "Error should mention max length: {msg}" ); } #[test] fn password_at_max_length_accepted() { let max_password = "a".repeat(MAX_PASSWORD_BYTES); let result = normalize_password(&max_password); assert!(result.is_ok()); } #[test] fn password_just_under_max_length_accepted() { let password = "a".repeat(MAX_PASSWORD_BYTES - 1); let result = normalize_password(&password); assert!(result.is_ok()); } // ── Salt reuse detection ── #[test] fn two_wraps_use_different_salts() { let master_key = generate_master_key(); let e1_json = wrap_master_key(&master_key, "pass").unwrap(); let e2_json = wrap_master_key(&master_key, "pass").unwrap(); let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap(); let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap(); assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt"); assert_ne!( e1.nonce, e2.nonce, "Each wrap must use a unique random nonce" ); } // ── Key derivation determinism ── #[test] fn key_derivation_deterministic_multiple_calls() { let salt = [77u8; 32]; let password = "deterministic-test-password"; let k1 = derive_wrapping_key(password, &salt).unwrap(); let k2 = derive_wrapping_key(password, &salt).unwrap(); let k3 = derive_wrapping_key(password, &salt).unwrap(); assert_eq!(*k1, *k2); assert_eq!(*k2, *k3); } // ── Key rotation: re-wrap with new password, old data still readable ── #[test] fn key_rotation_preserves_data_access() { let master_key = generate_master_key(); let plaintext = b"encrypted before password change"; // Encrypt data with the master key let encrypted = encrypt_data(plaintext, &master_key).unwrap(); // Wrap master key with old password let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap(); // Simulate password change: unwrap with old, re-wrap with new let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap(); assert_eq!(recovered_key, master_key); let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap(); // Verify: unwrap with new password gives same key let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap(); assert_eq!(key_from_new, master_key); // Verify: old encrypted data can still be decrypted let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap(); assert_eq!(decrypted, plaintext); // Verify: old password no longer works on new envelope let result = unwrap_master_key(&new_envelope, "old-pass"); assert!(result.is_err()); } // ── Encryption roundtrip with various data sizes ── #[test] fn encrypt_decrypt_empty_data() { let master_key = generate_master_key(); let encrypted = encrypt_data(b"", &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert!(decrypted.is_empty()); } #[test] fn encrypt_decrypt_single_byte() { let master_key = generate_master_key(); let encrypted = encrypt_data(&[42], &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, vec![42]); } #[test] fn encrypt_decrypt_large_payload() { let master_key = generate_master_key(); // 1MB of data let plaintext: Vec = (0..1_000_000).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_data(&plaintext, &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } // ── Wrong key gives error, not garbage ── #[test] fn wrong_key_gives_decryption_error_not_garbage() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"this should fail cleanly with wrong key"; let encrypted = encrypt_data(plaintext, &key1).unwrap(); let result = decrypt_data(&encrypted, &key2); // Must be an error, not a successful decryption to garbage assert!(result.is_err()); assert!( matches!(result.unwrap_err(), SyncKitError::DecryptionFailed), "Wrong key must produce DecryptionFailed, not garbage output" ); } #[test] fn wrong_key_bytes_gives_decryption_error_not_garbage() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"binary data check"; let encrypted = encrypt_bytes(plaintext, &key1).unwrap(); let result = decrypt_bytes(&encrypted, &key2); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } // ── JSON encryption edge cases ── #[test] fn json_encrypt_decrypt_null() { let master_key = generate_master_key(); let original = serde_json::Value::Null; let encrypted = encrypt_json(&original, &master_key).unwrap(); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn json_encrypt_decrypt_nested_object() { let master_key = generate_master_key(); let original = serde_json::json!({ "level1": { "level2": { "level3": [1, 2, 3], "flag": true } }, "empty_array": [], "empty_object": {} }); let encrypted = encrypt_json(&original, &master_key).unwrap(); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn json_decrypt_with_wrong_key_fails() { let key1 = generate_master_key(); let key2 = generate_master_key(); let original = serde_json::json!({"secret": "data"}); let encrypted = encrypt_json(&original, &key1).unwrap(); let result = decrypt_json(&encrypted, &key2); assert!(result.is_err()); } #[test] fn json_decrypt_non_string_value_fails() { let master_key = generate_master_key(); let not_a_string = serde_json::json!(42); let result = decrypt_json(¬_a_string, &master_key); assert!(result.is_err()); } // ── Blob (bytes) edge cases ── #[test] fn bytes_zero_byte_blob_roundtrip() { let master_key = generate_master_key(); let empty: &[u8] = &[]; let encrypted = encrypt_bytes(empty, &master_key).unwrap(); assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert!(decrypted.is_empty()); } #[test] fn bytes_boundary_size_blob() { let master_key = generate_master_key(); // Test at exactly the nonce size boundary let data = vec![0xAB; NONCE_SIZE]; let encrypted = encrypt_bytes(&data, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, data); } #[test] fn bytes_1mb_blob_roundtrip() { let master_key = generate_master_key(); let data: Vec = (0..1_048_576).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_bytes(&data, &master_key).unwrap(); assert_eq!(encrypted.len(), data.len() + ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, data); } // ── Tampered ciphertext detection ── #[test] fn tampered_ciphertext_detected() { let master_key = generate_master_key(); let plaintext = b"integrity check"; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let mut blob = B64.decode(&encrypted).unwrap(); // Flip a byte in the ciphertext portion (after the nonce) let idx = NONCE_SIZE + 1; blob[idx] ^= 0xFF; let tampered = B64.encode(&blob); let result = decrypt_data(&tampered, &master_key); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[test] fn tampered_nonce_detected() { let master_key = generate_master_key(); let plaintext = b"nonce tamper check"; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let mut blob = B64.decode(&encrypted).unwrap(); // Flip a byte in the nonce blob[0] ^= 0xFF; let tampered = B64.encode(&blob); let result = decrypt_data(&tampered, &master_key); assert!(result.is_err()); } // ── Envelope validation edge cases ── #[test] fn invalid_envelope_json_rejected() { let result = unwrap_master_key("not valid json at all", "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn envelope_with_wrong_salt_length_rejected() { let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode([0u8; 16]), // 16 bytes, should be 32 nonce: B64.encode([0u8; NONCE_SIZE]), ciphertext: B64.encode([0u8; 48]), m: ARGON2_MEM_COST_KB, t: ARGON2_TIME_COST, p: ARGON2_PARALLELISM, }; let json = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&json, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn envelope_with_wrong_nonce_length_rejected() { let envelope = KeyEnvelope { v: ENVELOPE_VERSION, salt: B64.encode([0u8; 32]), nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24 ciphertext: B64.encode([0u8; 48]), m: ARGON2_MEM_COST_KB, t: ARGON2_TIME_COST, p: ARGON2_PARALLELISM, }; let json = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&json, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } // ── verify_password_against_envelope ── #[test] fn verify_password_correct() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = verify_password_against_envelope(&envelope, "correct"); assert!(result.is_ok()); assert_eq!(result.unwrap(), master_key); } #[test] fn verify_password_wrong() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = verify_password_against_envelope(&envelope, "wrong"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[test] fn wrap_unwrap_roundtrip() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "mypassword").unwrap(); let recovered = unwrap_master_key(&envelope, "mypassword").unwrap(); assert_eq!(master_key, recovered); } #[test] fn wrap_uses_random_salt() { let master_key = generate_master_key(); let e1 = wrap_master_key(&master_key, "pass").unwrap(); let e2 = wrap_master_key(&master_key, "pass").unwrap(); // Different envelopes (random salt + random nonce) assert_ne!(e1, e2); // Both decrypt correctly assert_eq!(unwrap_master_key(&e1, "pass").unwrap(), master_key); assert_eq!(unwrap_master_key(&e2, "pass").unwrap(), master_key); } #[test] fn wrong_password_fails_unwrap() { let master_key = generate_master_key(); let envelope = wrap_master_key(&master_key, "correct").unwrap(); let result = unwrap_master_key(&envelope, "wrong"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[test] fn data_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let plaintext = b"Hello, world! This is sensitive data."; let encrypted = encrypt_data(plaintext, &master_key).unwrap(); let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn same_plaintext_different_ciphertext() { let master_key = generate_master_key(); let plaintext = b"same data"; let e1 = encrypt_data(plaintext, &master_key).unwrap(); let e2 = encrypt_data(plaintext, &master_key).unwrap(); assert_ne!(e1, e2, "Random nonces must produce different ciphertext"); // But both decrypt to the same plaintext assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext); assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext); } #[test] fn wrong_key_fails_decrypt() { let key1 = generate_master_key(); let key2 = generate_master_key(); let plaintext = b"secret"; let encrypted = encrypt_data(plaintext, &key1).unwrap(); let result = decrypt_data(&encrypted, &key2); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[test] fn envelope_version_check() { let master_key = generate_master_key(); let envelope_json = wrap_master_key(&master_key, "pass").unwrap(); // Tamper with version let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap(); envelope.v = 99; let tampered = serde_json::to_string(&envelope).unwrap(); let result = unwrap_master_key(&tampered, "pass"); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::InvalidEnvelope(_) )); } #[test] fn truncated_ciphertext_rejected() { let master_key = generate_master_key(); let encrypted = encrypt_data(b"data", &master_key).unwrap(); // Decode, truncate, re-encode let mut blob = B64.decode(&encrypted).unwrap(); blob.truncate(10); // Way too short let truncated = B64.encode(&blob); let result = decrypt_data(&truncated, &master_key); assert!(result.is_err()); } #[test] fn json_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let original = serde_json::json!({ "title": "Buy milk", "priority": 3, "tags": ["groceries", "urgent"] }); let encrypted = encrypt_json(&original, &master_key).unwrap(); assert!(encrypted.is_string(), "Encrypted JSON should be a string"); let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, original); } #[test] fn zeroize_on_drop() { let key = generate_master_key(); let guarded = ZeroizeOnDrop(key); // Verify we can use it assert_eq!(guarded.len(), 32); // Drop happens automatically, we can't easily test memory zeroing // but we verify the API works without panic. drop(guarded); } // ── encrypt_bytes / decrypt_bytes ── #[test] fn bytes_encrypt_decrypt_roundtrip() { let master_key = generate_master_key(); let plaintext = b"raw binary blob data \x00\x01\x02\xff"; let encrypted = encrypt_bytes(plaintext, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn bytes_encrypt_has_correct_overhead() { let master_key = generate_master_key(); let plaintext = vec![0u8; 1000]; let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap(); assert_eq!(encrypted.len(), plaintext.len() + ENCRYPTION_OVERHEAD); } #[test] fn bytes_same_plaintext_different_ciphertext() { let master_key = generate_master_key(); let plaintext = b"same data"; let e1 = encrypt_bytes(plaintext, &master_key).unwrap(); let e2 = encrypt_bytes(plaintext, &master_key).unwrap(); assert_ne!(e1, e2); assert_eq!(decrypt_bytes(&e1, &master_key).unwrap(), plaintext); assert_eq!(decrypt_bytes(&e2, &master_key).unwrap(), plaintext); } #[test] fn bytes_wrong_key_fails() { let key1 = generate_master_key(); let key2 = generate_master_key(); let encrypted = encrypt_bytes(b"secret", &key1).unwrap(); let result = decrypt_bytes(&encrypted, &key2); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[test] fn bytes_truncated_rejected() { let master_key = generate_master_key(); let encrypted = encrypt_bytes(b"data", &master_key).unwrap(); let result = decrypt_bytes(&encrypted[..10], &master_key); assert!(result.is_err()); } #[test] fn bytes_empty_plaintext_roundtrip() { let master_key = generate_master_key(); let plaintext = b""; let encrypted = encrypt_bytes(plaintext, &master_key).unwrap(); assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn bytes_large_blob_roundtrip() { let master_key = generate_master_key(); let plaintext: Vec = (0..100_000).map(|i| (i % 256) as u8).collect(); let encrypted = encrypt_bytes(&plaintext, &master_key).unwrap(); let decrypted = decrypt_bytes(&encrypted, &master_key).unwrap(); assert_eq!(decrypted, plaintext); } // ── AAD binding + wire version tag (v2) ── #[test] fn aad_for_entry_is_injective_across_boundary() { // ("a","bc") and ("ab","c") must not collide, or a server could swap // table/row_id halves and keep the AAD constant. assert_ne!( aad_for_entry("a", "bc").unwrap(), aad_for_entry("ab", "c").unwrap() ); assert_eq!( aad_for_entry("tasks", "r1").unwrap(), aad_for_entry("tasks", "r1").unwrap() ); } #[test] fn aad_for_entry_rejects_separator_byte() { // A field carrying the 0x1f separator would break injectivity, so it is // rejected rather than silently encoded. assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err()); assert!(aad_for_entry("tasks", "r\u{1f}1").is_err()); assert!(aad_for_entry("tasks", "r1").is_ok()); } #[test] fn argon2_params_out_of_range_are_rejected() { let salt = [7u8; 32]; // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation. assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err()); // Mobile-OOM fix: 512 MiB, valid under the old 1 GiB ceiling, is now // rejected (before any allocation) by the 256 MiB ceiling. assert!(derive_wrapping_key_with_params("pw", &salt, 512 * 1024, 3, 1).is_err()); // Weakened memory (KDF downgrade) rejected. assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err()); // Zero time / parallelism rejected. assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err()); assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err()); // The pinned production parameters are inside the accepted range. assert!( derive_wrapping_key_with_params( "pw", &salt, ARGON2_MEM_COST_KB, ARGON2_TIME_COST, ARGON2_PARALLELISM ) .is_ok() ); } #[test] fn blob_total_len_exceeding_input_is_rejected_not_allocated() { let key = generate_master_key(); // A v3 header claiming u64::MAX plaintext with no chunk bytes must be // rejected on the length bound, not attempt an astronomical allocation. let mut buf = Vec::new(); buf.extend_from_slice(WIRE_V3_TAG_BYTES); buf.push(3); buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes()); buf.extend_from_slice(&u64::MAX.to_le_bytes()); assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err()); } #[test] fn v2_data_is_tagged_and_roundtrips() { let key = generate_master_key(); let ctx = AeadContext::entry("tasks", "row-1"); let wire = encrypt_data_aad(b"hello", &key, &ctx).unwrap(); assert!( wire.starts_with("sk2:"), "v2 payload must carry the wire tag: {wire}" ); let pt = decrypt_data_aad(&wire, &key, &ctx).unwrap(); assert_eq!(pt, b"hello"); } #[test] fn v2_relocation_to_different_row_fails_closed() { // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1) // must not decrypt when the server presents it under (tasks,row-2). let key = generate_master_key(); let sealed = encrypt_data_aad(b"secret", &key, &AeadContext::entry("tasks", "row-1")).unwrap(); let relocated = decrypt_data_aad(&sealed, &key, &AeadContext::entry("tasks", "row-2")); assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed))); let relocated_table = decrypt_data_aad(&sealed, &key, &AeadContext::entry("notes", "row-1")); assert!(matches!( relocated_table, Err(SyncKitError::DecryptionFailed) )); } /// Known-answer vectors for the XChaCha20-Poly1305 envelope, computed /// outside RustCrypto: HChaCha20 implemented against the test vector in /// draft-irtf-cfrg-xchacha-03 §2.2.1, then the IETF ChaCha20-Poly1305 leg /// run through python `cryptography`. This is the ciphertext already on /// disk and on the wire, so it must decrypt byte-for-byte forever, a /// cipher-crate upgrade that silently changed the envelope would break /// every existing user, and the roundtrip tests above would not notice. /// /// key = 00..1f, nonce = 40..57, plaintext = the string asserted below. #[test] fn envelope_matches_independent_known_answer() { const V2_ENTRY: &str = "sk2:QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuJwl14RY250chudQ2lSbk6V"; const LEGACY: &str = "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXp0BrE7uJDTbqmvHbw/MV97LMn+R4NzztBBGcK3pzRuKvdRcL/9EeoK9LSYyzSJY7"; const PLAINTEXT: &[u8] = b"synckit envelope v2 known answer"; let mut key = [0u8; KEY_SIZE]; for (i, b) in key.iter_mut().enumerate() { *b = u8::try_from(i).unwrap(); } let ctx = AeadContext::entry("notes", "row-1"); assert_eq!(decrypt_data_aad(V2_ENTRY, &key, &ctx).unwrap(), PLAINTEXT); assert_eq!(decrypt_data(LEGACY, &key).unwrap(), PLAINTEXT); // The AAD is genuinely bound: the same bytes under a different address // must fail, or the vector above would prove nothing about binding. assert!(matches!( decrypt_data_aad(V2_ENTRY, &key, &AeadContext::entry("notes", "row-2")), Err(SyncKitError::DecryptionFailed) )); } #[test] fn legacy_untagged_still_decrypts_under_aad_reader() { // A v1 (untagged, empty-AAD) payload must keep decrypting through the // tag-aware reader, regardless of the context passed, no flag-day. let key = generate_master_key(); let legacy = encrypt_data(b"old data", &key).unwrap(); assert!(!legacy.starts_with("sk2:")); let pt = decrypt_data_aad(&legacy, &key, &AeadContext::entry("tasks", "row-1")).unwrap(); assert_eq!(pt, b"old data"); } #[test] fn v2_json_roundtrip_and_relocation_fails() { let key = generate_master_key(); let value = serde_json::json!({"title": "Buy milk"}); let ctx = AeadContext::entry("tasks", "row-1"); let wire = encrypt_json_aad(&value, &key, &ctx).unwrap(); assert!(wire.as_str().unwrap().starts_with("sk2:")); assert_eq!(decrypt_json_aad(&wire, &key, &ctx).unwrap(), value); let moved = decrypt_json_aad(&wire, &key, &AeadContext::entry("tasks", "row-2")); assert!(moved.is_err()); } #[test] fn v2_bytes_tagged_roundtrip_and_relocation_fails() { let key = generate_master_key(); let ctx_a = AeadContext::blob("sha256-aaa"); let ctx_b = AeadContext::blob("sha256-bbb"); let blob = encrypt_bytes_aad(b"blob bytes", &key, &ctx_a).unwrap(); assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag"); assert_eq!( decrypt_bytes_aad(&blob, &key, &ctx_a).unwrap(), b"blob bytes" ); // Substituting a different hash's context fails closed. assert!(matches!( decrypt_bytes_aad(&blob, &key, &ctx_b), Err(SyncKitError::DecryptionFailed) )); } #[test] fn legacy_bytes_still_decrypt_under_aad_reader() { let key = generate_master_key(); let legacy = encrypt_bytes(b"old blob", &key).unwrap(); assert!(!legacy.starts_with(b"sk2:")); let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap(); assert_eq!(pt, b"old blob"); } // ── chunked blob format (v3) ── #[test] fn chunked_blob_roundtrips_across_chunk_boundaries() { let key = generate_master_key(); // Spans three chunks (two full + a partial), exercising the boundary math. let plaintext: Vec = (0..(BLOB_CHUNK_SIZE * 2 + 123)).map(|i| i as u8).collect(); let hash = "sha256-abc"; let wire = encrypt_blob_chunked(&plaintext, &key, hash).unwrap(); assert!(is_chunked_blob(&wire), "must carry the sk3: tag"); let (header, _) = parse_blob_header(&wire).unwrap(); assert_eq!(header.total_len, plaintext.len()); assert_eq!(header.chunk_count, 3); assert_eq!(decrypt_blob_chunked(&wire, &key, hash).unwrap(), plaintext); } #[test] fn chunked_blob_handles_empty_and_single_chunk() { let key = generate_master_key(); for pt in [vec![], b"small blob".to_vec()] { let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); assert_eq!(parse_blob_header(&wire).unwrap().0.chunk_count, 1); assert_eq!(decrypt_blob_chunked(&wire, &key, "h").unwrap(), pt); } } #[test] fn streaming_seal_matches_whole_buffer_encrypt() { let key = generate_master_key(); let hash = "sha256-stream"; // Two full chunks plus a partial, and the empty blob, which the // streaming path has to special-case the same way (one empty chunk). for pt in [ vec![], b"one small chunk".to_vec(), (0..(BLOB_CHUNK_SIZE * 2 + 7)).map(|i| i as u8).collect(), ] { let whole = encrypt_blob_chunked(&pt, &key, hash).unwrap(); // What a streaming uploader does: header first, then seal each // chunk as it reads it. Nonces are random, so the bytes differ, // the contract is the LENGTH (which every part boundary is signed // against) and that the result opens to the same plaintext. let chunk_count = blob_chunk_count_for(pt.len()); let mut streamed = blob_header_bytes(pt.len()); if pt.is_empty() { streamed.extend_from_slice(&seal_blob_chunk(&[], &key, hash, 0, 1).unwrap()); } else { for (i, chunk) in pt.chunks(BLOB_CHUNK_SIZE).enumerate() { streamed.extend_from_slice( &seal_blob_chunk(chunk, &key, hash, i as u32, chunk_count).unwrap(), ); } } assert_eq!(streamed.len(), whole.len()); assert_eq!(streamed.len(), blob_encrypted_len(pt.len())); assert_eq!(decrypt_blob_chunked(&streamed, &key, hash).unwrap(), pt); } } #[test] fn blob_encrypted_len_predicts_the_wire_size() { let key = generate_master_key(); // Boundary sizes: the prediction is what a multipart start declares, so // being off by one byte anywhere breaks a signed Content-Length. for len in [ 0, 1, BLOB_CHUNK_SIZE - 1, BLOB_CHUNK_SIZE, BLOB_CHUNK_SIZE + 1, BLOB_CHUNK_SIZE * 3, ] { let pt = vec![7u8; len]; let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); assert_eq!(blob_encrypted_len(len), wire.len(), "len {len}"); } } #[test] fn chunked_blob_wrong_hash_fails_closed() { let key = generate_master_key(); let pt = b"bound to its address".to_vec(); let wire = encrypt_blob_chunked(&pt, &key, "hash-a").unwrap(); // A different content hash changes every chunk's AAD -> open fails. assert!(matches!( decrypt_blob_chunked(&wire, &key, "hash-b"), Err(SyncKitError::DecryptionFailed) )); } #[test] fn chunked_blob_tamper_and_truncation_fail_closed() { let key = generate_master_key(); let pt: Vec = (0..(BLOB_CHUNK_SIZE + 50)).map(|i| i as u8).collect(); let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap(); // Flip a ciphertext byte -> AEAD tag mismatch. let mut tampered = wire.clone(); let last = tampered.len() - 1; tampered[last] ^= 0x01; assert!(decrypt_blob_chunked(&tampered, &key, "h").is_err()); // Drop the final chunk's bytes -> truncation detected. let header = parse_blob_header(&wire).unwrap().0; let truncated = &wire[..wire.len() - header.sealed_chunk_len(header.chunk_count - 1)]; assert!(decrypt_blob_chunked(truncated, &key, "h").is_err()); } #[test] fn parse_blob_header_rejects_non_v3() { let key = generate_master_key(); let v2 = encrypt_bytes_aad(b"x", &key, &AeadContext::blob("h")).unwrap(); assert!(parse_blob_header(&v2).is_err()); assert!(!is_chunked_blob(&v2)); } // ── Group-entry AAD binding ── #[test] fn group_entry_roundtrips_under_gck() { let gck = generate_master_key(); let ctx = AeadContext::group_entry("grp1", "tasks", "row-9"); let wire = encrypt_data_aad(b"shared task", &gck, &ctx).unwrap(); let out = decrypt_data_aad(&wire, &gck, &ctx).unwrap(); assert_eq!(out, b"shared task"); } #[test] fn group_entry_relocated_to_another_group_fails() { let gck = generate_master_key(); let wire = encrypt_data_aad( b"shared task", &gck, &AeadContext::group_entry("grp1", "tasks", "row-9"), ) .unwrap(); // Same GCK and same (table, row_id), but a different group_id: a server // that filed this ciphertext under another group cannot make it open. let moved = AeadContext::group_entry("grp2", "tasks", "row-9"); assert!(matches!( decrypt_data_aad(&wire, &gck, &moved), Err(SyncKitError::DecryptionFailed) )); } #[test] fn group_entry_and_personal_entry_aad_never_collide() { // A personal Entry and a GroupEntry with the same table/row_id must not // share associated data (the separator-count invariant), so a ciphertext // cannot cross the personal/group boundary. let personal = AeadContext::entry("tasks", "row-9"); let group = AeadContext::group_entry("", "tasks", "row-9"); assert_ne!(personal.aad().unwrap(), group.aad().unwrap()); let gck = generate_master_key(); let wire = encrypt_data_aad(b"x", &gck, &group).unwrap(); assert!(decrypt_data_aad(&wire, &gck, &personal).is_err()); } #[test] fn group_entry_rejects_separator_byte_in_any_field() { let gck = generate_master_key(); for ctx in [ AeadContext::group_entry("g\u{1f}x", "tasks", "r"), AeadContext::group_entry("g", "ta\u{1f}sks", "r"), AeadContext::group_entry("g", "tasks", "r\u{1f}ow"), ] { assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err()); } } }