Skip to main content

max / makenotwork

synckit: harden crypto and conflict resolution against a hostile server (audit Phase 1) Security & data-integrity remediation from the 2026-07-02 /audit arbiter: - Reject far-future HLCs in LWW: is_clock_poisoned() makes a poisoned entry lose to an honest one in resolve_lww_at and CleanChanges::gated, bounding clock-poisoning to inter-device skew instead of unbounded permanent wins. - resolve_field_merge falls back to timestamp LWW on a null/absent base instead of unconditionally discarding a strictly-newer local edit. - Clamp server-supplied Argon2 params to an accepted range (reject OOM-DoS inflation and KDF-downgrade weakening before deriving). - Bound blob total_len against the input before allocating (u64::MAX DoS). - aad_for_entry rejects the 0x1f separator byte, making entry-AAD injectivity structural without a wire-format change. - ChangeEntry captures unknown fields via serde(flatten) so an older client round-trips a newer client's fields instead of silently dropping them. - Zeroize the decoded key String in keystore load_key; wrap the rotation new key in ZeroizeOnDrop across the whole re-encrypt flow. - Correct the HLC doc overclaim; pin the canonical-payload sort invariant. Gate: 405 crate tests pass, cargo clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-03 00:50 UTC
Commit: 898766b83619e9f1fb135c4065bedfd95a3da6a9
Parent: 87a6fd0
9 files changed, +406 insertions, -136 deletions
@@ -187,6 +187,7 @@ impl SyncKitClient {
187 187 hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()),
188 188 timestamp: entry.timestamp,
189 189 data: None,
190 + extra: Default::default(),
190 191 })
191 192 }
192 193
@@ -341,6 +342,7 @@ impl SyncKitClient {
341 342 hlc,
342 343 timestamp: entry.timestamp,
343 344 data,
345 + extra: Default::default(),
344 346 })
345 347 }
346 348
@@ -365,6 +367,7 @@ impl SyncKitClient {
365 367 hlc,
366 368 timestamp: entry.timestamp,
367 369 data,
370 + extra: Default::default(),
368 371 })
369 372 }
370 373 }
@@ -569,6 +572,7 @@ mod tests {
569 572 timestamp: Utc::now(),
570 573 hlc,
571 574 data: None,
575 + extra: Default::default(),
572 576 };
573 577
574 578 let wire = client.encrypt_change(entry).unwrap();
@@ -602,6 +606,7 @@ mod tests {
602 606 timestamp: Utc::now(),
603 607 hlc: Default::default(),
604 608 data: Some(serde_json::json!({"title": "test"})),
609 + extra: Default::default(),
605 610 };
606 611
607 612 let err = client.encrypt_change(entry).unwrap_err();
@@ -622,6 +627,7 @@ mod tests {
622 627 timestamp: Utc::now(),
623 628 hlc: Default::default(),
624 629 data: Some(original_data.clone()),
630 + extra: Default::default(),
625 631 };
626 632
627 633 let wire = client.encrypt_change(entry).unwrap();
@@ -650,6 +656,7 @@ mod tests {
650 656 timestamp: ts,
651 657 hlc: Default::default(),
652 658 data: Some(original_data.clone()),
659 + extra: Default::default(),
653 660 };
654 661
655 662 let wire = client.encrypt_change(entry).unwrap();
@@ -894,6 +901,7 @@ mod tests {
894 901 timestamp: ts,
895 902 hlc: Default::default(),
896 903 data: Some(serde_json::json!({"name": "Alice"})),
904 + extra: Default::default(),
897 905 };
898 906
899 907 let wire = client.encrypt_change(entry).unwrap();
@@ -919,6 +927,7 @@ mod tests {
919 927 timestamp: Utc::now(),
920 928 hlc: Default::default(),
921 929 data: Some(serde_json::json!({"title": "Task 1"})),
930 + extra: Default::default(),
922 931 },
923 932 ChangeEntry {
924 933 table: "tasks".to_string(),
@@ -927,6 +936,7 @@ mod tests {
927 936 timestamp: Utc::now(),
928 937 hlc: Default::default(),
929 938 data: Some(serde_json::json!({"title": "Task 2", "done": true})),
939 + extra: Default::default(),
930 940 },
931 941 ChangeEntry {
932 942 table: "events".to_string(),
@@ -935,6 +945,7 @@ mod tests {
935 945 timestamp: Utc::now(),
936 946 hlc: Default::default(),
937 947 data: None,
948 + extra: Default::default(),
938 949 },
939 950 ];
940 951
@@ -983,6 +994,7 @@ mod tests {
983 994 timestamp: Utc::now(),
984 995 hlc: Default::default(),
985 996 data: Some(serde_json::json!({"name": "\u{30C6}\u{30B9}\u{30C8}"})),
997 + extra: Default::default(),
986 998 };
987 999
988 1000 let wire = client.encrypt_change(entry).unwrap();
@@ -1016,6 +1028,7 @@ mod tests {
1016 1028 timestamp: Utc::now(),
1017 1029 hlc: Default::default(),
1018 1030 data: Some(serde_json::json!(42)),
1031 + extra: Default::default(),
1019 1032 };
1020 1033
1021 1034 let wire = client.encrypt_change(entry).unwrap();
@@ -48,6 +48,10 @@ impl SyncKitClient {
48 48 // promote on completion, so re-encryption uses the same key it commits.
49 49 let (new_master_key, new_envelope) =
50 50 Self::rotation_key_material(key_state.pending_key, password)?;
51 + // Wrap immediately so the new key is zeroized on every exit path — an
52 + // error in the re-encrypt or complete loops below would otherwise drop
53 + // the bare array without scrubbing it.
54 + let new_master_key = crypto::ZeroizeOnDrop(new_master_key);
51 55
52 56 // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the
53 57 // same device: it returns the existing rotation, ignoring `new_envelope`
@@ -101,7 +105,7 @@ impl SyncKitClient {
101 105 // 6. Update local state
102 106 let (app_id, user_id) = self.require_session_ids()?;
103 107 crate::keystore::store_key(app_id, user_id, &new_master_key)?;
104 - *self.master_key.write() = Some(crypto::ZeroizeOnDrop(new_master_key));
108 + *self.master_key.write() = Some(new_master_key);
105 109 *self.master_key_id.write() = new_key_id;
106 110 *self.pending_key.write() = None;
107 111
@@ -281,6 +281,7 @@ mod tests {
281 281 timestamp: Utc::now(),
282 282 hlc: Default::default(),
283 283 data: Some(serde_json::json!({"title": "Test task", "done": false})),
284 + extra: Default::default(),
284 285 };
285 286
286 287 let json = serde_json::to_string(&entry).unwrap();
@@ -301,6 +302,7 @@ mod tests {
301 302 timestamp: Utc::now(),
302 303 hlc: Default::default(),
303 304 data: None,
305 + extra: Default::default(),
304 306 };
305 307
306 308 let json = serde_json::to_string(&entry).unwrap();
@@ -52,9 +52,21 @@ impl CleanChanges {
52 52 /// when its HLC is older than or equal to the committed clock; everything
53 53 /// else is returned in pull order, ready to apply.
54 54 pub fn gated(self, committed_hlc: impl Fn(&str, &str) -> Option<Hlc>) -> Vec<ChangeEntry> {
55 + let now = Utc::now();
55 56 self.0
56 57 .into_iter()
57 58 .filter(|p| {
59 + // A clean change is applied without passing through `resolve_lww`,
60 + // so the clock-poisoning guard must also live here: never apply an
61 + // entry whose wall clock is implausibly far in the future.
62 + if is_clock_poisoned(&p.entry.hlc, now) {
63 + tracing::warn!(
64 + table = %p.entry.table,
65 + wall_ms = p.entry.hlc.wall_ms,
66 + "dropping clean change with implausibly-future HLC"
67 + );
68 + return false;
69 + }
58 70 committed_hlc(&p.entry.table, &p.entry.row_id)
59 71 .is_none_or(|committed| p.entry.hlc > committed)
60 72 })
@@ -190,6 +202,59 @@ pub fn detect_conflicts(
190 202 /// bytes — a deterministic order both devices compute identically — rather than
191 203 /// unconditionally keeping local (which would let A keep `a` while B keeps `b`).
192 204 pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution {
205 + resolve_lww_at(local, remote, Utc::now())
206 + }
207 +
208 + /// Maximum forward wall-clock drift tolerated before an HLC is judged
209 + /// clock-poisoned. An entry whose wall component is beyond `now + MAX_HLC_DRIFT_MS`
210 + /// carries an implausible future timestamp — from a misconfigured clock or a
211 + /// hostile peer holding the shared key — and must not be allowed to win LWW
212 + /// against an honest entry. Set generously (5 min) so honest inter-device skew is
213 + /// never flagged.
214 + pub const MAX_HLC_DRIFT_MS: i64 = 5 * 60 * 1000;
215 +
216 + /// Whether `hlc`'s wall clock is implausibly far in the future relative to `now`
217 + /// — the clock-poisoning signal. A bare wall clock, and even an unclamped HLC,
218 + /// lets such an entry win *every* conflict until real time catches up (years,
219 + /// for a hostile timestamp); this predicate is how [`resolve_lww_at`] refuses to
220 + /// let it. Exposed so an app driving its own apply loop can quarantine the same
221 + /// entries at ingest.
222 + pub fn is_clock_poisoned(hlc: &Hlc, now: DateTime<Utc>) -> bool {
223 + hlc.wall_ms > now.timestamp_millis().saturating_add(MAX_HLC_DRIFT_MS)
224 + }
225 +
226 + /// [`resolve_lww`] with an explicit `now`, so the poisoning guard is
227 + /// deterministic and testable.
228 + ///
229 + /// A poisoned entry (see [`is_clock_poisoned`]) *loses* to a non-poisoned one
230 + /// regardless of raw HLC order — this is what stops a far-future clock from
231 + /// winning forever. Merely capping the future value would not: capped at
232 + /// `now + drift` it would perpetually track "now" and keep winning by the drift
233 + /// margin. When both sides are poisoned, or neither is, the normal HLC order
234 + /// applies. Two honest devices agree except in the narrow window where an entry
235 + /// straddles the drift threshold by less than their clock skew — vastly better
236 + /// than the unbounded, permanent domination an unguarded compare allows.
237 + pub fn resolve_lww_at(local: &ChangeEntry, remote: &PulledChange, now: DateTime<Utc>) -> Resolution {
238 + match (
239 + is_clock_poisoned(&local.hlc, now),
240 + is_clock_poisoned(&remote.entry.hlc, now),
241 + ) {
242 + (false, true) => {
243 + tracing::warn!(
244 + remote_wall_ms = remote.entry.hlc.wall_ms,
245 + "remote HLC wall-clock is implausibly far in the future; rejecting it in LWW"
246 + );
247 + return Resolution::KeepLocal;
248 + }
249 + (true, false) => {
250 + tracing::warn!(
251 + local_wall_ms = local.hlc.wall_ms,
252 + "local HLC wall-clock is implausibly far in the future; letting remote win LWW"
253 + );
254 + return Resolution::KeepRemote;
255 + }
256 + _ => {}
257 + }
193 258 match local.hlc.cmp(&remote.entry.hlc) {
194 259 std::cmp::Ordering::Greater => Resolution::KeepLocal,
195 260 std::cmp::Ordering::Less => Resolution::KeepRemote,
@@ -206,6 +271,13 @@ pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution {
206 271 /// Deterministic byte encoding of a row payload for the exact-HLC tiebreak.
207 272 /// `serde_json`'s default `Map` is sorted, so equal values always serialize to
208 273 /// equal bytes on every device — the property the tiebreak relies on.
274 + ///
275 + /// INVARIANT: this convergence holds only while `serde_json` keeps insertion
276 + /// order OFF (i.e. the `preserve_order` feature is NOT enabled anywhere in the
277 + /// dependency tree). If it is ever turned on, map-key order becomes
278 + /// insertion-dependent and two devices can compute different tiebreak bytes for
279 + /// equal values — divergence. The `canonical_payload_sorts_map_keys` test pins
280 + /// this (it fails the moment insertion order leaks in).
209 281 fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> {
210 282 match data {
211 283 Some(v) => serde_json::to_vec(v).unwrap_or_default(),
@@ -219,9 +291,11 @@ fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> {
219 291 /// each side changed, then merges non-overlapping changes. For overlapping
220 292 /// fields, the newer timestamp wins.
221 293 ///
222 - /// Returns `Resolution::KeepRemote` if any input is not a JSON object
223 - /// (including `Value::Null` for a missing base). Callers should fall back
224 - /// to [`resolve_lww`] when the base snapshot is unavailable.
294 + /// If any input is not a JSON object (including `Value::Null` for a missing base
295 + /// snapshot), a field-level merge is impossible, so this falls back to
296 + /// last-writer-wins on the timestamps rather than unconditionally keeping remote
297 + /// — which would silently discard a strictly-newer local edit whenever no base
298 + /// is available (e.g. a first-ever edit).
225 299 pub fn resolve_field_merge(
226 300 local: &serde_json::Value,
227 301 remote: &serde_json::Value,
@@ -234,7 +308,13 @@ pub fn resolve_field_merge(
234 308 remote.as_object(),
235 309 base.as_object(),
236 310 ) else {
237 - return Resolution::KeepRemote;
311 + // No usable base: last-writer-wins on timestamp, keeping local when it is
312 + // strictly newer instead of dropping it.
313 + return if local_ts > remote_ts {
314 + Resolution::KeepLocal
315 + } else {
316 + Resolution::KeepRemote
317 + };
238 318 };
239 319
240 320 // Compute diffs: keys where local/remote differ from base
@@ -338,6 +418,7 @@ mod tests {
338 418 timestamp: ts,
339 419 hlc: Hlc::from_legacy(ts.timestamp_millis(), local_node()),
340 420 data: Some(json!({"value": "test"})),
421 + extra: Default::default(),
341 422 }
342 423 }
343 424
@@ -1099,4 +1180,60 @@ mod tests {
1099 1180 _ => panic!("Expected Merged"),
1100 1181 }
1101 1182 }
1183 +
1184 + // 50 years in milliseconds, far beyond MAX_HLC_DRIFT_MS.
1185 + const FIFTY_YEARS_MS: i64 = 50i64 * 365 * 24 * 3600 * 1000;
1186 +
1187 + #[test]
1188 + fn lww_rejects_poisoned_remote_for_honest_local() {
1189 + let now = Utc::now();
1190 + let now_ms = now.timestamp_millis();
1191 + let mut local = make_entry("tasks", "r1", ChangeOp::Update, now);
1192 + local.hlc = Hlc { wall_ms: now_ms, counter: 0, node: local_node() };
1193 + let mut pulled = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1);
1194 + // Unguarded, a wall clock 50 years ahead would win every conflict for decades.
1195 + pulled.entry.hlc = Hlc { wall_ms: now_ms + FIFTY_YEARS_MS, counter: 0, node: Uuid::new_v4() };
1196 + assert!(is_clock_poisoned(&pulled.entry.hlc, now));
1197 + assert!(matches!(resolve_lww_at(&local, &pulled, now), Resolution::KeepLocal));
1198 + }
1199 +
1200 + #[test]
1201 + fn lww_rejects_poisoned_local_for_honest_remote() {
1202 + let now = Utc::now();
1203 + let now_ms = now.timestamp_millis();
1204 + let mut local = make_entry("tasks", "r1", ChangeOp::Update, now);
1205 + local.hlc = Hlc { wall_ms: now_ms + FIFTY_YEARS_MS, counter: 0, node: local_node() };
1206 + let mut pulled = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1);
1207 + pulled.entry.hlc = Hlc { wall_ms: now_ms, counter: 0, node: Uuid::new_v4() };
1208 + assert!(matches!(resolve_lww_at(&local, &pulled, now), Resolution::KeepRemote));
1209 + }
1210 +
1211 + #[test]
1212 + fn field_merge_null_base_falls_back_to_lww_not_keep_remote() {
1213 + let local = json!({"a": 1});
1214 + let remote = json!({"a": 2});
1215 + let base = serde_json::Value::Null;
1216 + let local_ts = Utc::now();
1217 + // Local strictly newer: must be kept, not silently discarded (the old bug).
1218 + let remote_older = local_ts - chrono::Duration::seconds(10);
1219 + assert!(matches!(
1220 + resolve_field_merge(&local, &remote, &base, local_ts, remote_older),
1221 + Resolution::KeepLocal
1222 + ));
1223 + // Remote newer: remote wins.
1224 + let remote_newer = local_ts + chrono::Duration::seconds(10);
1225 + assert!(matches!(
1226 + resolve_field_merge(&local, &remote, &base, local_ts, remote_newer),
1227 + Resolution::KeepRemote
1228 + ));
1229 + }
1230 +
1231 + #[test]
1232 + fn canonical_payload_sorts_map_keys() {
1233 + // Pins the exact-HLC tiebreak's convergence invariant: serde_json must
1234 + // serialize object keys in sorted order. Fails loudly if `preserve_order`
1235 + // is ever enabled anywhere in the dependency tree.
1236 + let bytes = canonical_payload(&Some(json!({"b": 1, "a": 2})));
1237 + assert_eq!(bytes, br#"{"a":2,"b":1}"#.to_vec());
1238 + }
1102 1239 }
@@ -41,6 +41,15 @@ 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 + // 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 + const ARGON2_MEM_MAX_KB: u32 = 1024 * 1024; // 1 GiB
50 + const ARGON2_TIME_MAX: u32 = 16;
51 + const ARGON2_PARALLELISM_MAX: u32 = 16;
52 +
44 53 fn default_argon_mem() -> u32 { ARGON2_MEM_COST_KB }
45 54 fn default_argon_time() -> u32 { ARGON2_TIME_COST }
46 55 fn default_argon_par() -> u32 { ARGON2_PARALLELISM }
@@ -129,6 +138,18 @@ fn derive_wrapping_key_with_params(
129 138 time_cost: u32,
130 139 parallelism: u32,
131 140 ) -> Result<ZeroizeOnDrop> {
141 + // Reject out-of-range parameters before the (expensive) allocation. An
142 + // envelope's params are attacker-reachable; a clamp is pointless (wrong
143 + // params derive a wrong key that fails the tag anyway), so fail closed.
144 + if !(ARGON2_MEM_MIN_KB..=ARGON2_MEM_MAX_KB).contains(&mem_kb)
145 + || !(1..=ARGON2_TIME_MAX).contains(&time_cost)
146 + || !(1..=ARGON2_PARALLELISM_MAX).contains(&parallelism)
147 + {
148 + return Err(SyncKitError::InvalidEnvelope(
149 + "Argon2 parameters outside the accepted range".into(),
150 + ));
151 + }
152 +
132 153 let normalized = normalize_password(password)?;
133 154
134 155 let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE))
@@ -301,10 +322,10 @@ impl<'a> AeadContext<'a> {
301 322 /// The associated-data bytes for this context. A server that relocates a
302 323 /// ciphertext changes its context, hence its AAD, so decryption fails
303 324 /// closed — the AEAD tag no longer authenticates the moved bytes.
304 - fn aad(&self) -> Vec<u8> {
325 + fn aad(&self) -> Result<Vec<u8>> {
305 326 match self {
306 327 AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id),
307 - AeadContext::Blob { hash } => hash.as_bytes().to_vec(),
328 + AeadContext::Blob { hash } => Ok(hash.as_bytes().to_vec()),
308 329 }
309 330 }
310 331 }
@@ -312,16 +333,23 @@ impl<'a> AeadContext<'a> {
312 333 /// Canonical associated-data encoding binding an entry's ciphertext to its
313 334 /// `(table, row_id)` address.
314 335 ///
315 - /// The unit separator (`0x1f`) cannot appear in the encoded form of either
316 - /// component's length boundary ambiguously, so `("a", "bc")` and `("ab", "c")`
317 - /// produce distinct AAD. Private: callers reach it only through
318 - /// [`AeadContext::Entry`], which is the point of the seal.
319 - fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> {
336 + /// The encoding is `table || 0x1f || row_id`. This is injective as long as
337 + /// neither field contains the `0x1f` unit separator, so we reject any field that
338 + /// does — making `("a", "bc")` and `("ab", "c")` provably distinct rather than
339 + /// distinct-by-assumption. Rejecting (vs length-prefixing) keeps the wire format
340 + /// unchanged, so existing ciphertext still decrypts. Private: callers reach it
341 + /// only through [`AeadContext::Entry`], which is the point of the seal.
342 + fn aad_for_entry(table: &str, row_id: &str) -> Result<Vec<u8>> {
343 + if table.as_bytes().contains(&0x1f) || row_id.as_bytes().contains(&0x1f) {
344 + return Err(SyncKitError::Crypto(
345 + "table or row_id contains the 0x1f AAD separator".into(),
346 + ));
347 + }
320 348 let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1);
321 349 aad.extend_from_slice(table.as_bytes());
322 350 aad.push(0x1f);
323 351 aad.extend_from_slice(row_id.as_bytes());
324 - aad
352 + Ok(aad)
325 353 }
326 354
327 355 /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`.
@@ -375,7 +403,7 @@ pub fn encrypt_data_aad(
375 403 master_key: &[u8; KEY_SIZE],
376 404 ctx: &AeadContext,
377 405 ) -> Result<String> {
378 - Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad())?)))
406 + Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad()?)?)))
379 407 }
380 408
381 409 /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged
@@ -387,7 +415,7 @@ pub fn decrypt_data_aad(
387 415 ctx: &AeadContext,
388 416 ) -> Result<Vec<u8>> {
389 417 match encoded.strip_prefix(WIRE_V2_TAG) {
390 - Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()),
418 + Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()?),
391 419 None => open(&B64.decode(encoded)?, master_key, &[]),
392 420 }
393 421 }
@@ -410,7 +438,7 @@ pub fn encrypt_bytes_aad(
410 438 master_key: &[u8; KEY_SIZE],
411 439 ctx: &AeadContext,
412 440 ) -> Result<Vec<u8>> {
413 - let sealed = seal(plaintext, master_key, &ctx.aad())?;
441 + let sealed = seal(plaintext, master_key, &ctx.aad()?)?;
414 442 let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len());
415 443 out.extend_from_slice(WIRE_V2_TAG_BYTES);
416 444 out.extend_from_slice(&sealed);
@@ -425,7 +453,7 @@ pub fn decrypt_bytes_aad(
425 453 ctx: &AeadContext,
426 454 ) -> Result<Vec<u8>> {
427 455 match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) {
428 - Some(rest) => open(rest, master_key, &ctx.aad()),
456 + Some(rest) => open(rest, master_key, &ctx.aad()?),
429 457 None => open(encrypted, master_key, &[]),
430 458 }
431 459 }
@@ -583,6 +611,15 @@ pub fn decrypt_blob_chunked(
583 611 hash: &str,
584 612 ) -> Result<Vec<u8>> {
585 613 let (header, consumed) = parse_blob_header(encrypted)?;
614 + // `total_len` is an attacker-controlled u64 from the wire header. Plaintext can
615 + // never exceed its own ciphertext, so reject any claim larger than the input
616 + // before allocating — otherwise a header of `u64::MAX` aborts on the capacity
617 + // request (a trivial DoS).
618 + if header.total_len > encrypted.len() {
619 + return Err(SyncKitError::Crypto(
620 + "v3 blob total_len exceeds encrypted input".into(),
621 + ));
622 + }
586 623 let mut rest = &encrypted[consumed..];
587 624 let mut out = Vec::with_capacity(header.total_len);
588 625 for i in 0..header.chunk_count {
@@ -1371,8 +1408,59 @@ mod tests {
1371 1408 fn aad_for_entry_is_injective_across_boundary() {
1372 1409 // ("a","bc") and ("ab","c") must not collide, or a server could swap
1373 1410 // table/row_id halves and keep the AAD constant.
1374 - assert_ne!(aad_for_entry("a", "bc"), aad_for_entry("ab", "c"));
1375 - assert_eq!(aad_for_entry("tasks", "r1"), aad_for_entry("tasks", "r1"));
1411 + assert_ne!(
1412 + aad_for_entry("a", "bc").unwrap(),
1413 + aad_for_entry("ab", "c").unwrap()
1414 + );
1415 + assert_eq!(
1416 + aad_for_entry("tasks", "r1").unwrap(),
1417 + aad_for_entry("tasks", "r1").unwrap()
1418 + );
1419 + }
1420 +
1421 + #[test]
1422 + fn aad_for_entry_rejects_separator_byte() {
1423 + // A field carrying the 0x1f separator would break injectivity, so it is
1424 + // rejected rather than silently encoded.
1425 + assert!(aad_for_entry("ta\u{1f}sks", "r1").is_err());
1426 + assert!(aad_for_entry("tasks", "r\u{1f}1").is_err());
1427 + assert!(aad_for_entry("tasks", "r1").is_ok());
1428 + }
1429 +
1430 + #[test]
1431 + fn argon2_params_out_of_range_are_rejected() {
1432 + let salt = [7u8; 32];
1433 + // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation.
1434 + assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err());
1435 + // Weakened memory (KDF downgrade) rejected.
1436 + assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err());
1437 + // Zero time / parallelism rejected.
1438 + assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 0, 1).is_err());
1439 + assert!(derive_wrapping_key_with_params("pw", &salt, 65_536, 3, 0).is_err());
1440 + // The pinned production parameters are inside the accepted range.
1441 + assert!(
1442 + derive_wrapping_key_with_params(
1443 + "pw",
1444 + &salt,
1445 + ARGON2_MEM_COST_KB,
1446 + ARGON2_TIME_COST,
1447 + ARGON2_PARALLELISM
1448 + )
1449 + .is_ok()
1450 + );
1451 + }
1452 +
1453 + #[test]
1454 + fn blob_total_len_exceeding_input_is_rejected_not_allocated() {
1455 + let key = generate_master_key();
1456 + // A v3 header claiming u64::MAX plaintext with no chunk bytes must be
1457 + // rejected on the length bound, not attempt an astronomical allocation.
1458 + let mut buf = Vec::new();
1459 + buf.extend_from_slice(WIRE_V3_TAG_BYTES);
1460 + buf.push(3);
1461 + buf.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
1462 + buf.extend_from_slice(&u64::MAX.to_le_bytes());
1463 + assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err());
1376 1464 }
1377 1465
1378 1466 #[test]
@@ -66,9 +66,13 @@ pub fn load_key(app_id: AppId, user_id: UserId) -> Result<Option<[u8; 32]>> {
66 66 let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
67 67
68 68 match entry.get_password() {
69 - Ok(encoded) => {
69 + Ok(mut encoded) => {
70 70 use zeroize::Zeroize;
71 - let mut bytes = B64.decode(&encoded)?;
71 + // `encoded` is base64 of the raw master key — zeroize it too, not just
72 + // the decoded bytes, so no plaintext key encoding lingers on the heap.
73 + let decoded = B64.decode(&encoded);
74 + encoded.zeroize();
75 + let mut bytes = decoded?;
72 76 if bytes.len() != 32 {
73 77 bytes.zeroize();
74 78 return Err(SyncKitError::Keychain(
@@ -34,6 +34,7 @@
34 34 //! timestamp: Utc::now(),
35 35 //! hlc: Default::default(),
36 36 //! data: Some(serde_json::json!({"title": "Buy milk"})),
37 + //! extra: Default::default(),
37 38 //! },
38 39 //! ]).await?;
39 40 //!
@@ -52,11 +52,15 @@ impl ChangeOp {
52 52 /// monotonic by a tiebreak counter, with the originating device as a final
53 53 /// tiebreak. HLCs are the ordering used for conflict resolution.
54 54 ///
55 - /// Why not a bare wall-clock timestamp: a device with a fast-running clock would
56 - /// otherwise win every last-write-wins conflict, and two edits in the same
57 - /// millisecond would be unordered. The HLC keeps causality (it never goes
58 - /// backwards and advances past anything it has seen) and breaks exact ties
59 - /// deterministically by `node`, so every device resolves a conflict identically.
55 + /// Why not a bare wall-clock timestamp: two edits in the same millisecond would
56 + /// be unordered. The HLC keeps causality (it never goes backwards and advances
57 + /// past anything it has seen) and breaks exact ties deterministically by `node`,
58 + /// so every device resolves a conflict identically.
59 + ///
60 + /// The HLC does NOT, on its own, stop a device with a fast/hostile clock from
61 + /// winning every LWW conflict — the highest `wall_ms` still wins. That is bounded
62 + /// separately, at comparison time, by the forward-drift cap in
63 + /// [`resolve_lww_at`](crate::conflict::resolve_lww_at) (see `MAX_HLC_DRIFT_MS`).
60 64 ///
61 65 /// Ordering is lexicographic: `(wall_ms, counter, node)`. The `node` field makes
62 66 /// the value globally unique across devices, so resolution is convergent without
@@ -210,6 +214,14 @@ pub struct ChangeEntry {
210 214 /// Serialized as absent (not null) when `None`.
211 215 #[serde(skip_serializing_if = "Option::is_none")]
212 216 pub data: Option<serde_json::Value>,
217 + /// Forward-compat capture of any fields a newer client added to this
218 + /// (decrypted, E2E) payload that this build does not know about. Preserved
219 + /// verbatim through deserialize -> re-serialize so an older client cannot
220 + /// silently drop a resolution-relevant field (e.g. a future tombstone marker)
221 + /// and thereby change the conflict winner. Empty on current-format entries,
222 + /// so it serializes to nothing and the wire format is unchanged.
223 + #[serde(flatten)]
224 + pub extra: serde_json::Map<String, serde_json::Value>,
213 225 }
214 226
215 227 /// Wire format sent to server (data is already encrypted).
@@ -551,6 +563,7 @@ mod tests {
551 563 timestamp: chrono::Utc::now(),
552 564 hlc: Default::default(),
553 565 data: None,
566 + extra: Default::default(),
554 567 };
555 568 let json = serde_json::to_string(&entry).unwrap();
556 569 assert!(!json.contains("\"data\""), "None data should be omitted: {json}");
@@ -565,26 +578,35 @@ mod tests {
565 578 timestamp: chrono::Utc::now(),
566 579 hlc: Default::default(),
567 580 data: Some(json!({"k": "v"})),
581 + extra: Default::default(),
568 582 };
569 583 let json = serde_json::to_string(&entry).unwrap();
570 584 assert!(json.contains("\"data\""), "Some data should be present: {json}");
571 585 }
572 586
573 587 #[test]
574 - fn change_entry_deserialization_ignores_extra_fields() {
588 + fn change_entry_preserves_unknown_fields_across_roundtrip() {
575 589 let json = r#"{
576 590 "table": "tasks",
577 591 "op": "INSERT",
578 592 "row_id": "r1",
579 593 "timestamp": "2025-01-15T10:00:00Z",
580 594 "data": {"title": "test"},
581 - "extra_field": "should be ignored",
595 + "future_marker": "should survive",
582 596 "another_unknown": 42
583 597 }"#;
584 598 let entry: ChangeEntry = serde_json::from_str(json).unwrap();
585 599 assert_eq!(entry.table, "tasks");
586 600 assert_eq!(entry.op, ChangeOp::Insert);
587 - assert_eq!(entry.data.unwrap()["title"], "test");
601 + assert_eq!(entry.data.as_ref().unwrap()["title"], "test");
602 + // A newer client's fields are captured, not dropped...
603 + assert_eq!(entry.extra["future_marker"], "should survive");
604 + assert_eq!(entry.extra["another_unknown"], 42);
605 + // ...and round-trip back out, so an older client re-serializing this
606 + // entry cannot silently discard a resolution-relevant field.
607 + let reserialized = serde_json::to_value(&entry).unwrap();
608 + assert_eq!(reserialized["future_marker"], "should survive");
609 + assert_eq!(reserialized["another_unknown"], 42);
588 610 }
589 611
590 612 #[test]
@@ -29,8 +29,8 @@ fn fake_jwt(exp: i64) -> String {
29 29 "exp": exp,
30 30 "iat": exp - 3600,
31 31 });
32 - let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
33 - .encode(payload.to_string().as_bytes());
32 + let payload_b64 =
33 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
34 34 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
35 35 format!("{header}.{payload_b64}.{sig}")
36 36 }
@@ -56,8 +56,7 @@ fn client_for(server: &MockServer) -> SyncKitClient {
56 56 fn authed_client(server: &MockServer) -> SyncKitClient {
57 57 let client = client_for(server);
58 58 let (user_id, app_id) = test_ids();
59 - client
60 - .restore_session(&fresh_token(), user_id, app_id);
59 + client.restore_session(&fresh_token(), user_id, app_id);
61 60 client
62 61 }
63 62
@@ -147,7 +146,9 @@ async fn authenticate_retries_on_503() {
147 146 .await;
148 147
149 148 let client = client_for(&server);
150 - let result = client.authenticate("user@test.com", "password", "test-key").await;
149 + let result = client
150 + .authenticate("user@test.com", "password", "test-key")
151 + .await;
151 152 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
152 153 }
153 154
@@ -261,6 +262,7 @@ async fn push_encrypts_data() {
261 262 timestamp: Utc::now(),
262 263 hlc: Default::default(),
263 264 data: Some(json!({"title": "Secret task"})),
265 + extra: Default::default(),
264 266 }],
265 267 )
266 268 .await
@@ -341,7 +343,10 @@ async fn push_retries_on_503() {
341 343 let key = synckit_client::crypto::generate_master_key();
342 344 client.set_master_key_raw(key);
343 345
344 - let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap();
346 + let cursor = client
347 + .push(DeviceId::new(Uuid::new_v4()), vec![])
348 + .await
349 + .unwrap();
345 350 assert_eq!(cursor, 5);
346 351 }
347 352
@@ -360,7 +365,10 @@ async fn push_fails_immediately_on_401() {
360 365 let key = synckit_client::crypto::generate_master_key();
361 366 client.set_master_key_raw(key);
362 367
363 - let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err();
368 + let err = client
369 + .push(DeviceId::new(Uuid::new_v4()), vec![])
370 + .await
371 + .unwrap_err();
364 372 assert!(matches!(err, SyncKitError::Server { status: 401, .. }));
365 373 }
366 374
@@ -516,7 +524,9 @@ async fn blob_upload_retries_on_503() {
516 524 client.set_master_key_raw(key);
517 525
518 526 let presigned = format!("{}{}", server.uri(), upload_path);
519 - let result = client.blob_upload("sha256-x", &presigned, b"data".to_vec()).await;
527 + let result = client
528 + .blob_upload("sha256-x", &presigned, b"data".to_vec())
529 + .await;
520 530 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
521 531 }
522 532
@@ -546,8 +556,7 @@ async fn near_expiry_jwt_returns_token_expired() {
546 556
547 557 // Token expires in 10 seconds (within 30-second buffer)
548 558 let near_expiry = fake_jwt(Utc::now().timestamp() + 10);
549 - client
550 - .restore_session(&near_expiry, user_id, app_id);
559 + client.restore_session(&near_expiry, user_id, app_id);
551 560
552 561 let err = client.status().await.unwrap_err();
553 562 assert!(
@@ -599,7 +608,10 @@ async fn push_without_auth_returns_not_authenticated() {
599 608 let server = MockServer::start().await;
600 609 let client = client_for(&server);
601 610
602 - let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err();
611 + let err = client
612 + .push(DeviceId::new(Uuid::new_v4()), vec![])
613 + .await
614 + .unwrap_err();
603 615 assert!(matches!(err, SyncKitError::NotAuthenticated));
604 616 }
605 617
@@ -612,8 +624,7 @@ async fn has_server_key_true_on_200() {
612 624 Mock::given(method("GET"))
613 625 .and(path("/api/v1/sync/keys"))
614 626 .respond_with(
615 - ResponseTemplate::new(200)
616 - .set_body_json(json!({"encrypted_key": "envelope-data"})),
627 + ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope-data"})),
617 628 )
618 629 .mount(&server)
619 630 .await;
@@ -650,8 +661,7 @@ async fn has_server_key_retries_on_500() {
650 661 Mock::given(method("GET"))
651 662 .and(path("/api/v1/sync/keys"))
652 663 .respond_with(
653 - ResponseTemplate::new(200)
654 - .set_body_json(json!({"encrypted_key": "envelope"})),
664 + ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope"})),
655 665 )
656 666 .mount(&server)
657 667 .await;
@@ -741,10 +751,7 @@ async fn error_400_not_retried() {
741 751 .await;
742 752
743 753 let client = authed_client(&server);
744 - let err = client
745 - .register_device("Device", "test")
746 - .await
747 - .unwrap_err();
754 + let err = client.register_device("Device", "test").await.unwrap_err();
748 755 assert!(matches!(err, SyncKitError::Server { status: 400, .. }));
749 756 }
750 757
@@ -856,18 +863,13 @@ async fn change_password_wrong_old_password_with_cached_key_fails() {
856 863 // Server returns the envelope on GET
857 864 Mock::given(method("GET"))
858 865 .and(path("/api/v1/sync/keys"))
859 - .respond_with(
860 - ResponseTemplate::new(200)
861 - .set_body_json(json!({"encrypted_key": envelope})),
862 - )
866 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
863 867 .mount(&server)
864 868 .await;
865 869
866 870 // Attempt to change password with wrong old password.
867 871 // The key IS cached, but the old password must still be validated.
868 - let result = client
869 - .change_password("wrong-old-pass", "new-pass")
870 - .await;
872 + let result = client.change_password("wrong-old-pass", "new-pass").await;
871 873
872 874 assert!(
873 875 result.is_err(),
@@ -888,10 +890,7 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() {
888 890 // Server returns the envelope on GET
889 891 Mock::given(method("GET"))
890 892 .and(path("/api/v1/sync/keys"))
891 - .respond_with(
892 - ResponseTemplate::new(200)
893 - .set_body_json(json!({"encrypted_key": envelope})),
894 - )
893 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
895 894 .mount(&server)
896 895 .await;
897 896
@@ -902,10 +901,11 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() {
902 901 .mount(&server)
903 902 .await;
904 903
905 - let result = client
906 - .change_password("correct-old-pass", "new-pass")
907 - .await;
908 - assert!(result.is_ok(), "change_password should succeed with correct old password");
904 + let result = client.change_password("correct-old-pass", "new-pass").await;
905 + assert!(
906 + result.is_ok(),
907 + "change_password should succeed with correct old password"
908 + );
909 909
910 910 // Verify the PUT request was made (new envelope was uploaded)
911 911 let requests = server.received_requests().await.unwrap();
@@ -916,11 +916,13 @@ async fn change_password_correct_old_password_with_cached_key_succeeds() {
916 916 assert_eq!(put_requests.len(), 1, "Should have sent exactly one PUT");
917 917
918 918 // Verify the new envelope can be unwrapped with the new password
919 - let put_body: serde_json::Value =
920 - serde_json::from_slice(&put_requests[0].body).unwrap();
919 + let put_body: serde_json::Value = serde_json::from_slice(&put_requests[0].body).unwrap();
921 920 let new_envelope = put_body["encrypted_key"].as_str().unwrap();
922 921 let recovered = synckit_client::crypto::unwrap_master_key(new_envelope, "new-pass").unwrap();
923 - assert_eq!(recovered, master_key, "New envelope should unwrap to the same master key");
922 + assert_eq!(
923 + recovered, master_key,
924 + "New envelope should unwrap to the same master key"
925 + );
924 926 }
925 927
926 928 #[tokio::test]
@@ -935,16 +937,11 @@ async fn change_password_wrong_old_password_without_cached_key_fails() {
935 937
936 938 Mock::given(method("GET"))
937 939 .and(path("/api/v1/sync/keys"))
938 - .respond_with(
939 - ResponseTemplate::new(200)
940 - .set_body_json(json!({"encrypted_key": envelope})),
941 - )
940 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
942 941 .mount(&server)
943 942 .await;
944 943
945 - let result = client
946 - .change_password("wrong-old-pass", "new-pass")
947 - .await;
944 + let result = client.change_password("wrong-old-pass", "new-pass").await;
948 945
949 946 assert!(
950 947 result.is_err(),
@@ -959,15 +956,11 @@ async fn change_password_wrong_old_password_without_cached_key_fails() {
959 956 #[tokio::test]
960 957 async fn change_password_old_envelope_invalid_with_new_password() {
961 958 let server = MockServer::start().await;
962 - let (client, _master_key, envelope) =
963 - setup_change_password_test(&server, "old-pass").await;
959 + let (client, _master_key, envelope) = setup_change_password_test(&server, "old-pass").await;
964 960
965 961 Mock::given(method("GET"))
966 962 .and(path("/api/v1/sync/keys"))
967 - .respond_with(
968 - ResponseTemplate::new(200)
969 - .set_body_json(json!({"encrypted_key": envelope})),
970 - )
963 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
971 964 .mount(&server)
972 965 .await;
973 966
@@ -1014,7 +1007,10 @@ async fn push_empty_changes_succeeds() {
1014 1007 let key = synckit_client::crypto::generate_master_key();
1015 1008 client.set_master_key_raw(key);
1016 1009
1017 - let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap();
1010 + let cursor = client
1011 + .push(DeviceId::new(Uuid::new_v4()), vec![])
1012 + .await
1013 + .unwrap();
1018 1014 assert_eq!(cursor, 0);
1019 1015 }
1020 1016
@@ -1026,9 +1022,7 @@ async fn push_malformed_json_response_handled() {
1026 1022
1027 1023 Mock::given(method("POST"))
1028 1024 .and(path("/api/v1/sync/push"))
1029 - .respond_with(
1030 - ResponseTemplate::new(200).set_body_string("not valid json at all"),
1031 - )
1025 + .respond_with(ResponseTemplate::new(200).set_body_string("not valid json at all"))
1032 1026 .mount(&server)
1033 1027 .await;
1034 1028
@@ -1052,9 +1046,7 @@ async fn pull_malformed_json_response_handled() {
1052 1046
1053 1047 Mock::given(method("POST"))
1054 1048 .and(path("/api/v1/sync/pull"))
1055 - .respond_with(
1056 - ResponseTemplate::new(200).set_body_string("{invalid json}"),
1057 - )
1049 + .respond_with(ResponseTemplate::new(200).set_body_string("{invalid json}"))
1058 1050 .mount(&server)
1059 1051 .await;
1060 1052
@@ -1072,9 +1064,7 @@ async fn status_malformed_json_response_handled() {
1072 1064
1073 1065 Mock::given(method("GET"))
1074 1066 .and(path("/api/v1/sync/status"))
1075 - .respond_with(
1076 - ResponseTemplate::new(200).set_body_string("this is not json"),
1077 - )
1067 + .respond_with(ResponseTemplate::new(200).set_body_string("this is not json"))
1078 1068 .mount(&server)
1079 1069 .await;
1080 1070
@@ -1100,7 +1090,9 @@ async fn server_error_message_preserved() {
1100 1090 let client = authed_client(&server);
1101 1091 let err = client.status().await.unwrap_err();
1102 1092 match err {
1103 - SyncKitError::Server { status, message, .. } => {
1093 + SyncKitError::Server {
1094 + status, message, ..
1095 + } => {
1104 1096 assert_eq!(status, 422);
1105 1097 assert!(
1106 1098 message.contains("Validation failed"),
@@ -1139,6 +1131,7 @@ async fn concurrent_push_operations_no_data_corruption() {
1139 1131 timestamp: Utc::now(),
1140 1132 hlc: Default::default(),
1141 1133 data: Some(json!({"index": i})),
1134 + extra: Default::default(),
1142 1135 };
1143 1136 c.push(device_id, vec![entry]).await
1144 1137 }));
@@ -1191,7 +1184,10 @@ async fn concurrent_push_and_pull_interleaved() {
1191 1184
1192 1185 for h in handles {
1193 1186 let result = h.await.unwrap();
1194 - assert!(result.is_ok(), "Interleaved push/pull should succeed: {result:?}");
1187 + assert!(
1188 + result.is_ok(),
1189 + "Interleaved push/pull should succeed: {result:?}"
1190 + );
1195 1191 }
1196 1192 }
1197 1193
@@ -1220,10 +1216,14 @@ async fn push_many_changes_succeeds() {
1220 1216 timestamp: Utc::now(),
1221 1217 hlc: Default::default(),
1222 1218 data: Some(json!({"index": i, "value": format!("data-{i}")})),
1219 + extra: Default::default(),
1223 1220 })
1224 1221 .collect();
1225 1222
1226 - let cursor = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap();
1223 + let cursor = client
1224 + .push(DeviceId::new(Uuid::new_v4()), changes)
1225 + .await
1226 + .unwrap();
1227 1227 assert_eq!(cursor, 1000);
1228 1228 }
1229 1229
@@ -1237,10 +1237,12 @@ async fn expired_token_detected_before_push() {
1237 1237
1238 1238 let expired = fake_jwt(Utc::now().timestamp() - 100);
1239 1239 client.restore_session(&expired, user_id, app_id);
1240 - client
1241 - .set_master_key_raw(synckit_client::crypto::generate_master_key());
1240 + client.set_master_key_raw(synckit_client::crypto::generate_master_key());
1242 1241
1243 - let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err();
1242 + let err = client
1243 + .push(DeviceId::new(Uuid::new_v4()), vec![])
1244 + .await
1245 + .unwrap_err();
1244 1246 assert!(
1245 1247 matches!(err, SyncKitError::TokenExpired),
1246 1248 "Expired token should be detected pre-flight, got: {err:?}"
@@ -1255,10 +1257,12 @@ async fn expired_token_detected_before_pull() {
1255 1257
1256 1258 let expired = fake_jwt(Utc::now().timestamp() - 100);
1257 1259 client.restore_session(&expired, user_id, app_id);
1258 - client
1259 - .set_master_key_raw(synckit_client::crypto::generate_master_key());
1260 + client.set_master_key_raw(synckit_client::crypto::generate_master_key());
1260 1261
1261 - let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err();
1262 + let err = client
1263 + .pull(DeviceId::new(Uuid::new_v4()), 0)
1264 + .await
1265 + .unwrap_err();
1262 1266 assert!(matches!(err, SyncKitError::TokenExpired));
1263 1267 }
1264 1268
@@ -1389,9 +1393,13 @@ async fn push_with_data_fails_without_master_key() {
1389 1393 timestamp: Utc::now(),
1390 1394 hlc: Default::default(),
1391 1395 data: Some(json!({"title": "test"})),
1396 + extra: Default::default(),
1392 1397 }];
1393 1398
1394 - let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err();
1399 + let err = client
1400 + .push(DeviceId::new(Uuid::new_v4()), changes)
1401 + .await
1402 + .unwrap_err();
1395 1403 assert!(
1396 1404 matches!(err, SyncKitError::NoMasterKey),
1397 1405 "Push with data should fail without master key: {err:?}"
@@ -1421,9 +1429,13 @@ async fn push_delete_requires_master_key() {
1421 1429 timestamp: Utc::now(),
1422 1430 hlc: Default::default(),
1423 1431 data: None,
1432 + extra: Default::default(),
1424 1433 }];
1425 1434
1426 - let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err();
1435 + let err = client
1436 + .push(DeviceId::new(Uuid::new_v4()), changes)
1437 + .await
1438 + .unwrap_err();
1427 1439 assert!(
1428 1440 matches!(err, SyncKitError::NoMasterKey),
1429 1441 "Delete now seals an HLC envelope and needs the key: {err:?}"
@@ -1494,13 +1506,17 @@ async fn double_push_same_data_both_succeed() {
1494 1506 timestamp: Utc::now(),
1495 1507 hlc: Default::default(),
1496 1508 data: Some(json!({"title": "duplicate push test"})),
1509 + extra: Default::default(),
1497 1510 };
1498 1511
1499 1512 let cursor1 = client
1500 1513 .push(DeviceId::new(Uuid::new_v4()), vec![entry.clone()])
1501 1514 .await
1502 1515 .unwrap();
1503 - let cursor2 = client.push(DeviceId::new(Uuid::new_v4()), vec![entry]).await.unwrap();
1516 + let cursor2 = client
1517 + .push(DeviceId::new(Uuid::new_v4()), vec![entry])
1518 + .await
1519 + .unwrap();
1504 1520
1505 1521 assert_eq!(cursor1, 1);
1506 1522 assert_eq!(cursor2, 2);
@@ -1545,10 +1561,7 @@ async fn setup_encryption_new_without_auth_fails() {
1545 1561 let server = MockServer::start().await;
1546 1562 let client = client_for(&server);
1547 1563
1548 - let err = client
1549 - .setup_encryption_new("password")
1550 - .await
1551 - .unwrap_err();
1564 + let err = client.setup_encryption_new("password").await.unwrap_err();
1552 1565 assert!(matches!(err, SyncKitError::NotAuthenticated));
1553 1566 }
1554 1567
@@ -1580,15 +1593,11 @@ async fn setup_encryption_existing_recovers_key() {
1580 1593 let server = MockServer::start().await;
1581 1594
1582 1595 let master_key = synckit_client::crypto::generate_master_key();
1583 - let envelope =
1584 - synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap();
1596 + let envelope = synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap();
1585 1597
1586 1598 Mock::given(method("GET"))
1587 1599 .and(path("/api/v1/sync/keys"))
1588 - .respond_with(
1589 - ResponseTemplate::new(200)
1590 - .set_body_json(json!({"encrypted_key": envelope})),
1591 - )
1600 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
1592 1601 .mount(&server)
1593 1602 .await;
1594 1603
@@ -1613,10 +1622,7 @@ async fn setup_encryption_existing_wrong_password_fails() {
1613 1622
1614 1623 Mock::given(method("GET"))
1615 1624 .and(path("/api/v1/sync/keys"))
1616 - .respond_with(
1617 - ResponseTemplate::new(200)
1618 - .set_body_json(json!({"encrypted_key": envelope})),
1619 - )
1625 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
1620 1626 .mount(&server)
1621 1627 .await;
1622 1628
@@ -1649,8 +1655,7 @@ async fn setup_encryption_existing_retries_on_server_error() {
1649 1655 let server = MockServer::start().await;
1650 1656
1651 1657 let master_key = synckit_client::crypto::generate_master_key();
1652 - let envelope =
1653 - synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap();
1658 + let envelope = synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap();
1654 1659
1655 1660 Mock::given(method("GET"))
1656 1661 .and(path("/api/v1/sync/keys"))
@@ -1661,10 +1666,7 @@ async fn setup_encryption_existing_retries_on_server_error() {
1661 1666
1662 1667 Mock::given(method("GET"))
1663 1668 .and(path("/api/v1/sync/keys"))
1664 - .respond_with(
1665 - ResponseTemplate::new(200)
1666 - .set_body_json(json!({"encrypted_key": envelope})),
1667 - )
1669 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
1668 1670 .mount(&server)
1669 1671 .await;
1670 1672
@@ -1734,6 +1736,7 @@ async fn encryption_setup_cross_device_roundtrip() {
1734 1736 timestamp: Utc::now(),
1735 1737 hlc: Default::default(),
1736 1738 data: Some(original_data.clone()),
1739 + extra: Default::default(),
1737 1740 }],
1738 1741 )
1739 1742 .await
@@ -1760,10 +1763,7 @@ async fn encryption_setup_cross_device_roundtrip() {
1760 1763
1761 1764 Mock::given(method("GET"))
1762 1765 .and(path("/api/v1/sync/keys"))
1763 - .respond_with(
1764 - ResponseTemplate::new(200)
1765 - .set_body_json(json!({"encrypted_key": envelope})),
1766 - )
1766 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope})))
1767 1767 .mount(&server)
1768 1768 .await;
1769 1769
@@ -1833,8 +1833,7 @@ async fn all_4xx_error_codes_mapped() {
1833 1833 Mock::given(method("GET"))
1834 1834 .and(path("/api/v1/sync/status"))
1835 1835 .respond_with(
1836 - ResponseTemplate::new(status_code)
1837 - .set_body_string(format!("Error {status_code}")),
1836 + ResponseTemplate::new(status_code).set_body_string(format!("Error {status_code}")),
1838 1837 )
1839 1838 .mount(&server)
1840 1839 .await;
@@ -1843,13 +1842,13 @@ async fn all_4xx_error_codes_mapped() {
1843 1842 let err = client.status().await.unwrap_err();
1844 1843
1845 1844 match err {
1846 - SyncKitError::Server { status, message, .. } => {
1845 + SyncKitError::Server {
1846 + status, message, ..
1847 + } => {
1847 1848 assert_eq!(status, status_code);
1848 1849 assert!(message.contains(&format!("Error {status_code}")));
1849 1850 }
1850 - other => panic!(
1851 - "Status {status_code} should map to Server error, got: {other:?}"
1852 - ),
1851 + other => panic!("Status {status_code} should map to Server error, got: {other:?}"),
1853 1852 }
1854 1853 }
1855 1854 }
@@ -1862,10 +1861,7 @@ async fn all_5xx_error_codes_retried() {
1862 1861 // First request fails with 5xx
1863 1862 Mock::given(method("GET"))
1864 1863 .and(path("/api/v1/sync/status"))
1865 - .respond_with(
1866 - ResponseTemplate::new(status_code)
1867 - .set_body_string("Server Error"),
1868 - )
1864 + .respond_with(ResponseTemplate::new(status_code).set_body_string("Server Error"))
1869 1865 .up_to_n_times(1)
1870 1866 .mount(&server)
1871 1867 .await;
@@ -1934,7 +1930,10 @@ async fn pull_without_auth_returns_not_authenticated() {
1934 1930 let server = MockServer::start().await;
1935 1931 let client = client_for(&server);
1936 1932
1937 - let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err();
1933 + let err = client
1934 + .pull(DeviceId::new(Uuid::new_v4()), 0)
1935 + .await
1936 + .unwrap_err();
1938 1937 assert!(matches!(err, SyncKitError::NotAuthenticated));
1939 1938 }
1940 1939
@@ -1995,6 +1994,7 @@ async fn end_to_end_push_pull_encryption_roundtrip() {
1995 1994 timestamp: Utc::now(),
Lines truncated