Skip to main content

max / makenotwork

Move synckit-client and synckit-config out to the synckit repo The sync SDK is its own repo now (~/Code/synckit), so it versions and deploys independently of MNW. mnw-cli repoints its path dep to the new location; the server is unaffected, since it implements the sync endpoints rather than linking the client crate. The synckit server routes stay in this server. Splitting them into a standalone service is the separate mnw-strategy-synckit-vps-separation plan.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:30 UTC
Signed with PGP, not checked
Commit: 9d43b2ac5fb15ea3d36779042d4873da54e532cd
Parent: aec45f4
47 files changed, +1 insertion, -22994 deletions
@@ -17,7 +17,7 @@
17 17 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
18 18 anyhow = "1"
19 19 bytes = "1"
20 - synckit-client = { path = "../shared/synckit-client", default-features = false }
20 + synckit-client = { path = "../../synckit/synckit-client", default-features = false }
21 21 uuid = "1"
22 22
23 23 [lints.rust]
@@ -1,14 +1,0 @@
1 - /target
2 -
3 - # Environment
4 - .env
5 - .env.*
6 -
7 - # OS
8 - .DS_Store
9 -
10 - # IDE
11 - .idea/
12 - .vscode/
13 - *.swp
14 - *.swo
@@ -1,3347 +1,0 @@
1 - # This file is automatically @generated by Cargo.
2 - # It is not intended for manual editing.
3 - version = 4
4 -
5 - [[package]]
6 - name = "aead"
7 - version = "0.6.1"
8 - source = "registry+https://github.com/rust-lang/crates.io-index"
9 - checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
10 - dependencies = [
11 - "crypto-common 0.2.2",
12 - "inout 0.2.2",
13 - ]
14 -
15 - [[package]]
16 - name = "aes"
17 - version = "0.8.4"
18 - source = "registry+https://github.com/rust-lang/crates.io-index"
19 - checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
20 - dependencies = [
21 - "cfg-if",
22 - "cipher 0.4.4",
23 - "cpufeatures 0.2.17",
24 - ]
25 -
26 - [[package]]
27 - name = "aho-corasick"
28 - version = "1.1.4"
29 - source = "registry+https://github.com/rust-lang/crates.io-index"
30 - checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
31 - dependencies = [
32 - "memchr",
33 - ]
34 -
35 - [[package]]
36 - name = "android_system_properties"
37 - version = "0.1.5"
38 - source = "registry+https://github.com/rust-lang/crates.io-index"
39 - checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
40 - dependencies = [
41 - "libc",
42 - ]
43 -
44 - [[package]]
45 - name = "anyhow"
46 - version = "1.0.102"
47 - source = "registry+https://github.com/rust-lang/crates.io-index"
48 - checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
49 -
50 - [[package]]
51 - name = "apple-native-keyring-store"
52 - version = "1.0.1"
53 - source = "registry+https://github.com/rust-lang/crates.io-index"
54 - checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
55 - dependencies = [
56 - "keyring-core",
57 - "log",
58 - "security-framework",
59 - ]
60 -
61 - [[package]]
62 - name = "argon2"
63 - version = "0.5.3"
64 - source = "registry+https://github.com/rust-lang/crates.io-index"
65 - checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
66 - dependencies = [
67 - "base64ct",
68 - "blake2",
69 - "cpufeatures 0.2.17",
70 - "password-hash",
71 - ]
72 -
73 - [[package]]
74 - name = "assert-json-diff"
75 - version = "2.0.2"
76 - source = "registry+https://github.com/rust-lang/crates.io-index"
77 - checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
78 - dependencies = [
79 - "serde",
80 - "serde_json",
81 - ]
82 -
83 - [[package]]
84 - name = "async-broadcast"
85 - version = "0.7.2"
86 - source = "registry+https://github.com/rust-lang/crates.io-index"
87 - checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
88 - dependencies = [
89 - "event-listener",
90 - "event-listener-strategy",
91 - "futures-core",
92 - "pin-project-lite",
93 - ]
94 -
95 - [[package]]
96 - name = "async-channel"
97 - version = "2.5.0"
98 - source = "registry+https://github.com/rust-lang/crates.io-index"
99 - checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
100 - dependencies = [
101 - "concurrent-queue",
102 - "event-listener-strategy",
103 - "futures-core",
104 - "pin-project-lite",
105 - ]
106 -
107 - [[package]]
108 - name = "async-executor"
109 - version = "1.14.0"
110 - source = "registry+https://github.com/rust-lang/crates.io-index"
111 - checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
112 - dependencies = [
113 - "async-task",
114 - "concurrent-queue",
115 - "fastrand",
116 - "futures-lite",
117 - "pin-project-lite",
118 - "slab",
119 - ]
120 -
121 - [[package]]
122 - name = "async-io"
123 - version = "2.6.0"
124 - source = "registry+https://github.com/rust-lang/crates.io-index"
125 - checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
126 - dependencies = [
127 - "autocfg",
128 - "cfg-if",
129 - "concurrent-queue",
130 - "futures-io",
131 - "futures-lite",
132 - "parking",
133 - "polling",
134 - "rustix",
135 - "slab",
136 - "windows-sys 0.61.2",
137 - ]
138 -
139 - [[package]]
140 - name = "async-lock"
141 - version = "3.4.2"
142 - source = "registry+https://github.com/rust-lang/crates.io-index"
143 - checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
144 - dependencies = [
145 - "event-listener",
146 - "event-listener-strategy",
147 - "pin-project-lite",
148 - ]
149 -
150 - [[package]]
151 - name = "async-process"
152 - version = "2.5.0"
153 - source = "registry+https://github.com/rust-lang/crates.io-index"
154 - checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
155 - dependencies = [
156 - "async-channel",
157 - "async-io",
158 - "async-lock",
159 - "async-signal",
160 - "async-task",
161 - "blocking",
162 - "cfg-if",
163 - "event-listener",
164 - "futures-lite",
165 - "rustix",
166 - ]
167 -
168 - [[package]]
169 - name = "async-recursion"
170 - version = "1.1.1"
171 - source = "registry+https://github.com/rust-lang/crates.io-index"
172 - checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
173 - dependencies = [
174 - "proc-macro2",
175 - "quote",
176 - "syn 2.0.117",
177 - ]
178 -
179 - [[package]]
180 - name = "async-signal"
181 - version = "0.2.14"
182 - source = "registry+https://github.com/rust-lang/crates.io-index"
183 - checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
184 - dependencies = [
185 - "async-io",
186 - "async-lock",
187 - "atomic-waker",
188 - "cfg-if",
189 - "futures-core",
190 - "futures-io",
191 - "rustix",
192 - "signal-hook-registry",
193 - "slab",
194 - "windows-sys 0.61.2",
195 - ]
196 -
197 - [[package]]
198 - name = "async-task"
199 - version = "4.7.1"
200 - source = "registry+https://github.com/rust-lang/crates.io-index"
201 - checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
202 -
203 - [[package]]
204 - name = "async-trait"
205 - version = "0.1.91"
206 - source = "registry+https://github.com/rust-lang/crates.io-index"
207 - checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
208 - dependencies = [
209 - "proc-macro2",
210 - "quote",
211 - "syn 3.0.2",
212 - ]
213 -
214 - [[package]]
215 - name = "atomic-waker"
216 - version = "1.1.2"
217 - source = "registry+https://github.com/rust-lang/crates.io-index"
218 - checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
219 -
220 - [[package]]
221 - name = "autocfg"
222 - version = "1.5.0"
223 - source = "registry+https://github.com/rust-lang/crates.io-index"
224 - checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
225 -
226 - [[package]]
227 - name = "base64"
228 - version = "0.22.1"
229 - source = "registry+https://github.com/rust-lang/crates.io-index"
230 - checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
231 -
232 - [[package]]
233 - name = "base64ct"
234 - version = "1.8.3"
235 - source = "registry+https://github.com/rust-lang/crates.io-index"
236 - checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
237 -
238 - [[package]]
239 - name = "bitflags"
240 - version = "2.11.0"
241 - source = "registry+https://github.com/rust-lang/crates.io-index"
242 - checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
243 -
244 - [[package]]
245 - name = "blake2"
246 - version = "0.10.6"
247 - source = "registry+https://github.com/rust-lang/crates.io-index"
248 - checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
249 - dependencies = [
250 - "digest 0.10.7",
251 - ]
252 -
253 - [[package]]
254 - name = "block-buffer"
255 - version = "0.10.4"
256 - source = "registry+https://github.com/rust-lang/crates.io-index"
257 - checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
258 - dependencies = [
259 - "generic-array",
260 - ]
261 -
262 - [[package]]
263 - name = "block-buffer"
264 - version = "0.12.1"
265 - source = "registry+https://github.com/rust-lang/crates.io-index"
266 - checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
267 - dependencies = [
268 - "hybrid-array",
269 - ]
270 -
271 - [[package]]
272 - name = "block-padding"
273 - version = "0.3.3"
274 - source = "registry+https://github.com/rust-lang/crates.io-index"
275 - checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
276 - dependencies = [
277 - "generic-array",
278 - ]
279 -
280 - [[package]]
281 - name = "blocking"
282 - version = "1.6.2"
283 - source = "registry+https://github.com/rust-lang/crates.io-index"
284 - checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
285 - dependencies = [
286 - "async-channel",
287 - "async-task",
288 - "futures-io",
289 - "futures-lite",
290 - "piper",
291 - ]
292 -
293 - [[package]]
294 - name = "bumpalo"
295 - version = "3.20.2"
296 - source = "registry+https://github.com/rust-lang/crates.io-index"
297 - checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
298 -
299 - [[package]]
300 - name = "byteorder"
301 - version = "1.5.0"
302 - source = "registry+https://github.com/rust-lang/crates.io-index"
303 - checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
304 -
305 - [[package]]
306 - name = "bytes"
307 - version = "1.11.1"
308 - source = "registry+https://github.com/rust-lang/crates.io-index"
309 - checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
310 -
311 - [[package]]
312 - name = "cbc"
313 - version = "0.1.2"
314 - source = "registry+https://github.com/rust-lang/crates.io-index"
315 - checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
316 - dependencies = [
317 - "cipher 0.4.4",
318 - ]
319 -
320 - [[package]]
321 - name = "cc"
322 - version = "1.2.56"
323 - source = "registry+https://github.com/rust-lang/crates.io-index"
324 - checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
325 - dependencies = [
326 - "find-msvc-tools",
327 - "shlex",
328 - ]
329 -
330 - [[package]]
331 - name = "cfg-if"
332 - version = "1.0.4"
333 - source = "registry+https://github.com/rust-lang/crates.io-index"
334 - checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
335 -
336 - [[package]]
337 - name = "chacha20"
338 - version = "0.10.1"
339 - source = "registry+https://github.com/rust-lang/crates.io-index"
340 - checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
341 - dependencies = [
342 - "cfg-if",
343 - "cipher 0.5.2",
344 - "cpufeatures 0.3.0",
345 - "rand_core 0.10.1",
346 - ]
347 -
348 - [[package]]
349 - name = "chacha20poly1305"
350 - version = "0.11.0"
351 - source = "registry+https://github.com/rust-lang/crates.io-index"
352 - checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
353 - dependencies = [
354 - "aead",
355 - "chacha20",
356 - "cipher 0.5.2",
357 - "poly1305",
358 - ]
359 -
360 - [[package]]
361 - name = "chrono"
362 - version = "0.4.44"
363 - source = "registry+https://github.com/rust-lang/crates.io-index"
364 - checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
365 - dependencies = [
366 - "iana-time-zone",
367 - "js-sys",
368 - "num-traits",
369 - "serde",
370 - "wasm-bindgen",
371 - "windows-link",
372 - ]
373 -
374 - [[package]]
375 - name = "cipher"
376 - version = "0.4.4"
377 - source = "registry+https://github.com/rust-lang/crates.io-index"
378 - checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
379 - dependencies = [
380 - "crypto-common 0.1.7",
381 - "inout 0.1.4",
382 - ]
383 -
384 - [[package]]
385 - name = "cipher"
386 - version = "0.5.2"
387 - source = "registry+https://github.com/rust-lang/crates.io-index"
388 - checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
389 - dependencies = [
390 - "block-buffer 0.12.1",
391 - "crypto-common 0.2.2",
392 - "inout 0.2.2",
393 - ]
394 -
395 - [[package]]
396 - name = "cmov"
397 - version = "0.5.4"
398 - source = "registry+https://github.com/rust-lang/crates.io-index"
399 - checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
400 -
401 - [[package]]
402 - name = "combine"
403 - version = "4.6.7"
404 - source = "registry+https://github.com/rust-lang/crates.io-index"
405 - checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
406 - dependencies = [
407 - "bytes",
408 - "memchr",
409 - ]
410 -
411 - [[package]]
412 - name = "concurrent-queue"
413 - version = "2.5.0"
414 - source = "registry+https://github.com/rust-lang/crates.io-index"
415 - checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
416 - dependencies = [
417 - "crossbeam-utils",
418 - ]
419 -
420 - [[package]]
421 - name = "const-oid"
422 - version = "0.10.2"
423 - source = "registry+https://github.com/rust-lang/crates.io-index"
424 - checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
425 -
426 - [[package]]
427 - name = "core-foundation"
428 - version = "0.9.4"
429 - source = "registry+https://github.com/rust-lang/crates.io-index"
430 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
431 - dependencies = [
432 - "core-foundation-sys",
433 - "libc",
434 - ]
435 -
436 - [[package]]
437 - name = "core-foundation"
438 - version = "0.10.1"
439 - source = "registry+https://github.com/rust-lang/crates.io-index"
440 - checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
441 - dependencies = [
442 - "core-foundation-sys",
443 - "libc",
444 - ]
445 -
446 - [[package]]
447 - name = "core-foundation-sys"
448 - version = "0.8.7"
449 - source = "registry+https://github.com/rust-lang/crates.io-index"
450 - checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
451 -
452 - [[package]]
453 - name = "cpufeatures"
454 - version = "0.2.17"
455 - source = "registry+https://github.com/rust-lang/crates.io-index"
456 - checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
457 - dependencies = [
458 - "libc",
459 - ]
460 -
461 - [[package]]
462 - name = "cpufeatures"
463 - version = "0.3.0"
464 - source = "registry+https://github.com/rust-lang/crates.io-index"
465 - checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
466 - dependencies = [
467 - "libc",
468 - ]
469 -
470 - [[package]]
471 - name = "crossbeam-utils"
472 - version = "0.8.22"
473 - source = "registry+https://github.com/rust-lang/crates.io-index"
474 - checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
475 -
476 - [[package]]
477 - name = "crypto-common"
478 - version = "0.1.7"
479 - source = "registry+https://github.com/rust-lang/crates.io-index"
480 - checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
481 - dependencies = [
482 - "generic-array",
483 - "typenum",
484 - ]
485 -
486 - [[package]]
487 - name = "crypto-common"
488 - version = "0.2.2"
489 - source = "registry+https://github.com/rust-lang/crates.io-index"
490 - checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
491 - dependencies = [
492 - "getrandom 0.4.1",
493 - "hybrid-array",
494 - "rand_core 0.10.1",
495 - ]
496 -
497 - [[package]]
498 - name = "ctutils"
499 - version = "0.4.2"
500 - source = "registry+https://github.com/rust-lang/crates.io-index"
Lines truncated
@@ -1,123 +1,0 @@
1 - [package]
2 - name = "synckit-client"
3 - version = "0.6.0"
4 - edition = "2024"
5 - description = "SyncKit client SDK with end-to-end encryption"
6 - license-file = "LICENSE"
7 -
8 - [features]
9 - default = ["keychain", "store"]
10 - keychain = ["dep:keyring"]
11 - # The higher-level SyncStore engine (owns a rusqlite connection to the app's DB).
12 - # Default-on so app consumers get it for free; a consumer that only needs the
13 - # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
14 - # it and avoids compiling bundled SQLite.
15 - store = ["dep:rusqlite", "dep:synckit-config"]
16 - # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
17 - # bypass key derivation. Enabled automatically for this crate's own test builds
18 - # via the self dev-dependency below; never part of `default`, so a real consumer
19 - # cannot reach a raw-key injection point.
20 - testing = []
21 -
22 - [dependencies]
23 - # Encryption
24 - chacha20poly1305 = "0.11"
25 - argon2 = "0.5"
26 - rand = "0.10"
27 - base64 = "0.22"
28 - zeroize = "1"
29 - # X25519 ECDH — the only asymmetric primitive in the crate, used solely by the
30 - # group-key layer (`identity.rs`) to seal a Group Content Key to a member's public
31 - # key. Pure-Rust (curve25519-dalek), no aws-lc/ring. Deliberately just the curve op:
32 - # the GCK itself is sealed with the XChaCha20-Poly1305 above, not a sealed-box crate.
33 - # `static_secrets` enables the reusable `StaticSecret` (a member's long-lived key).
34 - x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] }
35 -
36 - # PKCE (OAuth2) challenge hashing
37 - sha2 = "0.11"
38 - hex = "0.4"
39 -
40 - # HTTP
41 - # rustls-no-provider: rustls TLS with the OS-native trust store (rustls-platform-verifier),
42 - # no bundled crypto provider — the consuming app installs one process default (ring).
43 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "stream", "form", "charset", "http2", "system-proxy"] }
44 - bytes = "1"
45 - tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "io-util"] }
46 - tokio-stream = "0.1"
47 -
48 - # Serialization
49 - serde = { version = "1", features = ["derive"] }
50 - serde_json = "1"
51 - chrono = { version = "0.4", features = ["serde"] }
52 - uuid = { version = "1", features = ["v4", "serde"] }
53 -
54 - # OS keychain (optional)
55 - keyring = { version = "4", optional = true }
56 -
57 - # URL encoding
58 - urlencoding = "2"
59 -
60 - # Unicode
61 - unicode-normalization = "0.1"
62 -
63 - # Synchronization
64 - parking_lot = "0.12"
65 -
66 - # Error handling & logging
67 - thiserror = "2"
68 - tracing = "0.1"
69 -
70 - # SyncStore engine (feature = "store"). Owns the app's SQLite connection. Bundled
71 - # so no system libsqlite is needed; `functions` to register the `hash_row_id` UDF.
72 - rusqlite = { version = "0.39", features = ["bundled", "functions"], optional = true }
73 - # The local config store (posture types + KV store). The config sync adapter
74 - # (src/store/config.rs) turns a ConfigSpec into a SyncTable. Path dep: sibling in
75 - # the MNW tree, versioned together.
76 - synckit-config = { path = "../synckit-config", optional = true }
77 -
78 - [dev-dependencies]
79 - wiremock = "0.6"
80 - # reqwest is built `rustls-no-provider`, so real consumers install a rustls crypto
81 - # provider at startup (audiofiles installs ring). The test suite has no such app, so
82 - # it installs ring itself before building any client. Dev-only: never in a consumer build.
83 - rustls = { version = "0.23", default-features = false, features = ["ring"] }
84 - # Self dev-dependency: turns on the `testing` feature for this crate's own
85 - # unit/integration test builds without leaking it into a consumer's default set.
86 - # `default-features = false` so it only ADDS `testing` to whatever the test run
87 - # already selected — under a plain `cargo test` the package still builds with its
88 - # own defaults (keychain + store), but `cargo test --no-default-features` can turn
89 - # keychain OFF (making `keystore::store_key` the no-op stub) to run the keychain-
90 - # free rotation orchestration test below.
91 - synckit-client = { path = ".", default-features = false, features = ["testing"] }
92 -
93 - [lints.rust]
94 - unused = "warn"
95 - unreachable_pub = "warn"
96 -
97 - [lints.clippy]
98 - pedantic = { level = "warn", priority = -1 }
99 - # Allow-list tuned from a measured breakdown across server/multithreaded/pter
100 - # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
101 - # else in `pedantic` stays a warning. Keep this block identical across repos.
102 - module_name_repetitions = "allow"
103 - # Doc lints. No docs-completeness push is underway.
104 - missing_errors_doc = "allow"
105 - missing_panics_doc = "allow"
106 - doc_markdown = "allow"
107 - # Numeric casts. Endemic and mostly intentional in size and byte math.
108 - cast_possible_truncation = "allow"
109 - cast_sign_loss = "allow"
110 - cast_precision_loss = "allow"
111 - cast_possible_wrap = "allow"
112 - cast_lossless = "allow"
113 - # Subjective structure and style nags. High churn, low signal.
114 - must_use_candidate = "allow"
115 - too_many_lines = "allow"
116 - struct_excessive_bools = "allow"
117 - similar_names = "allow"
118 - items_after_statements = "allow"
119 - single_match_else = "allow"
120 - # Frequent false-positives in TUI and router-heavy code.
121 - match_same_arms = "allow"
122 - unnecessary_wraps = "allow"
123 - type_complexity = "allow"
@@ -1,120 +1,0 @@
1 - Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work)
2 -
3 - PolyForm Noncommercial License 1.0.0
4 -
5 - <https://polyformproject.org/licenses/noncommercial/1.0.0>
6 -
7 - Acceptance
8 -
9 - In order to get any license under these terms, you must agree to them as
10 - both strict obligations and conditions to all your licenses.
11 -
12 - Copyright License
13 -
14 - The licensor grants you a copyright license for the software to do
15 - everything you might do with the software that would otherwise infringe
16 - the licensor's copyright in it for any permitted purpose. However, you
17 - may only distribute the software according to Distribution License and
18 - make changes or new works based on the software according to Changes and
19 - New Works License.
20 -
21 - Distribution License
22 -
23 - The licensor grants you an additional copyright license to distribute
24 - copies of the software. Your license to distribute covers distributing
25 - the software with changes and new works permitted by Changes and New
26 - Works License.
27 -
28 - Notices
29 -
30 - You must ensure that anyone who gets a copy of any part of the software
31 - from you also gets a copy of these terms or the URL for them above, as
32 - well as copies of any plain-text lines beginning with "Required Notice:"
33 - that the licensor provided with the software. For example:
34 -
35 - Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
36 -
37 - Changes and New Works License
38 -
39 - The licensor grants you an additional copyright license to make changes
40 - and new works based on the software for any permitted purpose.
41 -
42 - Patent License
43 -
44 - The licensor grants you a patent license for the software that covers
45 - patent claims the licensor can license, or becomes able to license, that
46 - you would infringe by using the software.
47 -
48 - Noncommercial Purposes
49 -
50 - Any noncommercial purpose is a permitted purpose.
51 -
52 - Personal Uses
53 -
54 - Personal use for research, experiment, and testing for the benefit of
55 - public knowledge, personal study, private entertainment, hobby projects,
56 - amateur pursuits, or religious observance, without any anticipated
57 - commercial application, is use for a permitted purpose.
58 -
59 - Noncommercial Organizations
60 -
61 - Use by any charitable organization, educational institution, public
62 - research organization, public safety or health organization,
63 - environmental protection organization, or government institution is use
64 - for a permitted purpose regardless of the source of funding or
65 - obligations resulting from the funding.
66 -
67 - Fair Use
68 -
69 - You may have "fair use" rights for the software under the law. These
70 - terms do not limit them.
71 -
72 - No Other Rights
73 -
74 - These terms do not allow you to sublicense or transfer any of your
75 - licenses to anyone else, or prevent the licensor from granting licenses
76 - to anyone else. These terms do not imply any other licenses.
77 -
78 - Patent Defense
79 -
80 - If you make any written claim that the software infringes or contributes
81 - to infringement of any patent, your patent license for the software
82 - granted under these terms ends immediately. If your company makes such a
83 - claim, your patent license ends immediately for work on behalf of your
84 - company.
85 -
86 - Violations
87 -
88 - The first time you are notified in writing that you have violated any of
89 - these terms, or done anything with the software not covered by your
90 - licenses, your licenses can nonetheless continue if you come into full
91 - compliance with these terms, and take practical steps to correct past
92 - violations, within 32 days of receiving notice. Otherwise, all your
93 - licenses end immediately.
94 -
95 - No Liability
96 -
97 - As far as the law allows, the software comes as is, without any warranty
98 - or condition, and the licensor will not be liable to you for any damages
99 - arising out of these terms or the use or nature of the software, under
100 - any kind of legal claim.
101 -
102 - Definitions
103 -
104 - The licensor is the individual or entity offering these terms, and the
105 - software is the software the licensor makes available under these terms.
106 -
107 - You refers to the individual or entity agreeing to these terms.
108 -
109 - Your company is any legal entity, sole proprietorship, or other kind of
110 - organization that you work for, plus all organizations that have control
111 - over, are under the control of, or are under common control with that
112 - organization. Control means ownership of substantially all the assets of
113 - an entity, or the power to direct its management and policies by vote,
114 - contract, or otherwise. Control can be direct or indirect.
115 -
116 - Your licenses are all the licenses granted to you for the software under
117 - these terms.
118 -
119 - Use means anything you do with the software requiring one of your
120 - licenses.
@@ -1,79 +1,0 @@
1 - # synckit-client
2 -
3 - End-to-end encrypted cloud sync SDK for Rust applications, built for the [MNW SyncKit](https://makenot.work) server.
4 -
5 - All row data and binary blobs are encrypted client-side (XChaCha20-Poly1305) before leaving the device. The server only ever stores ciphertext.
6 -
7 - ## Features
8 -
9 - - **E2E encryption** -- XChaCha20-Poly1305 with Argon2id key derivation (64 MB, 3 iterations)
10 - - **OS keychain integration** -- master key cached in macOS Keychain, Linux secret-service, or Windows Credential Manager
11 - - **Blob encryption** -- binary files encrypted with fixed 40-byte overhead (no base64 expansion)
12 - - **Retry with backoff** -- transient failures (network, 5xx, 429) retried up to 3 times with exponential delay
13 - - **OAuth2 PKCE** -- browser-based auth flow alongside email/password
14 - - **Token expiry detection** -- client-side JWT check with 30-second buffer
15 -
16 - ## Quick Start
17 -
18 - ```rust
19 - use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry, ChangeOp};
20 - use chrono::Utc;
21 -
22 - let client = SyncKitClient::new(SyncKitConfig {
23 - server_url: "https://makenot.work".into(),
24 - api_key: "your-api-key".into(),
25 - });
26 -
27 - // Authenticate. The third arg is the developer-defined SDK key for billing
28 - // attribution — typically one key per workspace/org/end-user.
29 - let (user_id, app_id) = client.authenticate("user@example.com", "password", "workspace-42").await?;
30 -
31 - // Set up encryption (first device)
32 - client.setup_encryption_new("password").await?;
33 -
34 - // Register this device
35 - let device = client.register_device("MacBook Pro", "macos").await?;
36 -
37 - // Push encrypted data
38 - let cursor = client.push(device.id, vec![
39 - ChangeEntry {
40 - table: "tasks".into(),
41 - op: ChangeOp::Insert,
42 - row_id: uuid::Uuid::new_v4().to_string(),
43 - timestamp: Utc::now(),
44 - data: Some(serde_json::json!({"title": "Buy milk"})),
45 - },
46 - ]).await?;
47 -
48 - // Pull and auto-decrypt
49 - let (changes, cursor, has_more) = client.pull(device.id, 0).await?;
50 - ```
51 -
52 - ## Crate Structure
53 -
54 - | File | Role |
55 - |------|------|
56 - | `lib.rs` | Crate root, re-exports, doc example |
57 - | `client.rs` | `SyncKitClient` -- HTTP methods, retry logic, token expiry detection |
58 - | `crypto.rs` | Key derivation (Argon2id), key wrapping, per-entry and per-blob encrypt/decrypt |
59 - | `error.rs` | `SyncKitError` enum (10 variants: HTTP, server, JSON, crypto, keychain, auth) |
60 - | `keystore.rs` | OS keychain read/write/delete, feature-gated with no-op stubs |
61 - | `types.rs` | Wire protocol types (`ChangeEntry`, `ChangeOp`, `Device`, `SyncStatus`) |
62 -
63 - ## Feature Flags
64 -
65 - | Flag | Default | Description |
66 - |------|---------|-------------|
67 - | `keychain` | on | OS keychain storage via the `keyring` crate. Disable with `default-features = false` for headless/CI environments. |
68 -
69 - ## Security Properties
70 -
71 - - **Server-zero-knowledge** -- the server never receives the plaintext master key or user data
72 - - **Key zeroization** -- volatile writes clear the master key from memory on drop
73 - - **Random salt per wrap** -- re-wrapping with the same password produces a different envelope
74 - - **Minimum ciphertext validation** -- decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag)
75 - - **No key material in logs** -- tracing events never include key bytes or ciphertext
76 -
77 - ## License
78 -
79 - PolyForm Noncommercial 1.0.0
@@ -1,13 +1,0 @@
1 - # A raw `Response::json`/`text`/`bytes` reads the whole body with no size limit,
2 - # so a hostile or buggy server can stream an arbitrarily large body and OOM the
3 - # client (worst case: the public, unauthenticated OTA updater check). Every body
4 - # read must go through `client::helpers::read_body_capped` (or its `read_json_capped`
5 - # / `read_text_capped` wrappers), which drains `bytes_stream()` under a hard cap.
6 - # `blob_download` streams `bytes_stream()` directly under its own 4 GiB cap.
7 - # This seal makes a new uncapped read a compile-time (clippy) error, not a latent
8 - # OOM lever someone has to remember to avoid.
9 - disallowed-methods = [
10 - { path = "reqwest::Response::json", reason = "unbounded body read; use client::helpers::read_json_capped" },
11 - { path = "reqwest::Response::text", reason = "unbounded body read; use client::helpers::read_text_capped" },
12 - { path = "reqwest::Response::bytes", reason = "unbounded body read; use client::helpers::read_body_capped (or bytes_stream() under an explicit cap)" },
13 - ]
@@ -1,944 +1,0 @@
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.5.0.
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, #[non_exhaustive]): Http, Server,
25 - Json, NoMasterKey, DecryptionFailed, IntegrityFailed,
26 - InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired,
27 - InvalidArgument, Internal, and Keychain (feature-gated behind
28 - `keychain`).
29 - keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
30 - Windows Credential Manager) via the `keyring` crate.
31 - Feature-gated behind `keychain` (default on). No-op stubs
32 - when disabled.
33 - types.rs Request/response types matching the server wire protocol.
34 - Public types: ChangeEntry, ChangeOp, Device, SyncStatus,
35 - BlobUploadUrlResponse. Internal types: auth, push/pull, key,
36 - OAuth, blob requests.
37 -
38 - Public re-exports from lib.rs:
39 - SyncKitClient, SyncKitConfig, SessionInfo,
40 - ChangeEntry, ChangeOp, Device, SyncStatus,
41 - SyncKitError, Result
42 - ```
43 -
44 - ## Key lifecycle
45 -
46 - ```
47 - password
48 - |
49 - v
50 - Argon2id (64 MB, 3 iterations, parallelism 1)
51 - + random 32-byte salt (stored in envelope)
52 - |
53 - v
54 - wrapping_key (256-bit)
55 - |
56 - v
57 - XChaCha20-Poly1305 encrypt/decrypt
58 - |
59 - v
60 - master_key (256-bit, randomly generated on first device)
61 - |
62 - v
63 - XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt
64 - ```
65 -
66 - - **First device**: generates a random master key, wraps it with the password,
67 - pushes the encrypted envelope to the server, caches plaintext in OS keychain.
68 - - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps
69 - with the password, caches in OS keychain.
70 - - **Subsequent launches**: loads the master key directly from the OS keychain
71 - (no password prompt, no server call).
72 - - **Password change**: decrypts master key with old password, re-wraps with new
73 - password (fresh random salt), pushes new envelope to server. The master key
74 - itself does not change, so existing ciphertext remains valid.
75 -
76 - ## Authentication flow
77 -
78 - ### Email/password
79 -
80 - 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key.
81 - 2. Server validates credentials and returns a JWT, user ID, and app ID.
82 - 3. Client stores the session in an internal `Mutex<Option<Session>>`.
83 -
84 - ### OAuth2 PKCE
85 -
86 - 1. Caller generates a PKCE code verifier and challenge.
87 - 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge.
88 - 3. Caller opens the URL in a browser and starts a localhost callback server.
89 - 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges
90 - the authorization code for a JWT via `/oauth/token`.
91 - 5. Session is stored identically to email/password auth.
92 -
93 - ### Session restoration
94 -
95 - `restore_session(token, user_id, app_id)` populates the session from stored
96 - credentials (e.g., OS keychain) without making any HTTP calls. The caller is
97 - responsible for checking `is_token_expired()` and re-authenticating if needed.
98 -
99 - ### Session utilities
100 -
101 - - `session_info()` returns `Option<SessionInfo>` (token, user_id, app_id)
102 - without making HTTP calls. Returns `None` if not authenticated.
103 - - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer.
104 - Returns `true` if no session exists or the token is about to expire.
105 - - `clear_session()` clears in-memory session and master key. Does not
106 - affect OS keychain (call `keystore::delete_key` separately).
107 - - `config()` returns a reference to the `SyncKitConfig`.
108 -
109 - ## Encryption setup flow
110 -
111 - ```
112 - has_server_key()?
113 - |
114 - +-- false --> setup_encryption_new(password)
115 - | Generate master key, wrap, push envelope, cache in keychain
116 - |
117 - +-- true --> setup_encryption_existing(password)
118 - Pull envelope, unwrap with password, cache in keychain
119 - ```
120 -
121 - On subsequent launches, `try_load_key_from_keychain()` loads the master key
122 - from the OS keychain without any server interaction or password prompt.
123 -
124 - ## Push/Pull protocol
125 -
126 - ### The HLC envelope
127 -
128 - Every change, **including Deletes**, is encrypted as a JSON *envelope*, not as
129 - the bare row data:
130 -
131 - ```json
132 - { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": <row|null> }
133 - ```
134 -
135 - Encrypting the envelope (rather than just the row) is what carries the hybrid
136 - logical clock (HLC) inside the E2E ciphertext, so the server can order entries by
137 - `seq` but never sees or orders by the clock. A Delete has `data: null` but still
138 - seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded
139 - `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy
140 - bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`.
141 -
142 - ### Wire version tag and AAD binding
143 -
144 - The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed
145 - `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` =
146 - `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader
147 - always knows which AAD to apply:
148 -
149 - - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD.
150 - - untagged (legacy, pre-v2) → open with empty AAD.
151 -
152 - This means a malicious or compromised server cannot relocate a valid ciphertext
153 - to a different row or table: the AAD no longer matches and the open fails closed.
154 - Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
155 - legacy entries into the v2 form opportunistically.
156 -
157 - The AAD is never passed as raw bytes. Callers name a context, `AeadContext::Entry
158 - { table, row_id }` or `AeadContext::Blob { hash }`, and the crypto layer derives
159 - the AAD from it. The byte-level builder is private, so an empty or mismatched AAD
160 - is not expressible at the call site.
161 -
162 - ### Envelope version dispatch (`__skver`)
163 -
164 - The decrypted entry payload is an envelope `{ __skver: 2, __skhlc, data }`.
165 - Splitting it back into `(hlc, data)` dispatches on the explicit `__skver` tag via
166 - a typed `WireVersion`: an unknown future version is a hard error, not a silent
167 - fall-through to a bare-row read. A tag-less payload carrying an embedded `__skhlc`
168 - is a gen-1 envelope; anything else is a legacy bare row.
169 -
170 - ### Chunked blob format (`sk3:`)
171 -
172 - Blobs use a chunked AEAD format so a reader decrypts and verifies one chunk at a
173 - time. Layout: `sk3:` `version(u8=3)` `chunk_size(u32 LE)` `total_len(u64 LE)`
174 - followed by sealed 1 MiB chunks. Each chunk's AAD binds `(content_hash,
175 - chunk_index, chunk_count)`, so reorder, truncation, duplication, and cross-blob
176 - substitution all fail closed. `blob_download` streams the body and decrypts
177 - chunk-by-chunk (peak memory is the plaintext plus one in-flight chunk, not
178 - ciphertext *and* plaintext together); legacy `sk2:`/untagged blobs still decrypt
179 - whole-buffer with no flag-day.
180 -
181 - ### Push
182 -
183 - 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC.
184 - 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master
185 - key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the
186 - `sk2:`-tagged base64 string.
187 - 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a
188 - client-generated `batch_id` (idempotent push: a replayed batch returns the
189 - existing cursor).
190 - 4. Server appends entries to the changelog and returns the new cursor (i64).
191 - 5. Retries on transient failures (see below).
192 -
193 - ### Pull
194 -
195 - 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
196 - 2. Server returns changelog entries since that cursor, a new cursor, and a
197 - `has_more` flag for pagination.
198 - 3. Client decrypts each entry's envelope (auto-detecting the wire version) and
199 - returns plaintext `ChangeEntry` values, HLC included, to the caller.
200 - 4. Retries on transient failures.
201 -
202 - **Caller contract.** The SDK does not persist the cursor or dedup re-delivered
203 - changes. Persist the returned cursor only *after* applying the returned changes
204 - (same transaction where possible), and make apply idempotent per
205 - `(table, row_id, hlc)`, a reconnect can re-deliver a batch.
206 -
207 - ### Conflict resolution
208 -
209 - Conflicts are resolved client-side (the server cannot read plaintext).
210 - `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms,
211 - counter, node)` wins, so a newer edit beats an older delete and vice-versa, and
212 - every device converges on the same winner. An *exactly* equal HLC normally means
213 - a same-device echo; if two installs share a `node` UUID (cloned config), the tie
214 - is broken deterministically on the canonical payload bytes so both devices still
215 - converge.
216 -
217 - `detect_conflicts` only flags rows with an *un-pushed* local edit. Its clean set
218 - is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read
219 - the applicable entries out is `gated(committed_hlc)`, which drops any clean change
220 - older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch
221 - those clocks first.) This makes "apply a clean change without checking the
222 - committed clock", which would let an older remote edit clobber a newer local
223 - value, unrepresentable rather than a contract the caller has to remember.
224 -
225 - ### Sequence numbers
226 -
227 - The server assigns a monotonically increasing sequence number (`seq`) to each
228 - changelog entry. The cursor is the `seq` of the last entry the client has seen.
229 - Pulling with cursor 0 fetches from the beginning.
230 -
231 - ## Blob encryption
232 -
233 - Binary blobs (files, images, audio samples) are encrypted client-side before
234 - upload:
235 -
236 - 1. `blob_upload_url(hash, plaintext_size)` requests a presigned S3 PUT URL from
237 - the server. If the blob already exists (content-addressed by hash), no upload
238 - is needed. The declared size is converted to the ciphertext length before it
239 - is sent, because the server signs it as `Content-Length` and the PUT carries
240 - sealed bytes.
241 - 2. `blob_upload(hash, presigned_url, data)` encrypts the plaintext bytes with the
242 - master key, **binding the content hash as AEAD associated data**, and PUTs the
243 - ciphertext to S3.
244 - 3. `blob_confirm(hash, size)` tells the server the upload completed.
245 -
246 - Steps 1 and 2 are the in-memory path, for bytes with no file behind them. Every
247 - consumer app instead calls `blob_upload_streaming(hash, path)` (below), which
248 - covers the same ground with bounded memory and no size ceiling, then step 3.
249 - 4. `blob_download_url(hash)` requests a presigned GET URL.
250 - 5. `blob_download(expected_hash, presigned_url)` fetches the ciphertext, decrypts
251 - with the hash as AAD, then **re-verifies** that the plaintext hashes to
252 - `expected_hash` (returning `IntegrityFailed` otherwise). The AEAD tag proves
253 - the bytes were not forged; the re-hash proves the server served the bytes for
254 - *this* address, closing the substitution/rollback gap.
255 -
256 - Blob encryption uses `encrypt_bytes_aad`/`decrypt_bytes_aad` (raw bytes, no
257 - base64) to avoid the ~33% base64 overhead on large files. v2 overhead is 44 bytes
258 - per blob: a 4-byte `sk2:` wire tag + 24-byte XChaCha20 nonce + 16-byte Poly1305
259 - tag.
260 -
261 - ### Large blobs: the multipart path
262 -
263 - Steps 1 and 2 buffer the whole ciphertext, and the server refuses a one-shot PUT
264 - above its own ceiling. For a blob already on disk, `blob_upload_streaming(hash,
265 - path)` replaces both with a multipart session and holds one part at a time:
266 -
267 - 1. It computes the exact ciphertext length from the file size alone
268 - (`blob_encrypted_len`) and declares it to `blobs/multipart/start`, which
269 - returns the part geometry both sides then derive identically.
270 - 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full
271 - part is ready, and PUTs each part to a presigned URL signed for that exact
272 - `Content-Length`. Each presigned part URL is requested on its own, one per
273 - part, since a part's checksum only exists once it has been sealed and only one
274 - part is ever held in memory.
275 - 3. It recomputes the SHA-256 as it reads, so a file that changed since the
276 - caller hashed it fails rather than landing under a stale content address.
277 - 4. Any failure past `start` aborts the session, since uploaded parts bill until
278 - released. `blob_confirm` is still the caller's next step, unchanged.
279 -
280 - It works at any size (a small blob is a single part), so prefer it whenever the
281 - blob is file-backed. Not yet resumable across a process restart: the session id
282 - is not persisted.
283 -
284 - ## Keychain integration
285 -
286 - The `keychain` feature (default on) uses the `keyring` crate (v4) to store
287 - the master key in the OS credential store:
288 -
289 - - **macOS**: Keychain Services
290 - - **Linux**: secret-service (D-Bus Secret Service API)
291 - - **Windows**: Windows Credential Manager
292 -
293 - Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
294 - (account). When the feature is disabled, store/load/delete are no-ops that
295 - return `Ok`.
296 -
297 - ## Retry and resilience
298 -
299 - Push and pull use `retry_request()` with exponential backoff:
300 -
301 - - **Max retries**: 3
302 - - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt)
303 - - **Transient errors** (retried): network failures (timeout, DNS, connection
304 - refused), server errors (5xx), rate limiting (429)
305 - - **Permanent errors** (not retried): client errors (4xx except 429),
306 - serialization errors, encryption errors, missing session/key
307 -
308 - Token expiry is detected client-side by decoding the JWT `exp` claim with a
309 - 30-second buffer. If the token is about to expire, the client returns
310 - `SyncKitError::TokenExpired` so the caller can re-authenticate before sending
311 - a request that would fail with 401.
312 -
313 - ## Key design decisions
314 -
315 - ### Why XChaCha20-Poly1305
316 -
317 - - 192-bit nonces are large enough to generate randomly without realistic
318 - collision risk (unlike AES-GCM's 96-bit nonces).
319 - - Removes the need for a nonce counter or nonce-misuse resistance scheme.
320 - - Performance is comparable to AES-GCM on modern hardware.
321 -
322 - ### Why Argon2id
323 -
324 - - OWASP-recommended for password hashing. Resists both side-channel (Argon2i)
325 - and GPU brute-force (Argon2d) attacks.
326 - - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum.
327 -
328 - ### Why random salt per wrap
329 -
330 - Each `wrap_master_key` call generates a fresh 32-byte random salt. This means
331 - re-wrapping with the same password produces a completely different envelope,
332 - preventing precomputation attacks and ensuring password changes are
333 - cryptographically distinct.
334 -
335 - ### Why no token refresh
336 -
337 - The server currently issues short-lived JWTs without refresh tokens. The client
338 - detects expiry and returns `TokenExpired`, leaving re-authentication to the
339 - caller. This keeps the SDK stateless with respect to refresh logic and avoids
340 - storing long-lived credentials in memory.
341 -
342 - ## Security properties
343 -
344 - - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync
345 - entries, blobs). It never receives the plaintext master key or user data.
346 - - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to
347 - clear the master key from memory when the guard is dropped.
348 - - **No key material in logs**: Tracing statements log events (key stored, key
349 - loaded) but never log key bytes or ciphertext.
350 - - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
351 - (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
352 - - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with
353 - `(table, row_id)` as AEAD associated data, and blob ciphertext with the content
354 - hash; downloaded blobs are additionally re-hashed and rejected if they do not
355 - match their requested address. So the untrusted server cannot relocate or
356 - substitute a valid ciphertext without the open (or the hash check) failing
357 - closed. See "Wire version tag and AAD binding".
358 - - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
359 - and travels with its Argon2 cost parameters, so the work factor can be raised
360 - later without breaking existing envelopes.
361 -
362 - ---
363 -
364 - # The `SyncStore` higher-level helper
365 -
366 - > Status: shipped in 0.6 behind the default-on `store` feature (`src/store/`).
367 - > Everything above describes the base SDK (transport + crypto + HLC + conflict
368 - > primitives); this section describes the layer *above* those primitives that
369 - > absorbs the SQLite plumbing the three first-party apps otherwise hand-write.
370 - > The design was reverse-engineered from the GoingsOn, audiofiles, and Balanced
371 - > Breakfast sync services; a consumer that only needs the transport/crypto SDK
372 - > (e.g. mnw-cli) sets `default-features = false` and skips it.
373 -
374 - ## Motivation
375 -
376 - Without this layer the SDK stops at "encrypt/decrypt a `Vec<ChangeEntry>` and
377 - move it over the wire," and each consuming app writes ~700–1,000 lines of
378 - *identical* engine on top of it: a `sync_changelog` + `sync_state` schema, per-table triggers, a
379 - drain-loop push, a cursor-loop pull, an FK-ordered apply with JSON→SQL binding,
380 - HLC stamping, an initial snapshot, retention/cleanup, and a scheduler
381 - (tick + SSE + backoff + status events). Measured across the three apps this is
382 - the overwhelming majority of their sync code, and it is mechanical.
383 -
384 - The single largest boilerplate source is **column-list triplication**: every
385 - syncable table names its synced columns three or four times, in the trigger DDL
386 - (`json_object(...)`), in the initial-snapshot projection, and in the apply-side
387 - whitelist, kept in agreement only by hand and a round-trip drift test. When they
388 - drift, a column silently arrives NULL (this is a real shipped bug class). One
389 - declared column list should generate all of them.
390 -
391 - The goal of `SyncStore` is: an app **declares its tables and policy once**, and
392 - the engine owns everything mechanical. This is the "simple" pillar of the product
393 - (see the private roadmap), the differentiators (private, secure, economical) are
394 - already in the 0.6 primitives; this layer is what makes them easy to adopt.
395 -
396 - ## What is generic (absorbed) vs. what is policy (declared)
397 -
398 - The three apps agree, byte-for-byte in spirit, on all of this, it moves into the
399 - engine:
400 -
401 - - `sync_state` KV + the transactional `applying_remote` echo-suppression flag.
402 - - `sync_changelog` schema, the `pushed` flag, cursor bookkeeping.
403 - - Push: HLC-stamp unpushed rows, batch ≤500, map to `ChangeEntry`, `client.push`,
404 - mark pushed, skip-and-retain corrupt/unknown-op rows, drain until empty.
405 - - Pull: paginated `client.pull_rich` loop, apply-then-persist-cursor (the SDK's
406 - documented crash-safe contract), auto-decrypt.
407 - - Apply: partition into upserts/deletes, iterate the declared table order
408 - (parents-first upserts, children-first deletes), JSON→SQL type coercion,
409 - per-row constraint-violation *skip-and-log* (a poison row never wedges the
410 - cursor), HLC gating against committed clocks, `resolve_lww` for true conflicts.
411 - - Initial snapshot, `cleanup_changelog` (prune pushed > N days), changelog
412 - retention cap, pending-change count.
413 - - The scheduler skeleton: 60 s tick raced against the SSE `changed` stream, the
414 - auth/key/enabled gate chain, a shared sync mutex, exponential backoff, and the
415 - 402 → subscription-required special case.
416 - - Device registration and the `perform_sync` orchestration order.
417 -
418 - Everything an app genuinely varies is small and enumerable. It becomes a
419 - declarative `SyncSchema` plus a couple of trait hooks:
420 -
421 - | Policy | Seen in | Expressed as |
422 - |---|---|---|
423 - | Which tables, their columns, FK order | all | `SyncSchema { tables: [SyncTable…] }`, declaration order = FK order |
424 - | Primary key shape (single / composite) | BB tag tables, AF `tags` | `PrimaryKey::{Single, Composite}` |
425 - | Partial-column, UPDATE-only sync | BB `feed_items` (is_read/is_starred) | `SyncMode::PartialUpdate { set }` |
426 - | Ignore remote deletes | BB `feed_items` | `DeleteMode::Ignore` |
427 - | Soft-delete (tombstone) instead of hard | AF `samples.deleted_at` (CASCADE safety) | `DeleteMode::Tombstone { column }` |
428 - | Preserve local-only columns on upsert | GO `email_accounts` credentials | `preserve_local: &[col]` |
429 - | Column defaults on first insert | GO `password=''` for NOT NULL | `insert_defaults: &[(col, val)]` |
430 - | Row references an *unsynced* table (relax FK) | GO `tasks.source_email_id` | `references_unsynced: true` |
431 - | Row-id privacy hashing on the wire | AF content tables (`hash_row_id(salt,…)`) | `RowIdScheme::Hashed` |
432 - | Exclude some rows/keys from sync | AF `user_config` `sync_*`, `loose_files` | `exclude_where: Option<&str>` (a SQL predicate, see AF example; must be SQL because it also compiles into the generated trigger's WHEN clause, not just the apply guard) |
433 - | Conflict model | GO/AF (HLC), BB (server-order) | `ConflictStrategy::{HybridLogicalClock, ServerOrder}` |
434 - | Blob-bearing tables | AF only | `BlobPolicy` trait (below) |
435 - | Retention cap, cleanup window, batch, interval | all (different numbers) | `SyncConfig` |
436 - | Device-name source, status sink, error mapping | all (framework-specific) | `SyncObserver` trait |
437 -
438 - User scoping (a multi-tenant `WHERE user_id = ?` filter) is deliberately **not**
439 - an engine-level concern. The GoingsOn worked example (below) confirmed the single-
440 - user desktop apps never scope the *changelog* by user, the only place a user
441 - filter appears is inside GO's blob queries, which is policy-local to its
442 - `BlobPolicy`. A changelog-level user filter belongs to the future multi-tenant
443 - work (a third-party-phase prerequisite), not this layer.
444 -
445 - ## The declarative schema
446 -
447 - ```rust
448 - // Abridged from the shipped `store::schema` types. The real fields are private
449 - // (`pub(crate)`) and constructed through the `SyncSchema`/`SyncTable` builders
450 - // (`.table(...)`, `.pk(&[...])`, `.conflict(...)`); shown here public for shape.
451 - pub struct SyncSchema {
452 - /// Declaration order IS the FK order: parents first. The engine upserts in
453 - /// this order and deletes in the reverse. No hand-maintained reversed list.
454 - pub tables: Vec<SyncTable>,
455 - /// Schema-wide conflict model (HLC or server-order).
456 - pub conflict: ConflictStrategy,
457 - }
458 -
459 - pub struct SyncTable {
460 - pub name: &'static str,
461 - /// The one column whitelist. Drives generated triggers, the snapshot
462 - /// projection, and apply-side binding — killing the triplication.
463 - pub columns: &'static [&'static str],
464 - /// PK column(s): one element = single key, more = composite (concatenated
465 - /// with ':' for the wire row-id). A slice, not a Single/Composite enum —
466 - /// simpler and matches the `.pk(&[...])` builder. Defaults to `["id"]`.
467 - pub pk: &'static [&'static str],
468 - pub mode: SyncMode,
469 - pub deletes: DeleteMode,
470 - pub row_id: RowIdScheme,
471 - /// Columns to leave untouched on upsert (secrets that stay per-device).
472 - pub preserve_local: &'static [&'static str],
473 - /// Values to inject only on first INSERT (satisfy NOT NULL on preserved cols).
474 - pub insert_defaults: &'static [(&'static str, &'static str)],
475 - /// This table carries FKs into a table that is not synced; relax FK
476 - /// enforcement while applying it.
477 - pub references_unsynced: bool,
478 - /// Optional SQL predicate that excludes rows from sync (AF config-key
479 - /// denylist). A SQL string, not a closure, because it also compiles into
480 - /// the generated trigger's WHEN clause — not just the apply-side guard.
481 - pub exclude_where: Option<&'static str>,
482 - }
483 -
484 - pub enum SyncMode {
485 - /// Upsert every whitelisted column (INSERT … ON CONFLICT DO UPDATE).
486 - Full,
487 - /// UPDATE only `set` columns where PK matches; never insert, never replace.
488 - /// BB feed_items: sync just is_read/is_starred, never the article body.
489 - PartialUpdate { set: &'static [&'static str] },
490 - }
491 -
492 - pub enum DeleteMode {
493 - Hard,
494 - /// Remote deletes are dropped on the floor (BB feed_items — content
495 - /// re-fetches from source).
496 - Ignore,
497 - /// Apply as `UPDATE … SET <column> = now()` instead of DELETE, so local
498 - /// ON DELETE CASCADE can't wipe organization (AF samples).
499 - Tombstone { column: &'static str },
500 - }
Lines truncated
@@ -1,307 +1,0 @@
1 - # SyncKit Client SDK: Integration Patterns
2 -
3 - How GoingsOn, Balanced Breakfast, and audiofiles consume the SyncKit client SDK. Use this guide to add sync to a new app.
4 -
5 - ## Common Architecture
6 -
7 - All three apps follow the same pattern:
8 -
9 - ```
10 - App (SQLite)
11 - ├── Syncable tables with INSERT/UPDATE/DELETE triggers
12 - ├── sync_changelog table (captures local mutations)
13 - ├── sync_state table (key-value: device_id, cursor, flags)
14 - ├── Sync service module (push/pull logic)
15 - └── Scheduler (background timer, exponential backoff)
16 - ```
17 -
18 - ### Trigger-Based Changelog
19 -
20 - Each syncable table has SQL triggers that write to `sync_changelog`:
21 - - Triggers fire on INSERT, UPDATE, DELETE
22 - - When `applying_remote = '1'` in sync_state, triggers are suppressed (prevents echo-back)
23 - - Only whitelisted columns are included in the JSON payload
24 - - Row IDs are UUIDs (GO, BB) or content hashes (AF)
25 -
26 - ### Push/Pull Cycle
27 -
28 - 1. **Push**: Read unpushed changelog entries (batch 500) → encrypt via SDK → send to MNW → mark as pushed
29 - 2. **Pull**: Fetch remote changes from MNW → decrypt via SDK → apply locally with triggers suppressed → update cursor
30 - 3. **Cleanup**: Delete pushed entries older than 7 days
31 -
32 - ### FK-Safe Ordering
33 -
34 - Both push and pull use foreign-key-safe ordering:
35 - - **Upserts**: Parents first (projects before tasks, VFS before nodes)
36 - - **Deletes**: Children first (tasks before projects, nodes before VFS)
37 -
38 - ---
39 -
40 - ## Per-App Integration
41 -
42 - ### GoingsOn (13 tables)
43 -
44 - **Tables synced:**
45 - ```
46 - projects → milestones, tasks, events
47 - tasks → annotations, subtasks
48 - contacts → contact_emails, contact_phones, contact_social_handles, contact_custom_fields
49 - email_accounts (16 config columns only — credentials excluded)
50 - ```
51 -
52 - **Special handling:**
53 - - Email account passwords and OAuth tokens are excluded from the column whitelist, never leave the device
54 - - Tasks with `source_email_id` referencing unsynced emails: FK enforcement relaxed during remote apply
55 -
56 - **Location:** `src-tauri/src/sync_service.rs` (1814 lines, 43 tests)
57 -
58 - ### Balanced Breakfast (5 tables)
59 -
60 - **Tables synced:**
61 - ```
62 - feeds → feed_tags, query_feeds
63 - user_config
64 - feed_items (partial: is_read + is_starred only)
65 - ```
66 -
67 - **Special handling:**
68 - - `feed_items`: Uses UPDATE (not INSERT OR REPLACE), only syncs user read/star state, never full content
69 - - `feed_items` deletes are ignored, content re-fetches from source feeds
70 - - Changelog retention cap: MAX_CHANGELOG_ENTRIES = 10,000 (prevents unbounded growth)
71 -
72 - **Location:** `src-tauri/src/sync_service.rs` (1062 lines, 30 tests)
73 -
74 - ### audiofiles (9 tables)
75 -
76 - **Tables synced:**
77 - ```
78 - vfs → vfs_nodes
79 - samples → audio_analysis, tags, collection_members
80 - collections → collection_members
81 - smart_folders
82 - user_config
83 - ```
84 -
85 - **Special handling (blob sync):**
86 - 1. VFS entries have a `sync_files` flag controlling blob sync
87 - 2. Samples marked `cloud_only = 1` if blob doesn't exist locally
88 - 3. After push/pull, upload pending blobs (local files in sync-enabled VFS)
89 - 4. Download missing blobs (cloud_only samples where file is needed)
90 -
91 - **Location:** `crates/audiofiles-sync/src/service.rs` (1438 lines, 48 tests)
92 -
93 - ---
94 -
95 - ## SDK Public API
96 -
97 - ### Authentication
98 -
99 - ```rust
100 - use synckit_client::SyncKitClient;
101 -
102 - // Create client
103 - let client = SyncKitClient::new(SyncKitConfig {
104 - server_url: "https://makenot.work".into(),
105 - api_key: "your-app-api-key".into(),
106 - });
107 -
108 - // OAuth2 PKCE flow
109 - let auth_url = client.build_authorize_url(port, state, code_challenge);
110 - // ... user completes browser flow ...
111 - let (user_id, app_id) = client.authenticate_with_code(code, code_verifier, port, sdk_key).await?;
112 -
113 - // Session management
114 - client.is_token_expired() -> bool
115 - client.session_info() -> Option<SessionInfo>
116 - client.clear_session() -> Result<()>
117 - ```
118 -
119 - ### Encryption Setup
120 -
121 - ```rust
122 - // First device: generate new master key
123 - client.setup_encryption_new(password).await?;
124 -
125 - // Subsequent devices: decrypt existing key from server
126 - client.setup_encryption_existing(password).await?;
127 -
128 - // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager)
129 - client.try_load_key_from_keychain().await? -> bool
130 -
131 - // Check if server has encrypted key (determines new vs existing flow)
132 - client.has_server_key().await? -> bool
133 - client.has_master_key() -> bool
134 - ```
135 -
136 - ### Device Management
137 -
138 - ```rust
139 - client.register_device(hostname, platform).await? -> Device
140 - client.list_devices().await? -> Vec<Device>
141 - ```
142 -
143 - ### Push/Pull
144 -
145 - ```rust
146 - use synckit_client::{ChangeEntry, ChangeOp};
147 -
148 - // Push encrypted changes to server
149 - client.push(device_id, changes: Vec<ChangeEntry>).await?;
150 -
151 - // Pull decrypted changes from server
152 - let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?;
153 - ```
154 -
155 - `ChangeEntry` fields:
156 - - `table`: Table name (string)
157 - - `op`: `ChangeOp::Insert`, `Update`, or `Delete`
158 - - `row_id`: Primary key (string)
159 - - `timestamp`: When the change was made
160 - - `data`: `Option<serde_json::Value>` (None for deletes)
161 -
162 - ### Blob Operations (audiofiles only)
163 -
164 - ```rust
165 - // Upload a file-backed blob. Streams it a part at a time, so peak memory is
166 - // one part rather than the whole file, and dedups server-side before reading.
167 - if client.blob_upload_streaming(hash, &path).await? == BlobUploadOutcome::Uploaded {
168 - client.blob_confirm(hash, size).await?;
169 - }
170 -
171 - // Download
172 - let url = client.blob_download_url(hash).await?;
173 - let data = client.blob_download(url).await?;
174 - ```
175 -
176 - For bytes already in memory (no file to stream), the one-shot pair still exists:
177 - `blob_upload_url(hash, plaintext_size)` then `blob_upload(hash, url, data)`, then
178 - `blob_confirm`. It buffers the whole ciphertext and the server refuses it above
179 - its one-shot ceiling, so prefer the streaming call whenever there is a path.
180 -
181 - ---
182 -
183 - ## First-Run Sequence
184 -
185 - ### First Device
186 -
187 - 1. User clicks "Connect to Sync"
188 - 2. App builds auth URL with PKCE challenge → opens browser
189 - 3. User logs in on MNW, approves scopes
190 - 4. Browser redirects to `localhost:PORT` with authorization code
191 - 5. App exchanges code for JWT via SDK
192 - 6. App detects no server key → shows "Set Encryption Password" dialog
193 - 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key
194 - 8. App registers device → stores device_id in sync_state
195 - 9. App creates initial snapshot (INSERT all existing rows to changelog)
196 - 10. First sync cycle runs
197 -
198 - ### Same Device, Later Run
199 -
200 - 1. App calls `try_load_key_from_keychain()` → restores session + key
201 - 2. If success, ready to sync
202 - 3. If keychain empty, re-run OAuth flow
203 -
204 - ### Additional Device
205 -
206 - 1. OAuth flow as above
207 - 2. App detects server has key → shows "Enter Encryption Password" dialog
208 - 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain
209 - 4. Register new device, create initial snapshot, sync
210 -
211 - ---
212 -
213 - ## Adding Sync to a New App
214 -
215 - ### 1. Database Schema
216 -
217 - Add these tables:
218 -
219 - ```sql
220 - CREATE TABLE sync_changelog (
221 - id INTEGER PRIMARY KEY AUTOINCREMENT,
222 - table_name TEXT NOT NULL,
223 - op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
224 - row_id TEXT NOT NULL,
225 - timestamp INTEGER NOT NULL,
226 - data TEXT, -- JSON, NULL for DELETE
227 - pushed INTEGER DEFAULT 0
228 - );
229 -
230 - CREATE TABLE sync_state (
231 - key TEXT PRIMARY KEY,
232 - value TEXT NOT NULL
233 - );
234 -
235 - -- Seed defaults
236 - INSERT INTO sync_state VALUES ('pull_cursor', '0');
237 - INSERT INTO sync_state VALUES ('applying_remote', '0');
238 - INSERT INTO sync_state VALUES ('initial_snapshot_done', '0');
239 - INSERT INTO sync_state VALUES ('auto_sync_enabled', '1');
240 - INSERT INTO sync_state VALUES ('sync_interval_minutes', '15');
241 - ```
242 -
243 - ### 2. Triggers
244 -
245 - For each syncable table, add three triggers:
246 -
247 - ```sql
248 - CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table
249 - BEGIN
250 - SELECT CASE
251 - WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
252 - THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data)
253 - VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'),
254 - json_object('col1', NEW.col1, 'col2', NEW.col2)))
255 - END;
256 - END;
257 - -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL)
258 - ```
259 -
260 - ### 3. Core Module
261 -
262 - Implement these functions:
263 -
264 - | Function | Purpose |
265 - |----------|---------|
266 - | `get_sync_state(key)` | Read from sync_state |
267 - | `set_sync_state(key, value)` | Write to sync_state |
268 - | `ensure_device_registered(client)` | Cache device_id after first registration |
269 - | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog |
270 - | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed |
271 - | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor |
272 - | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order |
273 - | `apply_delete(table, row_id)` | DELETE with FK order |
274 - | `cleanup_changelog()` | Prune pushed entries older than 7 days |
275 -
276 - ### 4. Scheduler
277 -
278 - - Check every 60 seconds if sync is due
279 - - On first run: `create_initial_snapshot()` if needed
280 - - Exponential backoff on failure (2^N minutes, capped at 15)
281 - - Emit status events for UI updates
282 -
283 - ### 5. Commands / UI
284 -
285 - Expose these to the frontend:
286 - - `sync_status()`: configured, authenticated, encryption, device, pending changes
287 - - `sync_start_auth()`: returns auth URL + state + verifier
288 - - `sync_complete_auth(code, state, verifier)`: exchanges for JWT
289 - - `sync_setup_encryption_new/existing(password)`: calls SDK method
290 - - `sync_now()`: triggers immediate cycle
291 - - `sync_disconnect()`: clears credentials and session
292 -
293 - ---
294 -
295 - ## Key Files
296 -
297 - | What | Where |
298 - |------|-------|
299 - | SDK source | `MNW/shared/synckit-client/src/` |
300 - | SDK auth | `MNW/shared/synckit-client/src/client/auth.rs` |
301 - | SDK push/pull | `MNW/shared/synckit-client/src/client/sync.rs` |
302 - | SDK encryption | `MNW/shared/synckit-client/src/crypto.rs` |
303 - | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` |
304 - | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` |
305 - | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` |
306 - | Server endpoints | `MNW/server/src/routes/synckit.rs` |
307 - | Server DB | `MNW/server/src/db/synckit.rs` |
@@ -1,454 +1,0 @@
1 - //! Authentication and session lifecycle.
2 - //!
3 - //! Password and OAuth login, session restore from a stored token, and the
4 - //! local JWT-expiry check the API methods use to fail fast before a request.
5 -
6 - use bytes::Bytes;
7 - use std::sync::Arc;
8 - use tracing::instrument;
9 - #[cfg(test)]
10 - use uuid::Uuid;
11 -
12 - use crate::{
13 - error::Result,
14 - ids::{AppId, UserId},
15 - types::{AuthRequest, AuthResponse, OAuthTokenResponse},
16 - };
17 -
18 - use super::helpers::{Idempotency, check_response, jwt_exp};
19 - use super::{SecretToken, Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS};
20 -
21 - impl SyncKitClient {
22 - /// Authenticate with the MNW server. Returns (user_id, app_id).
23 - ///
24 - /// `key` is the developer-defined SDK key this session's storage counts
25 - /// against. The dev's backend chooses it, typically one key per
26 - /// workspace/org/end-user.
27 - ///
28 - /// Stores the session internally. If called concurrently from multiple
29 - /// threads, the last write wins, earlier sessions are silently overwritten.
30 - ///
31 - /// # Errors
32 - ///
33 - /// Returns `Server { status: 401, .. }` for wrong credentials.
34 - #[instrument(skip(self, email, password, key))]
35 - pub async fn authenticate(
36 - &self,
37 - email: &str,
38 - password: &str,
39 - key: &str,
40 - ) -> Result<(UserId, AppId)> {
41 - let body = Bytes::from(serde_json::to_vec(&AuthRequest {
42 - email,
43 - password,
44 - api_key: &self.config.api_key,
45 - key,
46 - })?);
47 -
48 - let resp = self
49 - .retry_request(
50 - Idempotency::IdempotentWrite {
51 - on: "credentials; re-auth reissues an equivalent token",
52 - },
53 - || {
54 - let req = self
55 - .http
56 - .post(&self.endpoints.auth)
57 - .header("content-type", "application/json")
58 - .body(body.clone());
59 - async move { check_response(req.send().await?).await }
60 - },
61 - )
62 - .await?;
63 - let auth: AuthResponse = crate::client::helpers::read_json_capped(
64 - resp,
65 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
66 - )
67 - .await?;
68 -
69 - let user_id = auth.user_id;
70 - let app_id = auth.app_id;
71 - let token_exp = jwt_exp(&auth.token);
72 -
73 - *self.session.write() = Some(Session {
74 - token: Arc::new(SecretToken::new(auth.token)),
75 - token_exp,
76 - user_id,
77 - app_id,
78 - });
79 -
80 - tracing::info!("Authenticated as user {user_id} for app {app_id}");
81 - Ok((user_id, app_id))
82 - }
83 -
84 - /// Restore a session from previously stored credentials (e.g. OS keychain).
85 - ///
86 - /// Sets the internal session state without making any HTTP calls.
87 - /// Used on app startup to restore from stored credentials without re-authenticating.
88 - pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) {
89 - let token_exp = jwt_exp(token);
90 - *self.session.write() = Some(Session {
91 - token: Arc::new(SecretToken::new(token.to_string())),
92 - token_exp,
93 - user_id,
94 - app_id,
95 - });
96 - tracing::info!("Session restored for user {user_id}, app {app_id}");
97 - }
98 -
99 - /// Clear the in-memory session and master key.
100 - ///
101 - /// After calling this, the client will need to re-authenticate and set up
102 - /// encryption again. Does not affect OS keychain storage, call
103 - /// `keystore::delete_master_key` separately if needed.
104 - pub fn clear_session(&self) {
105 - *self.session.write() = None;
106 - *self.master_key.write() = None;
107 - tracing::info!("Session and master key cleared");
108 - }
109 -
110 - /// Check whether the current session token has expired (or will expire
111 - /// within a 30-second buffer). Returns `true` if there is no session or
112 - /// if the token's `exp` claim is in the past. Returns `false` if the
113 - /// token cannot be decoded (assumes not expired, the server will reject
114 - /// it with a 401 if it actually is).
115 - pub fn is_token_expired(&self) -> bool {
116 - let guard = self.session.read();
117 - let Some(session) = guard.as_ref() else {
118 - return true;
119 - };
120 - match session.token_exp {
121 - Some(exp) => {
122 - let now = chrono::Utc::now().timestamp();
123 - now >= exp - TOKEN_EXPIRY_BUFFER_SECS
124 - }
125 - None => false,
126 - }
127 - }
128 -
129 - // ── OAuth ──
130 -
131 - /// Build the authorization URL for the OAuth2 PKCE flow.
132 - ///
133 - /// The caller is responsible for generating the PKCE verifier/challenge,
134 - /// starting the localhost callback server, and opening the browser.
135 - ///
136 - /// Sends `scope=sync` explicitly: this is the SyncKit pairing flow and the
137 - /// full sync-API token is what it needs. The server treats an omitted scope
138 - /// as least-privilege userinfo (not sync), so the scope must be sent for the
139 - /// token exchange to yield a sync-capable token.
140 - pub fn build_authorize_url(
141 - &self,
142 - redirect_port: u16,
143 - state: &str,
144 - code_challenge: &str,
145 - ) -> String {
146 - format!(
147 - "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync",
148 - self.config.server_url,
149 - urlencoding::encode(&self.config.api_key),
150 - urlencoding::encode(&format!("http://127.0.0.1:{redirect_port}/")),
151 - urlencoding::encode(state),
152 - urlencoding::encode(code_challenge),
153 - )
154 - }
155 -
156 - /// Exchange an OAuth2 authorization code for a SyncKit JWT.
157 - ///
158 - /// `key` is the developer-defined SDK key for billing attribution, see
159 - /// [`authenticate`](Self::authenticate).
160 - ///
161 - /// Call this after receiving the code from the localhost callback server.
162 - /// On success, stores the session internally (same as `authenticate()`).
163 - #[instrument(skip(self, code, code_verifier, key))]
164 - pub async fn authenticate_with_code(
165 - &self,
166 - code: &str,
167 - code_verifier: &str,
168 - redirect_port: u16,
169 - key: &str,
170 - ) -> Result<(UserId, AppId)> {
171 - let redirect_uri = format!("http://127.0.0.1:{redirect_port}/");
172 -
173 - let form_params = [
174 - ("grant_type", "authorization_code"),
175 - ("code", code),
176 - ("redirect_uri", &redirect_uri),
177 - ("code_verifier", code_verifier),
178 - ("client_id", &self.config.api_key),
179 - ("key", key),
180 - ];
181 -
182 - // OAuth authorization codes are single-use. Retrying after a
183 - // network error would send an already-consumed code, producing a
184 - // permanent 400. Send exactly once and let the caller restart the
185 - // OAuth flow on failure.
186 - let resp = check_response(
187 - self.http
188 - .post(&self.endpoints.oauth_token)
189 - .form(&form_params)
190 - .send()
191 - .await?,
192 - )
193 - .await?;
194 - let token_resp: OAuthTokenResponse = crate::client::helpers::read_json_capped(
195 - resp,
196 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
197 - )
198 - .await?;
199 -
200 - let user_id = token_resp.user_id;
201 - let app_id = token_resp.app_id;
202 - let token_exp = jwt_exp(&token_resp.access_token);
203 -
204 - *self.session.write() = Some(Session {
205 - token: Arc::new(SecretToken::new(token_resp.access_token)),
206 - token_exp,
207 - user_id,
208 - app_id,
209 - });
210 -
211 - tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
212 - Ok((user_id, app_id))
213 - }
214 - }
215 -
216 - #[cfg(test)]
217 - mod tests {
218 - use super::*;
219 - use base64::Engine;
220 - use chrono::Utc;
221 -
222 - use crate::error::SyncKitError;
223 -
224 - fn test_config() -> super::super::SyncKitConfig {
225 - super::super::SyncKitConfig {
226 - server_url: "https://example.com".to_string(),
227 - api_key: "test-api-key-123".to_string(),
228 - }
229 - }
230 -
231 - fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
232 - (
233 - crate::ids::AppId::new(
234 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
235 - ),
236 - crate::ids::UserId::new(
237 - Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
238 - ),
239 - )
240 - }
241 -
242 - fn fake_jwt(exp: i64) -> String {
243 - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
244 - .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
245 - let payload_json = serde_json::json!({
246 - "sub": "550e8400-e29b-41d4-a716-446655440000",
247 - "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
248 - "exp": exp,
249 - "iat": exp - 3600,
250 - });
251 - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
252 - .encode(payload_json.to_string().as_bytes());
253 - let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
254 - format!("{header}.{payload}.{signature}")
255 - }
256 -
257 - // ── restore_session ──
258 -
259 - #[test]
260 - fn restore_session_makes_client_authenticated() {
261 - let client = SyncKitClient::new(test_config());
262 - let (app_id, user_id) = test_ids();
263 -
264 - client.restore_session("fake-token", user_id, app_id);
265 -
266 - let info = client.session_info().expect("session should exist");
267 - assert_eq!(info.token.as_str(), "fake-token");
268 - assert_eq!(info.user_id, user_id);
269 - assert_eq!(info.app_id, app_id);
270 - }
271 -
272 - #[test]
273 - fn restore_session_overwrites_previous_session() {
274 - let client = SyncKitClient::new(test_config());
275 - let (app_id, user_id) = test_ids();
276 -
277 - client.restore_session("first-token", user_id, app_id);
278 - client.restore_session("second-token", user_id, app_id);
279 -
280 - let info = client.session_info().unwrap();
281 - assert_eq!(info.token.as_str(), "second-token");
282 - }
283 -
284 - // ── build_authorize_url ──
285 -
286 - #[test]
287 - fn build_authorize_url_includes_all_params() {
288 - let client = SyncKitClient::new(test_config());
289 - let url = client.build_authorize_url(8080, "random-state", "challenge123");
290 -
291 - assert!(url.starts_with("https://example.com/oauth/authorize?"));
292 - assert!(url.contains("response_type=code"));
293 - assert!(url.contains("client_id=test-api-key-123"));
294 - assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F"));
295 - assert!(url.contains("state=random-state"));
296 - assert!(url.contains("code_challenge=challenge123"));
297 - assert!(url.contains("code_challenge_method=S256"));
298 - // The pairing flow must request the sync scope explicitly, the server
299 - // treats an omitted scope as least-privilege userinfo, not sync.
300 - assert!(url.contains("scope=sync"));
301 - }
302 -
303 - #[test]
304 - fn build_authorize_url_encodes_special_chars() {
305 - let config = super::super::SyncKitConfig {
306 - server_url: "https://example.com".to_string(),
307 - api_key: "key with spaces&special=chars".to_string(),
308 - };
309 - let client = SyncKitClient::new(config);
310 - let url = client.build_authorize_url(9090, "state/with/slashes", "ch+all=enge");
311 -
312 - assert!(url.contains("key%20with%20spaces%26special%3Dchars"));
313 - assert!(url.contains("state%2Fwith%2Fslashes"));
314 - assert!(url.contains("ch%2Ball%3Denge"));
315 - }
316 -
317 - #[test]
318 - fn build_authorize_url_different_ports() {
319 - let client = SyncKitClient::new(test_config());
320 -
321 - let url_low = client.build_authorize_url(1234, "s", "c");
322 - assert!(url_low.contains("127.0.0.1%3A1234"));
323 -
324 - let url_high = client.build_authorize_url(65535, "s", "c");
325 - assert!(url_high.contains("127.0.0.1%3A65535"));
326 - }
327 -
328 - // ── is_token_expired ──
329 -
330 - #[test]
331 - fn is_token_expired_true_without_session() {
332 - let client = SyncKitClient::new(test_config());
333 - assert!(client.is_token_expired());
334 - }
335 -
336 - #[test]
337 - fn is_token_expired_true_with_expired_token() {
338 - let client = SyncKitClient::new(test_config());
339 - let (app_id, user_id) = test_ids();
340 - let token = fake_jwt(Utc::now().timestamp() - 3600);
341 - client.restore_session(&token, user_id, app_id);
342 - assert!(client.is_token_expired());
343 - }
344 -
345 - #[test]
346 - fn is_token_expired_false_with_fresh_token() {
347 - let client = SyncKitClient::new(test_config());
348 - let (app_id, user_id) = test_ids();
349 - let token = fake_jwt(Utc::now().timestamp() + 3600);
350 - client.restore_session(&token, user_id, app_id);
351 - assert!(!client.is_token_expired());
352 - }
353 -
354 - // ── require_token with expiry ──
355 -
356 - #[test]
357 - fn require_token_returns_token_expired_for_expired_token() {
358 - let client = SyncKitClient::new(test_config());
359 - let (app_id, user_id) = test_ids();
360 - let token = fake_jwt(Utc::now().timestamp() - 3600);
361 - client.restore_session(&token, user_id, app_id);
362 -
363 - let err = client.require_token().unwrap_err();
364 - assert!(matches!(err, SyncKitError::TokenExpired));
365 - }
366 -
367 - #[test]
368 - fn require_token_succeeds_with_fresh_token() {
369 - let client = SyncKitClient::new(test_config());
370 - let (app_id, user_id) = test_ids();
371 - let token = fake_jwt(Utc::now().timestamp() + 3600);
372 - client.restore_session(&token, user_id, app_id);
373 -
374 - assert!(client.require_token().is_ok());
375 - }
376 -
377 - // ── clear_session ──
378 -
379 - #[test]
380 - fn clear_session_clears_master_key() {
381 - let client = SyncKitClient::new(test_config());
382 - let (app_id, user_id) = test_ids();
383 - client.restore_session("token", user_id, app_id);
384 - client.set_master_key_raw([42u8; 32]);
385 -
386 - assert!(client.session_info().is_some());
387 - assert!(client.has_master_key());
388 -
389 - client.clear_session();
390 -
391 - assert!(client.session_info().is_none());
392 - assert!(!client.has_master_key());
393 - }
394 -
395 - // ── OAuth types ──
396 -
397 - #[test]
398 - fn oauth_token_response_deserialization() {
399 - let json = r#"{
400 - "access_token": "jwt-access-token",
401 - "token_type": "Bearer",
402 - "expires_in": 3600,
403 - "user_id": "550e8400-e29b-41d4-a716-446655440000",
404 - "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
405 - }"#;
406 -
407 - let resp: OAuthTokenResponse = serde_json::from_str(json).unwrap();
408 - assert_eq!(resp.access_token, "jwt-access-token");
409 - assert_eq!(resp.token_type, "Bearer");
410 - assert_eq!(resp.expires_in, 3600);
411 - }
412 -
413 - // ── Auth types ──
414 -
415 - #[test]
416 - fn auth_request_serialization() {
417 - let req = AuthRequest {
418 - email: "user@example.com",
419 - password: "secret123",
420 - api_key: "ak_test",
421 - key: "session-key-abc",
422 - };
423 -
424 - let json = serde_json::to_string(&req).unwrap();
425 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
426 - assert_eq!(parsed["email"], "user@example.com");
427 - assert_eq!(parsed["password"], "secret123");
428 - assert_eq!(parsed["api_key"], "ak_test");
429 - assert_eq!(parsed["key"], "session-key-abc");
430 - }
431 -
432 - #[test]
433 - fn auth_response_deserialization() {
434 - let json = r#"{
435 - "token": "jwt.token.here",
436 - "user_id": "550e8400-e29b-41d4-a716-446655440000",
437 - "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
438 - }"#;
439 -
440 - let resp: AuthResponse = serde_json::from_str(json).unwrap();
441 - assert_eq!(resp.token, "jwt.token.here");
442 - assert_eq!(
443 - resp.user_id.as_uuid(),
444 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
445 - );
446 - }
447 -
448 - #[test]
449 - fn auth_response_missing_token_fails() {
450 - let json = r#"{"user_id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}"#;
451 - let result = serde_json::from_str::<AuthResponse>(json);
452 - assert!(result.is_err());
453 - }
454 - }
@@ -1,718 +1,0 @@
1 - //! Content-addressed blob upload and download.
2 - //!
3 - //! Blobs are encrypted client-side and stored under the SHA-256 of their
4 - //! plaintext. Uploads go through a presigned URL after a size/quota check;
5 - //! downloads are decrypted and re-hashed on arrival and discarded on any
6 - //! mismatch, so a server that swaps or rolls back a blob cannot slip it past
7 - //! the client.
8 -
9 - use std::path::Path;
10 -
11 - use base64::Engine;
12 - use bytes::{Buf, Bytes, BytesMut};
13 - use sha2::{Digest, Sha256};
14 - use tokio::io::AsyncReadExt;
15 - use tokio_stream::StreamExt;
16 - use tracing::instrument;
17 -
18 - use crate::{
19 - crypto,
20 - error::{Result, SyncKitError},
21 - types::{
22 - BlobConfirmRequest, BlobDownloadUrlRequest, BlobDownloadUrlResponse,
23 - BlobMultipartAbortRequest, BlobMultipartCompleteRequest, BlobMultipartCompletedPart,
24 - BlobMultipartPartsRequest, BlobMultipartPartsResponse, BlobMultipartStartRequest,
25 - BlobMultipartStartResponse, BlobUploadUrlRequest, BlobUploadUrlResponse,
26 - },
27 - };
28 -
29 - use super::SyncKitClient;
30 - use super::helpers::{Idempotency, check_response};
31 -
32 - /// Upper bound on a single decrypted blob held in memory, guarding against a
33 - /// hostile server returning an absurdly large body that would OOM the client.
34 - /// Generous (4 GiB) so legitimate large media still flow.
35 - const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024;
36 -
37 - /// What [`SyncKitClient::blob_upload_streaming`] did.
38 - ///
39 - /// The caller needs to tell the two apart: only an upload that sent bytes has
40 - /// anything to confirm.
41 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 - pub enum BlobUploadOutcome {
43 - /// The parts were uploaded and assembled. Call [`SyncKitClient::blob_confirm`]
44 - /// next to record the blob server-side.
45 - Uploaded,
46 - /// The server already held this content address, so no bytes were sent and
47 - /// there is nothing to confirm. Server-asserted, not verified, see
48 - /// [`BlobUploadUrlResponse::already_exists`] for what a wrong `true` costs.
49 - AlreadyPresent,
50 - }
51 -
52 - impl SyncKitClient {
53 - /// Request a presigned upload URL for a blob.
54 - /// Returns (upload_url, already_exists). If `already_exists` is true,
55 - /// the blob is already on the server and no upload is needed.
56 - ///
57 - /// `size_bytes` is the **plaintext** length, the size the caller sees on
58 - /// disk. What [`Self::blob_upload`] actually PUTs is the sealed ciphertext,
59 - /// which is longer by the v3 framing, and the server signs the declared
60 - /// size into the presigned URL as `Content-Length` (a SignedHeader), so
61 - /// declaring the plaintext length makes every PUT fail the signature. This
62 - /// method converts, keeping the wire-format arithmetic inside the SDK
63 - /// instead of asking three consumer apps to know it.
64 - ///
65 - /// `already_exists` is the server's claim, not a verified fact. See
66 - /// [`BlobUploadUrlResponse::already_exists`] for what a false `true` costs
67 - /// (a withheld blob, never a substituted one).
68 - #[instrument(skip(self))]
69 - pub async fn blob_upload_url(
70 - &self,
71 - hash: &str,
72 - size_bytes: i64,
73 - ) -> Result<BlobUploadUrlResponse> {
74 - let plaintext_len = usize::try_from(size_bytes)
75 - .map_err(|_| SyncKitError::InvalidArgument("size_bytes must be non-negative".into()))?;
76 - let token = self.require_token()?;
77 -
78 - let body = Bytes::from(serde_json::to_vec(&BlobUploadUrlRequest {
79 - hash: hash.to_string(),
80 - size_bytes: crypto::blob_encrypted_len(plaintext_len) as i64,
81 - })?);
82 -
83 - self.retry_request_json(Idempotency::ReadOnly, || {
84 - let req = self
85 - .http
86 - .post(&self.endpoints.blobs_upload)
87 - .bearer_auth(&token)
88 - .header("content-type", "application/json")
89 - .body(body.clone());
90 - async move { check_response(req.send().await?).await }
91 - })
92 - .await
93 - }
94 -
95 - /// Upload blob data directly to S3 via a presigned PUT URL.
96 - ///
97 - /// Encrypts the data with the master key before uploading, binding the
98 - /// content `hash` as AEAD associated data so the ciphertext is tied to its
99 - /// content address. The plaintext is never sent to the server, preserving
100 - /// the E2E encryption guarantee.
101 - ///
102 - /// This is the in-memory path: it buffers the whole ciphertext, so the input
103 - /// is capped at [`MAX_BLOB_BYTES`] rather than risking an unbounded
104 - /// allocation. For anything file-backed, prefer
105 - /// [`Self::blob_upload_streaming`], which holds one part at a time and is
106 - /// the only path the server accepts above its one-shot PUT ceiling.
107 - #[instrument(skip(self, presigned_url, data))]
108 - pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> {
109 - if data.len() > MAX_BLOB_BYTES {
110 - return Err(SyncKitError::InvalidArgument(format!(
111 - "blob is {} bytes, over the {MAX_BLOB_BYTES}-byte in-memory cap; use blob_upload_streaming for a file this size",
112 - data.len()
113 - )));
114 - }
115 - let master_key = self.require_master_key()?;
116 - // v3 chunked format: each chunk sealed and bound to (hash, index, count),
117 - // so the reader verifies and decrypts one chunk at a time.
118 - let encrypted = Bytes::from(crypto::encrypt_blob_chunked(&data, &master_key, hash)?);
119 - // Release the plaintext before the (potentially long, multi-GB) network
120 - // send so the sustained peak during transfer is ~1x the blob, not ~2x.
121 - // The per-attempt `encrypted.clone()` below is a cheap `Bytes` refcount
122 - // bump, not a copy. Never materializing the full ciphertext at all is
123 - // what `blob_upload_streaming` does, for callers holding a file.
124 - drop(data);
125 -
126 - self.retry_request(
127 - Idempotency::IdempotentWrite {
128 - on: "content hash (S3 key is content-addressed)",
129 - },
130 - || {
131 - let req = self
132 - .http_stream
133 - .put(presigned_url)
134 - .header("content-type", "application/octet-stream")
135 - .body(encrypted.clone());
136 - async move { check_response(req.send().await?).await }
137 - },
138 - )
139 - .await?;
140 -
141 - Ok(())
142 - }
143 -
144 - /// Upload a file-backed blob through the multipart session, never holding
145 - /// more than one part in memory.
146 - ///
147 - /// This is the bounded-memory counterpart to [`Self::blob_upload`], and the
148 - /// only path for blobs above the server's one-shot PUT ceiling. It works at
149 - /// any size (a small blob is a single part), so it is the better default
150 - /// whenever the blob is already a file on disk.
151 - ///
152 - /// The v3 chunk geometry is what makes it possible. A presigned PUT needs a
153 - /// signed `Content-Length` up front, and `blob_encrypted_len` gives the
154 - /// exact ciphertext length from the plaintext length alone, so the server
155 - /// signs every part boundary before a single byte has been read, and this
156 - /// method fills those boundaries by sealing 1 MiB chunks as it streams the
157 - /// file. Peak memory is one part plus one chunk, not the whole blob.
158 - ///
159 - /// `hash` must be the SHA-256 of the file's plaintext, as elsewhere. It is
160 - /// recomputed while streaming and the upload is abandoned on mismatch: the
161 - /// caller hashed the file in an earlier pass, so a file edited in between
162 - /// would otherwise be stored under an address that does not describe it.
163 - ///
164 - /// Returns [`BlobUploadOutcome`]: on `Uploaded` the caller calls
165 - /// [`Self::blob_confirm`] next, exactly as with the one-shot path (this
166 - /// method replaces the transport only); on `AlreadyPresent` the server
167 - /// already held the content, nothing was read or sent, and there is nothing
168 - /// to confirm. The dedup check happens before any file read or sealing, so
169 - /// a re-sync of content the server already has costs one round trip.
170 - #[instrument(skip(self, path), fields(path = %path.display()))]
171 - pub async fn blob_upload_streaming(
172 - &self,
173 - hash: &str,
174 - path: &Path,
175 - ) -> Result<BlobUploadOutcome> {
176 - let master_key = self.require_master_key()?;
177 -
178 - let meta = tokio::fs::metadata(path).await.map_err(|e| {
179 - SyncKitError::Internal(format!("stat blob file {}: {e}", path.display()))
180 - })?;
181 - let plaintext_len = usize::try_from(meta.len())
182 - .map_err(|_| SyncKitError::InvalidArgument("blob file is larger than usize".into()))?;
183 - if plaintext_len > MAX_BLOB_BYTES {
184 - return Err(SyncKitError::InvalidArgument(format!(
185 - "blob is {plaintext_len} bytes, over the {MAX_BLOB_BYTES}-byte client cap"
186 - )));
187 - }
188 - // The session is sized in ciphertext, which is knowable from the
189 - // plaintext length alone, that is the whole trick.
190 - let size_bytes = i64::try_from(crypto::blob_encrypted_len(plaintext_len))
191 - .map_err(|_| SyncKitError::InvalidArgument("blob ciphertext exceeds i64".into()))?;
192 -
193 - let token = self.require_token()?;
194 - let start_body = Bytes::from(serde_json::to_vec(&BlobMultipartStartRequest {
195 - hash: hash.to_string(),
196 - size_bytes,
197 - })?);
198 - let start: BlobMultipartStartResponse = self
199 - .retry_request_json(Idempotency::ReadOnly, || {
200 - let req = self
201 - .http
202 - .post(&self.endpoints.blobs_multipart_start)
203 - .bearer_auth(&token)
204 - .header("content-type", "application/json")
205 - .body(start_body.clone());
206 - async move { check_response(req.send().await?).await }
207 - })
208 - .await?;
209 -
210 - if start.already_exists {
211 - return Ok(BlobUploadOutcome::AlreadyPresent);
212 - }
213 -
214 - // Every failure past this point leaves uploaded parts that S3 bills for
215 - // until the session is aborted, so the abort lives in exactly one place:
216 - // here, around the whole transfer. The server-side orphan reaper is the
217 - // backstop for a client that dies outright.
218 - let transfer = self
219 - .stream_blob_parts(hash, &start, path, plaintext_len, size_bytes, &master_key)
220 - .await;
221 - let parts = match transfer {
222 - Ok(parts) => parts,
223 - Err(e) => {
224 - if let Err(abort_err) = self.blob_multipart_abort(hash, &start.upload_id).await {
225 - tracing::warn!(
226 - error = %abort_err,
227 - "failed to abort multipart blob session; the server reaper will release it"
228 - );
229 - }
230 - return Err(e);
231 - }
232 - };
233 -
234 - let complete_body = Bytes::from(serde_json::to_vec(&BlobMultipartCompleteRequest {
235 - hash: hash.to_string(),
236 - upload_id: start.upload_id.clone(),
237 - parts,
238 - })?);
239 - self.retry_request(
240 - Idempotency::IdempotentWrite {
241 - on: "upload_id (completing twice assembles the same object)",
242 - },
243 - || {
244 - let req = self
245 - .http
246 - .post(&self.endpoints.blobs_multipart_complete)
247 - .bearer_auth(&token)
248 - .header("content-type", "application/json")
249 - .body(complete_body.clone());
250 - async move { check_response(req.send().await?).await }
251 - },
252 - )
253 - .await?;
254 -
255 - Ok(BlobUploadOutcome::Uploaded)
256 - }
257 -
258 - /// Seal the file chunk by chunk, cutting the ciphertext at the server's
259 - /// signed part boundaries and PUTting each part. Returns the completed
260 - /// `(part_number, etag)` pairs.
261 - ///
262 - /// Peak memory is one part plus one chunk: the staging buffer never holds
263 - /// more than a part's worth, because a full part is drained and sent the
264 - /// moment it is complete.
265 - async fn stream_blob_parts(
266 - &self,
267 - hash: &str,
268 - start: &BlobMultipartStartResponse,
269 - path: &Path,
270 - plaintext_len: usize,
271 - size_bytes: i64,
272 - master_key: &[u8; 32],
273 - ) -> Result<Vec<BlobMultipartCompletedPart>> {
274 - let part_size = usize::try_from(start.part_size).map_err(|_| {
275 - SyncKitError::Internal(format!(
276 - "server part_size {} exceeds usize",
277 - start.part_size
278 - ))
279 - })?;
280 - if part_size == 0 || start.part_count == 0 {
281 - return Err(SyncKitError::Internal(
282 - "server returned an empty multipart plan".into(),
283 - ));
284 - }
285 -
286 - // The part geometry is server-supplied, so bound it before it drives any
287 - // allocation. Without this a hostile part_count aborts the client on an
288 - // over-large `Vec`/`BytesMut` reservation, and a part_count/part_size that
289 - // does not match the blob defeats the one-part-plus-one-chunk memory bound.
290 - // The plan must describe exactly the ciphertext we are about to upload
291 - // (the server derives it from this same `size_bytes`), so recompute and
292 - // compare rather than trust it.
293 - const MAX_PART_SIZE: usize = 1 << 30; // 1 GiB; real plan parts are well under this
294 - const MAX_PART_COUNT: u32 = 10_000; // S3's hard multipart-part ceiling
295 - if part_size > MAX_PART_SIZE {
296 - return Err(SyncKitError::Internal(format!(
297 - "server part_size {part_size} exceeds the {MAX_PART_SIZE}-byte ceiling"
298 - )));
299 - }
300 - if start.part_count > MAX_PART_COUNT {
301 - return Err(SyncKitError::Internal(format!(
302 - "server part_count {} exceeds the {MAX_PART_COUNT}-part ceiling",
303 - start.part_count
304 - )));
305 - }
306 - let total = usize::try_from(size_bytes).map_err(|_| {
307 - SyncKitError::Internal(format!("blob size {size_bytes} is not a valid length"))
308 - })?;
309 - let expected_parts = total.div_ceil(part_size);
310 - if start.part_count as usize != expected_parts {
311 - return Err(SyncKitError::Internal(format!(
312 - "server multipart plan (part_count {}, part_size {part_size}) does not match the \
313 - {total}-byte blob (expected {expected_parts} parts)",
314 - start.part_count
315 - )));
316 - }
317 -
318 - let mut file = tokio::fs::File::open(path).await.map_err(|e| {
319 - SyncKitError::Internal(format!("open blob file {}: {e}", path.display()))
320 - })?;
321 -
322 - let chunk_count = crypto::blob_chunk_count_for(plaintext_len);
323 - let mut hasher = Sha256::new();
324 - // Plaintext scratch: zeroized on drop so a decrypted chunk does not linger.
325 - let mut plain = zeroize::Zeroizing::new(vec![0u8; crypto::BLOB_CHUNK_SIZE]);
326 - let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE);
327 - staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len));
328 -
329 - let mut completed = Vec::with_capacity(start.part_count as usize);
330 - let mut next_part = 1u32;
331 -
332 - for index in 0..chunk_count {
333 - let offset = index as usize * crypto::BLOB_CHUNK_SIZE;
334 - let want = (plaintext_len - offset).min(crypto::BLOB_CHUNK_SIZE);
335 - file.read_exact(&mut plain[..want]).await.map_err(|e| {
336 - SyncKitError::Internal(format!(
337 - "read blob file {} at offset {offset}: {e}",
338 - path.display()
339 - ))
340 - })?;
341 - hasher.update(&plain[..want]);
342 - staged.extend_from_slice(&crypto::seal_blob_chunk(
343 - &plain[..want],
344 - master_key,
345 - hash,
346 - index,
347 - chunk_count,
348 - )?);
349 -
350 - // Every part but the last is exactly `part_size`; the remainder is
351 - // the final part, so it is never sent from inside this loop.
352 - while staged.len() >= part_size && next_part < start.part_count {
353 - let body = staged.split_to(part_size).freeze();
354 - let etag = self
355 - .upload_blob_part(hash, start, size_bytes, next_part, body)
356 - .await?;
357 - completed.push(BlobMultipartCompletedPart {
358 - part_number: next_part as i32,
359 - etag,
360 - });
361 - next_part += 1;
362 - }
363 - }
364 -
365 - // Verify the content address before the object can be assembled. A
366 - // mismatch here aborts the session, so a file that changed after the
367 - // caller hashed it is never stored under the stale address.
368 - let actual = hex::encode(hasher.finalize());
369 - if actual != hash {
370 - return Err(SyncKitError::IntegrityFailed {
371 - expected: hash.to_string(),
372 - actual,
373 - });
374 - }
375 -
376 - if next_part != start.part_count {
377 - return Err(SyncKitError::Internal(format!(
378 - "sealed stream produced {} parts, server planned {}",
379 - next_part, start.part_count
380 - )));
381 - }
382 - let body = staged.split().freeze();
383 - let etag = self
384 - .upload_blob_part(hash, start, size_bytes, next_part, body)
385 - .await?;
386 - completed.push(BlobMultipartCompletedPart {
387 - part_number: next_part as i32,
388 - etag,
389 - });
390 -
391 - Ok(completed)
392 - }
393 -
394 - /// PUT one part to its presigned URL and return the ETag S3 assigned it.
395 - ///
396 - /// The URL is requested for this part alone, and carries the part's SHA-256
397 - /// bound as `x-amz-checksum-sha256`, so S3 rehashes what it receives and
398 - /// rejects a corrupted part at write time instead of letting it sit until a
399 - /// download fails to decrypt. That is the reason parts are not requested in
400 - /// windows: a digest only exists once the part has been sealed, and only one
401 - /// part is ever in memory. The cost is one small round trip per part.
402 - async fn upload_blob_part(
403 - &self,
404 - hash: &str,
405 - start: &BlobMultipartStartResponse,
406 - size_bytes: i64,
407 - part_number: u32,
408 - body: Bytes,
409 - ) -> Result<String> {
410 - let checksum = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(&body));
411 -
412 - let token = self.require_token()?;
413 - let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest {
414 - hash: hash.to_string(),
415 - upload_id: start.upload_id.clone(),
416 - size_bytes,
417 - first_part: part_number,
418 - count: 1,
419 - checksums: vec![checksum.clone()],
420 - })?);
421 - let window: BlobMultipartPartsResponse = self
422 - .retry_request_json(Idempotency::ReadOnly, || {
423 - let req = self
424 - .http
425 - .post(&self.endpoints.blobs_multipart_parts)
426 - .bearer_auth(&token)
427 - .header("content-type", "application/json")
428 - .body(req_body.clone());
429 - async move { check_response(req.send().await?).await }
430 - })
431 - .await?;
432 -
433 - let part = window.parts.into_iter().next().ok_or_else(|| {
434 - SyncKitError::Internal(format!("server returned no URL for part {part_number}"))
435 - })?;
436 - // The two sides derive the plan independently, so a disagreement means
437 - // one of them is wrong about the geometry. Sending anyway would fail the
438 - // signed Content-Length at S3 with a far less legible error.
439 - if part.part_number != part_number as i32 || part.content_length != body.len() as u64 {
440 - return Err(SyncKitError::Internal(format!(
441 - "part geometry mismatch: server signed part {} for {} bytes, client has part {part_number} of {} bytes",
442 - part.part_number,
443 - part.content_length,
444 - body.len()
445 - )));
446 - }
447 -
448 - let resp = self
449 - .retry_request(
450 - Idempotency::IdempotentWrite {
451 - on: "(upload_id, part_number), re-PUTting a part replaces it",
452 - },
453 - || {
454 - let req = self
455 - .http_stream
456 - .put(&part.url)
457 - .header("content-type", "application/octet-stream")
458 - // Mandatory, not optional: the checksum is a signed
459 - // header, so omitting it fails the signature.
460 - .header("x-amz-checksum-sha256", &checksum)
461 - .body(body.clone());
462 - async move { check_response(req.send().await?).await }
463 - },
464 - )
465 - .await?;
466 -
467 - // S3 identifies each part by the ETag it returns; complete cannot be
468 - // assembled without it.
469 - resp.headers()
470 - .get(reqwest::header::ETAG)
471 - .and_then(|v| v.to_str().ok())
472 - .map(std::string::ToString::to_string)
473 - .ok_or_else(|| {
474 - SyncKitError::Internal(format!("part {part_number} upload returned no ETag"))
475 - })
476 - }
477 -
478 - /// Release an abandoned multipart session and the parts it holds.
479 - async fn blob_multipart_abort(&self, hash: &str, upload_id: &str) -> Result<()> {
480 - let token = self.require_token()?;
481 - let body = Bytes::from(serde_json::to_vec(&BlobMultipartAbortRequest {
482 - hash: hash.to_string(),
483 - upload_id: upload_id.to_string(),
484 - })?);
485 - self.retry_request(
486 - Idempotency::IdempotentWrite {
487 - on: "upload_id (aborting twice is a no-op)",
488 - },
489 - || {
490 - let req = self
491 - .http
492 - .post(&self.endpoints.blobs_multipart_abort)
493 - .bearer_auth(&token)
494 - .header("content-type", "application/json")
495 - .body(body.clone());
496 - async move { check_response(req.send().await?).await }
497 - },
498 - )
499 - .await?;
500 - Ok(())
Lines truncated
@@ -1,278 +1,0 @@
1 - //! Master-key setup and password lifecycle.
2 - //!
3 - //! Establishes the master key on a first or subsequent device by wrapping and
4 - //! unwrapping it against the server-held key envelope, caches it in the OS
5 - //! keychain when available, and re-wraps it on a password change.
6 -
7 - use bytes::Bytes;
8 - use std::sync::Arc;
9 - use tracing::instrument;
10 -
11 - use crate::{
12 - crypto,
13 - error::Result,
14 - keystore,
15 - types::{GetKeyResponse, PutKeyRequest},
16 - };
17 -
18 - use super::helpers::{Idempotency, check_response};
19 - use super::{SecretToken, SyncKitClient};
20 -
21 - impl SyncKitClient {
22 - /// Check if the server has an encrypted master key for this user.
23 - #[instrument(skip(self))]
24 - pub async fn has_server_key(&self) -> Result<bool> {
25 - let (url, token) = self.key_url_and_token()?;
26 -
27 - let result = self
28 - .retry_request(Idempotency::ReadOnly, || {
29 - let req = self.http.get(url).bearer_auth(&token);
30 - async move {
31 - let resp = req.send().await?;
32 - match resp.status().as_u16() {
33 - 200 | 404 => Ok(resp),
34 - status => {
35 - let message = crate::client::helpers::read_text_capped(
36 - resp,
37 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
38 - )
39 - .await;
40 - Err(crate::error::SyncKitError::Server {
41 - status,
42 - message,
43 - retry_after_secs: None,
44 - })
45 - }
46 - }
47 - }
48 - })
49 - .await?;
50 -
51 - Ok(result.status().as_u16() == 200)
52 - }
53 -
54 - /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
55 - #[instrument(skip(self, password))]
56 - pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
57 - let (app_id, user_id) = self.require_session_ids()?;
58 -
59 - // Wrap the fresh key immediately so it is scrubbed on every early-return
60 - // path below (wrap/put/store can each fail), not left as a bare array on
61 - // the stack until the final move into the lock.
62 - let master_key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
63 - let envelope = crypto::wrap_master_key(&master_key.0, password)?;
64 -
65 - // Push to server (expected_version 0 = first key)
66 - self.put_server_key(&envelope, 0).await?;
67 -
68 - // Cache in OS keychain
69 - keystore::store_key(app_id, user_id, &master_key.0)?;
70 -
71 - // Store in memory
72 - *self.master_key.write() = Some(master_key);
73 -
74 - tracing::info!("New master key generated and stored");
75 - Ok(())
76 - }
77 -
78 - /// Second device: pull encrypted master key from server, decrypt with password, cache.
79 - #[instrument(skip(self, password))]
80 - pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
81 - let (app_id, user_id) = self.require_session_ids()?;
82 -
83 - let (envelope_json, _key_version) = self.get_server_key().await?;
84 - // Wrap immediately so a failed keychain store scrubs the key rather than
85 - // dropping a bare array.
86 - let master_key =
87 - crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?);
88 -
89 - // Cache in OS keychain
90 - keystore::store_key(app_id, user_id, &master_key.0)?;
91 -
92 - // Store in memory
93 - *self.master_key.write() = Some(master_key);
94 -
95 - tracing::info!("Master key recovered from server");
96 - Ok(())
97 - }
98 -
99 - /// Subsequent launches: try to load the master key from the OS keychain.
100 - /// Returns true if a key was found.
101 - pub fn try_load_key_from_keychain(&self) -> Result<bool> {
102 - let (app_id, user_id) = self.require_session_ids()?;
103 -
104 - if let Some(key) = keystore::load_key(app_id, user_id)? {
105 - *self.master_key.write() = Some(key);
106 - tracing::info!("Master key loaded from keychain");
107 - Ok(true)
108 - } else {
109 - Ok(false)
110 - }
111 - }
112 -
113 - /// Change the encryption password. Always validates the old password
114 - /// against the server envelope before re-encrypting with the new password.
115 - ///
116 - /// Even when the master key is cached in memory (normal case, user is
117 - /// logged in), the old password is verified by attempting to unwrap the
118 - /// server envelope. This prevents an attacker with session access from
119 - /// changing the password without knowing the current one.
120 - #[instrument(skip(self, old_password, new_password))]
121 - pub async fn change_password(&self, old_password: &str, new_password: &str) -> Result<()> {
122 - // Always fetch the envelope from the server and verify old_password
123 - // can decrypt it, regardless of whether we have a cached key.
124 - let (envelope_json, key_version) = self.get_server_key().await?;
125 - let verified_key = crypto::verify_password_against_envelope(&envelope_json, old_password)?;
126 -
127 - let master_key = crypto::ZeroizeOnDrop(verified_key);
128 -
129 - // If we hold the master key in memory, the envelope the server just handed
130 - // us MUST wrap that same key. A hostile server could otherwise substitute a
131 - // *different* envelope that also unwraps under `old_password` (e.g. one it
132 - // captured), making us re-wrap the wrong key under the new password and
133 - // lock the user out of their own (original-key-encrypted) data. Refuse.
134 - if let Some(cached) = self.master_key.read().as_ref()
135 - && cached.0 != master_key.0
136 - {
137 - return Err(crate::error::SyncKitError::Crypto(
138 - "server key envelope does not match the in-memory master key; refusing to change password".into(),
139 - ));
140 - }
141 -
142 - // Re-wrap with new password (generates fresh random salt)
143 - let new_envelope = crypto::wrap_master_key(&master_key.0, new_password)?;
144 -
145 - // Optimistic lock: reject if another device changed the password
146 - // between our GET and this PUT.
147 - self.put_server_key(&new_envelope, key_version).await?;
148 -
149 - tracing::info!("Encryption password changed");
150 - Ok(())
151 - }
152 -
153 - /// Build the key endpoint URL and extract the bearer token.
154 - pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<SecretToken>)> {
155 - let token = self.require_token()?;
156 - Ok((&self.endpoints.keys, token))
157 - }
158 -
159 - /// Upload the encrypted master key envelope to the server (PUT /api/sync/keys).
160 - ///
161 - /// `expected_version` is the key version the client expects. The server
162 - /// rejects with 409 if the current version doesn't match (another device
163 - /// changed the password). Use 0 for the initial key upload.
164 - pub(super) async fn put_server_key(
165 - &self,
166 - envelope_json: &str,
167 - expected_version: i32,
168 - ) -> Result<()> {
169 - let (url, token) = self.key_url_and_token()?;
170 -
171 - let body = Bytes::from(serde_json::to_vec(&PutKeyRequest {
172 - encrypted_key: envelope_json.to_string(),
173 - expected_version,
174 - })?);
175 -
176 - self.retry_request(
177 - Idempotency::IdempotentWrite {
178 - on: "expected_version optimistic lock",
179 - },
180 - || {
181 - let req = self
182 - .http
183 - .put(url)
184 - .bearer_auth(&token)
185 - .header("content-type", "application/json")
186 - .body(body.clone());
187 - async move { check_response(req.send().await?).await }
188 - },
189 - )
190 - .await?;
191 - Ok(())
192 - }
193 -
194 - /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
195 - /// Returns `(envelope_json, key_version)`.
196 - pub(super) async fn get_server_key(&self) -> Result<(String, i32)> {
197 - let resp = self.get_server_key_full().await?;
198 - Ok((resp.encrypted_key, resp.key_version.unwrap_or(0)))
199 - }
200 -
201 - /// Download the full key state, including any in-progress rotation's
202 - /// `pending_key`. Used by `rotate_key` to resume an interrupted rotation
203 - /// with the already-committed key rather than minting a fresh one.
204 - pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> {
205 - let (url, token) = self.key_url_and_token()?;
206 -
207 - self.retry_request_json(Idempotency::ReadOnly, || {
208 - let req = self.http.get(url).bearer_auth(&token);
209 - async move { check_response(req.send().await?).await }
210 - })
211 - .await
212 - }
213 - }
214 -
215 - #[cfg(test)]
216 - mod tests {
217 - use crate::error::SyncKitError;
218 -
219 - use super::*;
220 -
221 - fn test_config() -> super::super::SyncKitConfig {
222 - super::super::SyncKitConfig {
223 - server_url: "https://example.com".to_string(),
224 - api_key: "test-api-key-123".to_string(),
225 - }
226 - }
227 -
228 - fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
229 - (
230 - crate::ids::AppId::new(
231 - uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
232 - ),
233 - crate::ids::UserId::new(
234 - uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
235 - ),
236 - )
237 - }
238 -
239 - #[test]
240 - fn key_url_and_token_builds_correct_url() {
241 - let client = SyncKitClient::new(test_config());
242 - let (app_id, user_id) = test_ids();
243 - client.restore_session("bearer-token", user_id, app_id);
244 -
245 - let (url, token) = client.key_url_and_token().unwrap();
246 - assert_eq!(url, "https://example.com/api/v1/sync/keys");
247 - assert_eq!(token.as_str(), "bearer-token");
248 - }
249 -
250 - #[test]
251 - fn key_url_and_token_fails_without_session() {
252 - let client = SyncKitClient::new(test_config());
253 - let err = client.key_url_and_token().unwrap_err();
254 - assert!(matches!(err, SyncKitError::NotAuthenticated));
255 - }
256 -
257 - // ── Key types ──
258 -
259 - #[test]
260 - fn put_key_request_serialization() {
261 - let req = PutKeyRequest {
262 - encrypted_key: "envelope-json-here".to_string(),
263 - expected_version: 1,
264 - };
265 -
266 - let json = serde_json::to_string(&req).unwrap();
267 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
268 - assert_eq!(parsed["encrypted_key"], "envelope-json-here");
269 - assert_eq!(parsed["expected_version"], 1);
270 - }
271 -
272 - #[test]
273 - fn get_key_response_deserialization() {
274 - let json = r#"{"encrypted_key": "{\"v\":1,\"salt\":\"...\",\"nonce\":\"...\",\"ciphertext\":\"...\"}"}"#;
275 - let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
276 - assert!(resp.encrypted_key.contains("\"v\":1"));
277 - }
278 - }
@@ -1,481 +1,0 @@
1 - //! Group sync: list the caller's groups, fetch their sealed GCK grant, and
2 - //! push/pull a group's shared changelog under its Group Content Key.
3 - //!
4 - //! The GCK is supplied by the caller, the `SyncStore` resolves it from the grant
5 - //! via the identity key (a later slice); these methods only encrypt/decrypt with
6 - //! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so
7 - //! a ciphertext cannot be relocated across groups. Design: wiki
8 - //! synckit-multiscope-design.
9 -
10 - use bytes::Bytes;
11 - use tracing::instrument;
12 - use uuid::Uuid;
13 -
14 - use crate::{
15 - crypto,
16 - error::Result,
17 - identity::{IdentityKeypair, IdentityPublicKey, generate_group_key, seal_gck_to_member},
18 - ids::{DeviceId, GroupId, UserId},
19 - types::{
20 - ChangeEntry, GroupGrant, GroupMember, PullRequest, PullResponse, PulledChange,
21 - PushResponse, SyncGroup, WirePushRequest,
22 - },
23 - };
24 -
25 - use super::SyncKitClient;
26 - use super::helpers::{Idempotency, check_response};
27 -
28 - /// Request body for `POST /groups`. The id is client-generated so the admin's
29 - /// grant can be sealed bound to it before the group exists.
30 - #[derive(serde::Serialize)]
31 - struct CreateGroupBody<'a> {
32 - id: GroupId,
33 - name: &'a str,
34 - admin_sealed_gck: String,
35 - admin_pubkey: String,
36 - }
37 -
38 - /// Request body for `POST /groups/{id}/members`.
39 - #[derive(serde::Serialize)]
40 - struct AddMemberBody<'a> {
41 - member_email: &'a str,
42 - sealed_gck: String,
43 - member_pubkey: &'a str,
44 - #[serde(skip_serializing_if = "Option::is_none")]
45 - role: Option<&'a str>,
46 - }
47 -
48 - impl SyncKitClient {
49 - /// List the groups the authenticated user belongs to within this app.
50 - #[instrument(skip(self))]
51 - pub async fn list_groups(&self) -> Result<Vec<SyncGroup>> {
52 - let token = self.require_token()?;
53 - self.retry_request_json(Idempotency::ReadOnly, || {
54 - let req = self.http.get(self.endpoints.groups()).bearer_auth(&token);
55 - async move { check_response(req.send().await?).await }
56 - })
57 - .await
58 - }
59 -
60 - /// This user's identity public key (base64), derived from their master key.
61 - /// A member shares it with a group admin so the admin can seal the GCK to it
62 - /// (the paste-a-public-key model). Requires the master key to be loaded.
63 - pub fn my_identity_public_key(&self) -> Result<String> {
64 - let master = self.require_master_key()?;
65 - Ok(IdentityKeypair::from_master_key(&master)
66 - .public_key()
67 - .to_base64())
68 - }
69 -
70 - /// Create a group with the caller as its admin and first member. Mints a
71 - /// fresh Group Content Key, seals it to the caller's own identity public key
72 - /// (derived from the master key), and registers the group.
73 - ///
74 - /// The group id is generated client-side: the admin's grant is sealed with the
75 - /// group id bound as associated data, so the id must be known before the seal,
76 - /// before the server round-trip. Generation starts at 1.
77 - #[instrument(skip(self))]
78 - pub async fn create_group(&self, name: &str) -> Result<SyncGroup> {
79 - let token = self.require_token()?;
80 - let master = self.require_master_key()?;
81 - let identity = IdentityKeypair::from_master_key(&master);
82 - let group_id = GroupId::new(Uuid::new_v4());
83 - let gck = generate_group_key();
84 - let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 1)?;
85 -
86 - let body = Bytes::from(serde_json::to_vec(&CreateGroupBody {
87 - id: group_id,
88 - name,
89 - admin_sealed_gck: sealed,
90 - admin_pubkey: identity.public_key().to_base64(),
91 - })?);
92 - let url = self.endpoints.groups().to_string();
93 -
94 - // The client-chosen id makes this safe to retry: a replay hits the PK and
95 - // the group already exists, but the sealed grant is identical.
96 - self.retry_request_json(Idempotency::Keyed, || {
97 - let req = self
98 - .http
99 - .post(&url)
100 - .bearer_auth(&token)
101 - .header("content-type", "application/json")
102 - .body(body.clone());
103 - async move { check_response(req.send().await?).await }
104 - })
105 - .await
106 - }
107 -
108 - /// Add a member to a group by account email, sealing the group's current GCK
109 - /// to the member's identity public key (obtained out of band, the member
110 - /// pastes their public key from [`my_identity_public_key`]). Admin only.
111 - #[instrument(skip(self, member_pubkey_b64))]
112 - pub async fn add_member(
113 - &self,
114 - group_id: GroupId,
115 - member_email: &str,
116 - member_pubkey_b64: &str,
117 - ) -> Result<()> {
118 - let token = self.require_token()?;
119 - // Resolve the group's current GCK (and its generation) from our own grant.
120 - let grant = self.group_grant(group_id).await?;
121 - let master = self.require_master_key()?;
122 - let gck = Self::open_group_grant(&grant, &master, group_id)?;
123 -
124 - let member_pubkey = IdentityPublicKey::from_base64(member_pubkey_b64)?;
125 - let sealed = seal_gck_to_member(
126 - &gck,
127 - &member_pubkey,
128 - &group_id.to_string(),
129 - grant.gck_version,
130 - )?;
131 -
132 - let body = Bytes::from(serde_json::to_vec(&AddMemberBody {
133 - member_email,
134 - sealed_gck: sealed,
135 - member_pubkey: member_pubkey_b64,
136 - role: None,
137 - })?);
138 - let url = self.endpoints.group_members(group_id);
139 -
140 - self.retry_request(Idempotency::Keyed, || {
141 - let req = self
142 - .http
143 - .post(&url)
144 - .bearer_auth(&token)
145 - .header("content-type", "application/json")
146 - .body(body.clone());
147 - async move { check_response(req.send().await?).await }
148 - })
149 - .await?;
150 - Ok(())
151 - }
152 -
153 - /// List a group's members (id, role, joined-at). Admin only (the server gates
154 - /// it); use to show who can be removed.
155 - #[instrument(skip(self))]
156 - pub async fn list_members(&self, group_id: GroupId) -> Result<Vec<GroupMember>> {
157 - let token = self.require_token()?;
158 - let url = self.endpoints.group_members(group_id);
159 - self.retry_request_json(Idempotency::ReadOnly, || {
160 - let req = self.http.get(&url).bearer_auth(&token);
161 - async move { check_response(req.send().await?).await }
162 - })
163 - .await
164 - }
165 -
166 - /// Remove a member from a group by user id. Admin only. The server membership
167 - /// ACL revokes the member's group read/write access immediately.
168 - ///
169 - /// Forward secrecy for writes made *after* removal requires rotating the GCK
170 - /// (re-mint, re-seal to the remaining members, bump the generation), which the
171 - /// server does not yet expose, a later slice. This method performs the
172 - /// membership revocation only; data the member already pulled is in their hands.
173 - #[instrument(skip(self))]
174 - pub async fn remove_member(&self, group_id: GroupId, member: UserId) -> Result<()> {
175 - let token = self.require_token()?;
176 - let url = self.endpoints.group_member(group_id, member);
177 - self.retry_request(Idempotency::Keyed, || {
178 - let req = self.http.delete(&url).bearer_auth(&token);
179 - async move { check_response(req.send().await?).await }
180 - })
181 - .await?;
182 - Ok(())
183 - }
184 -
185 - /// Fetch the caller's own sealed GCK grant for a group. Open it with the
186 - /// member's identity private key to recover the GCK.
187 - #[instrument(skip(self))]
188 - pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> {
189 - let token = self.require_token()?;
190 - let url = self.endpoints.group_grant(group_id);
191 - self.retry_request_json(Idempotency::ReadOnly, || {
192 - let req = self.http.get(&url).bearer_auth(&token);
193 - async move { check_response(req.send().await?).await }
194 - })
195 - .await
196 - }
197 -
198 - /// Push encrypted changes to a group's shared changelog under its GCK.
199 - /// Returns the server cursor after the push.
200 - #[instrument(skip(self, gck, changes))]
201 - pub async fn group_push(
202 - &self,
203 - group_id: GroupId,
204 - gck: &[u8; 32],
205 - device_id: DeviceId,
206 - changes: Vec<ChangeEntry>,
207 - ) -> Result<i64> {
208 - let token = self.require_token()?;
209 - let group_str = group_id.to_string();
210 - let wire_changes = changes
211 - .into_iter()
212 - .map(|c| Self::encrypt_group_change_with_key(&group_str, c, gck))
213 - .collect::<Result<Vec<_>>>()?;
214 -
215 - let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
216 - device_id,
217 - batch_id: Uuid::new_v4(),
218 - changes: wire_changes,
219 - })?);
220 - let url = self.endpoints.group_push(group_id);
221 -
222 - let push_resp: PushResponse = self
223 - .retry_request_json(Idempotency::Keyed, || {
224 - let req = self
225 - .http
226 - .post(&url)
227 - .bearer_auth(&token)
228 - .header("content-type", "application/json")
229 - .body(body.clone());
230 - async move { check_response(req.send().await?).await }
231 - })
232 - .await?;
233 - Ok(push_resp.cursor)
234 - }
235 -
236 - /// Pull a group's changes since `cursor`, decrypting under its GCK. Returns
237 - /// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready
238 - /// for conflict resolution. Group pull has no master-key-rotation window (a
239 - /// group's key rotates server-side, re-encrypted in place), so the decrypt is
240 - /// a single-key pass.
241 - #[instrument(skip(self, gck))]
242 - pub async fn group_pull_rich(
243 - &self,
244 - group_id: GroupId,
245 - gck: &[u8; 32],
246 - device_id: DeviceId,
247 - cursor: i64,
248 - ) -> Result<(Vec<PulledChange>, i64, bool)> {
249 - let token = self.require_token()?;
250 - let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
251 - let url = self.endpoints.group_pull(group_id);
252 -
253 - let pull_resp: PullResponse = self
254 - .retry_request_json(Idempotency::ReadOnly, || {
255 - let req = self
256 - .http
257 - .post(&url)
258 - .bearer_auth(&token)
259 - .header("content-type", "application/json")
260 - .body(body.clone());
261 - async move { check_response(req.send().await?).await }
262 - })
263 - .await?;
264 -
265 - let group_str = group_id.to_string();
266 - let changes = pull_resp
267 - .changes
268 - .into_iter()
269 - .map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck))
270 - .collect::<Result<Vec<_>>>()?;
271 - Ok((changes, pull_resp.cursor, pull_resp.has_more))
272 - }
273 -
274 - /// Resolve a group's decrypted Group Content Key for `gck_version`, fetching
275 - /// and opening the sealed grant on a cache miss.
276 - ///
277 - /// Fast path: a cached GCK at the requested version is returned without a
278 - /// network call. On a miss (or a version mismatch, the group's key rotated),
279 - /// the sealed grant is fetched, opened with the identity keypair derived from
280 - /// this client's master key, and cached under the grant's actual version. The
281 - /// master key and identity secret never leave the client, the `SyncStore`
282 - /// only ever receives the resolved GCK.
283 - // Consumed by `sync_now`'s scope iteration in slice 4.
284 - #[allow(dead_code)]
285 - pub(crate) async fn group_content_key(
286 - &self,
287 - group_id: GroupId,
288 - gck_version: i32,
289 - ) -> Result<crypto::ZeroizeOnDrop> {
290 - if let Some((v, gck)) = self.gck_cache.read().get(&group_id)
291 - && *v == gck_version
292 - {
293 - return Ok(crypto::ZeroizeOnDrop(**gck));
294 - }
295 -
296 - let grant = self.group_grant(group_id).await?;
297 - let master = self.require_master_key()?;
298 - let gck = Self::open_group_grant(&grant, &master, group_id)?;
299 - let out = crypto::ZeroizeOnDrop(*gck);
300 - self.gck_cache
301 - .write()
302 - .insert(group_id, (grant.gck_version, gck));
303 - Ok(out)
304 - }
305 -
306 - /// Open a sealed grant with the identity keypair derived from `master_key`.
307 - /// Pure (no I/O), so the derive-and-open path is unit-testable.
308 - fn open_group_grant(
309 - grant: &GroupGrant,
310 - master_key: &[u8; 32],
311 - group_id: GroupId,
312 - ) -> Result<crypto::ZeroizeOnDrop> {
313 - let identity = crate::identity::IdentityKeypair::from_master_key(master_key);
314 - let gck = crate::identity::open_gck_grant(
315 - &grant.sealed_gck,
316 - &identity,
317 - &group_id.to_string(),
318 - grant.gck_version,
319 - )?;
320 - Ok(crypto::ZeroizeOnDrop(gck))
321 - }
322 -
323 - /// Drop a group's cached GCK, forcing the next
324 - /// [`group_content_key`](Self::group_content_key) to re-fetch its grant. Call
325 - /// on a decrypt failure (a stale key after a rotation this client missed).
326 - // Consumed by `sync_now`'s scope iteration in slice 4.
327 - #[allow(dead_code)]
328 - pub(crate) fn invalidate_gck(&self, group_id: GroupId) {
329 - self.gck_cache.write().remove(&group_id);
330 - }
331 - }
332 -
333 - #[cfg(test)]
334 - mod tests {
335 - use super::*;
336 - use crate::error::SyncKitError;
337 - use crate::ids::DeviceId;
338 - use crate::types::{ChangeOp, Hlc, PullChangeEntry, WireChangeEntry};
339 - use chrono::Utc;
340 -
341 - fn insert(table: &str, row: &str, data: serde_json::Value) -> ChangeEntry {
342 - ChangeEntry {
343 - table: table.into(),
344 - op: ChangeOp::Insert,
345 - row_id: row.into(),
346 - timestamp: Utc::now(),
347 - hlc: Hlc::zero(DeviceId::nil()),
348 - data: Some(data),
349 - extra: serde_json::Map::default(),
350 - }
351 - }
352 -
353 - fn to_pull(wire: WireChangeEntry, device: DeviceId, seq: i64) -> PullChangeEntry {
354 - PullChangeEntry {
355 - seq,
356 - device_id: device,
357 - table: wire.table,
358 - op: wire.op,
359 - row_id: wire.row_id,
360 - timestamp: wire.timestamp,
361 - data: wire.data,
362 - key_id: None,
363 - }
364 - }
365 -
366 - #[test]
367 - fn group_change_encrypt_decrypt_roundtrip_preserves_data_and_hlc() {
368 - let gck = crypto::generate_master_key();
369 - let device = DeviceId::new(uuid::Uuid::new_v4());
370 - let hlc = Hlc {
371 - wall_ms: 7,
372 - counter: 1,
373 - node: device,
374 - };
375 - let mut e = insert("tasks", "r1", serde_json::json!({ "title": "shared" }));
376 - e.hlc = hlc;
377 -
378 - let wire = SyncKitClient::encrypt_group_change_with_key("grp-1", e, &gck).unwrap();
379 - let pulled =
380 - SyncKitClient::decrypt_group_change_to_pulled("grp-1", to_pull(wire, device, 3), &gck)
381 - .unwrap();
382 -
383 - assert_eq!(pulled.seq, 3);
384 - assert_eq!(pulled.device_id, device);
385 - assert_eq!(
386 - pulled.entry.data.unwrap(),
387 - serde_json::json!({ "title": "shared" })
388 - );
389 - assert_eq!(pulled.entry.hlc, hlc);
390 - }
391 -
392 - #[test]
393 - fn group_ciphertext_cannot_be_opened_under_another_group() {
394 - let gck = crypto::generate_master_key();
395 - let wire = SyncKitClient::encrypt_group_change_with_key(
396 - "grp-A",
397 - insert("t", "r", serde_json::json!(1)),
398 - &gck,
399 - )
400 - .unwrap();
401 - // Same GCK, but a different group id in the AAD: the open fails closed.
402 - let err = SyncKitClient::decrypt_group_change_to_pulled(
403 - "grp-B",
404 - to_pull(wire, DeviceId::nil(), 1),
405 - &gck,
406 - )
407 - .unwrap_err();
408 - assert!(matches!(err, SyncKitError::DecryptionFailed));
409 - }
410 -
411 - #[test]
412 - fn wrong_gck_fails_closed() {
413 - let gck = crypto::generate_master_key();
414 - let other = crypto::generate_master_key();
415 - let wire = SyncKitClient::encrypt_group_change_with_key(
416 - "grp-1",
417 - insert("t", "r", serde_json::json!(1)),
418 - &gck,
419 - )
420 - .unwrap();
421 - let err = SyncKitClient::decrypt_group_change_to_pulled(
422 - "grp-1",
423 - to_pull(wire, DeviceId::nil(), 1),
424 - &other,
425 - )
426 - .unwrap_err();
427 - assert!(matches!(err, SyncKitError::DecryptionFailed));
428 - }
429 -
430 - #[test]
431 - fn create_group_admin_self_grant_is_openable() {
432 - // Mirrors create_group's sealing path: a client-generated group id, the
433 - // GCK sealed to the admin's own derived identity at generation 1. The
434 - // admin's device must be able to open its own grant back to the same GCK,
435 - // or it would be locked out of the group it just created.
436 - let master = crypto::generate_master_key();
437 - let identity = crate::identity::IdentityKeypair::from_master_key(&master);
438 - let group = GroupId::new(uuid::Uuid::new_v4());
439 - let gck = crate::identity::generate_group_key();
440 - let sealed = crate::identity::seal_gck_to_member(
441 - &gck,
442 - &identity.public_key(),
443 - &group.to_string(),
444 - 1,
445 - )
446 - .unwrap();
447 - let grant = GroupGrant {
448 - sealed_gck: sealed,
449 - gck_version: 1,
450 - };
451 - let opened = SyncKitClient::open_group_grant(&grant, &master, group).unwrap();
452 - assert_eq!(*opened, gck);
453 - }
454 -
455 - #[test]
456 - fn open_group_grant_recovers_gck_via_derived_identity() {
457 - // The admin seals the GCK to the member's derived public key; the member's
458 - // client re-derives the identity from its master key and opens the grant.
459 - let group = GroupId::new(uuid::Uuid::new_v4());
460 - let master_key = crypto::generate_master_key();
461 - let member = crate::identity::IdentityKeypair::from_master_key(&master_key);
462 - let gck = crate::identity::generate_group_key();
463 - let sealed =
464 - crate::identity::seal_gck_to_member(&gck, &member.public_key(), &group.to_string(), 4)
465 - .unwrap();
466 - let grant = GroupGrant {
467 - sealed_gck: sealed,
468 - gck_version: 4,
469 - };
470 -
471 - let opened = SyncKitClient::open_group_grant(&grant, &master_key, group).unwrap();
472 - assert_eq!(*opened, gck);
473 -
474 - // A wrong-version grant (AAD mismatch) fails closed.
475 - let bad = GroupGrant {
476 - sealed_gck: grant.sealed_gck.clone(),
477 - gck_version: 5,
478 - };
479 - assert!(SyncKitClient::open_group_grant(&bad, &master_key, group).is_err());
480 - }
481 - }
@@ -1,1455 +1,0 @@
1 - //! Internal HTTP plumbing shared across the client modules.
2 - //!
3 - //! Response-status to [`SyncKitError`] mapping, idempotency-key generation,
4 - //! size-capped body reads (so a hostile server cannot exhaust memory on a
5 - //! control response), wire-version negotiation, and JWT-expiry parsing. This
6 - //! module is `pub(crate)`: none of it is part of the SDK's public API.
7 -
8 - use base64::Engine;
9 -
10 - use crate::{
11 - crypto,
12 - error::{Result, SyncKitError},
13 - types::{ChangeEntry, Hlc, PullChangeEntry, WireChangeEntry},
14 - };
15 -
16 - use super::{BASE_DELAY, MAX_RETRIES, SyncKitClient};
17 -
18 - /// Proof, supplied at the call site, that retrying an operation is sound.
19 - ///
20 - /// Retry replays the request, so it is only safe when the server treats the
21 - /// operation idempotently. Requiring this argument makes that judgement an
22 - /// explicit, reviewable decision, and the judgement has runtime teeth:
23 - /// [`may_retry`](Self::may_retry) gates the retry loop, so an
24 - /// [`Unsafe`](Self::Unsafe) operation physically cannot be auto-replayed.
25 - ///
26 - /// The variants split "safe to replay" into a **read** and a **write that the
27 - /// server dedups**, and the write variant must *name* what makes replay
28 - /// idempotent. That is the forcing function: a mutating create tagged
29 - /// auto-retryable without naming its dedup key does not compile, so the
30 - /// `ota_create_release`/`ota_register_artifact` class, a `CREATE` that silently
31 - /// relied on a server unique constraint nobody had written down, cannot recur
32 - /// unnoticed.
33 - #[derive(Clone, Copy, Debug)]
34 - pub(super) enum Idempotency {
35 - /// A read with no server-side effect (a `GET`, or a `POST` that only reads).
36 - /// Replay is trivially safe.
37 - ReadOnly,
38 - /// A write whose replay collapses to a single effect because the server
39 - /// dedups on `on`, a content-addressed key, an optimistic version, or a
40 - /// `UNIQUE` constraint. `on` documents *why* replay is harmless, at the call
41 - /// site, and naming it is mandatory.
42 - IdempotentWrite {
43 - /// The key/constraint the server dedups on (e.g. `"(app_id, version)
44 - /// unique"`). Documentation, surfaced in review and logs.
45 - on: &'static str,
46 - },
47 - /// Carries a client-generated idempotency key (e.g. push's `batch_id`) so
48 - /// the server collapses duplicate deliveries into one effect.
49 - Keyed,
50 - /// Not idempotent and not idempotency-keyed: replaying it would mint
51 - /// duplicate external state (e.g. a second Stripe Checkout session). The
52 - /// retry helpers attempt it exactly once.
53 - Unsafe,
54 - }
55 -
56 - impl Idempotency {
57 - /// Whether the retry helpers may replay this operation on a transient error.
58 - fn may_retry(self) -> bool {
59 - matches!(
60 - self,
61 - Idempotency::ReadOnly | Idempotency::IdempotentWrite { .. } | Idempotency::Keyed
62 - )
63 - }
64 -
65 - /// The documented dedup basis for an idempotent write (`None` for the other
66 - /// variants), surfaced in the retry log so a replayed request records *why*
67 - /// replay was safe.
68 - fn dedup_basis(self) -> Option<&'static str> {
69 - match self {
70 - Idempotency::IdempotentWrite { on } => Some(on),
71 - _ => None,
72 - }
73 - }
74 - }
75 -
76 - /// Upper bound on a control/JSON/error response body read into memory. Blob
77 - /// bodies stream through [`super::SyncKitClient::blob_download`]'s own 4 GiB cap;
78 - /// every *other* response, JSON control replies, OTA manifests, SSE-open errors,
79 - /// 4xx/5xx error bodies, is small, so a hostile or buggy server streaming a
80 - /// multi-gigabyte body into one of them is a pure OOM lever. 8 MiB is generous
81 - /// for any legitimate control body.
82 - pub(super) const MAX_CONTROL_BODY_BYTES: usize = 8 * 1024 * 1024;
83 -
84 - /// Read a response body into memory with a hard byte cap, the ONE sanctioned
85 - /// way to buffer a non-blob body.
86 - ///
87 - /// `reqwest`'s `Response::json`/`text`/`bytes` read the whole body with no size
88 - /// limit, so a hostile server can stream an arbitrarily large body and OOM the
89 - /// client (worst case: the public, unauthenticated OTA updater check). This
90 - /// drains `bytes_stream()` and aborts the instant the running total exceeds
91 - /// `limit`, and fast-rejects on an honest oversized `Content-Length` before
92 - /// reading a byte. Those raw reader methods are banned by `clippy.toml`
93 - /// (`disallowed-methods`) so a new call site cannot reintroduce an uncapped read.
94 - pub(super) async fn read_body_capped(
95 - resp: reqwest::Response,
96 - limit: usize,
97 - ) -> Result<bytes::Bytes> {
98 - use tokio_stream::StreamExt;
99 - if let Some(len) = resp.content_length()
100 - && len > limit as u64
101 - {
102 - return Err(SyncKitError::Internal(format!(
103 - "response Content-Length {len} exceeds {limit}-byte cap"
104 - )));
105 - }
106 - let mut stream = resp.bytes_stream();
107 - let mut buf = bytes::BytesMut::new();
108 - while let Some(chunk) = stream.next().await {
109 - let chunk = chunk.map_err(SyncKitError::Http)?;
110 - if buf.len() + chunk.len() > limit {
111 - return Err(SyncKitError::Internal(format!(
112 - "response body exceeds {limit}-byte cap"
113 - )));
114 - }
115 - buf.extend_from_slice(&chunk);
116 - }
117 - Ok(buf.freeze())
118 - }
119 -
120 - /// [`read_body_capped`] + JSON deserialize, the capped replacement for
121 - /// `resp.json::<T>()`.
122 - pub(super) async fn read_json_capped<T: serde::de::DeserializeOwned>(
123 - resp: reqwest::Response,
124 - limit: usize,
125 - ) -> Result<T> {
126 - let bytes = read_body_capped(resp, limit).await?;
127 - Ok(serde_json::from_slice(&bytes)?)
128 - }
129 -
130 - /// [`read_body_capped`] + lossy UTF-8, the capped replacement for `resp.text()`
131 - /// on error bodies (which are only logged, so lossy decoding and a `""` fallback
132 - /// on an oversized/failed read are fine).
133 - pub(super) async fn read_text_capped(resp: reqwest::Response, limit: usize) -> String {
134 - match read_body_capped(resp, limit).await {
135 - Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
136 - Err(_) => String::new(),
137 - }
138 - }
139 -
140 - /// Current on-wire HLC-envelope version, written into the `__skver` tag.
141 - const ENVELOPE_VERSION: u64 = 2;
142 -
143 - /// The on-wire HLC-envelope version, parsed from the `__skver` tag.
144 - ///
145 - /// Parsing is the explicit dispatch point: an unknown (future) version is a
146 - /// hard error, never a silent fallback. That closes the X2 hazard, a protocol
147 - /// bump that an older client cannot understand fails loudly instead of being
148 - /// mis-decoded as a bare row and corrupting the clock.
149 - #[derive(Clone, Copy, PartialEq, Eq, Debug)]
150 - enum WireVersion {
151 - /// The current envelope: `{ __skver: 2, __skhlc, data }`.
152 - V2,
153 - }
154 -
155 - impl WireVersion {
156 - fn parse(tag: u64) -> Result<Self> {
157 - match tag {
158 - 2 => Ok(WireVersion::V2),
159 - other => Err(SyncKitError::Crypto(format!(
160 - "unknown sync envelope version {other}; this client is too old to read it"
161 - ))),
162 - }
163 - }
164 - }
165 -
166 - impl SyncKitClient {
167 - /// Retry an async HTTP operation with exponential backoff.
168 - ///
169 - /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
170 - /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
171 - /// Client errors (4xx except 429) are considered permanent and returned immediately.
172 - ///
173 - /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
174 - pub(super) async fn retry_request<F, Fut>(
175 - &self,
176 - idempotency: Idempotency,
177 - mut operation: F,
178 - ) -> Result<reqwest::Response>
179 - where
180 - F: FnMut() -> Fut,
181 - Fut: std::future::Future<Output = Result<reqwest::Response>>,
182 - {
183 - // A non-idempotent operation gets exactly one attempt: zero retries.
184 - let max_attempts = if idempotency.may_retry() {
185 - MAX_RETRIES
186 - } else {
187 - 0
188 - };
189 - let mut last_err = None;
190 -
191 - for attempt in 0..=max_attempts {
192 - match operation().await {
193 - Ok(resp) => return Ok(resp),
194 - Err(err) => {
195 - if !is_transient(&err) {
196 - return Err(err);
197 - }
198 -
199 - if attempt < max_attempts {
200 - let delay = retry_delay(&err, attempt);
201 - tracing::debug!(
202 - attempt = attempt + 1,
203 - max_retries = MAX_RETRIES,
204 - delay_ms = delay.as_millis() as u64,
205 - error = %err,
206 - idempotency_basis = idempotency.dedup_basis(),
207 - "Transient error, retrying after backoff",
208 - );
209 - tokio::time::sleep(delay).await;
210 - }
211 -
212 - last_err = Some(err);
213 - }
214 - }
215 - }
216 -
217 - Err(last_err.expect("loop ran at least once"))
218 - }
219 -
220 - /// Retry an HTTP operation and deserialize the JSON response body inside
221 - /// the retry loop. This ensures a transient body-read failure (truncated
222 - /// response, connection reset mid-body) is retried rather than surfacing
223 - /// as a permanent error after the server already committed the operation.
224 - ///
225 - /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
226 - pub(super) async fn retry_request_json<F, Fut, T>(
227 - &self,
228 - idempotency: Idempotency,
229 - mut operation: F,
230 - ) -> Result<T>
231 - where
232 - F: FnMut() -> Fut,
233 - Fut: std::future::Future<Output = Result<reqwest::Response>>,
234 - T: serde::de::DeserializeOwned,
235 - {
236 - // A non-idempotent operation gets exactly one attempt: zero retries.
237 - let max_attempts = if idempotency.may_retry() {
238 - MAX_RETRIES
239 - } else {
240 - 0
241 - };
242 - let mut last_err = None;
243 -
244 - for attempt in 0..=max_attempts {
245 - match operation().await {
246 - Ok(resp) => match read_json_capped::<T>(resp, MAX_CONTROL_BODY_BYTES).await {
247 - Ok(parsed) => return Ok(parsed),
248 - Err(e) => {
249 - let err = e;
250 - if attempt < max_attempts {
251 - let delay = retry_delay(&err, attempt);
252 - tracing::debug!(
253 - attempt = attempt + 1,
254 - max_retries = MAX_RETRIES,
255 - delay_ms = delay.as_millis() as u64,
256 - error = %err,
257 - "Response body read failed, retrying",
258 - );
259 - tokio::time::sleep(delay).await;
260 - }
261 - last_err = Some(err);
262 - }
263 - },
264 - Err(err) => {
265 - if !is_transient(&err) {
266 - return Err(err);
267 - }
268 -
269 - if attempt < max_attempts {
270 - let delay = retry_delay(&err, attempt);
271 - tracing::debug!(
272 - attempt = attempt + 1,
273 - max_retries = max_attempts,
274 - delay_ms = delay.as_millis() as u64,
275 - error = %err,
276 - "Transient error, retrying after backoff",
277 - );
278 - tokio::time::sleep(delay).await;
279 - }
280 -
281 - last_err = Some(err);
282 - }
283 - }
284 - }
285 -
286 - Err(last_err.expect("loop ran at least once"))
287 - }
288 -
289 - /// Encrypt a change entry for the wire. Every change now seals an HLC
290 - /// envelope (Deletes included), so the master key is always required.
291 - #[cfg(test)]
292 - pub(super) fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
293 - let master_key = self.require_master_key()?;
294 - Self::encrypt_change_with_key(entry, &master_key)
295 - }
296 -
297 - /// Decrypt a pulled legacy entry that has no encrypted payload (a pre-HLC
298 - /// Delete). The HLC is synthesized from the entry's `client_timestamp`. No key
299 - /// needed.
300 - #[cfg(test)]
301 - pub(super) fn decrypt_change_no_data(entry: PullChangeEntry) -> Result<ChangeEntry> {
302 - debug_assert!(entry.data.is_none());
303 - Ok(ChangeEntry {
304 - table: entry.table,
305 - op: entry.op,
306 - row_id: entry.row_id,
307 - hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id),
308 - timestamp: entry.timestamp,
309 - data: None,
310 - extra: serde_json::Map::default(),
311 - })
312 - }
313 -
314 - /// Wrap a change's HLC and payload into one JSON envelope. Encrypting the
315 - /// envelope (rather than the bare row payload) is what carries the HLC inside
316 - /// the E2E ciphertext, including for Deletes, which have no row payload. The
317 - /// server stores the ciphertext opaquely and never sees the clock.
318 - ///
319 - /// The `__skver` tag is a positive, explicit version marker. A reader
320 - /// dispatches on it (see [`WireVersion`]) rather than structurally guessing,
321 - /// so a future format bump is rejected loudly instead of silently misread.
322 - fn hlc_envelope(hlc: &Hlc, data: Option<&serde_json::Value>) -> serde_json::Value {
323 - serde_json::json!({ "__skver": ENVELOPE_VERSION, "__skhlc": hlc, "data": data })
324 - }
325 -
326 - /// Split a decrypted payload back into `(hlc, data)`.
327 - ///
328 - /// Dispatch is explicit, not structural:
329 - /// - A `__skver` tag means a versioned envelope; the version is parsed via
330 - /// [`WireVersion::parse`], which **errors loudly** on an unknown future
331 - /// version rather than silently falling back to a bare-row read (which
332 - /// would corrupt the clock, the X2 hazard).
333 - /// - No `__skver` but an embedded `__skhlc` that parses is a gen-1 envelope
334 - /// (predates the version tag).
335 - /// - Anything else is a legacy bare-row payload whose HLC is synthesized
336 - /// from `node` + `timestamp_ms`.
337 - fn split_hlc_envelope(
338 - decrypted: serde_json::Value,
339 - node: crate::ids::DeviceId,
340 - timestamp_ms: i64,
341 - ) -> Result<(Hlc, Option<serde_json::Value>)> {
342 - if let Some(obj) = decrypted.as_object() {
343 - if let Some(tag) = obj.get("__skver") {
344 - // Explicit version present: dispatch, rejecting unknown loudly.
345 - let tag = tag.as_u64().ok_or_else(|| {
346 - SyncKitError::Crypto("envelope __skver tag is not an integer".into())
347 - })?;
348 - return match WireVersion::parse(tag)? {
349 - WireVersion::V2 => {
350 - let hlc = obj
351 - .get("__skhlc")
352 - .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
353 - .ok_or_else(|| {
354 - SyncKitError::Crypto("v2 envelope missing __skhlc".into())
355 - })?;
356 - let data = obj.get("data").cloned().filter(|v| !v.is_null());
357 - Ok((hlc, data))
358 - }
359 - };
360 - }
361 - // gen-1 envelope: embedded HLC, no version tag.
362 - if let Some(hlc) = obj
363 - .get("__skhlc")
364 - .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
365 - {
366 - let data = obj.get("data").cloned().filter(|v| !v.is_null());
367 - return Ok((hlc, data));
368 - }
369 - }
370 - // Legacy bare-row payload: the decrypted value is the row data itself.
371 - Ok((Hlc::from_legacy(timestamp_ms, node), Some(decrypted)))
372 - }
373 -
374 - /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock
375 - /// acquisition. Every change (including Deletes) is encrypted, because the HLC
376 - /// envelope always needs sealing. The ciphertext is bound to its
377 - /// `(table, row_id)` address via AEAD associated data, so a server cannot
378 - /// relocate it to another row/table without the open failing closed.
379 - pub(super) fn encrypt_change_with_key(
380 - entry: ChangeEntry,
381 - master_key: &[u8; 32],
382 - ) -> Result<WireChangeEntry> {
383 - let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
384 - let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
385 - let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &ctx)?);
386 -
387 - Ok(WireChangeEntry {
388 - table: entry.table,
389 - op: entry.op,
390 - row_id: entry.row_id,
391 - timestamp: entry.timestamp,
392 - data: encrypted_data,
393 - })
394 - }
395 -
396 - /// Decrypt the data field of a pulled change entry.
397 - #[cfg(test)]
398 - pub(super) fn decrypt_change(&self, entry: PullChangeEntry) -> Result<ChangeEntry> {
399 - if entry.data.is_some() {
400 - let master_key = self.require_master_key()?;
401 - Self::decrypt_change_with_key(entry, &master_key)
402 - } else {
403 - Self::decrypt_change_no_data(entry)
404 - }
405 - }
406 -
407 - /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`].
408 - ///
409 - /// Used by `pull_rich()` to produce conflict-detection-ready results.
410 - pub(super) fn decrypt_change_to_pulled(
411 - entry: PullChangeEntry,
412 - master_key: &[u8; 32],
413 - ) -> Result<crate::types::PulledChange> {
414 - let device_id = entry.device_id;
415 - let seq = entry.seq;
416 - let decrypted = Self::decrypt_change_with_key(entry, master_key)?;
417 - Ok(crate::types::PulledChange {
418 - entry: decrypted,
419 - device_id,
420 - seq,
421 - })
422 - }
423 -
424 - /// Decrypt a pulled entry during a rotation window, selecting the key by the
425 - /// entry's `key_id`. Generic over the decrypt step so both the plain-pull
426 - /// (`ChangeEntry`) and rich-pull (`PulledChange`) paths share exactly this
427 - /// selection logic, including the unknown-`key_id` fallback that tries the
428 - /// primary key then the pending key. Previously the live pull path
429 - /// reimplemented a fallback-less variant while the tested one sat unused.
430 - pub(super) fn decrypt_with_rotation_keys<T, F>(
431 - entry: PullChangeEntry,
432 - primary_key: &[u8; 32],
433 - primary_key_id: i32,
434 - pending_key: &[u8; 32],
435 - pending_key_id: i32,
436 - decrypt_fn: &F,
437 - ) -> Result<T>
438 - where
439 - F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
440 - {
441 - let effective_key_id = entry.key_id.unwrap_or(1);
442 - if effective_key_id == pending_key_id {
443 - decrypt_fn(entry, pending_key)
444 - } else if effective_key_id == primary_key_id || effective_key_id <= 1 {
445 - decrypt_fn(entry, primary_key)
446 - } else {
447 - // Unknown key_id, try primary, then fall back to pending.
448 - match decrypt_fn(entry.clone(), primary_key) {
449 - Ok(result) => Ok(result),
450 - Err(_) => decrypt_fn(entry, pending_key),
451 - }
452 - }
453 - }
454 -
455 - /// Encrypt a change for a **group** changelog, sealed under the group's GCK
456 - /// and bound to `(group_id, table, row_id)` as AEAD associated data. Same HLC
457 - /// envelope as the personal path; the extra `group_id` binding makes a
458 - /// ciphertext non-relocatable across groups (the p1 crypto guarantee).
459 - pub(super) fn encrypt_group_change_with_key(
460 - group_id: &str,
461 - entry: ChangeEntry,
462 - gck: &[u8; 32],
463 - ) -> Result<WireChangeEntry> {
464 - let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
465 - let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
466 - let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, gck, &ctx)?);
467 -
468 - Ok(WireChangeEntry {
469 - table: entry.table,
470 - op: entry.op,
471 - row_id: entry.row_id,
472 - timestamp: entry.timestamp,
473 - data: encrypted_data,
474 - })
475 - }
476 -
477 - /// Decrypt a pulled group entry under the group's GCK, preserving `device_id`
478 - /// and `seq` in a [`PulledChange`]. The counterpart to
479 - /// [`encrypt_group_change_with_key`](Self::encrypt_group_change_with_key).
480 - /// Group pull has no master-key-rotation window (a group's key rotates
481 - /// server-side, re-encrypted in place), so this is the whole decrypt path.
482 - pub(super) fn decrypt_group_change_to_pulled(
483 - group_id: &str,
484 - entry: PullChangeEntry,
485 - gck: &[u8; 32],
486 - ) -> Result<crate::types::PulledChange> {
487 - let device_id = entry.device_id;
488 - let seq = entry.seq;
489 - let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
490 - let (hlc, data) = match entry.data {
491 - Some(ref value) => {
492 - let decrypted = crypto::decrypt_json_aad(value, gck, &ctx)?;
493 - Self::split_hlc_envelope(decrypted, device_id, entry.timestamp.timestamp_millis())?
494 - }
495 - None => (
496 - Hlc::from_legacy(entry.timestamp.timestamp_millis(), device_id),
497 - None,
498 - ),
499 - };
500 - Ok(crate::types::PulledChange {
Lines truncated