Skip to main content

max / synckit-client

Move project docs into repo for ~/Code directory layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-30 02:26 UTC
Commit: 9b41b39cdb9776d8ddec7366abd099467d373b7f
Parent: db8a161
3 files changed, +446 insertions, -0 deletions
@@ -0,0 +1,235 @@
1 + # SyncKit Client SDK -- Architecture
2 +
3 + ## Overview
4 +
5 + The SyncKit Client SDK (`synckit-client`) is a Rust crate that provides
6 + end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps
7 + (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull
8 + changelog entries without the server ever seeing plaintext data.
9 +
10 + Version: 0.2.1. Rust edition: 2021.
11 +
12 + ## Crate structure
13 +
14 + ```
15 + src/
16 + lib.rs Crate root. Re-exports public types, quick-start doc example.
17 + client.rs SyncKitClient struct and all HTTP methods (auth, push/pull,
18 + blobs, devices, encryption setup). Retry logic and token
19 + expiry detection live here.
20 + crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping
21 + (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob
22 + encrypt/decrypt, ZeroizeOnDrop guard.
23 + error.rs SyncKitError enum (thiserror). 12 variants: Http, Server,
24 + Json, NoMasterKey, DecryptionFailed, InvalidEnvelope, Crypto,
25 + Base64, NotAuthenticated, TokenExpired, Internal, and
26 + Keychain (feature-gated behind `keychain`).
27 + keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
28 + Windows Credential Manager) via the `keyring` crate.
29 + Feature-gated behind `keychain` (default on). No-op stubs
30 + when disabled.
31 + types.rs Request/response types matching the server wire protocol.
32 + Public types: ChangeEntry, ChangeOp, Device, SyncStatus,
33 + BlobUploadUrlResponse. Internal types: auth, push/pull, key,
34 + OAuth, blob requests.
35 +
36 + Public re-exports from lib.rs:
37 + SyncKitClient, SyncKitConfig, SessionInfo,
38 + ChangeEntry, ChangeOp, Device, SyncStatus,
39 + SyncKitError, Result
40 + ```
41 +
42 + ## Key lifecycle
43 +
44 + ```
45 + password
46 + |
47 + v
48 + Argon2id (64 MB, 3 iterations, parallelism 1)
49 + + random 32-byte salt (stored in envelope)
50 + |
51 + v
52 + wrapping_key (256-bit)
53 + |
54 + v
55 + XChaCha20-Poly1305 encrypt/decrypt
56 + |
57 + v
58 + master_key (256-bit, randomly generated on first device)
59 + |
60 + v
61 + XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt
62 + ```
63 +
64 + - **First device**: generates a random master key, wraps it with the password,
65 + pushes the encrypted envelope to the server, caches plaintext in OS keychain.
66 + - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps
67 + with the password, caches in OS keychain.
68 + - **Subsequent launches**: loads the master key directly from the OS keychain
69 + (no password prompt, no server call).
70 + - **Password change**: decrypts master key with old password, re-wraps with new
71 + password (fresh random salt), pushes new envelope to server. The master key
72 + itself does not change, so existing ciphertext remains valid.
73 +
74 + ## Authentication flow
75 +
76 + ### Email/password
77 +
78 + 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key.
79 + 2. Server validates credentials and returns a JWT, user ID, and app ID.
80 + 3. Client stores the session in an internal `Mutex<Option<Session>>`.
81 +
82 + ### OAuth2 PKCE
83 +
84 + 1. Caller generates a PKCE code verifier and challenge.
85 + 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge.
86 + 3. Caller opens the URL in a browser and starts a localhost callback server.
87 + 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges
88 + the authorization code for a JWT via `/oauth/token`.
89 + 5. Session is stored identically to email/password auth.
90 +
91 + ### Session restoration
92 +
93 + `restore_session(token, user_id, app_id)` populates the session from stored
94 + credentials (e.g., OS keychain) without making any HTTP calls. The caller is
95 + responsible for checking `is_token_expired()` and re-authenticating if needed.
96 +
97 + ### Session utilities
98 +
99 + - `session_info()` returns `Option<SessionInfo>` (token, user_id, app_id)
100 + without making HTTP calls. Returns `None` if not authenticated.
101 + - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer.
102 + Returns `true` if no session exists or the token is about to expire.
103 + - `clear_session()` clears in-memory session and master key. Does not
104 + affect OS keychain (call `keystore::delete_key` separately).
105 + - `config()` returns a reference to the `SyncKitConfig`.
106 +
107 + ## Encryption setup flow
108 +
109 + ```
110 + has_server_key()?
111 + |
112 + +-- false --> setup_encryption_new(password)
113 + | Generate master key, wrap, push envelope, cache in keychain
114 + |
115 + +-- true --> setup_encryption_existing(password)
116 + Pull envelope, unwrap with password, cache in keychain
117 + ```
118 +
119 + On subsequent launches, `try_load_key_from_keychain()` loads the master key
120 + from the OS keychain without any server interaction or password prompt.
121 +
122 + ## Push/Pull protocol
123 +
124 + ### Push
125 +
126 + 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields.
127 + 2. Client serializes each `data` field to JSON bytes, encrypts with the master
128 + key (XChaCha20-Poly1305, random nonce), and base64-encodes the result.
129 + 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID.
130 + 4. Server appends entries to the changelog and returns the new cursor (i64).
131 + 5. Retries on transient failures (see below).
132 +
133 + ### Pull
134 +
135 + 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
136 + 2. Server returns changelog entries since that cursor, a new cursor, and a
137 + `has_more` flag for pagination.
138 + 3. Client decrypts each entry's `data` field and returns plaintext `ChangeEntry`
139 + values to the caller.
140 + 4. Retries on transient failures.
141 +
142 + ### Sequence numbers
143 +
144 + The server assigns a monotonically increasing sequence number (`seq`) to each
145 + changelog entry. The cursor is the `seq` of the last entry the client has seen.
146 + Pulling with cursor 0 fetches from the beginning.
147 +
148 + ## Blob encryption
149 +
150 + Binary blobs (files, images, audio samples) are encrypted client-side before
151 + upload:
152 +
153 + 1. `blob_upload_url(hash, size)` requests a presigned S3 PUT URL from the server.
154 + If the blob already exists (content-addressed by hash), no upload is needed.
155 + 2. `blob_upload(presigned_url, data)` encrypts the plaintext bytes with the
156 + master key and PUTs the ciphertext to S3.
157 + 3. `blob_confirm(hash, size)` tells the server the upload completed.
158 + 4. `blob_download_url(hash)` requests a presigned GET URL.
159 + 5. `blob_download(presigned_url)` fetches the ciphertext from S3 and decrypts it.
160 +
161 + Blob encryption uses `encrypt_bytes`/`decrypt_bytes` (raw bytes, no base64) to
162 + avoid the ~33% base64 overhead on large files. Encryption overhead is fixed at
163 + 40 bytes per blob: 24-byte XChaCha20 nonce + 16-byte Poly1305 authentication
164 + tag.
165 +
166 + ## Keychain integration
167 +
168 + The `keychain` feature (default on) uses the `keyring` crate (v3) to store
169 + the master key in the OS credential store:
170 +
171 + - **macOS**: Keychain Services
172 + - **Linux**: secret-service (D-Bus Secret Service API)
173 + - **Windows**: Windows Credential Manager
174 +
175 + Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
176 + (account). When the feature is disabled, store/load/delete are no-ops that
177 + return `Ok`.
178 +
179 + ## Retry and resilience
180 +
181 + Push and pull use `retry_request()` with exponential backoff:
182 +
183 + - **Max retries**: 3
184 + - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt)
185 + - **Transient errors** (retried): network failures (timeout, DNS, connection
186 + refused), server errors (5xx), rate limiting (429)
187 + - **Permanent errors** (not retried): client errors (4xx except 429),
188 + serialization errors, encryption errors, missing session/key
189 +
190 + Token expiry is detected client-side by decoding the JWT `exp` claim with a
191 + 30-second buffer. If the token is about to expire, the client returns
192 + `SyncKitError::TokenExpired` so the caller can re-authenticate before sending
193 + a request that would fail with 401.
194 +
195 + ## Key design decisions
196 +
197 + ### Why XChaCha20-Poly1305
198 +
199 + - 192-bit nonces are large enough to generate randomly without realistic
200 + collision risk (unlike AES-GCM's 96-bit nonces).
201 + - Removes the need for a nonce counter or nonce-misuse resistance scheme.
202 + - Performance is comparable to AES-GCM on modern hardware.
203 +
204 + ### Why Argon2id
205 +
206 + - OWASP-recommended for password hashing. Resists both side-channel (Argon2i)
207 + and GPU brute-force (Argon2d) attacks.
208 + - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum.
209 +
210 + ### Why random salt per wrap
211 +
212 + Each `wrap_master_key` call generates a fresh 32-byte random salt. This means
213 + re-wrapping with the same password produces a completely different envelope,
214 + preventing precomputation attacks and ensuring password changes are
215 + cryptographically distinct.
216 +
217 + ### Why no token refresh
218 +
219 + The server currently issues short-lived JWTs without refresh tokens. The client
220 + detects expiry and returns `TokenExpired`, leaving re-authentication to the
221 + caller. This keeps the SDK stateless with respect to refresh logic and avoids
222 + storing long-lived credentials in memory.
223 +
224 + ## Security properties
225 +
226 + - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync
227 + entries, blobs). It never receives the plaintext master key or user data.
228 + - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to
229 + clear the master key from memory when the guard is dropped.
230 + - **No key material in logs**: Tracing statements log events (key stored, key
231 + loaded) but never log key bytes or ciphertext.
232 + - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
233 + (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
234 + - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
235 + to support future algorithm upgrades without breaking existing envelopes.
@@ -0,0 +1,193 @@
1 + # SyncKit Client SDK Audit Review
2 +
3 + - Last audited: 2026-03-28 (seventh audit, Run 12 cross-project)
4 + - Previous audit: 2026-03-18 (sixth audit, Run 9 cross-project)
5 + - Crate: `synckit-client` v0.3.0
6 + - Path: `Shared/synckit-client/`
7 +
8 + ## Overall Grade: A
9 +
10 + Run 12: 297 tests (197 unit + 99 integration + 1 doctest). 0 clippy warnings. Grade stable at A. No code changes. New dep advisory: rustls-webpki (RUSTSEC-2026-0049).
11 +
12 + ## Scorecard
13 +
14 + | Dimension | Grade |
15 + |-----------|-------|
16 + | Observability | A |
17 + | Code Quality | A |
18 + | Architecture | A |
19 + | Testing | A+ |
20 + | Security | A- |
21 + | Performance | A |
22 + | Documentation | A |
23 + | Dependencies | A |
24 + | Type Safety | A |
25 + | Concurrency | A |
26 + | Resilience | A |
27 + | API Consistency | A |
28 + | Codebase Size | A |
29 +
30 + ## Module Heatmap
31 +
32 + | Module | File | LOC | Tests | Grade | Notes |
33 + |--------|------|-----|-------|-------|-------|
34 + | client | `src/client.rs` | ~750 | 85 | A | Comprehensive unit tests. Auth state, encrypt/decrypt roundtrip (incl. unicode, empty row_id), type serialization, error classification, token expiry, OAuth URL construction. Blob E2E encryption. Send+Sync compile-time assert. with_http_client constructor for custom timeouts. |
35 + | crypto | `src/crypto.rs` | ~480 | 19 | A+ | Excellent coverage. Roundtrip, wrong-key, version, truncation, uniqueness, zeroize. Binary blob encrypt/decrypt. Random salt wrapping. |
36 + | types | `src/types.rs` | ~240 | 13 | A | Serde roundtrip, Display/serde consistency, from_str_opt rejection, Copy/Hash traits, skip_serializing_if, extra field tolerance, ISO timestamp parsing. |
37 + | keystore | `src/keystore.rs` | 94 | 18 | A | Service name construction, base64 roundtrips, length validation, error handling. Platform behavior documented. |
38 + | error | `src/error.rs` | ~100 | 10 | A | Send+Sync assert, Display for all variants, Debug no-panic, source() chain for Json/Base64, source() None for leaf variants, edge cases (empty/long messages). |
39 + | lib | `src/lib.rs` | 53 | 1 (doctest) | A | Clean re-exports. Doctest validates API surface compiles. |
40 +
41 + ## Cold Spots
42 +
43 + 1. ~~**client.rs** (639 LOC, 0 tests, grade C)~~ -- Resolved: 81 unit tests added, grade now A-.
44 + 2. ~~**keystore.rs** (0 tests, grade D)~~ -- Resolved: 18 tests added, grade now A-.
45 + 3. ~~**No HTTP timeout**~~ -- Fixed: 30s request timeout + 10s connect timeout on `Client::builder()`.
46 + 4. ~~**await_holding_lock in change_password**~~ -- Fixed: lock guard now dropped before `.await` point.
47 + 5. **No token refresh** -- JWT tokens expire but the SDK has no detection or refresh mechanism. Consumers handle 401s ad hoc. (Token expiry detection was added, but no automatic refresh.)
48 + 6. ~~**op field is raw String**~~ -- Resolved: replaced by ChangeOp enum with serde UPPERCASE.
49 +
50 + ## Strengths
51 +
52 + - **Crypto module is excellent** -- 12 unit tests covering all key operations. XChaCha20-Poly1305 with 192-bit random nonces eliminates nonce collision risk. Argon2id with OWASP-minimum parameters.
53 + - **Minimal API surface** -- 8 types re-exported from lib.rs. Wire-only types are `pub(crate)`. Internal helpers are private.
54 + - **Clean key hierarchy** -- Three layers (password -> wrapping key -> master key -> per-entry encryption) are well-documented and correctly implemented.
55 + - **No consumer-specific logic** -- Fully generic. No references to GO, BB, or AF. Table names and data shapes are opaque to the SDK.
56 + - **ZeroizeOnDrop** -- In-memory keys are zeroed on drop via volatile writes.
57 +
58 + ## Weaknesses
59 +
60 + - ~~**CRITICAL: Blob data NOT encrypted**~~ -- RESOLVED: encrypt_bytes/decrypt_bytes added to crypto.rs, blob_upload/blob_download now encrypt/decrypt transparently (40 bytes overhead per blob).
61 + - ~~**Deterministic Argon2 salt**~~ -- RESOLVED: wrap_master_key now generates random 32-byte salt, unwrap_master_key reads salt from envelope and derives wrapping key internally.
62 + - **No key rotation mechanism** -- No way to rotate the master key without re-encrypting all data.
63 + - ~~**13 Mutex `.unwrap()` calls**~~ -- RESOLVED: All replaced with `.lock().map_err()` returning SyncKitError::Internal.
64 + - ~~**Master key copies not zeroized**~~ -- RESOLVED: All call sites wrap in ZeroizeOnDrop.
65 + - ~~**Several public types that should be `pub(crate)`**~~ -- RESOLVED: KeyEnvelope, ZeroizeOnDrop inner field, 5 request/response types restricted.
66 +
67 + ### Resolved from first audit
68 + - ~~**client.rs untested**~~ -- 66 unit tests added (S4)
69 + - ~~**No resilience**~~ -- HTTP timeouts (30s + 10s connect), retry with backoff (3 retries), token expiry detection all added (S4)
70 + - ~~**change_password concurrency bug**~~ -- Lock guard dropped before await (S4)
71 + - ~~**op field raw String**~~ -- ChangeOp enum with serde UPPERCASE (S4)
72 +
73 + ## Mandatory Surprise
74 +
75 + **change_password race + deterministic salt -- Both resolved.**
76 +
77 + ~~The `change_password` method holds a `std::sync::Mutex` guard across an `.await` point (`get_server_key`), which Clippy flags as `await_holding_lock`.~~ Fixed: lock guard now dropped before `.await` point.
78 +
79 + More critically, if `put_server_key` fails after the old envelope is fetched, the method may log success while the server retains the old envelope. Concurrent `change_password` calls from two devices race silently -- one wins, the other's key cache becomes stale with no notification or error. (Race condition remains a theoretical concern but is low-priority given single-device typical usage.)
80 +
81 + ~~The Argon2 salt is deterministic from `(app_id, user_id)` via `SHA256(app_id || user_id)`. This means the salt never rotates on password change.~~ Fixed: wrap_master_key now generates a random 32-byte salt per operation, stored in the KeyEnvelope. unwrap_master_key reads the salt from the envelope and derives the wrapping key internally.
82 +
83 + ## Metrics Over Time
84 +
85 + | Date | LOC | Files | Tests | Tests/KLOC |
86 + |------|-----|-------|-------|------------|
87 + | 2026-03-11 | 1,416 | 6 | 13 | 9.2 |
88 + | 2026-03-13 | ~1.4K | 6 | 109 | ~77 |
89 + | 2026-03-13 (post-fix) | ~1.5K | 6 | 118 | ~79 |
90 + | 2026-03-13 (adversarial) | ~2.5K | 6 | 234 | ~94 |
91 + | 2026-03-13 (perf+resilience) | ~2.6K | 6 | 243 | ~94 |
92 + | 2026-03-13 (testing push) | ~2.8K | 6 | 297 | ~106 |
93 + | 2026-03-16 (Run 6) | 4,327 | 6 | 297 | ~69 |
94 + | 2026-03-18 (Run 9) | 4,327 | 6 | 298 | ~69 |
95 + | 2026-03-28 (Run 12) | 4,327 | 6 | 297 | ~69 |
96 +
97 + ## Changes Since Last Audit
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)
A docs/todo.md +18
@@ -0,0 +1,18 @@
1 + # SyncKit Client SDK — Todo
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).
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.)
9 +
10 + ## Deferred (Post-Beta)
11 + - [ ] Key rotation mechanism (requires server-side re-encryption of all sync_log entries)
12 +
13 + ## Key Paths
14 + - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, blob, helpers)
15 + - Crypto: `Shared/synckit-client/src/crypto.rs`
16 + - Types: `Shared/synckit-client/src/types.rs`
17 + - Keystore: `Shared/synckit-client/src/keystore.rs`
18 + - Tests: `Shared/synckit-client/tests/integration.rs`