Skip to main content

max / makenotwork

synckit: bind AEAD associated data + version tag, fix rotation resume (ultra-fuzz Run #1 Crypto) - crypto: add sk2:-tagged, AAD-bound encrypt/decrypt variants over aead::Payload (legacy untagged path preserved, empty AAD) + aad_for_entry helper. Closes X1a at the primitive layer; relocation across (table,row_id) now fails closed. - crypto: store Argon2 m/t/p in the key envelope (serde-default to current consts) so the work factor can be raised without a flag-day. - rotation: resume an interrupted rotation by adopting the server's committed pending_key instead of minting a fresh key (X3 data-loss fix); pure rotation_key_material helper + tests. - gate set_master_key_raw/with_http_client behind a new testing feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 22:14 UTC
Commit: 225a0eee6f0fc240bbf5f0a85b6c123d29b408ed
Parent: 0acbd0a
6 files changed, +346 insertions, -86 deletions
@@ -1531,6 +1531,7 @@ dependencies = [
1531 1531 "serde",
1532 1532 "serde_json",
1533 1533 "sha2",
1534 + "synckit-client",
1534 1535 "thiserror",
1535 1536 "tokio",
1536 1537 "tokio-stream",
@@ -8,6 +8,11 @@ license-file = "LICENSE"
8 8 [features]
9 9 default = ["keychain"]
10 10 keychain = ["dep:keyring"]
11 + # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
12 + # bypass key derivation. Enabled automatically for this crate's own test builds
13 + # via the self dev-dependency below; never part of `default`, so a real consumer
14 + # cannot reach a raw-key injection point.
15 + testing = []
11 16
12 17 [dependencies]
13 18 # Encryption
@@ -50,3 +55,6 @@ tracing = "0.1"
50 55
51 56 [dev-dependencies]
52 57 wiremock = "0.6"
58 + # Self dev-dependency: turns on the `testing` feature for this crate's own
59 + # unit/integration test builds without leaking it into a consumer's default set.
60 + synckit-client = { path = ".", features = ["testing"] }
@@ -159,15 +159,21 @@ impl SyncKitClient {
159 159 /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
160 160 /// Returns `(envelope_json, key_version)`.
161 161 pub(super) async fn get_server_key(&self) -> Result<(String, i32)> {
162 + let resp = self.get_server_key_full().await?;
163 + Ok((resp.encrypted_key, resp.key_version.unwrap_or(0)))
164 + }
165 +
166 + /// Download the full key state, including any in-progress rotation's
167 + /// `pending_key`. Used by `rotate_key` to resume an interrupted rotation
168 + /// with the already-committed key rather than minting a fresh one.
169 + pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> {
162 170 let (url, token) = self.key_url_and_token()?;
163 171
164 - let key_resp: GetKeyResponse = self
165 - .retry_request_json(|| {
166 - let req = self.http.get(url).bearer_auth(&token);
167 - async move { check_response(req.send().await?).await }
168 - })
169 - .await?;
170 - Ok((key_resp.encrypted_key, key_resp.key_version.unwrap_or(0)))
172 + self.retry_request_json(|| {
173 + let req = self.http.get(url).bearer_auth(&token);
174 + async move { check_response(req.send().await?).await }
175 + })
176 + .await
171 177 }
172 178 }
173 179
@@ -201,7 +201,11 @@ impl SyncKitClient {
201 201 }
202 202
203 203 /// Create a new client with a custom HTTP client (for testing with custom timeouts).
204 + ///
205 + /// Test-only: gated behind the `testing` feature so consumer builds cannot
206 + /// substitute an unvalidated HTTP client.
204 207 #[doc(hidden)]
208 + #[cfg(any(test, feature = "testing"))]
205 209 pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self {
206 210 let endpoints = Endpoints::new(&config.server_url);
207 211 Self {
@@ -236,7 +240,11 @@ impl SyncKitClient {
236 240 }
237 241
238 242 /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
243 + ///
244 + /// Test-only: gated behind the `testing` feature so a consumer build has no
245 + /// chosen-key injection point that bypasses key derivation.
239 246 #[doc(hidden)]
247 + #[cfg(any(test, feature = "testing"))]
240 248 pub fn set_master_key_raw(&self, key: [u8; 32]) {
241 249 *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
242 250 }
@@ -15,8 +15,13 @@ impl SyncKitClient {
15 15 /// Rotate the master encryption key.
16 16 ///
17 17 /// Generates a new 256-bit key, re-encrypts all sync log entries in batches,
18 - /// and commits the new key to the server. Idempotent: if interrupted, calling
19 - /// `rotate_key` again resumes from where it left off.
18 + /// and commits the new key to the server.
19 + ///
20 + /// Genuinely idempotent: if a previous call was interrupted, the server still
21 + /// holds the rotation's `pending_key`. This call detects that and **resumes
22 + /// with the already-committed key** rather than minting a second new key —
23 + /// the latter would orphan any entries already re-encrypted under the first
24 + /// key. Only when there is no pending rotation is a fresh key generated.
20 25 ///
21 26 /// Requires the encryption password (to wrap the new key) and a device_id
22 27 /// (the device performing the rotation). Only one device can rotate at a time.
@@ -30,16 +35,22 @@ impl SyncKitClient {
30 35 device_id: Uuid,
31 36 password: &str,
32 37 ) -> Result<()> {
33 - // 1. Verify password and get old key
34 - let (envelope_json, key_version) = self.get_server_key().await?;
35 - let old_key = crypto::verify_password_against_envelope(&envelope_json, password)?;
38 + // 1. Verify password against the still-active key, and learn whether a
39 + // rotation is already pending for this user.
40 + let key_state = self.get_server_key_full().await?;
41 + let key_version = key_state.key_version.unwrap_or(0);
42 + let old_key = crypto::verify_password_against_envelope(&key_state.encrypted_key, password)?;
36 43 let old_key = crypto::ZeroizeOnDrop(old_key);
37 44
38 - // 2. Generate new key and wrap with password
39 - let new_master_key = crypto::generate_master_key();
40 - let new_envelope = crypto::wrap_master_key(&new_master_key, password)?;
45 + // 2. Resume an interrupted rotation by adopting its committed pending key,
46 + // or start fresh. Either way `new_envelope` is the key the server will
47 + // promote on completion, so re-encryption uses the same key it commits.
48 + let (new_master_key, new_envelope) =
49 + Self::rotation_key_material(key_state.pending_key, password)?;
41 50
42 - // 3. Begin rotation
51 + // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the
52 + // same device: it returns the existing rotation, ignoring `new_envelope`
53 + // on resume — which is why step 2 must adopt the committed key.
43 54 let begin_resp = self.begin_rotation(device_id, &new_envelope, key_version).await?;
44 55 let rotation_id = begin_resp.rotation_id;
45 56 let new_key_id = begin_resp.new_key_id;
@@ -97,6 +108,28 @@ impl SyncKitClient {
97 108 Ok(())
98 109 }
99 110
111 + /// Decide the rotation's new key material: adopt the server's committed
112 + /// `pending_key` when resuming an interrupted rotation, otherwise mint a
113 + /// fresh key. Returns `(new_master_key, new_envelope)` where the envelope is
114 + /// what the server promotes on completion — so the re-encryption key always
115 + /// matches the committed envelope. Pure (no IO) so it is unit-testable.
116 + fn rotation_key_material(
117 + pending: Option<PendingKeyInfo>,
118 + password: &str,
119 + ) -> Result<([u8; 32], String)> {
120 + match pending {
121 + Some(pending) => {
122 + let key = crypto::unwrap_master_key(&pending.encrypted_key, password)?;
123 + Ok((key, pending.encrypted_key))
124 + }
125 + None => {
126 + let key = crypto::generate_master_key();
127 + let envelope = crypto::wrap_master_key(&key, password)?;
128 + Ok((key, envelope))
129 + }
130 + }
131 + }
132 +
100 133 /// Pull a batch of entries needing re-encryption, re-encrypt them, and push back.
101 134 /// Returns true if there are no more entries to process.
102 135 async fn reencrypt_batch(
@@ -254,11 +287,32 @@ impl SyncKitClient {
254 287 mod tests {
255 288 use super::*;
256 289
257 - fn test_config() -> super::super::SyncKitConfig {
258 - super::super::SyncKitConfig {
259 - server_url: "https://example.com".to_string(),
260 - api_key: "test-api-key-123".to_string(),
261 - }
290 + #[test]
291 + fn rotation_resume_reuses_committed_pending_key() {
292 + // Simulate an interrupted rotation: the server holds a pending envelope
293 + // wrapping key K. Resuming must recover K (not mint a fresh key), or
294 + // entries already re-encrypted under K become undecryptable.
295 + let committed_key = crypto::generate_master_key();
296 + let pending_envelope = crypto::wrap_master_key(&committed_key, "pw").unwrap();
297 + let pending = Some(PendingKeyInfo {
298 + encrypted_key: pending_envelope.clone(),
299 + key_id: 2,
300 + });
301 +
302 + let (resumed_key, envelope) =
303 + SyncKitClient::rotation_key_material(pending, "pw").unwrap();
304 + assert_eq!(resumed_key, committed_key, "resume must adopt the committed key");
305 + assert_eq!(envelope, pending_envelope, "resume must keep the committed envelope");
306 + }
307 +
308 + #[test]
309 + fn rotation_fresh_start_mints_unwrappable_key() {
310 + // No pending rotation: a fresh key is generated and its envelope unwraps
311 + // back to that same key under the password.
312 + let (fresh_key, envelope) =
313 + SyncKitClient::rotation_key_material(None, "pw").unwrap();
314 + let recovered = crypto::unwrap_master_key(&envelope, "pw").unwrap();
315 + assert_eq!(recovered, fresh_key);
262 316 }
263 317
264 318 #[test]
@@ -18,7 +18,7 @@
18 18 use argon2::{Argon2, Algorithm, Version, Params};
19 19 use base64::{engine::general_purpose::STANDARD as B64, Engine};
20 20 use chacha20poly1305::{
21 - aead::{Aead, KeyInit},
21 + aead::{Aead, KeyInit, Payload},
22 22 XChaCha20Poly1305, XNonce,
23 23 };
24 24 use rand::RngCore;
@@ -41,7 +41,16 @@ const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB
41 41 const ARGON2_TIME_COST: u32 = 3;
42 42 const ARGON2_PARALLELISM: u32 = 1;
43 43
44 + fn default_argon_mem() -> u32 { ARGON2_MEM_COST_KB }
45 + fn default_argon_time() -> u32 { ARGON2_TIME_COST }
46 + fn default_argon_par() -> u32 { ARGON2_PARALLELISM }
47 +
44 48 /// Encrypted master key envelope stored on the server.
49 + ///
50 + /// The Argon2 cost parameters travel with the envelope (defaulting to the
51 + /// current constants for v1 envelopes that predate them), so the work factor
52 + /// can be raised later without a flag-day: an old envelope still re-derives
53 + /// with the parameters it was wrapped under.
45 54 #[derive(Debug, Serialize, Deserialize)]
46 55 pub(crate) struct KeyEnvelope {
47 56 /// Envelope version (currently 1).
@@ -52,6 +61,15 @@ pub(crate) struct KeyEnvelope {
52 61 pub nonce: String,
53 62 /// Encrypted master key (base64).
54 63 pub ciphertext: String,
64 + /// Argon2 memory cost in KiB.
65 + #[serde(default = "default_argon_mem")]
66 + pub m: u32,
67 + /// Argon2 time cost (iterations).
68 + #[serde(default = "default_argon_time")]
69 + pub t: u32,
70 + /// Argon2 parallelism.
71 + #[serde(default = "default_argon_par")]
72 + pub p: u32,
55 73 }
56 74
57 75 /// Generate a random 256-bit master key.
@@ -93,15 +111,28 @@ fn derive_wrapping_key(
93 111 password: &str,
94 112 salt: &[u8; 32],
95 113 ) -> Result<ZeroizeOnDrop> {
96 - let normalized = normalize_password(password)?;
97 -
98 - let params = Params::new(
114 + derive_wrapping_key_with_params(
115 + password,
116 + salt,
99 117 ARGON2_MEM_COST_KB,
100 118 ARGON2_TIME_COST,
101 119 ARGON2_PARALLELISM,
102 - Some(KEY_SIZE),
103 120 )
104 - .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
121 + }
122 +
123 + /// Derive a wrapping key with explicit Argon2 cost parameters (read from the
124 + /// envelope on unwrap, so old envelopes re-derive under their original cost).
125 + fn derive_wrapping_key_with_params(
126 + password: &str,
127 + salt: &[u8; 32],
128 + mem_kb: u32,
129 + time_cost: u32,
130 + parallelism: u32,
131 + ) -> Result<ZeroizeOnDrop> {
132 + let normalized = normalize_password(password)?;
133 +
134 + let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE))
135 + .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
105 136
106 137 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
107 138
@@ -161,6 +192,9 @@ pub fn wrap_master_key(
161 192 salt: B64.encode(salt),
162 193 nonce: B64.encode(nonce_bytes),
163 194 ciphertext: B64.encode(ciphertext),
195 + m: ARGON2_MEM_COST_KB,
196 + t: ARGON2_TIME_COST,
197 + p: ARGON2_PARALLELISM,
164 198 };
165 199
166 200 serde_json::to_string(&envelope).map_err(Into::into)
@@ -204,7 +238,8 @@ pub fn unwrap_master_key(
204 238
205 239 let mut salt = [0u8; 32];
206 240 salt.copy_from_slice(&salt_bytes);
207 - let wrapping_key = derive_wrapping_key(password, &salt)?;
241 + let wrapping_key =
242 + derive_wrapping_key_with_params(password, &salt, envelope.m, envelope.t, envelope.p)?;
208 243
209 244 let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
210 245 let nonce = XNonce::from_slice(&nonce_bytes);
@@ -226,12 +261,35 @@ pub fn unwrap_master_key(
226 261 Ok(key)
227 262 }
228 263
229 - /// Encrypt a data entry with the master key.
230 - /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]).
231 - pub fn encrypt_data(
232 - plaintext: &[u8],
233 - master_key: &[u8; KEY_SIZE],
234 - ) -> Result<String> {
264 + /// Wire-format tag marking a v2 payload: AEAD sealed with associated data
265 + /// binding the ciphertext to its logical position. Legacy (untagged) payloads
266 + /// were sealed with empty AAD. The tag is read *before* decryption, so the
267 + /// reader always knows which AAD to apply — no structural guessing.
268 + ///
269 + /// `:` is not in the base64 alphabet, so a tagged base64 string is
270 + /// unambiguous against a legacy one.
271 + const WIRE_V2_TAG: &str = "sk2:";
272 + /// Raw-bytes form of [`WIRE_V2_TAG`] for blob payloads.
273 + const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:";
274 +
275 + /// Canonical associated-data encoding binding an entry's ciphertext to its
276 + /// `(table, row_id)` address. A server that relocates a ciphertext to a
277 + /// different row or table changes the AAD, so decryption fails closed — the
278 + /// AEAD tag no longer authenticates the moved bytes.
279 + ///
280 + /// The unit separator (`0x1f`) cannot appear in the encoded form of either
281 + /// component's length boundary ambiguously, so `("a", "bc")` and `("ab", "c")`
282 + /// produce distinct AAD.
283 + pub fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> {
284 + let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1);
285 + aad.extend_from_slice(table.as_bytes());
286 + aad.push(0x1f);
287 + aad.extend_from_slice(row_id.as_bytes());
288 + aad
289 + }
290 +
291 + /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`.
292 + fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
235 293 let mut nonce_bytes = [0u8; NONCE_SIZE];
236 294 rand::rng().fill_bytes(&mut nonce_bytes);
237 295
@@ -239,90 +297,107 @@ pub fn encrypt_data(
239 297 let nonce = XNonce::from_slice(&nonce_bytes);
240 298
241 299 let ciphertext = cipher
242 - .encrypt(nonce, plaintext)
300 + .encrypt(nonce, Payload { msg: plaintext, aad })
243 301 .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
244 302
245 - // Wire format: nonce || ciphertext (which includes poly1305 tag)
246 303 let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
247 304 blob.extend_from_slice(&nonce_bytes);
248 305 blob.extend_from_slice(&ciphertext);
249 -
250 - Ok(B64.encode(blob))
306 + Ok(blob)
251 307 }
252 308
253 - /// Decrypt a data entry with the master key.
254 - /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
255 - pub fn decrypt_data(
256 - encoded: &str,
257 - master_key: &[u8; KEY_SIZE],
258 - ) -> Result<Vec<u8>> {
259 - let blob = B64.decode(encoded)?;
260 -
309 + /// AEAD open of a `nonce || ciphertext || tag` blob with the given AAD.
310 + fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
261 311 if blob.len() < NONCE_SIZE + 16 {
262 312 // Minimum: nonce + poly1305 tag (empty plaintext)
263 - return Err(SyncKitError::Crypto(
264 - "ciphertext too short".into(),
265 - ));
313 + return Err(SyncKitError::Crypto("ciphertext too short".into()));
266 314 }
267 -
268 315 let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
269 316 let cipher = XChaCha20Poly1305::new(master_key.into());
270 317 let nonce = XNonce::from_slice(nonce_bytes);
271 -
272 318 cipher
273 - .decrypt(nonce, ciphertext)
319 + .decrypt(nonce, Payload { msg: ciphertext, aad })
274 320 .map_err(|_| SyncKitError::DecryptionFailed)
275 321 }
276 322
277 - /// Encrypt raw bytes with the master key.
278 - /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
279 - /// Use this for blob data where base64 overhead is undesirable.
280 - pub fn encrypt_bytes(
323 + /// Encrypt a data entry with the master key (legacy, untagged, empty AAD).
324 + /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). Prefer
325 + /// [`encrypt_data_aad`] for new writes so the ciphertext is position-bound.
326 + pub fn encrypt_data(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<String> {
327 + Ok(B64.encode(seal(plaintext, master_key, &[])?))
328 + }
329 +
330 + /// Decrypt a legacy (untagged, empty-AAD) data entry.
331 + /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
332 + pub fn decrypt_data(encoded: &str, master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
333 + open(&B64.decode(encoded)?, master_key, &[])
334 + }
335 +
336 + /// Encrypt a data entry, binding `aad`, and emit the [`WIRE_V2_TAG`]-prefixed form.
337 + pub fn encrypt_data_aad(
281 338 plaintext: &[u8],
282 339 master_key: &[u8; KEY_SIZE],
283 - ) -> Result<Vec<u8>> {
284 - let mut nonce_bytes = [0u8; NONCE_SIZE];
285 - rand::rng().fill_bytes(&mut nonce_bytes);
340 + aad: &[u8],
341 + ) -> Result<String> {
342 + Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, aad)?)))
343 + }
286 344
287 - let cipher = XChaCha20Poly1305::new(master_key.into());
288 - let nonce = XNonce::from_slice(&nonce_bytes);
345 + /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged
346 + /// payload is opened with `aad`; an untagged (legacy) payload is opened with
347 + /// empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day.
348 + pub fn decrypt_data_aad(
349 + encoded: &str,
350 + master_key: &[u8; KEY_SIZE],
351 + aad: &[u8],
352 + ) -> Result<Vec<u8>> {
353 + match encoded.strip_prefix(WIRE_V2_TAG) {
354 + Some(rest) => open(&B64.decode(rest)?, master_key, aad),
355 + None => open(&B64.decode(encoded)?, master_key, &[]),
356 + }
357 + }
289 358
290 - let ciphertext = cipher
291 - .encrypt(nonce, plaintext)
292 - .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
359 + /// Encrypt raw bytes with the master key (legacy, untagged, empty AAD).
360 + /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
361 + pub fn encrypt_bytes(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
362 + seal(plaintext, master_key, &[])
363 + }
293 364
294 - let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
295 - blob.extend_from_slice(&nonce_bytes);
296 - blob.extend_from_slice(&ciphertext);
365 + /// Decrypt legacy (untagged, empty-AAD) raw bytes.
366 + pub fn decrypt_bytes(encrypted: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
367 + open(encrypted, master_key, &[])
368 + }
297 369
298 - Ok(blob)
370 + /// Encrypt raw bytes binding `aad`, emitting the `sk2:`-prefixed raw form.
371 + /// Used for blobs, where `aad` is the content hash.
372 + pub fn encrypt_bytes_aad(
373 + plaintext: &[u8],
374 + master_key: &[u8; KEY_SIZE],
375 + aad: &[u8],
376 + ) -> Result<Vec<u8>> {
377 + let sealed = seal(plaintext, master_key, aad)?;
378 + let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len());
379 + out.extend_from_slice(WIRE_V2_TAG_BYTES);
380 + out.extend_from_slice(&sealed);
381 + Ok(out)
299 382 }
300 383
301 - /// Decrypt raw bytes with the master key.
302 - /// Input is `nonce[24] || ciphertext || poly1305_tag[16]`.
303 - pub fn decrypt_bytes(
384 + /// Decrypt raw bytes, auto-detecting the wire version. A `sk2:`-tagged blob is
385 + /// opened with `aad`; an untagged (legacy) blob is opened with empty AAD.
386 + pub fn decrypt_bytes_aad(
304 387 encrypted: &[u8],
305 388 master_key: &[u8; KEY_SIZE],
389 + aad: &[u8],
306 390 ) -> Result<Vec<u8>> {
307 - if encrypted.len() < NONCE_SIZE + 16 {
308 - return Err(SyncKitError::Crypto(
309 - "ciphertext too short".into(),
310 - ));
391 + match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) {
392 + Some(rest) => open(rest, master_key, aad),
393 + None => open(encrypted, master_key, &[]),
311 394 }
312 -
313 - let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
314 - let cipher = XChaCha20Poly1305::new(master_key.into());
315 - let nonce = XNonce::from_slice(nonce_bytes);
316 -
317 - cipher
318 - .decrypt(nonce, ciphertext)
319 - .map_err(|_| SyncKitError::DecryptionFailed)
320 395 }
321 396
322 397 /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag).
323 398 pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16;
324 399
325 - /// Encrypt a JSON value, returning a JSON string suitable for the `data` field.
400 + /// Encrypt a JSON value (legacy, untagged, empty AAD). Prefer [`encrypt_json_aad`].
326 401 pub fn encrypt_json(
327 402 value: &serde_json::Value,
328 403 master_key: &[u8; KEY_SIZE],
@@ -334,17 +409,40 @@ pub fn encrypt_json(
334 409 Ok(serde_json::Value::String(encrypted?))
335 410 }
336 411
337 - /// Decrypt a JSON string from the `data` field back into the original value.
412 + /// Decrypt a legacy (untagged, empty-AAD) JSON string from the `data` field.
338 413 pub fn decrypt_json(
339 414 encrypted_value: &serde_json::Value,
340 415 master_key: &[u8; KEY_SIZE],
341 416 ) -> Result<serde_json::Value> {
417 + decrypt_json_aad(encrypted_value, master_key, &[])
418 + }
419 +
420 + /// Encrypt a JSON value binding `aad`, emitting the `sk2:`-tagged form.
421 + pub fn encrypt_json_aad(
422 + value: &serde_json::Value,
423 + master_key: &[u8; KEY_SIZE],
424 + aad: &[u8],
425 + ) -> Result<serde_json::Value> {
426 + use zeroize::Zeroize;
427 + let mut plaintext = serde_json::to_vec(value)?;
428 + let encrypted = encrypt_data_aad(&plaintext, master_key, aad);
429 + plaintext.zeroize();
430 + Ok(serde_json::Value::String(encrypted?))
431 + }
432 +
433 + /// Decrypt a JSON string from the `data` field, auto-detecting the wire
434 + /// version (tagged → use `aad`, untagged → empty AAD).
435 + pub fn decrypt_json_aad(
436 + encrypted_value: &serde_json::Value,
437 + master_key: &[u8; KEY_SIZE],
438 + aad: &[u8],
439 + ) -> Result<serde_json::Value> {
342 440 use zeroize::Zeroize;
343 441 let encoded = encrypted_value
344 442 .as_str()
345 443 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
346 444
347 - let mut plaintext = decrypt_data(encoded, master_key)?;
445 + let mut plaintext = decrypt_data_aad(encoded, master_key, aad)?;
348 446 let result = serde_json::from_slice(&plaintext);
349 447 plaintext.zeroize();
350 448 result.map_err(Into::into)
@@ -785,6 +883,9 @@ mod tests {
785 883 salt: B64.encode([0u8; 16]), // 16 bytes, should be 32
786 884 nonce: B64.encode([0u8; NONCE_SIZE]),
787 885 ciphertext: B64.encode([0u8; 48]),
886 + m: ARGON2_MEM_COST_KB,
887 + t: ARGON2_TIME_COST,
888 + p: ARGON2_PARALLELISM,
788 889 };
789 890 let json = serde_json::to_string(&envelope).unwrap();
790 891
@@ -803,6 +904,9 @@ mod tests {
803 904 salt: B64.encode([0u8; 32]),
804 905 nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24
805 906 ciphertext: B64.encode([0u8; 48]),
907 + m: ARGON2_MEM_COST_KB,
908 + t: ARGON2_TIME_COST,
909 + p: ARGON2_PARALLELISM,
806 910 };
807 911 let json = serde_json::to_string(&envelope).unwrap();
808 912
@@ -1046,4 +1150,83 @@ mod tests {
1046 1150
1047 1151 assert_eq!(decrypted, plaintext);
1048 1152 }
1153 +
1154 + // ── AAD binding + wire version tag (v2) ──
1155 +
1156 + #[test]
1157 + fn aad_for_entry_is_injective_across_boundary() {
1158 + // ("a","bc") and ("ab","c") must not collide, or a server could swap
1159 + // table/row_id halves and keep the AAD constant.
1160 + assert_ne!(aad_for_entry("a", "bc"), aad_for_entry("ab", "c"));
1161 + assert_eq!(aad_for_entry("tasks", "r1"), aad_for_entry("tasks", "r1"));
1162 + }
1163 +
1164 + #[test]
1165 + fn v2_data_is_tagged_and_roundtrips() {
1166 + let key = generate_master_key();
1167 + let aad = aad_for_entry("tasks", "row-1");
1168 + let wire = encrypt_data_aad(b"hello", &key, &aad).unwrap();
1169 + assert!(wire.starts_with("sk2:"), "v2 payload must carry the wire tag: {wire}");
1170 + let pt = decrypt_data_aad(&wire, &key, &aad).unwrap();
1171 + assert_eq!(pt, b"hello");
1172 + }
1173 +
1174 + #[test]
1175 + fn v2_relocation_to_different_row_fails_closed() {
1176 + // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1)
1177 + // must not decrypt when the server presents it under (tasks,row-2).
1178 + let key = generate_master_key();
1179 + let sealed = encrypt_data_aad(b"secret", &key, &aad_for_entry("tasks", "row-1")).unwrap();
1180 + let relocated = decrypt_data_aad(&sealed, &key, &aad_for_entry("tasks", "row-2"));
1181 + assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed)));
1182 + let relocated_table = decrypt_data_aad(&sealed, &key, &aad_for_entry("notes", "row-1"));
1183 + assert!(matches!(relocated_table, Err(SyncKitError::DecryptionFailed)));
1184 + }
1185 +
1186 + #[test]
1187 + fn legacy_untagged_still_decrypts_under_aad_reader() {
1188 + // A v1 (untagged, empty-AAD) payload must keep decrypting through the
1189 + // tag-aware reader, regardless of the AAD passed — no flag-day.
1190 + let key = generate_master_key();
1191 + let legacy = encrypt_data(b"old data", &key).unwrap();
1192 + assert!(!legacy.starts_with("sk2:"));
1193 + let pt = decrypt_data_aad(&legacy, &key, &aad_for_entry("tasks", "row-1")).unwrap();
1194 + assert_eq!(pt, b"old data");
1195 + }
1196 +
1197 + #[test]
1198 + fn v2_json_roundtrip_and_relocation_fails() {
1199 + let key = generate_master_key();
1200 + let value = serde_json::json!({"title": "Buy milk"});
1201 + let aad = aad_for_entry("tasks", "row-1");
1202 + let wire = encrypt_json_aad(&value, &key, &aad).unwrap();
1203 + assert!(wire.as_str().unwrap().starts_with("sk2:"));
1204 + assert_eq!(decrypt_json_aad(&wire, &key, &aad).unwrap(), value);
1205 + let moved = decrypt_json_aad(&wire, &key, &aad_for_entry("tasks", "row-2"));
1206 + assert!(moved.is_err());
1207 + }
1208 +
1209 + #[test]
1210 + fn v2_bytes_tagged_roundtrip_and_relocation_fails() {
1211 + let key = generate_master_key();
1212 + let hash_a = b"sha256-aaa";
1213 + let hash_b = b"sha256-bbb";
1214 + let blob = encrypt_bytes_aad(b"blob bytes", &key, hash_a).unwrap();
1215 + assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag");
1216 + assert_eq!(decrypt_bytes_aad(&blob, &key, hash_a).unwrap(), b"blob bytes");
1217 + // Substituting a different hash's AAD fails closed.
1218 + assert!(matches!(
1219 + decrypt_bytes_aad(&blob, &key, hash_b),
1220 + Err(SyncKitError::DecryptionFailed)
1221 + ));
1222 + }
1223 +
1224 + #[test]
1225 + fn legacy_bytes_still_decrypt_under_aad_reader() {
1226 + let key = generate_master_key();
1227 + let legacy = encrypt_bytes(b"old blob", &key).unwrap();
1228 + assert!(!legacy.starts_with(b"sk2:"));
1229 + let pt = decrypt_bytes_aad(&legacy, &key, b"sha256-whatever").unwrap();
1230 + assert_eq!(pt, b"old blob");
1231 + }
1049 1232 }