Skip to main content

max / makenotwork

9.4 KB · 237 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.3.1.
11
12 ## Crate structure
13
14 ```
15 src/
16 lib.rs Crate root. Re-exports public types, quick-start doc example.
17 client/ SyncKitClient struct and all HTTP methods, split across
18 submodules (auth, sync, blob, encryption, rotation,
19 subscribe, subscription, helpers). Retry logic and token
20 expiry detection live here.
21 crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping
22 (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob
23 encrypt/decrypt, ZeroizeOnDrop guard.
24 error.rs SyncKitError enum (thiserror). 12 variants: Http, Server,
25 Json, NoMasterKey, DecryptionFailed, InvalidEnvelope, Crypto,
26 Base64, NotAuthenticated, TokenExpired, Internal, and
27 Keychain (feature-gated behind `keychain`).
28 keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
29 Windows Credential Manager) via the `keyring` crate.
30 Feature-gated behind `keychain` (default on). No-op stubs
31 when disabled.
32 types.rs Request/response types matching the server wire protocol.
33 Public types: ChangeEntry, ChangeOp, Device, SyncStatus,
34 BlobUploadUrlResponse. Internal types: auth, push/pull, key,
35 OAuth, blob requests.
36
37 Public re-exports from lib.rs:
38 SyncKitClient, SyncKitConfig, SessionInfo,
39 ChangeEntry, ChangeOp, Device, SyncStatus,
40 SyncKitError, Result
41 ```
42
43 ## Key lifecycle
44
45 ```
46 password
47 |
48 v
49 Argon2id (64 MB, 3 iterations, parallelism 1)
50 + random 32-byte salt (stored in envelope)
51 |
52 v
53 wrapping_key (256-bit)
54 |
55 v
56 XChaCha20-Poly1305 encrypt/decrypt
57 |
58 v
59 master_key (256-bit, randomly generated on first device)
60 |
61 v
62 XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt
63 ```
64
65 - **First device**: generates a random master key, wraps it with the password,
66 pushes the encrypted envelope to the server, caches plaintext in OS keychain.
67 - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps
68 with the password, caches in OS keychain.
69 - **Subsequent launches**: loads the master key directly from the OS keychain
70 (no password prompt, no server call).
71 - **Password change**: decrypts master key with old password, re-wraps with new
72 password (fresh random salt), pushes new envelope to server. The master key
73 itself does not change, so existing ciphertext remains valid.
74
75 ## Authentication flow
76
77 ### Email/password
78
79 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key.
80 2. Server validates credentials and returns a JWT, user ID, and app ID.
81 3. Client stores the session in an internal `Mutex<Option<Session>>`.
82
83 ### OAuth2 PKCE
84
85 1. Caller generates a PKCE code verifier and challenge.
86 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge.
87 3. Caller opens the URL in a browser and starts a localhost callback server.
88 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges
89 the authorization code for a JWT via `/oauth/token`.
90 5. Session is stored identically to email/password auth.
91
92 ### Session restoration
93
94 `restore_session(token, user_id, app_id)` populates the session from stored
95 credentials (e.g., OS keychain) without making any HTTP calls. The caller is
96 responsible for checking `is_token_expired()` and re-authenticating if needed.
97
98 ### Session utilities
99
100 - `session_info()` returns `Option<SessionInfo>` (token, user_id, app_id)
101 without making HTTP calls. Returns `None` if not authenticated.
102 - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer.
103 Returns `true` if no session exists or the token is about to expire.
104 - `clear_session()` clears in-memory session and master key. Does not
105 affect OS keychain (call `keystore::delete_key` separately).
106 - `config()` returns a reference to the `SyncKitConfig`.
107
108 ## Encryption setup flow
109
110 ```
111 has_server_key()?
112 |
113 +-- false --> setup_encryption_new(password)
114 | Generate master key, wrap, push envelope, cache in keychain
115 |
116 +-- true --> setup_encryption_existing(password)
117 Pull envelope, unwrap with password, cache in keychain
118 ```
119
120 On subsequent launches, `try_load_key_from_keychain()` loads the master key
121 from the OS keychain without any server interaction or password prompt.
122
123 ## Push/Pull protocol
124
125 ### Push
126
127 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields.
128 2. Client serializes each `data` field to JSON bytes, encrypts with the master
129 key (XChaCha20-Poly1305, random nonce), and base64-encodes the result.
130 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID.
131 4. Server appends entries to the changelog and returns the new cursor (i64).
132 5. Retries on transient failures (see below).
133
134 ### Pull
135
136 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
137 2. Server returns changelog entries since that cursor, a new cursor, and a
138 `has_more` flag for pagination.
139 3. Client decrypts each entry's `data` field and returns plaintext `ChangeEntry`
140 values to the caller.
141 4. Retries on transient failures.
142
143 ### Sequence numbers
144
145 The server assigns a monotonically increasing sequence number (`seq`) to each
146 changelog entry. The cursor is the `seq` of the last entry the client has seen.
147 Pulling with cursor 0 fetches from the beginning.
148
149 ## Blob encryption
150
151 Binary blobs (files, images, audio samples) are encrypted client-side before
152 upload:
153
154 1. `blob_upload_url(hash, size)` requests a presigned S3 PUT URL from the server.
155 If the blob already exists (content-addressed by hash), no upload is needed.
156 2. `blob_upload(presigned_url, data)` encrypts the plaintext bytes with the
157 master key and PUTs the ciphertext to S3.
158 3. `blob_confirm(hash, size)` tells the server the upload completed.
159 4. `blob_download_url(hash)` requests a presigned GET URL.
160 5. `blob_download(presigned_url)` fetches the ciphertext from S3 and decrypts it.
161
162 Blob encryption uses `encrypt_bytes`/`decrypt_bytes` (raw bytes, no base64) to
163 avoid the ~33% base64 overhead on large files. Encryption overhead is fixed at
164 40 bytes per blob: 24-byte XChaCha20 nonce + 16-byte Poly1305 authentication
165 tag.
166
167 ## Keychain integration
168
169 The `keychain` feature (default on) uses the `keyring` crate (v3) to store
170 the master key in the OS credential store:
171
172 - **macOS**: Keychain Services
173 - **Linux**: secret-service (D-Bus Secret Service API)
174 - **Windows**: Windows Credential Manager
175
176 Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
177 (account). When the feature is disabled, store/load/delete are no-ops that
178 return `Ok`.
179
180 ## Retry and resilience
181
182 Push and pull use `retry_request()` with exponential backoff:
183
184 - **Max retries**: 3
185 - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt)
186 - **Transient errors** (retried): network failures (timeout, DNS, connection
187 refused), server errors (5xx), rate limiting (429)
188 - **Permanent errors** (not retried): client errors (4xx except 429),
189 serialization errors, encryption errors, missing session/key
190
191 Token expiry is detected client-side by decoding the JWT `exp` claim with a
192 30-second buffer. If the token is about to expire, the client returns
193 `SyncKitError::TokenExpired` so the caller can re-authenticate before sending
194 a request that would fail with 401.
195
196 ## Key design decisions
197
198 ### Why XChaCha20-Poly1305
199
200 - 192-bit nonces are large enough to generate randomly without realistic
201 collision risk (unlike AES-GCM's 96-bit nonces).
202 - Removes the need for a nonce counter or nonce-misuse resistance scheme.
203 - Performance is comparable to AES-GCM on modern hardware.
204
205 ### Why Argon2id
206
207 - OWASP-recommended for password hashing. Resists both side-channel (Argon2i)
208 and GPU brute-force (Argon2d) attacks.
209 - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum.
210
211 ### Why random salt per wrap
212
213 Each `wrap_master_key` call generates a fresh 32-byte random salt. This means
214 re-wrapping with the same password produces a completely different envelope,
215 preventing precomputation attacks and ensuring password changes are
216 cryptographically distinct.
217
218 ### Why no token refresh
219
220 The server currently issues short-lived JWTs without refresh tokens. The client
221 detects expiry and returns `TokenExpired`, leaving re-authentication to the
222 caller. This keeps the SDK stateless with respect to refresh logic and avoids
223 storing long-lived credentials in memory.
224
225 ## Security properties
226
227 - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync
228 entries, blobs). It never receives the plaintext master key or user data.
229 - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to
230 clear the master key from memory when the guard is dropped.
231 - **No key material in logs**: Tracing statements log events (key stored, key
232 loaded) but never log key bytes or ciphertext.
233 - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
234 (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
235 - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
236 to support future algorithm upgrades without breaking existing envelopes.
237