Skip to main content

max / synckit-client

Add filtered pull, rich pull, subscribe, conflict resolution - pull_filtered / pull_filtered_rich: table + timestamp filtering - pull_rich / pull_filtered_rich: preserve device_id + seq metadata - Subscribe module (tokio-stream): poll-based change subscription - Conflict resolution module: last-write-wins + field-level merge - PullFilter, PulledChange, FilteredPullRequest types - Keychain error From impl, helpers cleanup, integration test refactor - Docs: audit_history, competition, integration_patterns
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 22:21 UTC
Commit: 716f6af279f184c28d3c78f4c27042bd48137155
Parent: 9b41b39
19 files changed, +1721 insertions, -236 deletions
M Cargo.lock +12
@@ -1467,6 +1467,7 @@
1467 1467 "serde_json",
1468 1468 "thiserror",
1469 1469 "tokio",
1470 + "tokio-stream",
1470 1471 "tracing",
1471 1472 "unicode-normalization",
1472 1473 "urlencoding",
@@ -1611,6 +1612,17 @@
1611 1612 "tokio",
1612 1613 ]
1613 1614
1615 + [[package]]
1616 + name = "tokio-stream"
1617 + version = "0.1.18"
1618 + source = "registry+https://github.com/rust-lang/crates.io-index"
1619 + checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1620 + dependencies = [
1621 + "futures-core",
1622 + "pin-project-lite",
1623 + "tokio",
1624 + ]
1625 +
1614 1626 [[package]]
1615 1627 name = "tokio-util"
1616 1628 version = "0.7.18"
M Cargo.toml +1
@@ -21,6 +21,7 @@
21 21 reqwest = { version = "0.12", features = ["json", "native-tls"] }
22 22 bytes = "1"
23 23 tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
24 + tokio-stream = "0.1"
24 25
25 26 # Serialization
26 27 serde = { version = "1", features = ["derive"] }
@@ -94,100 +94,6 @@
94 94 | 2026-03-18 (Run 9) | 4,327 | 6 | 298 | ~69 |
95 95 | 2026-03-28 (Run 12) | 4,327 | 6 | 297 | ~69 |
96 96
97 - ## Changes Since Last Audit
97 + ---
98 98
99 - ### Seventh audit (2026-03-28, Run 12 cross-project)
100 - - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures.
101 - - **Grade:** A (maintained). v0.3.0.
102 - - **No code changes since Run 9.**
103 - - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
104 - - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive.
105 - - **No new findings.** All previous items remain resolved.
106 -
107 - ### Rust Patterns Audit (2026-03-21)
108 - - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone
109 - - Auth request structs already use `&'a str` -- confirmed optimal, no change needed
110 -
111 - ### Sixth audit (2026-03-18, Run 9 cross-project)
112 - - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings.
113 - - **Grade:** A (maintained). v0.3.0.
114 - - **No new findings.** All previous items remain resolved.
115 - - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests.
116 - - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords.
117 - - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor.
118 -
119 - ### Concurrency Upgrade (2026-03-13)
120 - - **Concurrency:** B+ -> A-
121 - - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass.
122 -
123 - ### Second audit (2026-03-13, pre-launch skeptical lens)
124 - - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted.
125 - - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation)
126 - - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum
127 - - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate)
128 - - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt")
129 -
130 - ### Post-audit remediation (2026-03-13)
131 - - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred.
132 - - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests)
133 - - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag).
134 - - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk.
135 - - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place.
136 - - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries.
137 - - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines).
138 -
139 - ### Observability Upgrade (2026-03-13)
140 - - Added Observability dimension to scorecard (grade A)
141 - - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params
142 - - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data
143 - - `use tracing::instrument;` import added to client.rs
144 - - `cargo check` passes clean
145 -
146 - ### Performance Upgrade (2026-03-13)
147 - - **Performance:** B -> A-
148 - - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call
149 - - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites)
150 - - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition
151 -
152 - ### Resilience Upgrade (2026-03-13)
153 - - **Resilience:** B- -> A-
154 - - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip
155 - - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully
156 - - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest.
157 -
158 - ### Adversarial Test Audit (2026-03-13)
159 - - **Grade:** A- -> A-. Testing grade upgraded from B- to A.
160 - - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC.
161 - - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes.
162 - - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs.
163 - - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests.
164 - - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests.
165 - - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests.
166 - - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests.
167 - - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration.
168 - - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered.
169 -
170 - ### Third audit (2026-03-16, Run 6 cross-project)
171 - - **Test count:** 297 (unchanged)
172 - - **Grade:** A (maintained).
173 - - **Source LOC:** 4,327 src + 2,749 test
174 - - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop.
175 - - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml.
176 - - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM).
177 - - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta).
178 -
179 - ### Testing Push (2026-03-13)
180 - - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A.
181 - - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest.
182 - - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization.
183 - - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages.
184 - - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip.
185 - - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout).
186 - - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults.
187 -
188 - ### Performance Upgrade (2026-03-13)
189 - - **Performance:** A- -> A
190 - - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations
191 - - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT)
192 - - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call
193 - - All 297 tests pass unchanged (2 test assertions updated for Arc deref)
99 + See [audit_history.md](./audit_history.md) for full chronological audit log.
M docs/todo.md +8 -7
@@ -1,18 +1,19 @@
1 1 # SyncKit Client SDK — Todo
2 2
3 - Done: All S1-S5 phases complete. All audit items resolved (Run 6 + Run 7). 297 tests + 1 doctest. client/ split into directory module (6 files).
3 + Done: All phases (S1-S5). Active: None. Next: Post-beta items below.
4 4
5 - Completed work archived in `docs/shared/synckit/audit_review.md` (Rust Patterns Audit section).
6 -
7 - ## Remaining
8 - - [ ] Write `competition.md` (competitive analysis vs Firebase, Supabase, Realm, CloudKit, etc.)
5 + v0.3.0. Audit grade A. 304 tests.
9 6
10 7 ## Deferred (Post-Beta)
8 + - [ ] Conflict resolution helpers — LWW, field-level merge, custom resolver callback in the SDK. Reduces client-side boilerplate. (Gap vs Ditto, Couchbase)
11 9 - [ ] Key rotation mechanism (requires server-side re-encryption of all sync_log entries)
12 10
11 + - [ ] WASM web client — compile SyncKit to WASM for browser use. Only if a consumer app ships a web companion.
12 + - [ ] C FFI layer — enables Swift/Kotlin/Python bindings. Only if non-Tauri consumers appear.
13 +
13 14 ## Key Paths
14 - - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, blob, helpers)
15 + - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, subscribe, blob, helpers)
15 16 - Crypto: `Shared/synckit-client/src/crypto.rs`
16 - - Types: `Shared/synckit-client/src/types.rs`
17 + - Types: `Shared/synckit-client/src/types.rs` (includes PullFilter, FilteredPullRequest)
17 18 - Keystore: `Shared/synckit-client/src/keystore.rs`
18 19 - Tests: `Shared/synckit-client/tests/integration.rs`
@@ -60,6 +60,13 @@
60 60 Keychain(String),
61 61 }
62 62
63 + #[cfg(feature = "keychain")]
64 + impl From<keyring::Error> for SyncKitError {
65 + fn from(e: keyring::Error) -> Self {
66 + SyncKitError::Keychain(e.to_string())
67 + }
68 + }
69 +
63 70 /// Convenience alias.
64 71 pub type Result<T> = std::result::Result<T, SyncKitError>;
65 72
M src/keystore.rs +6 -12
@@ -36,13 +36,9 @@
36 36 /// Store the master key in the OS keychain.
37 37 #[cfg(feature = "keychain")]
38 38 pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> {
39 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
40 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
41 -
39 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
42 40 let encoded = B64.encode(master_key);
43 - entry
44 - .set_password(&encoded)
45 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
41 + entry.set_password(&encoded)?;
46 42
47 43 tracing::debug!("Master key stored in OS keychain");
48 44 Ok(())
@@ -52,8 +48,7 @@
52 48 /// Returns None if no key is stored (not an error).
53 49 #[cfg(feature = "keychain")]
54 50 pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> {
55 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
56 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
51 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
57 52
58 53 match entry.get_password() {
59 54 Ok(encoded) => {
@@ -69,15 +64,14 @@
69 64 Ok(Some(key))
70 65 }
71 66 Err(keyring::Error::NoEntry) => Ok(None),
72 - Err(e) => Err(SyncKitError::Keychain(e.to_string())),
67 + Err(e) => Err(e.into()),
73 68 }
74 69 }
75 70
76 71 /// Delete the master key from the OS keychain.
77 72 #[cfg(feature = "keychain")]
78 73 pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> {
79 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))
80 - .map_err(|e| SyncKitError::Keychain(e.to_string()))?;
74 + let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
81 75
82 76 match entry.delete_credential() {
83 77 Ok(()) => {
@@ -85,7 +79,7 @@
85 79 Ok(())
86 80 }
87 81 Err(keyring::Error::NoEntry) => Ok(()), // Already gone
88 - Err(e) => Err(SyncKitError::Keychain(e.to_string())),
82 + Err(e) => Err(e.into()),
89 83 }
90 84 }
91 85
M src/lib.rs +6 -2
@@ -42,12 +42,16 @@
42 42 //! ```
43 43
44 44 pub mod client;
45 + pub mod conflict;
45 46 pub mod crypto;
46 47 pub mod error;
47 48 pub mod keystore;
48 49 pub mod types;
49 50
50 51 // Re-exports for convenience
51 - pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig};
52 + pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream};
53 + pub use conflict::{
54 + detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution,
55 + };
52 56 pub use error::{Result, SyncKitError};
53 - pub use types::{ChangeEntry, ChangeOp, Device, SyncStatus};
57 + pub use types::{ChangeEntry, ChangeOp, Device, PullFilter, PulledChange, SyncStatus};
M src/types.rs +83
@@ -160,6 +160,50 @@
160 160 pub data: Option<serde_json::Value>,
161 161 }
162 162
163 + // ── Filtered pull ──
164 +
165 + /// Optional filters for [`SyncKitClient::pull_filtered`].
166 + ///
167 + /// Both fields are optional and compose with AND. An empty/default filter
168 + /// is equivalent to an unfiltered pull.
169 + #[derive(Debug, Clone, Default, Serialize)]
170 + pub struct PullFilter {
171 + /// Only return entries for these table names.
172 + #[serde(skip_serializing_if = "Option::is_none")]
173 + pub tables: Option<Vec<String>>,
174 + /// Only return entries with `client_timestamp >= since`.
175 + #[serde(skip_serializing_if = "Option::is_none")]
176 + pub since: Option<DateTime<Utc>>,
177 + }
178 +
179 + #[derive(Serialize)]
180 + pub(crate) struct FilteredPullRequest {
181 + pub device_id: Uuid,
182 + pub cursor: i64,
183 + #[serde(skip_serializing_if = "Option::is_none")]
184 + pub tables: Option<Vec<String>>,
185 + #[serde(skip_serializing_if = "Option::is_none")]
186 + pub since: Option<DateTime<Utc>>,
187 + }
188 +
189 + // ── Pulled change (rich metadata) ──
190 +
191 + /// A change entry from pull with server metadata preserved.
192 + ///
193 + /// Wraps [`ChangeEntry`] with `device_id` and `seq` fields that are normally
194 + /// discarded during `pull()`. Used by [`SyncKitClient::pull_rich`] for
195 + /// conflict detection — the `device_id` identifies whether a change came from
196 + /// another device, and `seq` provides total server ordering.
197 + #[derive(Debug, Clone)]
198 + pub struct PulledChange {
199 + /// The decrypted change entry.
200 + pub entry: ChangeEntry,
201 + /// The device that originated this change.
202 + pub device_id: Uuid,
203 + /// Server sequence number (total ordering).
204 + pub seq: i64,
205 + }
206 +
163 207 // ── Keys ──
164 208
165 209 #[derive(Serialize)]
@@ -382,4 +426,43 @@
382 426 assert_eq!(format!("{:?}", ChangeOp::Update), "Update");
383 427 assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete");
384 428 }
429 +
430 + // ── PullFilter ──
431 +
432 + #[test]
433 + fn pull_filter_serialization_with_both_fields() {
434 + let filter = PullFilter {
435 + tables: Some(vec!["tasks".to_string(), "events".to_string()]),
436 + since: Some("2025-06-15T12:00:00Z".parse().unwrap()),
437 + };
438 + let json = serde_json::to_string(&filter).unwrap();
439 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
440 + assert_eq!(parsed["tables"].as_array().unwrap().len(), 2);
441 + assert!(parsed["since"].is_string());
442 + }
443 +
444 + #[test]
445 + fn pull_filter_serialization_with_none_fields() {
446 + let filter = PullFilter::default();
447 + let json = serde_json::to_string(&filter).unwrap();
448 + // None fields should be omitted entirely
449 + assert!(!json.contains("tables"));
450 + assert!(!json.contains("since"));
451 + assert_eq!(json, "{}");
452 + }
453 +
454 + #[test]
455 + fn filtered_pull_request_includes_filter_fields() {
456 + let req = FilteredPullRequest {
457 + device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
458 + cursor: 42,
459 + tables: Some(vec!["tasks".to_string()]),
460 + since: Some("2025-01-01T00:00:00Z".parse().unwrap()),
461 + };
462 + let json = serde_json::to_string(&req).unwrap();
463 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
464 + assert_eq!(parsed["cursor"], 42);
465 + assert_eq!(parsed["tables"].as_array().unwrap().len(), 1);
466 + assert!(parsed["since"].is_string());
467 + }
385 468 }
@@ -54,8 +54,7 @@
54 54 let client = client_for(server);
55 55 let (user_id, app_id) = test_ids();
56 56 client
57 - .restore_session(&fresh_token(), user_id, app_id)
58 - .unwrap();
57 + .restore_session(&fresh_token(), user_id, app_id);
59 58 client
60 59 }
61 60
@@ -99,7 +98,7 @@
99 98 .await
100 99 .unwrap();
101 100
102 - let info = client.session_info().unwrap().expect("session stored");
101 + let info = client.session_info().expect("session stored");
103 102 assert_eq!(info.user_id, user_id);
104 103 assert_eq!(info.app_id, app_id);
105 104 }
@@ -174,7 +173,7 @@
174 173
175 174 assert_eq!(uid, user_id);
176 175 assert_eq!(aid, app_id);
177 - assert!(client.session_info().unwrap().is_some());
176 + assert!(client.session_info().is_some());
178 177 }
179 178
180 179 // ── Device management ──
@@ -246,7 +245,7 @@
246 245
247 246 let client = authed_client(&server);
248 247 let key = synckit_client::crypto::generate_master_key();
249 - client.set_master_key_raw(key).unwrap();
248 + client.set_master_key_raw(key);
250 249
251 250 let device_id = Uuid::new_v4();
252 251 let cursor = client
@@ -285,7 +284,7 @@
285 284
286 285 let client = authed_client(&server);
287 286 let key = synckit_client::crypto::generate_master_key();
288 - client.set_master_key_raw(key).unwrap();
287 + client.set_master_key_raw(key);
289 288
290 289 // Encrypt a value to simulate what the server would return
291 290 let plaintext = json!({"title": "Decrypted task"});
@@ -336,7 +335,7 @@
336 335
337 336 let client = authed_client(&server);
338 337 let key = synckit_client::crypto::generate_master_key();
339 - client.set_master_key_raw(key).unwrap();
338 + client.set_master_key_raw(key);
340 339
341 340 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
342 341 assert_eq!(cursor, 5);
@@ -355,7 +354,7 @@
355 354
356 355 let client = authed_client(&server);
357 356 let key = synckit_client::crypto::generate_master_key();
358 - client.set_master_key_raw(key).unwrap();
357 + client.set_master_key_raw(key);
359 358
360 359 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
361 360 assert!(matches!(err, SyncKitError::Server { status: 401, .. }));
@@ -367,7 +366,7 @@
367 366
368 367 let client = authed_client(&server);
369 368 let key = synckit_client::crypto::generate_master_key();
370 - client.set_master_key_raw(key).unwrap();
369 + client.set_master_key_raw(key);
371 370
372 371 let device_id = Uuid::new_v4();
373 372
@@ -438,7 +437,7 @@
438 437
439 438 let client = authed_client(&server);
440 439 let key = synckit_client::crypto::generate_master_key();
441 - client.set_master_key_raw(key).unwrap();
440 + client.set_master_key_raw(key);
442 441
443 442 let plaintext = b"hello blob data";
444 443 let presigned = format!("{}{}", server.uri(), upload_path);
@@ -470,7 +469,7 @@
470 469
471 470 let client = authed_client(&server);
472 471 let key = synckit_client::crypto::generate_master_key();
473 - client.set_master_key_raw(key).unwrap();
472 + client.set_master_key_raw(key);
474 473
475 474 // Encrypt data to simulate what S3 would return
476 475 let plaintext = b"decrypted blob content";
@@ -508,7 +507,7 @@
508 507
509 508 let client = authed_client(&server);
510 509 let key = synckit_client::crypto::generate_master_key();
511 - client.set_master_key_raw(key).unwrap();
510 + client.set_master_key_raw(key);
512 511
513 512 let presigned = format!("{}{}", server.uri(), upload_path);
514 513 let result = client.blob_upload(&presigned, b"data".to_vec()).await;
@@ -524,7 +523,7 @@
524 523 let (user_id, app_id) = test_ids();
525 524
526 525 let expired = fake_jwt(Utc::now().timestamp() - 3600);
527 - client.restore_session(&expired, user_id, app_id).unwrap();
526 + client.restore_session(&expired, user_id, app_id);
528 527
529 528 let err = client.status().await.unwrap_err();
530 529 assert!(
@@ -542,8 +541,7 @@
542 541 // Token expires in 10 seconds (within 30-second buffer)
543 542 let near_expiry = fake_jwt(Utc::now().timestamp() + 10);
544 543 client
545 - .restore_session(&near_expiry, user_id, app_id)
546 - .unwrap();
544 + .restore_session(&near_expiry, user_id, app_id);
547 545
548 546 let err = client.status().await.unwrap_err();
549 547 assert!(
@@ -574,7 +572,7 @@
574 572 assert_eq!(status.total_changes, 0);
575 573
576 574 // Clear session
577 - client.clear_session().unwrap();
575 + client.clear_session();
578 576
579 577 // Now should fail
580 578 let err = client.status().await.unwrap_err();
@@ -681,7 +679,7 @@
681 679
682 680 let client = Arc::new(authed_client(&server));
683 681 let key = synckit_client::crypto::generate_master_key();
684 - client.set_master_key_raw(key).unwrap();
682 + client.set_master_key_raw(key);
685 683
686 684 let mut handles = Vec::new();
687 685 for _ in 0..4 {
@@ -838,7 +836,7 @@
838 836 let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap();
839 837
840 838 // Cache the master key in the client (simulating normal logged-in state)
841 - client.set_master_key_raw(master_key).unwrap();
839 + client.set_master_key_raw(master_key);
842 840
843 841 (client, master_key, envelope)
844 842 }
@@ -1008,7 +1006,7 @@
1008 1006
1009 1007 let client = authed_client(&server);
1010 1008 let key = synckit_client::crypto::generate_master_key();
1011 - client.set_master_key_raw(key).unwrap();
1009 + client.set_master_key_raw(key);
1012 1010
1013 1011 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
1014 1012 assert_eq!(cursor, 0);
@@ -1030,7 +1028,7 @@
1030 1028
1031 1029 let client = authed_client(&server);
1032 1030 let key = synckit_client::crypto::generate_master_key();
1033 - client.set_master_key_raw(key).unwrap();
1031 + client.set_master_key_raw(key);
1034 1032
1035 1033 let result = client.push(Uuid::new_v4(), vec![]).await;
1036 1034 assert!(result.is_err(), "Malformed JSON should produce an error");
@@ -1056,7 +1054,7 @@
1056 1054
1057 1055 let client = authed_client(&server);
1058 1056 let key = synckit_client::crypto::generate_master_key();
1059 - client.set_master_key_raw(key).unwrap();
1057 + client.set_master_key_raw(key);
1060 1058
1061 1059 let result = client.pull(Uuid::new_v4(), 0).await;
1062 1060 assert!(result.is_err(), "Malformed JSON should produce an error");
@@ -1121,7 +1119,7 @@
1121 1119
1122 1120 let client = Arc::new(authed_client(&server));
1123 1121 let key = synckit_client::crypto::generate_master_key();
1124 - client.set_master_key_raw(key).unwrap();
1122 + client.set_master_key_raw(key);
1125 1123
1126 1124 let mut handles = Vec::new();
1127 1125 for i in 0..8 {
@@ -1168,7 +1166,7 @@
1168 1166
1169 1167 let client = Arc::new(authed_client(&server));
1170 1168 let key = synckit_client::crypto::generate_master_key();
1171 - client.set_master_key_raw(key).unwrap();
1169 + client.set_master_key_raw(key);
1172 1170
1173 1171 let mut handles = Vec::new();
1174 1172 for i in 0..4 {
@@ -1204,7 +1202,7 @@
1204 1202
1205 1203 let client = authed_client(&server);
1206 1204 let key = synckit_client::crypto::generate_master_key();
1207 - client.set_master_key_raw(key).unwrap();
1205 + client.set_master_key_raw(key);
1208 1206
1209 1207 // Create 1000+ change entries
1210 1208 let changes: Vec<ChangeEntry> = (0..1100)
@@ -1230,10 +1228,9 @@
1230 1228 let (user_id, app_id) = test_ids();
1231 1229
1232 1230 let expired = fake_jwt(Utc::now().timestamp() - 100);
1233 - client.restore_session(&expired, user_id, app_id).unwrap();
1231 + client.restore_session(&expired, user_id, app_id);
1234 1232 client
1235 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
1236 - .unwrap();
1233 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
1237 1234
1238 1235 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
1239 1236 assert!(
@@ -1249,10 +1246,9 @@
1249 1246 let (user_id, app_id) = test_ids();
1250 1247
1251 1248 let expired = fake_jwt(Utc::now().timestamp() - 100);
1252 - client.restore_session(&expired, user_id, app_id).unwrap();
1249 + client.restore_session(&expired, user_id, app_id);
1253 1250 client
1254 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
1255 - .unwrap();
1251 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
1256 1252
1257 1253 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
1258 1254 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1265,7 +1261,7 @@
1265 1261 let (user_id, app_id) = test_ids();
1266 1262
1267 1263 let expired = fake_jwt(Utc::now().timestamp() - 100);
1268 - client.restore_session(&expired, user_id, app_id).unwrap();
1264 + client.restore_session(&expired, user_id, app_id);
1269 1265
1270 1266 let err = client.register_device("Test", "test").await.unwrap_err();
1271 1267 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1278,7 +1274,7 @@
1278 1274 let (user_id, app_id) = test_ids();
1279 1275
1280 1276 let expired = fake_jwt(Utc::now().timestamp() - 100);
1281 - client.restore_session(&expired, user_id, app_id).unwrap();
1277 + client.restore_session(&expired, user_id, app_id);
1282 1278
1283 1279 let err = client.list_devices().await.unwrap_err();
1284 1280 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -1299,7 +1295,7 @@
1299 1295
1300 1296 let client = authed_client(&server);
1301 1297 let key = synckit_client::crypto::generate_master_key();
1302 - client.set_master_key_raw(key).unwrap();
1298 + client.set_master_key_raw(key);
1303 1299
1304 1300 let presigned = format!("{}{}", server.uri(), upload_path);
1305 1301 let result = client.blob_upload(&presigned, vec![]).await;
@@ -1324,7 +1320,7 @@
1324 1320
1325 1321 let client = authed_client(&server);
1326 1322 let key = synckit_client::crypto::generate_master_key();
1327 - client.set_master_key_raw(key).unwrap();
1323 + client.set_master_key_raw(key);
1328 1324
1329 1325 let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01";
1330 1326
@@ -1471,7 +1467,7 @@
1471 1467
1472 1468 let client = authed_client(&server);
1473 1469 let key = synckit_client::crypto::generate_master_key();
1474 - client.set_master_key_raw(key).unwrap();
1470 + client.set_master_key_raw(key);
1475 1471
1476 1472 let entry = ChangeEntry {
1477 1473 table: "tasks".into(),
@@ -1505,12 +1501,12 @@
1505 1501 .await;
1506 1502
1507 1503 let client = authed_client(&server);
1508 - assert!(!client.has_master_key().unwrap());
1504 + assert!(!client.has_master_key());
1509 1505
1510 1506 client.setup_encryption_new("test-password").await.unwrap();
1511 1507
1512 1508 // Master key should now be in memory
1513 - assert!(client.has_master_key().unwrap());
1509 + assert!(client.has_master_key());
1514 1510
1515 1511 // Verify the PUT body contains a valid envelope unwrappable with the same password
1516 1512 let requests = server.received_requests().await.unwrap();
@@ -1557,7 +1553,7 @@
1557 1553 let client = authed_client(&server);
1558 1554 let result = client.setup_encryption_new("password").await;
1559 1555 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1560 - assert!(client.has_master_key().unwrap());
1556 + assert!(client.has_master_key());
1561 1557 }
1562 1558
1563 1559 #[tokio::test]
@@ -1578,14 +1574,14 @@
1578 1574 .await;
1579 1575
1580 1576 let client = authed_client(&server);
1581 - assert!(!client.has_master_key().unwrap());
1577 + assert!(!client.has_master_key());
1582 1578
1583 1579 client
1584 1580 .setup_encryption_existing("my-password")
1585 1581 .await
1586 1582 .unwrap();
1587 1583
1588 - assert!(client.has_master_key().unwrap());
1584 + assert!(client.has_master_key());
1589 1585 }
1590 1586
1591 1587 #[tokio::test]
@@ -1614,7 +1610,7 @@
1614 1610 matches!(err, SyncKitError::DecryptionFailed),
1615 1611 "Wrong password should produce DecryptionFailed: {err:?}"
1616 1612 );
1617 - assert!(!client.has_master_key().unwrap());
1613 + assert!(!client.has_master_key());
1618 1614 }
1619 1615
1620 1616 #[tokio::test]
@@ -1656,7 +1652,7 @@
1656 1652 let client = authed_client(&server);
1657 1653 let result = client.setup_encryption_existing("password").await;
1658 1654 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1659 - assert!(client.has_master_key().unwrap());
1655 + assert!(client.has_master_key());
1660 1656 }
1661 1657
1662 1658 #[tokio::test]
@@ -1895,7 +1891,7 @@
1895 1891
1896 1892 // Client has key2 (wrong key)
1897 1893 let client = authed_client(&server);
1898 - client.set_master_key_raw(key2).unwrap();
1894 + client.set_master_key_raw(key2);
1899 1895
1900 1896 let result = client
1901 1897 .blob_download(&format!("{}{}", server.uri(), download_path))
@@ -1952,7 +1948,7 @@
1952 1948
1953 1949 let client = authed_client(&server);
1954 1950 let key = synckit_client::crypto::generate_master_key();
1955 - client.set_master_key_raw(key).unwrap();
1951 + client.set_master_key_raw(key);
1956 1952
1957 1953 let device_id = Uuid::new_v4();
1958 1954 let original_data = json!({
@@ -2139,7 +2135,7 @@
2139 2135
2140 2136 let client = authed_client(&server);
2141 2137 let key = synckit_client::crypto::generate_master_key();
2142 - client.set_master_key_raw(key).unwrap();
2138 + client.set_master_key_raw(key);
2143 2139
2144 2140 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2145 2141 assert!(
@@ -2166,7 +2162,7 @@
2166 2162
2167 2163 let client = authed_client(&server);
2168 2164 let key = synckit_client::crypto::generate_master_key();
2169 - client.set_master_key_raw(key).unwrap();
2165 + client.set_master_key_raw(key);
2170 2166
2171 2167 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
2172 2168 assert!(
@@ -2290,7 +2286,7 @@
2290 2286
2291 2287 let client = authed_client(&server);
2292 2288 let key = synckit_client::crypto::generate_master_key();
2293 - client.set_master_key_raw(key).unwrap();
2289 + client.set_master_key_raw(key);
2294 2290
2295 2291 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2296 2292 match err {
@@ -2342,7 +2338,7 @@
2342 2338 assert_eq!(uid2, second_user_id);
2343 2339
2344 2340 // Session should now reflect the second auth
2345 - let info = client.session_info().unwrap().unwrap();
2341 + let info = client.session_info().unwrap();
2346 2342 assert_eq!(info.user_id, second_user_id);
2347 2343 }
2348 2344
@@ -2357,15 +2353,15 @@
2357 2353 .await;
2358 2354
2359 2355 let client = authed_client(&server);
2360 - assert!(client.session_info().unwrap().is_some());
2356 + assert!(client.session_info().is_some());
2361 2357
2362 - client.clear_session().unwrap();
2363 - assert!(client.session_info().unwrap().is_none());
2358 + client.clear_session();
2359 + assert!(client.session_info().is_none());
2364 2360
2365 2361 // Re-authenticate should work
2366 2362 let result = client.authenticate("user@test.com", "pass").await;
2367 2363 assert!(result.is_ok(), "Should be able to re-authenticate after clear: {result:?}");
2368 - assert!(client.session_info().unwrap().is_some());
2364 + assert!(client.session_info().is_some());
2369 2365 }
2370 2366
2371 2367 #[tokio::test]
@@ -2375,10 +2371,9 @@
2375 2371 let (user_id, app_id) = test_ids();
2376 2372
2377 2373 let expired = fake_jwt(Utc::now().timestamp() - 3600);
2378 - client.restore_session(&expired, user_id, app_id).unwrap();
2374 + client.restore_session(&expired, user_id, app_id);
2379 2375 client
2380 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
2381 - .unwrap();
2376 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
2382 2377
2383 2378 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2384 2379 assert!(
@@ -2402,11 +2397,11 @@
2402 2397 let client = authed_client(&server);
2403 2398
2404 2399 client.setup_encryption_new("pass1").await.unwrap();
2405 - assert!(client.has_master_key().unwrap());
2400 + assert!(client.has_master_key());
2406 2401
2407 2402 // Second call overwrites
2408 2403 client.setup_encryption_new("pass2").await.unwrap();
2409 - assert!(client.has_master_key().unwrap());
2404 + assert!(client.has_master_key());
2410 2405
2411 2406 // Verify the second PUT used a different envelope
2412 2407 let requests = server.received_requests().await.unwrap();
@@ -2449,7 +2444,7 @@
2449 2444
2450 2445 let client = authed_client(&server);
2451 2446 let key = synckit_client::crypto::generate_master_key();
2452 - client.set_master_key_raw(key).unwrap();
2447 + client.set_master_key_raw(key);
2453 2448
2454 2449 let plaintext = b"retry download test";
2455 2450 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
@@ -2486,7 +2481,7 @@
2486 2481
2487 2482 let client = authed_client(&server);
2488 2483 let key = synckit_client::crypto::generate_master_key();
2489 - client.set_master_key_raw(key).unwrap();
2484 + client.set_master_key_raw(key);
2490 2485
2491 2486 let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect();
2492 2487 let presigned = format!("{}{}", server.uri(), upload_path);
@@ -2574,7 +2569,7 @@
2574 2569 let mut handles = Vec::new();
2575 2570 for _ in 0..50 {
2576 2571 let c = Arc::clone(&client);
2577 - handles.push(tokio::spawn(async move { c.session_info().unwrap() }));
2572 + handles.push(tokio::spawn(async move { c.session_info() }));
2578 2573 }
2579 2574
2580 2575 for h in handles {
@@ -2588,13 +2583,12 @@
2588 2583 let server = MockServer::start().await;
2589 2584 let client = Arc::new(authed_client(&server));
2590 2585 client
2591 - .set_master_key_raw(synckit_client::crypto::generate_master_key())
2592 - .unwrap();
2586 + .set_master_key_raw(synckit_client::crypto::generate_master_key());
2593 2587
2594 2588 let mut handles = Vec::new();
2595 2589 for _ in 0..50 {
2596 2590 let c = Arc::clone(&client);
2597 - handles.push(tokio::spawn(async move { c.has_master_key().unwrap() }));
2591 + handles.push(tokio::spawn(async move { c.has_master_key() }));
2598 2592 }
2599 2593
2600 2594 for h in handles {
@@ -2643,7 +2637,7 @@
2643 2637
2644 2638 let client = Arc::new(authed_client(&server));
2645 2639 let key = synckit_client::crypto::generate_master_key();
2646 - client.set_master_key_raw(key).unwrap();
2640 + client.set_master_key_raw(key);
2647 2641
2648 2642 let mut handles = Vec::new();
2649 2643 for batch in 0..4 {
@@ -2689,8 +2683,7 @@
2689 2683 let client = short_timeout_client(server);
2690 2684 let (user_id, app_id) = test_ids();
2691 2685 client
2692 - .restore_session(&fresh_token(), user_id, app_id)
2693 - .unwrap();
2686 + .restore_session(&fresh_token(), user_id, app_id);
2694 2687 client
2695 2688 }
2696 2689
@@ -2742,7 +2735,7 @@
2742 2735
2743 2736 let client = authed_short_timeout_client(&server);
2744 2737 let key = synckit_client::crypto::generate_master_key();
2745 - client.set_master_key_raw(key).unwrap();
2738 + client.set_master_key_raw(key);
2746 2739
2747 2740 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
2748 2741 assert_eq!(cursor, 42);
M src/client/auth.rs +25 -27
@@ -60,7 +60,7 @@
60 60 ///
61 61 /// Sets the internal session state without making any HTTP calls.
62 62 /// Used on app startup to restore from stored credentials without re-authenticating.
63 - pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) -> Result<()> {
63 + pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) {
64 64 let token_exp = jwt_exp(token);
65 65 *self.session.write() = Some(Session {
66 66 token: Arc::new(token.to_string()),
@@ -69,7 +69,6 @@
69 69 app_id,
70 70 });
71 71 tracing::info!("Session restored for user {user_id}, app {app_id}");
72 - Ok(())
73 72 }
74 73
75 74 /// Clear the in-memory session and master key.
@@ -77,11 +76,10 @@
77 76 /// After calling this, the client will need to re-authenticate and set up
78 77 /// encryption again. Does not affect OS keychain storage — call
79 78 /// `keystore::delete_master_key` separately if needed.
80 - pub fn clear_session(&self) -> Result<()> {
79 + pub fn clear_session(&self) {
81 80 *self.session.write() = None;
82 81 *self.master_key.write() = None;
83 82 tracing::info!("Session and master key cleared");
84 - Ok(())
85 83 }
86 84
87 85 /// Check whether the current session token has expired (or will expire
@@ -89,17 +87,17 @@
89 87 /// if the token's `exp` claim is in the past. Returns `false` if the
90 88 /// token cannot be decoded (assumes not expired — the server will reject
91 89 /// it with a 401 if it actually is).
92 - pub fn is_token_expired(&self) -> Result<bool> {
90 + pub fn is_token_expired(&self) -> bool {
93 91 let guard = self.session.read();
94 92 let Some(session) = guard.as_ref() else {
95 - return Ok(true);
93 + return true;
96 94 };
97 95 match session.token_exp {
98 96 Some(exp) => {
99 97 let now = chrono::Utc::now().timestamp();
100 - Ok(now >= exp - TOKEN_EXPIRY_BUFFER_SECS)
98 + now >= exp - TOKEN_EXPIRY_BUFFER_SECS
101 99 }
102 - None => Ok(false),
100 + None => false,
103 101 }
104 102 }
105 103
@@ -219,9 +217,9 @@
219 217 let client = SyncKitClient::new(test_config());
220 218 let (app_id, user_id) = test_ids();
221 219
222 - client.restore_session("fake-token", user_id, app_id).unwrap();
220 + client.restore_session("fake-token", user_id, app_id);
223 221
224 - let info = client.session_info().unwrap().expect("session should exist");
222 + let info = client.session_info().expect("session should exist");
225 223 assert_eq!(*info.token, "fake-token");
226 224 assert_eq!(info.user_id, user_id);
227 225 assert_eq!(info.app_id, app_id);
@@ -232,10 +230,10 @@
232 230 let client = SyncKitClient::new(test_config());
233 231 let (app_id, user_id) = test_ids();
234 232
235 - client.restore_session("first-token", user_id, app_id).unwrap();
236 - client.restore_session("second-token", user_id, app_id).unwrap();
233 + client.restore_session("first-token", user_id, app_id);
234 + client.restore_session("second-token", user_id, app_id);
237 235
238 - let info = client.session_info().unwrap().unwrap();
236 + let info = client.session_info().unwrap();
239 237 assert_eq!(*info.token, "second-token");
240 238 }
241 239
@@ -285,7 +283,7 @@
285 283 #[test]
286 284 fn is_token_expired_true_without_session() {
287 285 let client = SyncKitClient::new(test_config());
288 - assert!(client.is_token_expired().unwrap());
286 + assert!(client.is_token_expired());
289 287 }
290 288
291 289 #[test]
@@ -293,8 +291,8 @@
293 291 let client = SyncKitClient::new(test_config());
294 292 let (app_id, user_id) = test_ids();
295 293 let token = fake_jwt(Utc::now().timestamp() - 3600);
296 - client.restore_session(&token, user_id, app_id).unwrap();
297 - assert!(client.is_token_expired().unwrap());
294 + client.restore_session(&token, user_id, app_id);
295 + assert!(client.is_token_expired());
298 296 }
299 297
300 298 #[test]
@@ -302,8 +300,8 @@
302 300 let client = SyncKitClient::new(test_config());
303 301 let (app_id, user_id) = test_ids();
304 302 let token = fake_jwt(Utc::now().timestamp() + 3600);
305 - client.restore_session(&token, user_id, app_id).unwrap();
306 - assert!(!client.is_token_expired().unwrap());
303 + client.restore_session(&token, user_id, app_id);
304 + assert!(!client.is_token_expired());
307 305 }
308 306
309 307 // ── require_token with expiry ──
@@ -313,7 +311,7 @@
313 311 let client = SyncKitClient::new(test_config());
314 312 let (app_id, user_id) = test_ids();
315 313 let token = fake_jwt(Utc::now().timestamp() - 3600);
316 - client.restore_session(&token, user_id, app_id).unwrap();
314 + client.restore_session(&token, user_id, app_id);
317 315
318 316 let err = client.require_token().unwrap_err();
319 317 assert!(matches!(err, SyncKitError::TokenExpired));
@@ -324,7 +322,7 @@
324 322 let client = SyncKitClient::new(test_config());
325 323 let (app_id, user_id) = test_ids();
326 324 let token = fake_jwt(Utc::now().timestamp() + 3600);
327 - client.restore_session(&token, user_id, app_id).unwrap();
325 + client.restore_session(&token, user_id, app_id);
328 326
329 327 assert!(client.require_token().is_ok());
330 328 }
@@ -335,16 +333,16 @@
335 333 fn clear_session_clears_master_key() {
336 334 let client = SyncKitClient::new(test_config());
337 335 let (app_id, user_id) = test_ids();
338 - client.restore_session("token", user_id, app_id).unwrap();
339 - client.set_master_key_raw([42u8; 32]).unwrap();
336 + client.restore_session("token", user_id, app_id);
337 + client.set_master_key_raw([42u8; 32]);
340 338
341 - assert!(client.session_info().unwrap().is_some());
342 - assert!(client.has_master_key().unwrap());
339 + assert!(client.session_info().is_some());
340 + assert!(client.has_master_key());
343 341
344 - client.clear_session().unwrap();
342 + client.clear_session();
345 343
346 - assert!(client.session_info().unwrap().is_none());
347 - assert!(!client.has_master_key().unwrap());
344 + assert!(client.session_info().is_none());
345 + assert!(!client.has_master_key());
348 346 }
349 347
350 348 // ── OAuth types ──
@@ -188,7 +188,7 @@
188 188 fn key_url_and_token_builds_correct_url() {
189 189 let client = SyncKitClient::new(test_config());
190 190 let (app_id, user_id) = test_ids();
191 - client.restore_session("bearer-token", user_id, app_id).unwrap();
191 + client.restore_session("bearer-token", user_id, app_id);
192 192
193 193 let (url, token) = client.key_url_and_token().unwrap();
194 194 assert_eq!(url, "https://example.com/api/sync/keys");
@@ -87,6 +87,20 @@
87 87 }
88 88 }
89 89
90 + /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`].
91 + ///
92 + /// Used by `pull_rich()` to produce conflict-detection-ready results.
93 + pub(super) fn decrypt_change_to_pulled(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<crate::types::PulledChange> {
94 + let device_id = entry.device_id;
95 + let seq = entry.seq;
96 + let decrypted = Self::decrypt_change_with_key(entry, master_key)?;
97 + Ok(crate::types::PulledChange {
98 + entry: decrypted,
99 + device_id,
100 + seq,
101 + })
102 + }
103 +
90 104 /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
91 105 pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
92 106 let decrypted_data = match entry.data {
@@ -505,7 +519,7 @@
505 519 fn encrypt_change_preserves_all_metadata() {
506 520 let client = SyncKitClient::new(test_config());
507 521 let key = crypto::generate_master_key();
508 - client.set_master_key_raw(key).unwrap();
522 + client.set_master_key_raw(key);
509 523
510 524 let ts = Utc::now();
511 525 let entry = ChangeEntry {
@@ -529,7 +543,7 @@
529 543 fn multiple_entries_encrypt_decrypt_roundtrip() {
530 544 let client = SyncKitClient::new(test_config());
531 545 let key = crypto::generate_master_key();
532 - client.set_master_key_raw(key).unwrap();
546 + client.set_master_key_raw(key);
533 547
534 548 let entries = [
535 549 ChangeEntry {
@@ -589,7 +603,7 @@
589 603 fn encrypt_decrypt_roundtrip_unicode_table() {
590 604 let client = SyncKitClient::new(test_config());
591 605 let key = crypto::generate_master_key();
592 - client.set_master_key_raw(key).unwrap();
606 + client.set_master_key_raw(key);
593 607
594 608 let entry = ChangeEntry {
595 609 table: "\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30FC}\u{30D6}\u{30EB}".into(),
@@ -620,7 +634,7 @@
620 634 fn encrypt_decrypt_roundtrip_empty_row_id() {
621 635 let client = SyncKitClient::new(test_config());
622 636 let key = crypto::generate_master_key();
623 - client.set_master_key_raw(key).unwrap();
637 + client.set_master_key_raw(key);
624 638
625 639 let entry = ChangeEntry {
626 640 table: "t".into(),
M src/client/mod.rs +24 -20
@@ -53,8 +53,11 @@
53 53 mod blob;
54 54 mod encryption;
55 55 pub(crate) mod helpers;
56 + mod subscribe;
56 57 mod sync;
57 58
59 + pub use subscribe::SyncNotifyStream;
60 +
58 61 use parking_lot::RwLock;
59 62 use reqwest::Client;
60 63 use std::sync::Arc;
@@ -92,6 +95,7 @@
92 95 devices: String,
93 96 push: String,
94 97 pull: String,
98 + subscribe: String,
95 99 status: String,
96 100 keys: String,
97 101 blobs_upload: String,
@@ -107,6 +111,7 @@
107 111 devices: format!("{base}/api/sync/devices"),
108 112 push: format!("{base}/api/sync/push"),
109 113 pull: format!("{base}/api/sync/pull"),
114 + subscribe: format!("{base}/api/sync/subscribe"),
110 115 status: format!("{base}/api/sync/status"),
111 116 keys: format!("{base}/api/sync/keys"),
112 117 blobs_upload: format!("{base}/api/sync/blobs/upload"),
@@ -184,25 +189,24 @@
184 189 }
185 190
186 191 /// Returns whether the master encryption key is loaded and ready.
187 - pub fn has_master_key(&self) -> Result<bool> {
188 - Ok(self.master_key.read().is_some())
192 + pub fn has_master_key(&self) -> bool {
193 + self.master_key.read().is_some()
189 194 }
190 195
191 196 /// Returns the current session info, if authenticated.
192 - pub fn session_info(&self) -> Result<Option<SessionInfo>> {
197 + pub fn session_info(&self) -> Option<SessionInfo> {
193 198 let guard = self.session.read();
194 - Ok(guard.as_ref().map(|s| SessionInfo {
199 + guard.as_ref().map(|s| SessionInfo {
195 200 token: Arc::clone(&s.token),
196 201 user_id: s.user_id,
197 202 app_id: s.app_id,
198 - }))
203 + })
199 204 }
200 205
201 206 /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
202 207 #[doc(hidden)]
203 - pub fn set_master_key_raw(&self, key: [u8; 32]) -> Result<()> {
208 + pub fn set_master_key_raw(&self, key: [u8; 32]) {
204 209 *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
205 - Ok(())
206 210 }
207 211
208 212 // ── Internal helpers ──
@@ -305,13 +309,13 @@
305 309 #[test]
306 310 fn new_client_starts_unauthenticated() {
307 311 let client = SyncKitClient::new(test_config());
308 - assert!(client.session_info().unwrap().is_none());
312 + assert!(client.session_info().is_none());
309 313 }
310 314
311 315 #[test]
312 316 fn new_client_has_no_master_key() {
313 317 let client = SyncKitClient::new(test_config());
314 - assert!(!client.has_master_key().unwrap());
318 + assert!(!client.has_master_key());
315 319 }
316 320
317 321 #[test]
@@ -352,7 +356,7 @@
352 356 fn require_token_succeeds_with_session() {
353 357 let client = SyncKitClient::new(test_config());
354 358 let (app_id, user_id) = test_ids();
355 - client.restore_session("my-token", user_id, app_id).unwrap();
359 + client.restore_session("my-token", user_id, app_id);
356 360
357 361 let token = client.require_token().unwrap();
358 362 assert_eq!(*token, "my-token");
@@ -371,7 +375,7 @@
371 375 fn require_session_ids_returns_correct_ids() {
372 376 let client = SyncKitClient::new(test_config());
373 377 let (app_id, user_id) = test_ids();
374 - client.restore_session("token", user_id, app_id).unwrap();
378 + client.restore_session("token", user_id, app_id);
375 379
376 380 let (returned_app, returned_user) = client.require_session_ids().unwrap();
377 381 assert_eq!(returned_app, app_id);
@@ -402,14 +406,14 @@
402 406 #[test]
403 407 fn has_master_key_false_initially() {
404 408 let client = SyncKitClient::new(test_config());
405 - assert!(!client.has_master_key().unwrap());
409 + assert!(!client.has_master_key());
406 410 }
407 411
408 412 #[test]
409 413 fn has_master_key_true_after_set() {
410 414 let client = SyncKitClient::new(test_config());
411 415 *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32]));
412 - assert!(client.has_master_key().unwrap());
416 + assert!(client.has_master_key());
413 417 }
414 418
415 419 // ── set_master_key_raw ──
@@ -417,12 +421,12 @@
417 421 #[test]
418 422 fn set_master_key_raw_makes_key_available() {
419 423 let client = SyncKitClient::new(test_config());
420 - assert!(!client.has_master_key().unwrap());
424 + assert!(!client.has_master_key());
421 425
422 426 let key = [99u8; 32];
423 - client.set_master_key_raw(key).unwrap();
427 + client.set_master_key_raw(key);
424 428
425 - assert!(client.has_master_key().unwrap());
429 + assert!(client.has_master_key());
426 430 assert_eq!(*client.require_master_key().unwrap(), key);
427 431 }
428 432
@@ -432,10 +436,10 @@
432 436 let key1 = [1u8; 32];
433 437 let key2 = [2u8; 32];
434 438
435 - client.set_master_key_raw(key1).unwrap();
439 + client.set_master_key_raw(key1);
436 440 assert_eq!(*client.require_master_key().unwrap(), key1);
437 441
438 - client.set_master_key_raw(key2).unwrap();
442 + client.set_master_key_raw(key2);
439 443 assert_eq!(*client.require_master_key().unwrap(), key2);
440 444 }
441 445
@@ -448,8 +452,8 @@
448 452 .build()
449 453 .unwrap();
450 454 let client = SyncKitClient::with_http_client(test_config(), http);
451 - assert!(client.session_info().unwrap().is_none());
452 - assert!(!client.has_master_key().unwrap());
455 + assert!(client.session_info().is_none());
456 + assert!(!client.has_master_key());
453 457 }
454 458
455 459 // ── Send + Sync assertions ──
@@ -154,6 +154,152 @@
154 154 Ok((changes, pull_resp.cursor, pull_resp.has_more))
155 155 }
156 156
157 + /// Pull changes from the server with optional table and timestamp filters.
158 + /// Decrypts `data` fields automatically.
159 + /// Returns (changes, new_cursor, has_more).
160 + ///
161 + /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default.
162 + #[instrument(skip(self, filter))]
163 + pub async fn pull_filtered(
164 + &self,
165 + device_id: Uuid,
166 + cursor: i64,
167 + filter: PullFilter,
168 + ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
169 + let token = self.require_token()?;
170 +
171 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
172 + device_id,
173 + cursor,
174 + tables: filter.tables,
175 + since: filter.since,
176 + })?);
177 +
178 + let resp = self
179 + .retry_request(|| {
180 + let req = self
181 + .http
182 + .post(&self.endpoints.pull)
183 + .bearer_auth(&token)
184 + .header("content-type", "application/json")
185 + .body(body.clone());
186 + async move { check_response(req.send().await?).await }
187 + })
188 + .await?;
189 +
190 + let pull_resp: PullResponse = resp.json().await?;
191 +
192 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
193 + let key_holder = if has_data {
194 + self.require_master_key()?
195 + } else {
196 + crypto::ZeroizeOnDrop([0u8; 32])
197 + };
198 + let master_key: &[u8; 32] = &key_holder;
199 + let changes = pull_resp
200 + .changes
201 + .into_iter()
202 + .map(|c| Self::decrypt_change_with_key(c, master_key))
203 + .collect::<Result<Vec<_>>>()?;
204 +
205 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
206 + }
207 +
208 + /// Pull changes from the server, preserving `device_id` and `seq` metadata.
209 + ///
210 + /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns
211 + /// [`PulledChange`] wrappers that retain server metadata needed for conflict
212 + /// detection. Returns (changes, new_cursor, has_more).
213 + #[instrument(skip(self))]
214 + pub async fn pull_rich(
215 + &self,
216 + device_id: Uuid,
217 + cursor: i64,
218 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
219 + let token = self.require_token()?;
220 +
221 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
222 +
223 + let resp = self
224 + .retry_request(|| {
225 + let req = self
226 + .http
227 + .post(&self.endpoints.pull)
228 + .bearer_auth(&token)
229 + .header("content-type", "application/json")
230 + .body(body.clone());
231 + async move { check_response(req.send().await?).await }
232 + })
233 + .await?;
234 +
235 + let pull_resp: PullResponse = resp.json().await?;
236 +
237 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
238 + let key_holder = if has_data {
239 + self.require_master_key()?
240 + } else {
241 + crypto::ZeroizeOnDrop([0u8; 32])
242 + };
243 + let master_key: &[u8; 32] = &key_holder;
244 + let changes = pull_resp
245 + .changes
246 + .into_iter()
247 + .map(|c| Self::decrypt_change_to_pulled(c, master_key))
248 + .collect::<Result<Vec<_>>>()?;
249 +
250 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
251 + }
252 +
253 + /// Pull changes with filters, preserving `device_id` and `seq` metadata.
254 + ///
255 + /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp
256 + /// filtering support. Returns (changes, new_cursor, has_more).
257 + #[instrument(skip(self, filter))]
258 + pub async fn pull_filtered_rich(
259 + &self,
260 + device_id: Uuid,
261 + cursor: i64,
262 + filter: PullFilter,
263 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
264 + let token = self.require_token()?;
265 +
266 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
267 + device_id,
268 + cursor,
269 + tables: filter.tables,
270 + since: filter.since,
271 + })?);
272 +
273 + let resp = self
274 + .retry_request(|| {
275 + let req = self
276 + .http
277 + .post(&self.endpoints.pull)
278 + .bearer_auth(&token)
279 + .header("content-type", "application/json")
280 + .body(body.clone());
281 + async move { check_response(req.send().await?).await }
282 + })
283 + .await?;
284 +
285 + let pull_resp: PullResponse = resp.json().await?;
286 +
287 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
288 + let key_holder = if has_data {
289 + self.require_master_key()?
290 + } else {
291 + crypto::ZeroizeOnDrop([0u8; 32])
292 + };
293 + let master_key: &[u8; 32] = &key_holder;
294 + let changes = pull_resp
295 + .changes
296 + .into_iter()
297 + .map(|c| Self::decrypt_change_to_pulled(c, master_key))
298 + .collect::<Result<Vec<_>>>()?;
299 +
300 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
301 + }
302 +
157 303 /// Get sync status (total changes, latest cursor).
158 304 #[instrument(skip(self))]
159 305 pub async fn status(&self) -> Result<SyncStatus> {
@@ -1,0 +1,101 @@
1 + # SyncKit Client SDK -- Audit History
2 +
3 + See [audit_review.md](./audit_review.md) for current scorecard and grades.
4 +
5 + ## Changes Since Last Audit
6 +
7 + ### Seventh audit (2026-03-28, Run 12 cross-project)
8 + - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures.
9 + - **Grade:** A (maintained). v0.3.0.
10 + - **No code changes since Run 9.**
11 + - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
12 + - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive.
13 + - **No new findings.** All previous items remain resolved.
14 +
15 + ### Rust Patterns Audit (2026-03-21)
16 + - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone
17 + - Auth request structs already use `&'a str` -- confirmed optimal, no change needed
18 +
19 + ### Sixth audit (2026-03-18, Run 9 cross-project)
20 + - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings.
21 + - **Grade:** A (maintained). v0.3.0.
22 + - **No new findings.** All previous items remain resolved.
23 + - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests.
24 + - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords.
25 + - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor.
26 +
27 + ### Concurrency Upgrade (2026-03-13)
28 + - **Concurrency:** B+ -> A-
29 + - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass.
30 +
31 + ### Second audit (2026-03-13, pre-launch skeptical lens)
32 + - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted.
33 + - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation)
34 + - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum
35 + - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate)
36 + - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt")
37 +
38 + ### Post-audit remediation (2026-03-13)
39 + - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred.
40 + - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests)
41 + - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag).
42 + - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk.
43 + - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place.
44 + - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries.
45 + - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines).
46 +
47 + ### Observability Upgrade (2026-03-13)
48 + - Added Observability dimension to scorecard (grade A)
49 + - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params
50 + - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data
51 + - `use tracing::instrument;` import added to client.rs
52 + - `cargo check` passes clean
53 +
54 + ### Performance Upgrade (2026-03-13)
55 + - **Performance:** B -> A-
56 + - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call
57 + - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites)
58 + - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition
59 +
60 + ### Resilience Upgrade (2026-03-13)
61 + - **Resilience:** B- -> A-
62 + - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip
63 + - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully
64 + - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest.
65 +
66 + ### Adversarial Test Audit (2026-03-13)
67 + - **Grade:** A- -> A-. Testing grade upgraded from B- to A.
68 + - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC.
69 + - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes.
70 + - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs.
71 + - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests.
72 + - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests.
73 + - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests.
74 + - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests.
75 + - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration.
76 + - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered.
77 +
78 + ### Third audit (2026-03-16, Run 6 cross-project)
79 + - **Test count:** 297 (unchanged)
80 + - **Grade:** A (maintained).
81 + - **Source LOC:** 4,327 src + 2,749 test
82 + - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop.
83 + - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml.
84 + - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM).
85 + - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta).
86 +
87 + ### Testing Push (2026-03-13)
88 + - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A.
89 + - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest.
90 + - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization.
91 + - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages.
92 + - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip.
93 + - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout).
94 + - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults.
95 +
96 + ### Performance Upgrade (2026-03-13)
97 + - **Performance:** A- -> A
98 + - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations
99 + - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT)
100 + - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call
101 + - All 297 tests pass unchanged (2 test assertions updated for Arc deref)
@@ -1,0 +1,142 @@
1 + # SyncKit Client SDK -- Competitive Analysis
2 +
3 + Last updated: 2026-04-10
4 +
5 + ## Positioning
6 +
7 + SyncKit is an E2E encrypted, Rust-native, offline-first sync SDK for indie desktop and mobile apps. The server (hosted on MNW) stores only encrypted blobs -- zero-knowledge by design. Bundled with OTA updates and device management. Consumers: GoingsOn, Balanced Breakfast, audiofiles (all Tauri apps).
8 +
9 + The key differentiators are server-zero-knowledge encryption (XChaCha20-Poly1305 + Argon2id, keys never leave the device), opaque-blob storage (bring-your-own-schema, no server-side migrations), and the bundled OTA + device management layer that no sync competitor offers. Pricing is bundled with MNW creator tiers ($10-40/mo), not per-read/write metered.
10 +
11 + ## Pricing Comparison
12 +
13 + | Tool | Price | Model |
14 + |------|-------|-------|
15 + | **SyncKit** | $10-40/mo (bundled) | Included in MNW creator tier |
16 + | Firebase Firestore | Pay-per-use | $0.18/100K reads+writes, $0.26/GB |
17 + | Supabase | $0-$599/mo | Freemium + usage overages |
18 + | PowerSync | $0-$599/mo | Usage-based (GB synced) |
19 + | ElectricSQL | Pay-per-write | $1/M writes, reads free |
20 + | Turso | $0-$417/mo | Storage-based tiers |
21 + | Convex | $0-$25/member/mo | Freemium + usage overages |
22 + | Ditto | Enterprise (custom) | Sales-driven |
23 + | Couchbase Mobile | Enterprise (~25K+ EUR/yr) | License-based |
24 + | Etebase | Free (self-host) | Source-available, hosted beta |
25 +
26 + ## Feature Matrix
27 +
28 + | Feature | SyncKit | Firebase | Supabase | PowerSync | ElectricSQL | Ditto | Etebase |
29 + |---------|:-------:|:--------:|:--------:|:---------:|:-----------:|:-----:|:-------:|
30 + | E2E encrypted | Y | N | N | N | N | N | Y |
31 + | Server-zero-knowledge | Y | N | N | N | N | N | Y |
32 + | Rust SDK (native) | Y | N | N | Alpha | Y | Y | Y |
33 + | Tauri integration | Y | N | N | Alpha | N | N | N |
34 + | Offline-first | Y | Partial | N | Y | Partial | Y | Y |
35 + | Bring-your-own-schema | Y | N | N | N | N | N | Partial |
36 + | OTA updates | Y | N | N | N | N | N | N |
37 + | Device management | Y | N | N | N | N | N | N |
38 + | OS keychain storage | Y | N | N | N | N | N | N |
39 + | Blob/file sync | Y | Y | Y | N | N | N | Y |
40 + | Self-hostable | Y | N | Y | Y | Y | Y | Y |
41 + | Real-time push | N | Y | Y | Y | Y | Y | N |
42 + | P2P sync (no server) | N | N | N | N | N | Y | N |
43 + | CRDT conflict resolution | N | N | N | N | N | Y | N |
44 + | Rich query engine | N | Y | Y | Y | Y | Y | N |
45 +
46 + ## Competitor Deep Dives
47 +
48 + ### 1. Firebase (Google)
49 +
50 + Managed BaaS with Realtime Database and Firestore. Massive ecosystem (Auth, Functions, Hosting, Analytics). Generous free tier. Near-instant real-time push via persistent connections. No native Rust SDK (community crates are server-side only, not offline-capable).
51 +
52 + **What SyncKit lacks:** real-time push subscriptions, multi-platform mobile SDKs, hosted auth, serverless functions, web dashboard. **What Firebase lacks:** E2E encryption, Rust SDK, Tauri support, offline desktop sync, OTA updates, device management, data portability (complete vendor lock-in, no self-hosting).
53 +
54 + ### 2. Supabase
55 +
56 + Open-source Firebase alternative on PostgreSQL. Full SQL power, RLS for access control, self-hostable. Growing ecosystem. Realtime via Postgres CDC. No offline-first without PowerSync add-on.
57 +
58 + **What SyncKit lacks:** SQL query engine, built-in auth, edge functions, web dashboard, large community. **What Supabase lacks:** E2E encryption, offline-first (requires PowerSync add-on), Rust SDK, Tauri support, OTA updates, device management.
59 +
60 + ### 3. PowerSync -- Primary Threat
61 +
62 + Offline-first sync layer between your existing database and client-side SQLite. **Released a Tauri SDK (alpha, March 2026)** built on a Rust SDK. Works with Postgres, MongoDB, MySQL, SQL Server. Self-hostable Open Edition.
63 +
64 + **What SyncKit lacks:** multi-database source support, client-side SQL queries, partial replication (sync rules), larger team and community. **What PowerSync lacks:** E2E encryption (sync service sees all data), OTA updates, device management, blob/file sync, OS keychain integration. Write-path goes directly to your backend -- PowerSync does not handle write conflicts.
65 +
66 + PowerSync is the most direct competitor. If they add encryption, they become serious competition. Their Tauri SDK being alpha-quality is a window.
67 +
68 + ### 4. ElectricSQL
69 +
70 + Postgres CDC engine streaming "shapes" (filtered table subsets) to clients. Read-path only -- writes go through your own API. Open-source (Apache 2.0). Innovative pricing: writes cost money, reads/fan-out are free and unlimited. Rust client available.
71 +
72 + **What SyncKit lacks:** read-path fan-out, per-shape subscriptions, 10-language client support. **What ElectricSQL lacks:** E2E encryption, offline-first writes (no local write queue built in), OTA updates, device management, conflict resolution (your problem), blob sync.
73 +
74 + ### 5. Ditto
75 +
76 + Enterprise P2P sync with Bluetooth/WiFi Direct mesh networking. Rust core. CRDT-based automatic conflict resolution. $82M raised (March 2025). Targets airlines, military, retail.
77 +
78 + **What SyncKit lacks:** P2P mesh sync, CRDT conflict resolution, enterprise support. **What Ditto lacks:** E2E application-layer encryption, indie pricing (enterprise sales only), OTA updates, bring-your-own-schema (CRDTs need structure).
79 +
80 + ### 6. Couchbase Lite + Sync Gateway
81 +
82 + Enterprise mobile database with bidirectional sync. Battle-tested in large deployments. Gained momentum from MongoDB Realm shutdown (Sept 2025). Configurable conflict handlers. P2P sync between Couchbase Lite instances.
83 +
84 + **What SyncKit lacks:** P2P sync, rich on-device query engine, enterprise track record. **What Couchbase lacks:** E2E encryption, indie pricing (~25K EUR/yr), Rust SDK (experimental C bindings only), simplicity (multi-component architecture), OTA updates.
85 +
86 + ### 7. Etebase -- Philosophical Peer
87 +
88 + The only other E2E encrypted sync SDK with a Rust library. Open-source server, self-hostable. SDKs for Rust, JS, Java/Kotlin, Python, C, C#. Used by EteSync (contacts/calendar sync).
89 +
90 + **What SyncKit lacks:** broader language coverage (6 languages vs 1). **What Etebase lacks:** Tauri integration, OTA updates, device management, OS keychain, blob support via presigned URLs, commercial backing, community momentum (very small team, unclear trajectory).
91 +
92 + ### 8. Realm / Atlas Device Sync (MongoDB) -- Shut Down
93 +
94 + End-of-life as of September 30, 2025. MongoDB deprecated all Atlas Device SDKs. Developers displaced into Couchbase, Ditto, PowerSync, and ObjectBox. The shutdown created a significant gap in the offline-first sync market.
95 +
96 + ### 9. Others
97 +
98 + **Turso:** Edge SQLite replication. Read replicas only, writes go to primary. Cheap ($5/mo) but not a multi-device sync solution -- no bidirectional sync, no offline writes.
99 +
100 + **Convex:** Reactive backend with automatic query subscriptions. No offline support (requires internet). Recently open-sourced (BSL, converts to Apache 2.0 after 3 years). Rust client available but secondary to TypeScript.
101 +
102 + **CouchDB/PouchDB:** Document-oriented database with built-in sync protocol. Offline-first, conflict handling via revision trees. No E2E encryption. Mature but aging. JavaScript-focused.
103 +
104 + **Syncthing:** P2P file sync. E2E encrypted, no central server. Designed for folder/file sync, not structured app data. No changelog-based sync, no SDK API, no conflict resolution for structured data.
105 +
106 + **CRDT libraries (Automerge, Yjs, Loro):** Building blocks for conflict-free merge, not sync services. Handle data structure merging; bring-your-own transport/storage/auth. Incompatible with SyncKit's zero-knowledge model (server cannot merge what it cannot read).
107 +
108 + ## What We Offer That Competitors Don't
109 +
110 + - **Server-zero-knowledge** -- the server stores only encrypted blobs. No data breaches because there is no data to breach. Compliance-friendly (GDPR, NIS2).
111 + - **Bring-your-own-schema** -- table names, row IDs, and data shapes are opaque to the server. No server-side migrations when your app schema changes.
112 + - **Bundled OTA updates** -- Tauri-compatible auto-update protocol. No competitor offers sync + OTA in one SDK.
113 + - **Bundled device management** -- register, list, deregister devices. Track sync state per device.
114 + - **OS keychain integration** -- encryption keys stored in macOS Keychain, Linux Secret Service, or Windows Credential Manager. Key material never touches disk.
115 + - **Minimal blob overhead** -- binary files encrypted with only 40 bytes overhead (24-byte nonce + 16-byte auth tag). No base64 expansion.
116 + - **Key zeroization** -- `ZeroizeOnDrop` on all key material. No key residue in memory after use.
117 + - **Flat pricing** -- included in MNW creator tier. No per-read/write metering, no surprise bills.
118 +
119 + ## Market Tailwinds
120 +
121 + - **MongoDB Realm shutdown (Sept 2025)** displaced developers seeking offline-first sync alternatives
122 + - **Tauri adoption growing ~55% YoY**, creating demand for Rust-native backends
123 + - **Regulatory pressure (GDPR, NIS2)** pushing toward E2E encryption and data minimization
124 + - **Local-first movement** gaining mainstream traction (Notion, Linear, Figma adopting offline-first)
125 + - **PowerSync Tauri SDK is alpha** -- their Rust/Tauri story is immature, giving SyncKit a window
126 +
127 + ## Target Users
128 +
129 + - Indie developers building Tauri desktop apps who need cloud sync without running a backend
130 + - Developers who prioritize user privacy and want zero-knowledge sync by default
131 + - Small teams shipping cross-platform apps (macOS/Windows/Linux) that need offline-first data
132 + - Anyone displaced from MongoDB Realm looking for a simpler, encrypted alternative
133 +
134 + ## Gaps and Potential Roadmap Items
135 +
136 + Based on what competitors offer that SyncKit does not:
137 +
138 + - **Real-time push notifications** -- Firebase/Supabase/Convex push changes instantly. SyncKit is pull-based (clients poll). A lightweight SSE channel for "something changed, pull now" would close this gap without compromising E2E encryption (the notification carries no data, just a signal).
139 + - **Selective sync / sync rules** -- PowerSync and ElectricSQL let clients sync subsets of data. SyncKit syncs the full changelog. For apps with large datasets, filtered sync (by device, by date range, by collection) would reduce bandwidth and latency.
140 + - **Conflict resolution helpers** -- Ditto and Couchbase offer configurable merge strategies. SyncKit leaves conflict resolution to the client. A toolkit of common strategies (LWW, field-level merge, custom resolver callback) in the SDK would reduce boilerplate.
141 + - **Web client (WASM)** -- every major competitor has a JavaScript/TypeScript SDK. A WASM-compiled SyncKit client would open the web platform. Low priority (current consumers are all desktop), but relevant if any consumer app ships a web companion.
142 + - **Multi-language SDKs** -- Etebase covers 6 languages, PowerSync covers 10+. SyncKit is Rust-only. A C FFI layer would enable bindings for Swift, Kotlin, Python, and JS. Only worth doing if non-Tauri consumers appear.