Skip to main content

max / synckit-client

9.4 KB · 236 lines History Blame Raw
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.
236