max / makenotwork
47 files changed,
+1 insertion,
-22587 deletions
| @@ -17,7 +17,7 @@ tracing = "0.1" | |||
| 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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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 +0,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
| @@ -1,889 +0,0 @@ | |||
| 1 | - | //! HTTP transport and high-level API with transparent end-to-end encryption. | |
| 2 | - | //! | |
| 3 | - | //! This module provides [`SyncKitClient`], the primary interface to the MNW | |
| 4 | - | //! SyncKit server. All encryption and decryption happens transparently inside | |
| 5 | - | //! the client, callers work with plaintext [`ChangeEntry`] values and never | |
| 6 | - | //! handle ciphertext directly. | |
| 7 | - | //! | |
| 8 | - | //! ## Method groups | |
| 9 | - | //! | |
| 10 | - | //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password), | |
| 11 | - | //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE), | |
| 12 | - | //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session). | |
| 13 | - | //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device), | |
| 14 | - | //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices), | |
| 15 | - | //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain), | |
| 16 | - | //! [`change_password`](SyncKitClient::change_password). | |
| 17 | - | //! - **Device management**: [`register_device`](SyncKitClient::register_device), | |
| 18 | - | //! [`list_devices`](SyncKitClient::list_devices). | |
| 19 | - | //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull), | |
| 20 | - | //! [`status`](SyncKitClient::status). | |
| 21 | - | //! - **Blob storage**: [`blob_upload_streaming`](SyncKitClient::blob_upload_streaming) | |
| 22 | - | //! (file-backed, bounded memory, the path for large blobs), | |
| 23 | - | //! [`blob_upload_url`](SyncKitClient::blob_upload_url) + | |
| 24 | - | //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory), | |
| 25 | - | //! [`blob_confirm`](SyncKitClient::blob_confirm), | |
| 26 | - | //! [`blob_download_url`](SyncKitClient::blob_download_url), | |
| 27 | - | //! [`blob_download`](SyncKitClient::blob_download). | |
| 28 | - | //! | |
| 29 | - | //! ## Internal state | |
| 30 | - | //! | |
| 31 | - | //! The client holds two `RwLock`-wrapped fields: the authenticated session | |
| 32 | - | //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both | |
| 33 | - | //! start as `None` and are populated by the authentication and encryption | |
| 34 | - | //! setup methods respectively. | |
| 35 | - | //! | |
| 36 | - | //! ## Thread safety | |
| 37 | - | //! | |
| 38 | - | //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public | |
| 39 | - | //! methods take `&self`, acquiring the internal locks only briefly to read | |
| 40 | - | //! or update state. The locks are never held across `.await` points. | |
| 41 | - | //! | |
| 42 | - | //! ## Retry strategy | |
| 43 | - | //! | |
| 44 | - | //! All HTTP operations retry transient failures (network errors, 5xx, | |
| 45 | - | //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors | |
| 46 | - | //! (4xx except 429) are permanent and returned immediately. | |
| 47 | - | //! | |
| 48 | - | //! ## Token handling | |
| 49 | - | //! | |
| 50 | - | //! The client decodes the JWT `exp` claim (without signature verification) | |
| 51 | - | //! and applies a 30-second expiry buffer. If the token is about to expire, | |
| 52 | - | //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller | |
| 53 | - | //! can re-authenticate before the request fails on the server. | |
| 54 | - | ||
| 55 | - | mod auth; | |
| 56 | - | mod blob; | |
| 57 | - | mod groups; | |
| 58 | - | pub use blob::BlobUploadOutcome; | |
| 59 | - | mod encryption; | |
| 60 | - | pub(crate) mod helpers; | |
| 61 | - | mod ota; | |
| 62 | - | mod rotation; | |
| 63 | - | mod subscribe; | |
| 64 | - | pub mod subscription; | |
| 65 | - | mod sync; | |
| 66 | - | ||
| 67 | - | pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease}; | |
| 68 | - | pub use subscribe::SyncNotifyStream; | |
| 69 | - | ||
| 70 | - | use parking_lot::RwLock; | |
| 71 | - | use reqwest::Client; | |
| 72 | - | use std::sync::Arc; | |
| 73 | - | use std::time::Duration; | |
| 74 | - | #[cfg(test)] | |
| 75 | - | use uuid::Uuid; | |
| 76 | - | ||
| 77 | - | use crate::{ | |
| 78 | - | crypto, | |
| 79 | - | error::{Result, SyncKitError}, | |
| 80 | - | ids::{AppId, GroupId, UserId}, | |
| 81 | - | }; | |
| 82 | - | ||
| 83 | - | /// Maximum number of retry attempts for transient failures. | |
| 84 | - | const MAX_RETRIES: u32 = 3; | |
| 85 | - | ||
| 86 | - | /// Base delay for exponential backoff (1s, 2s, 4s). | |
| 87 | - | const BASE_DELAY: Duration = Duration::from_secs(1); | |
| 88 | - | ||
| 89 | - | /// Seconds before actual expiry to consider the token expired. | |
| 90 | - | /// Avoids sending a request with a token that expires mid-flight. | |
| 91 | - | const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; | |
| 92 | - | ||
| 93 | - | /// Inactivity (read) timeout for the streaming HTTP client. Bounds the gap | |
| 94 | - | /// between successive body reads without capping the total transfer, so a stalled | |
| 95 | - | /// (slow-loris) blob/OTA/SSE connection is torn down while a legitimately long, | |
| 96 | - | /// steady transfer is not. Kept above the server's 30s SSE keepalive interval so | |
| 97 | - | /// an idle notification stream is never mistaken for a stall. | |
| 98 | - | const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90); | |
| 99 | - | ||
| 100 | - | /// Configuration for the SyncKit client. | |
| 101 | - | #[derive(Debug, Clone)] | |
| 102 | - | pub struct SyncKitConfig { | |
| 103 | - | /// Base URL of the MNW server (e.g. "https://makenot.work"). | |
| 104 | - | pub server_url: String, | |
| 105 | - | /// App API key (obtained from MNW dashboard). | |
| 106 | - | pub api_key: String, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | /// Pre-built endpoint URLs, computed once at client construction. | |
| 110 | - | struct Endpoints { | |
| 111 | - | auth: String, | |
| 112 | - | oauth_token: String, | |
| 113 | - | devices: String, | |
| 114 | - | push: String, | |
| 115 | - | pull: String, | |
| 116 | - | subscribe: String, | |
| 117 | - | status: String, | |
| 118 | - | keys: String, | |
| 119 | - | groups_base: String, | |
| 120 | - | blobs_upload: String, | |
| 121 | - | blobs_confirm: String, | |
| 122 | - | blobs_download: String, | |
| 123 | - | blobs_multipart_start: String, | |
| 124 | - | blobs_multipart_parts: String, | |
| 125 | - | blobs_multipart_complete: String, | |
| 126 | - | blobs_multipart_abort: String, | |
| 127 | - | subscription: String, | |
| 128 | - | subscription_checkout: String, | |
| 129 | - | subscription_quote: String, | |
| 130 | - | subscription_storage_cap: String, | |
| 131 | - | app_pricing: String, | |
| 132 | - | account: String, | |
| 133 | - | /// Base for OTA paths (`{server}/api/v1/sync/ota`). The app-scoped and public | |
| 134 | - | /// OTA URLs carry runtime ids (app, release, slug/target/arch/version) so they | |
| 135 | - | /// cannot be pre-built like the static endpoints above; the `ota_*` builder | |
| 136 | - | /// methods construct them from this base, keeping *all* path construction in | |
| 137 | - | /// this one type instead of re-deriving it with `format!` in `ota.rs`. | |
| 138 | - | ota_base: String, | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | impl Endpoints { | |
| 142 | - | fn new(base: &str) -> Self { | |
| 143 | - | let base = base.trim_end_matches('/'); | |
| 144 | - | Self { | |
| 145 | - | auth: format!("{base}/api/v1/sync/auth"), | |
| 146 | - | oauth_token: format!("{base}/oauth/token"), | |
| 147 | - | devices: format!("{base}/api/v1/sync/devices"), | |
| 148 | - | push: format!("{base}/api/v1/sync/push"), | |
| 149 | - | pull: format!("{base}/api/v1/sync/pull"), | |
| 150 | - | subscribe: format!("{base}/api/v1/sync/subscribe"), | |
| 151 | - | status: format!("{base}/api/v1/sync/status"), | |
| 152 | - | keys: format!("{base}/api/v1/sync/keys"), | |
| 153 | - | blobs_upload: format!("{base}/api/v1/sync/blobs/upload"), | |
| 154 | - | blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"), | |
| 155 | - | blobs_download: format!("{base}/api/v1/sync/blobs/download"), | |
| 156 | - | blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"), | |
| 157 | - | blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"), | |
| 158 | - | blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"), | |
| 159 | - | blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"), | |
| 160 | - | subscription: format!("{base}/api/v1/sync/subscription"), | |
| 161 | - | subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), | |
| 162 | - | subscription_quote: format!("{base}/api/v1/sync/subscription/quote"), | |
| 163 | - | subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"), | |
| 164 | - | app_pricing: format!("{base}/api/v1/sync/app/pricing"), | |
| 165 | - | account: format!("{base}/api/v1/sync/account"), | |
| 166 | - | ota_base: format!("{base}/api/v1/sync/ota"), | |
| 167 | - | groups_base: format!("{base}/api/v1/sync/groups"), | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | /// `GET`/`POST` the group collection: list the caller's groups, or create one. | |
| 172 | - | fn groups(&self) -> &str { | |
| 173 | - | &self.groups_base | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | /// `GET` here for the caller's own sealed GCK grant for a group. | |
| 177 | - | fn group_grant(&self, group_id: GroupId) -> String { | |
| 178 | - | format!("{}/{group_id}/grant", self.groups_base) | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | /// `POST` here to add a member to a group (admin only). | |
| 182 | - | fn group_members(&self, group_id: GroupId) -> String { | |
| 183 | - | format!("{}/{group_id}/members", self.groups_base) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | /// `DELETE` here to remove a member from a group (admin only). | |
| 187 | - | fn group_member(&self, group_id: GroupId, member: UserId) -> String { | |
| 188 | - | format!("{}/{group_id}/members/{member}", self.groups_base) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | /// `POST` encrypted changes to a group's shared changelog. | |
| 192 | - | fn group_push(&self, group_id: GroupId) -> String { | |
| 193 | - | format!("{}/{group_id}/push", self.groups_base) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | /// `POST` to pull a group's changes since a cursor. | |
| 197 | - | fn group_pull(&self, group_id: GroupId) -> String { | |
| 198 | - | format!("{}/{group_id}/pull", self.groups_base) | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | /// `POST` here to create a release, or list releases, for an app. | |
| 202 | - | fn ota_releases(&self, app_id: AppId) -> String { | |
| 203 | - | format!("{}/apps/{app_id}/releases", self.ota_base) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | /// `POST` here to register an artifact under a release. | |
| 207 | - | fn ota_artifacts(&self, app_id: AppId, release_id: uuid::Uuid) -> String { | |
| 208 | - | format!( | |
| 209 | - | "{}/apps/{app_id}/releases/{release_id}/artifacts", | |
| 210 | - | self.ota_base | |
| 211 | - | ) | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | /// `POST` here to confirm an uploaded artifact under a release. | |
| 215 | - | fn ota_confirm(&self, app_id: AppId, release_id: uuid::Uuid) -> String { | |
| 216 | - | format!( | |
| 217 | - | "{}/apps/{app_id}/releases/{release_id}/artifacts/confirm", | |
| 218 | - | self.ota_base | |
| 219 | - | ) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | /// The public, unauthenticated Tauri updater check URL. | |
| 223 | - | fn ota_updater(&self, slug: &str, target: &str, arch: &str, current_version: &str) -> String { | |
| 224 | - | format!("{}/{slug}/{target}/{arch}/{current_version}", self.ota_base) | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | /// A bearer token whose bytes are scrubbed from memory when the last reference | |
| 229 | - | /// is dropped. | |
| 230 | - | /// | |
| 231 | - | /// The SDK holds the session's JWT for the lifetime of the session; wrapping it | |
| 232 | - | /// so its final drop zeroizes the heap keeps the credential from lingering after | |
| 233 | - | /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and | |
| 234 | - | /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer | |
| 235 | - | /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is | |
| 236 | - | /// redacted so the token cannot leak into a log line. | |
| 237 | - | pub struct SecretToken(String); | |
| 238 | - | ||
| 239 | - | impl SecretToken { | |
| 240 | - | pub(crate) fn new(token: String) -> Self { | |
| 241 | - | SecretToken(token) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | /// The raw token string, for building the `Authorization` header. | |
| 245 | - | pub fn as_str(&self) -> &str { | |
| 246 | - | &self.0 | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | impl std::ops::Deref for SecretToken { | |
| 251 | - | type Target = str; | |
| 252 | - | fn deref(&self) -> &str { | |
| 253 | - | &self.0 | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | impl std::fmt::Display for SecretToken { | |
| 258 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 259 | - | f.write_str(&self.0) | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | impl std::fmt::Debug for SecretToken { | |
| 264 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 265 | - | f.write_str("SecretToken(<redacted>)") | |
| 266 | - | } | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | impl Drop for SecretToken { | |
| 270 | - | fn drop(&mut self) { | |
| 271 | - | use zeroize::Zeroize; | |
| 272 | - | self.0.zeroize(); | |
| 273 | - | } | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | /// Session state obtained after authentication. | |
| 277 | - | struct Session { | |
| 278 | - | token: Arc<SecretToken>, | |
| 279 | - | /// Cached `exp` claim from the JWT, extracted once at session creation. | |
| 280 | - | token_exp: Option<i64>, | |
| 281 | - | user_id: UserId, | |
| 282 | - | app_id: AppId, | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | /// Public session info returned by `session_info()`. | |
| 286 | - | pub struct SessionInfo { | |
| 287 | - | /// The JWT bearer token for API requests (shared ref-counted to avoid | |
| 288 | - | /// cloning; zeroized when the last reference drops). | |
| 289 | - | pub token: Arc<SecretToken>, | |
| 290 | - | /// The authenticated user's UUID. | |
| 291 | - | pub user_id: UserId, | |
| 292 | - | /// The SyncKit app UUID this session belongs to. | |
| 293 | - | pub app_id: AppId, | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | /// Info about a pending key rotation, cached from `GET /keys`. | |
| 297 | - | pub(crate) struct PendingKeyState { | |
| 298 | - | pub key: crypto::ZeroizeOnDrop, | |
| 299 | - | pub key_id: i32, | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | /// The SyncKit client. Handles authentication, encryption, and HTTP transport. | |
| 303 | - | pub struct SyncKitClient { | |
| 304 | - | config: SyncKitConfig, | |
| 305 | - | /// HTTP client for small JSON control-plane calls. Carries a whole-request | |
| 306 | - | /// timeout, those calls should never run long. | |
| 307 | - | http: Client, | |
| 308 | - | /// HTTP client for large-body and long-lived transfers (blob up/download, | |
| 309 | - | /// OTA artifacts, the SSE notification stream). Deliberately has NO | |
| 310 | - | /// whole-request timeout, a multi-gigabyte blob or an open push stream must | |
| 311 | - | /// not be torn down by a fixed clock, only a connect timeout. | |
| 312 | - | http_stream: Client, | |
| 313 | - | endpoints: Endpoints, | |
| 314 | - | session: RwLock<Option<Session>>, | |
| 315 | - | master_key: RwLock<Option<crypto::ZeroizeOnDrop>>, | |
| 316 | - | /// The key_id associated with the current master_key. Default 1 (pre-rotation). | |
| 317 | - | master_key_id: RwLock<i32>, | |
| 318 | - | /// Pending rotation key, if a rotation is in progress. | |
| 319 | - | pending_key: RwLock<Option<PendingKeyState>>, | |
| 320 | - | /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version, | |
| 321 | - | /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key) | |
| 322 | - | /// from the sealed grant; a version mismatch (rotation) or a decrypt failure | |
| 323 | - | /// forces a re-fetch. Holds one entry per group, the current generation. | |
| 324 | - | gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>, | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | impl SyncKitClient { | |
| 328 | - | /// Create a new client with the given configuration. | |
| 329 | - | pub fn new(config: SyncKitConfig) -> Self { | |
| 330 | - | // reqwest is built with `rustls-no-provider`; a real consumer app installs the | |
| 331 | - | // process-wide crypto provider (audiofiles installs ring) before it ever builds | |
| 332 | - | // a client. Unit tests have no such app, so install ring here once, or the | |
| 333 | - | // client build below would panic. Never compiled into a consumer build. | |
| 334 | - | #[cfg(test)] | |
| 335 | - | { | |
| 336 | - | static PROVIDER: std::sync::Once = std::sync::Once::new(); | |
| 337 | - | PROVIDER.call_once(|| { | |
| 338 | - | let _ = rustls::crypto::ring::default_provider().install_default(); | |
| 339 | - | }); | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | let https_only = requires_https(&config.server_url); | |
| 343 | - | let http = Client::builder() | |
| 344 | - | .timeout(Duration::from_secs(30)) | |
| 345 | - | .connect_timeout(Duration::from_secs(10)) | |
| 346 | - | .pool_max_idle_per_host(5) | |
| 347 | - | .pool_idle_timeout(Duration::from_secs(90)) | |
| 348 | - | .https_only(https_only) | |
| 349 | - | .build() | |
| 350 | - | .expect("failed to build HTTP client"); | |
| 351 | - | ||
| 352 | - | // No whole-request timeout: this client carries multi-gigabyte blobs and | |
| 353 | - | // the long-lived SSE stream, which a fixed 30s cap would break. It DOES | |
| 354 | - | // carry a read (inactivity) timeout: without one, a server that dribbles | |
| 355 | - | // one byte an hour holds a blob/OTA/SSE connection open forever (slow- | |
| 356 | - | // loris). The cap bounds the gap *between* reads, not the total transfer, | |
| 357 | - | // so a legitimate slow-but-steady transfer is unaffected. 90s sits well | |
| 358 | - | // above the server's 30s SSE keepalive, so an idle notification stream | |
| 359 | - | // (which receives a keepalive comment every 30s) is never torn down. | |
| 360 | - | let http_stream = Client::builder() | |
| 361 | - | .connect_timeout(Duration::from_secs(10)) | |
| 362 | - | .read_timeout(STREAM_READ_TIMEOUT) | |
| 363 | - | .pool_max_idle_per_host(5) | |
| 364 | - | .pool_idle_timeout(Duration::from_secs(90)) | |
| 365 | - | .https_only(https_only) | |
| 366 | - | .build() | |
| 367 | - | .expect("failed to build streaming HTTP client"); | |
| 368 | - | ||
| 369 | - | let endpoints = Endpoints::new(&config.server_url); | |
| 370 | - | Self { | |
| 371 | - | config, | |
| 372 | - | http, | |
| 373 | - | http_stream, | |
| 374 | - | endpoints, | |
| 375 | - | session: RwLock::new(None), | |
| 376 | - | master_key: RwLock::new(None), | |
| 377 | - | master_key_id: RwLock::new(1), | |
| 378 | - | pending_key: RwLock::new(None), | |
| 379 | - | gck_cache: RwLock::new(std::collections::HashMap::new()), | |
| 380 | - | } | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | /// Create a new client with a custom HTTP client (for testing with custom timeouts). | |
| 384 | - | /// | |
| 385 | - | /// Test-only: gated behind the `testing` feature so consumer builds cannot | |
| 386 | - | /// substitute an unvalidated HTTP client. | |
| 387 | - | #[doc(hidden)] | |
| 388 | - | #[cfg(any(test, feature = "testing"))] | |
| 389 | - | pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self { | |
| 390 | - | let endpoints = Endpoints::new(&config.server_url); | |
| 391 | - | Self { | |
| 392 | - | config, | |
| 393 | - | http: http.clone(), | |
| 394 | - | http_stream: http, | |
| 395 | - | endpoints, | |
| 396 | - | session: RwLock::new(None), | |
| 397 | - | master_key: RwLock::new(None), | |
| 398 | - | master_key_id: RwLock::new(1), | |
| 399 | - | pending_key: RwLock::new(None), | |
| 400 | - | gck_cache: RwLock::new(std::collections::HashMap::new()), | |
| 401 | - | } | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | /// Returns the client configuration. | |
| 405 | - | pub fn config(&self) -> &SyncKitConfig { | |
| 406 | - | &self.config | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | /// Returns whether the master encryption key is loaded and ready. | |
| 410 | - | pub fn has_master_key(&self) -> bool { | |
| 411 | - | self.master_key.read().is_some() | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | /// Returns the current session info, if authenticated. | |
| 415 | - | pub fn session_info(&self) -> Option<SessionInfo> { | |
| 416 | - | let guard = self.session.read(); | |
| 417 | - | guard.as_ref().map(|s| SessionInfo { | |
| 418 | - | token: Arc::clone(&s.token), | |
| 419 | - | user_id: s.user_id, | |
| 420 | - | app_id: s.app_id, | |
| 421 | - | }) | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | /// Set a raw 256-bit master key directly (for testing without Argon2 overhead). | |
| 425 | - | /// | |
| 426 | - | /// Test-only: gated behind the `testing` feature so a consumer build has no | |
| 427 | - | /// chosen-key injection point that bypasses key derivation. | |
| 428 | - | #[doc(hidden)] | |
| 429 | - | #[cfg(any(test, feature = "testing"))] | |
| 430 | - | pub fn set_master_key_raw(&self, key: [u8; 32]) { | |
| 431 | - | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | // ── Internal helpers ── | |
| 435 | - | ||
| 436 | - | /// Extract the bearer token from the current session. | |
| 437 | - | /// | |
| 438 | - | /// Returns `NotAuthenticated` if no session exists. Also checks token | |
| 439 | - | /// expiry and returns `TokenExpired` if the JWT `exp` claim is within | |
| 440 | - | /// 30 seconds of the current time. | |
| 441 | - | pub(crate) fn require_token(&self) -> Result<Arc<SecretToken>> { | |
| 442 | - | let guard = self.session.read(); | |
| 443 | - | let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?; | |
| 444 | - | ||
| 445 | - | if let Some(exp) = session.token_exp { | |
| 446 | - | let now = chrono::Utc::now().timestamp(); | |
| 447 | - | if now >= exp - TOKEN_EXPIRY_BUFFER_SECS { | |
| 448 | - | return Err(SyncKitError::TokenExpired); | |
| 449 | - | } | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | Ok(Arc::clone(&session.token)) | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | /// Extract `(app_id, user_id)` from the current session. | |
| 456 | - | /// | |
| 457 | - | /// Returns `NotAuthenticated` if no session exists. | |
| 458 | - | pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> { | |
| 459 | - | let guard = self.session.read(); | |
| 460 | - | guard | |
| 461 | - | .as_ref() | |
| 462 | - | .map(|s| (s.app_id, s.user_id)) | |
| 463 | - | .ok_or(SyncKitError::NotAuthenticated) | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | /// Return a copy of the 256-bit master encryption key, wrapped in | |
| 467 | - | /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`. | |
| 468 | - | /// | |
| 469 | - | /// Returns `NoMasterKey` if encryption has not been set up yet. | |
| 470 | - | pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> { | |
| 471 | - | let guard = self.master_key.read(); | |
| 472 | - | guard | |
| 473 | - | .as_ref() | |
| 474 | - | .map(|k| crypto::ZeroizeOnDrop(**k)) | |
| 475 | - | .ok_or(SyncKitError::NoMasterKey) | |
| 476 | - | } | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`). | |
| 480 | - | /// | |
| 481 | - | /// Production traffic carries the bearer sync token, so plaintext `http` to any | |
| 482 | - | /// non-loopback host is refused (defense-in-depth on top of normal cert | |
| 483 | - | /// validation). Loopback hosts stay exempt so local development and the | |
| 484 | - | /// mock-server test suite can use `http://127.0.0.1`. | |
| 485 | - | fn requires_https(server_url: &str) -> bool { | |
| 486 | - | let lower = server_url.trim().to_ascii_lowercase(); | |
| 487 | - | let Some(rest) = lower.strip_prefix("http://") else { | |
| 488 | - | // https (or any non-plaintext scheme): enforcing https_only is a no-op. | |
| 489 | - | return true; | |
| 490 | - | }; | |
| 491 | - | // Extract the host *exactly*. A prefix match (`starts_with("127.")`, | |
| 492 | - | // `starts_with("localhost")`) trusts `http://127.0.0.1.attacker.com` and | |
| 493 | - | // `http://localhost.evil.com`, hostnames that resolve to an attacker's IP, | |
| 494 | - | // and would send the bearer token to them in cleartext. Parse the authority, | |
| 495 | - | // strip the port, and require an exact `localhost` or an IP literal that is | |
| 496 | - | // genuinely loopback. `0.0.0.0` is the unspecified/bind-all address, not a | |
| 497 | - | // loopback you connect *to*, so `is_loopback()` correctly rejects it. | |
| 498 | - | let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); | |
| 499 | - | // Strip userinfo: everything up to the last `@` is credentials, not the host. | |
| 500 | - | // `http://127.0.0.1:80@evil.com` connects to evil.com, so a naive port-split |
Lines truncated
| @@ -1,315 +0,0 @@ | |||
| 1 | - | //! OTA (Over-The-Air) release publishing. | |
| 2 | - | //! | |
| 3 | - | //! These methods drive the app-owner side of the MNW OTA system: create a | |
| 4 | - | //! release, register an artifact (which returns a presigned S3 PUT URL), upload | |
| 5 | - | //! the bytes, and verify the public Tauri updater endpoint now serves it. They | |
| 6 | - | //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a | |
| 7 | - | //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate) | |
| 8 | - | //! and then calls these in sequence. | |
| 9 | - | //! | |
| 10 | - | //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted, they are public | |
| 11 | - | //! downloads served to every installed app, so the bytes are uploaded as-is. | |
| 12 | - | //! | |
| 13 | - | //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest | |
| 14 | - | //! ([`OtaManifest`]) field names are load-bearing, Tauri's updater plugin | |
| 15 | - | //! deserializes exactly these five fields. | |
| 16 | - | ||
| 17 | - | use bytes::Bytes; | |
| 18 | - | use serde::{Deserialize, Serialize}; | |
| 19 | - | use tracing::instrument; | |
| 20 | - | use uuid::Uuid; | |
| 21 | - | ||
| 22 | - | use super::SyncKitClient; | |
| 23 | - | use super::helpers::{Idempotency, check_response}; | |
| 24 | - | use crate::error::Result; | |
| 25 | - | ||
| 26 | - | /// A created OTA release (subset of the server's release response). | |
| 27 | - | #[derive(Debug, Clone, Deserialize)] | |
| 28 | - | pub struct OtaRelease { | |
| 29 | - | /// Release id, used when registering artifacts. | |
| 30 | - | pub id: Uuid, | |
| 31 | - | pub version: String, | |
| 32 | - | pub notes: String, | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | /// A presigned upload target for an OTA artifact. | |
| 36 | - | #[derive(Debug, Clone, Deserialize)] | |
| 37 | - | pub struct OtaArtifactUpload { | |
| 38 | - | /// Presigned S3 PUT URL, upload the artifact bytes here. | |
| 39 | - | pub upload_url: String, | |
| 40 | - | /// The S3 object key the artifact will live at. | |
| 41 | - | pub s3_key: String, | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | /// The Tauri-compatible updater manifest returned by the public updater check. | |
| 45 | - | /// | |
| 46 | - | /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's | |
| 47 | - | /// updater plugin reads these five and nothing else. | |
| 48 | - | #[derive(Debug, Clone, Deserialize)] | |
| 49 | - | pub struct OtaManifest { | |
| 50 | - | pub version: String, | |
| 51 | - | pub url: String, | |
| 52 | - | pub signature: String, | |
| 53 | - | pub notes: String, | |
| 54 | - | pub pub_date: String, | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | #[derive(Serialize)] | |
| 58 | - | struct CreateReleaseBody<'a> { | |
| 59 | - | version: &'a str, | |
| 60 | - | notes: &'a str, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | #[derive(Serialize)] | |
| 64 | - | struct RegisterArtifactBody<'a> { | |
| 65 | - | target: &'a str, | |
| 66 | - | arch: &'a str, | |
| 67 | - | file_size: i64, | |
| 68 | - | signature: &'a str, | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | impl SyncKitClient { | |
| 72 | - | /// Create a new OTA release for the authenticated app. | |
| 73 | - | /// | |
| 74 | - | /// The signature is per-artifact (Tauri signs each platform's file | |
| 75 | - | /// independently), so it is supplied to [`Self::ota_register_artifact`], not | |
| 76 | - | /// here. | |
| 77 | - | #[instrument(skip(self))] | |
| 78 | - | pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result<OtaRelease> { | |
| 79 | - | let token = self.require_token()?; | |
| 80 | - | let (app_id, _user_id) = self.require_session_ids()?; | |
| 81 | - | let url = self.endpoints.ota_releases(app_id); | |
| 82 | - | ||
| 83 | - | let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?); | |
| 84 | - | ||
| 85 | - | self.retry_request_json( | |
| 86 | - | Idempotency::IdempotentWrite { | |
| 87 | - | on: "(app_id, version) unique, migration 033", | |
| 88 | - | }, | |
| 89 | - | || { | |
| 90 | - | let req = self | |
| 91 | - | .http | |
| 92 | - | .post(&url) | |
| 93 | - | .bearer_auth(&token) | |
| 94 | - | .header("content-type", "application/json") | |
| 95 | - | .body(body.clone()); | |
| 96 | - | async move { check_response(req.send().await?).await } | |
| 97 | - | }, | |
| 98 | - | ) | |
| 99 | - | .await | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | /// Register an artifact for a release and obtain a presigned upload URL. | |
| 103 | - | /// | |
| 104 | - | /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU | |
| 105 | - | /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and | |
| 106 | - | /// `signature` is this file's minisign signature, Tauri's updater silently | |
| 107 | - | /// refuses an update whose signature is empty or belongs to another platform, | |
| 108 | - | /// so each artifact must carry its own. | |
| 109 | - | #[instrument(skip(self, signature))] | |
| 110 | - | pub async fn ota_register_artifact( | |
| 111 | - | &self, | |
| 112 | - | release_id: Uuid, | |
| 113 | - | target: &str, | |
| 114 | - | arch: &str, | |
| 115 | - | file_size: i64, | |
| 116 | - | signature: &str, | |
| 117 | - | ) -> Result<OtaArtifactUpload> { | |
| 118 | - | let token = self.require_token()?; | |
| 119 | - | let (app_id, _user_id) = self.require_session_ids()?; | |
| 120 | - | let url = self.endpoints.ota_artifacts(app_id, release_id); | |
| 121 | - | ||
| 122 | - | let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody { | |
| 123 | - | target, | |
| 124 | - | arch, | |
| 125 | - | file_size, | |
| 126 | - | signature, | |
| 127 | - | })?); | |
| 128 | - | ||
| 129 | - | self.retry_request_json( | |
| 130 | - | Idempotency::IdempotentWrite { | |
| 131 | - | on: "(release_id, target, arch) unique, migration 033", | |
| 132 | - | }, | |
| 133 | - | || { | |
| 134 | - | let req = self | |
| 135 | - | .http | |
| 136 | - | .post(&url) | |
| 137 | - | .bearer_auth(&token) | |
| 138 | - | .header("content-type", "application/json") | |
| 139 | - | .body(body.clone()); | |
| 140 | - | async move { check_response(req.send().await?).await } | |
| 141 | - | }, | |
| 142 | - | ) | |
| 143 | - | .await | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | /// Confirm an uploaded artifact: the server verifies the object landed and | |
| 147 | - | /// enqueues its malware scan. Until the scan clears, the artifact stays | |
| 148 | - | /// `pending` and is neither advertised nor downloadable. Call after | |
| 149 | - | /// [`Self::ota_upload_artifact`]. | |
| 150 | - | #[instrument(skip(self))] | |
| 151 | - | pub async fn ota_confirm_artifact( | |
| 152 | - | &self, | |
| 153 | - | release_id: Uuid, | |
| 154 | - | target: &str, | |
| 155 | - | arch: &str, | |
| 156 | - | ) -> Result<()> { | |
| 157 | - | let token = self.require_token()?; | |
| 158 | - | let (app_id, _user_id) = self.require_session_ids()?; | |
| 159 | - | let url = self.endpoints.ota_confirm(app_id, release_id); | |
| 160 | - | let body = Bytes::from(serde_json::to_vec(&serde_json::json!({ | |
| 161 | - | "target": target, | |
| 162 | - | "arch": arch, | |
| 163 | - | }))?); | |
| 164 | - | ||
| 165 | - | self.retry_request( | |
| 166 | - | Idempotency::IdempotentWrite { | |
| 167 | - | on: "(release_id, target, arch)", | |
| 168 | - | }, | |
| 169 | - | || { | |
| 170 | - | let req = self | |
| 171 | - | .http | |
| 172 | - | .post(&url) | |
| 173 | - | .bearer_auth(&token) | |
| 174 | - | .header("content-type", "application/json") | |
| 175 | - | .body(body.clone()); | |
| 176 | - | async move { check_response(req.send().await?).await } | |
| 177 | - | }, | |
| 178 | - | ) | |
| 179 | - | .await?; | |
| 180 | - | Ok(()) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | /// Upload artifact bytes to S3 via a presigned PUT URL. | |
| 184 | - | /// | |
| 185 | - | /// The bytes are sent as-is (no encryption, OTA artifacts are public). | |
| 186 | - | #[instrument(skip(self, presigned_url, data))] | |
| 187 | - | pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> { | |
| 188 | - | let data = Bytes::from(data); | |
| 189 | - | self.retry_request( | |
| 190 | - | Idempotency::IdempotentWrite { | |
| 191 | - | on: "S3 key (content-addressed)", | |
| 192 | - | }, | |
| 193 | - | || { | |
| 194 | - | let req = self | |
| 195 | - | .http_stream | |
| 196 | - | .put(presigned_url) | |
| 197 | - | .header("content-type", "application/octet-stream") | |
| 198 | - | .body(data.clone()); | |
| 199 | - | async move { check_response(req.send().await?).await } | |
| 200 | - | }, | |
| 201 | - | ) | |
| 202 | - | .await?; | |
| 203 | - | Ok(()) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | /// Check the public Tauri updater endpoint. | |
| 207 | - | /// | |
| 208 | - | /// Returns `Some(manifest)` when a newer version than `current_version` is | |
| 209 | - | /// available for `slug`/`target`/`arch`, or `None` when the client is up to | |
| 210 | - | /// date (HTTP 204). Use this to verify a freshly published release is live. | |
| 211 | - | #[instrument(skip(self))] | |
| 212 | - | pub async fn ota_updater_check( | |
| 213 | - | &self, | |
| 214 | - | slug: &str, | |
| 215 | - | target: &str, | |
| 216 | - | arch: &str, | |
| 217 | - | current_version: &str, | |
| 218 | - | ) -> Result<Option<OtaManifest>> { | |
| 219 | - | let url = self | |
| 220 | - | .endpoints | |
| 221 | - | .ota_updater(slug, target, arch, current_version); | |
| 222 | - | ||
| 223 | - | let resp = self | |
| 224 | - | .retry_request(Idempotency::ReadOnly, || { | |
| 225 | - | let req = self.http_stream.get(&url); | |
| 226 | - | async move { check_response(req.send().await?).await } | |
| 227 | - | }) | |
| 228 | - | .await?; | |
| 229 | - | ||
| 230 | - | if resp.status() == reqwest::StatusCode::NO_CONTENT { | |
| 231 | - | return Ok(None); | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | Ok(Some( | |
| 235 | - | crate::client::helpers::read_json_capped::<OtaManifest>( | |
| 236 | - | resp, | |
| 237 | - | crate::client::helpers::MAX_CONTROL_BODY_BYTES, | |
| 238 | - | ) | |
| 239 | - | .await?, | |
| 240 | - | )) | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | #[cfg(test)] | |
| 245 | - | mod tests { | |
| 246 | - | use super::*; | |
| 247 | - | ||
| 248 | - | #[test] | |
| 249 | - | fn create_release_body_serializes_expected_fields() { | |
| 250 | - | let body = CreateReleaseBody { | |
| 251 | - | version: "0.4.1", | |
| 252 | - | notes: "Bug fixes", | |
| 253 | - | }; | |
| 254 | - | let v: serde_json::Value = serde_json::to_value(&body).unwrap(); | |
| 255 | - | assert_eq!(v["version"], "0.4.1"); | |
| 256 | - | assert_eq!(v["notes"], "Bug fixes"); | |
| 257 | - | // Signature is per-artifact now, not on the release. | |
| 258 | - | assert!(v.get("signature").is_none()); | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | #[test] | |
| 262 | - | fn register_artifact_body_serializes_expected_fields() { | |
| 263 | - | let body = RegisterArtifactBody { | |
| 264 | - | target: "darwin", | |
| 265 | - | arch: "aarch64", | |
| 266 | - | file_size: 12_345, | |
| 267 | - | signature: "RWS...==", | |
| 268 | - | }; | |
| 269 | - | let v: serde_json::Value = serde_json::to_value(&body).unwrap(); | |
| 270 | - | assert_eq!(v["target"], "darwin"); | |
| 271 | - | assert_eq!(v["arch"], "aarch64"); | |
| 272 | - | assert_eq!(v["file_size"], 12_345); | |
| 273 | - | assert_eq!(v["signature"], "RWS...=="); | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | #[test] | |
| 277 | - | fn release_response_deserializes_with_uuid_id() { | |
| 278 | - | let json = r#"{ | |
| 279 | - | "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", | |
| 280 | - | "version": "0.4.1", | |
| 281 | - | "notes": "", | |
| 282 | - | "pub_date": "2026-06-07T00:00:00Z", | |
| 283 | - | "created_at": "2026-06-07T00:00:00Z" | |
| 284 | - | }"#; | |
| 285 | - | let r: OtaRelease = serde_json::from_str(json).unwrap(); | |
| 286 | - | assert_eq!(r.version, "0.4.1"); | |
| 287 | - | assert_eq!( | |
| 288 | - | r.id, | |
| 289 | - | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap() | |
| 290 | - | ); | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | #[test] | |
| 294 | - | fn artifact_upload_response_deserializes() { | |
| 295 | - | let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#; | |
| 296 | - | let u: OtaArtifactUpload = serde_json::from_str(json).unwrap(); | |
| 297 | - | assert_eq!(u.upload_url, "https://s3.example/put?sig=abc"); | |
| 298 | - | assert!(u.s3_key.ends_with("/artifact")); | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | #[test] | |
| 302 | - | fn manifest_deserializes_tauri_five_fields() { | |
| 303 | - | let json = r#"{ | |
| 304 | - | "version": "0.4.1", | |
| 305 | - | "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64", | |
| 306 | - | "signature": "RWS=", | |
| 307 | - | "notes": "Bug fixes", | |
| 308 | - | "pub_date": "2026-06-07T00:00:00+00:00" | |
| 309 | - | }"#; | |
| 310 | - | let m: OtaManifest = serde_json::from_str(json).unwrap(); | |
| 311 | - | assert_eq!(m.version, "0.4.1"); | |
| 312 | - | assert!(m.url.contains("/download/")); | |
| 313 | - | assert_eq!(m.signature, "RWS="); | |
| 314 | - | } | |
| 315 | - | } |
| @@ -1,512 +0,0 @@ | |||
| 1 | - | //! Master-key rotation orchestration. | |
| 2 | - | //! | |
| 3 | - | //! Drives the multi-round server protocol that re-encrypts every device's key | |
| 4 | - | //! envelope under a new master key, completes the rotation, and sweeps up | |
| 5 | - | //! stragglers. The round count is bounded by `MAX_ROTATION_ROUNDS` so a | |
| 6 | - | //! misbehaving server cannot spin the loop indefinitely. | |
| 7 | - | ||
| 8 | - | use bytes::Bytes; | |
| 9 | - | use tracing::instrument; | |
| 10 | - | use uuid::Uuid; | |
| 11 | - | ||
| 12 | - | use crate::{ | |
| 13 | - | crypto, | |
| 14 | - | error::Result, | |
| 15 | - | ids::DeviceId, | |
| 16 | - | types::{ | |
| 17 | - | BeginRotationRequest, BeginRotationResponse, CompleteRotationRequest, PendingKeyInfo, | |
| 18 | - | RotationBatchEntry, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest, | |
| 19 | - | RotationEntriesResponse, | |
| 20 | - | }, | |
| 21 | - | }; | |
| 22 | - | ||
| 23 | - | use super::SyncKitClient; | |
| 24 | - | use super::helpers::{Idempotency, check_response}; | |
| 25 | - | ||
| 26 | - | /// Hard ceiling on re-encrypt / straggler rounds within a single rotation. Each | |
| 27 | - | /// round re-encrypts one server batch and the set provably shrinks (re-encrypted | |
| 28 | - | /// entries drop out of the server's `key_id != new_key_id` filter), so a real | |
| 29 | - | /// rotation finishes in `total_entries / batch_size` rounds. The cap only fires | |
| 30 | - | /// when the set does NOT shrink, a stalled or hostile server that keeps handing | |
| 31 | - | /// back work, converting an unbounded spin into a bounded error. Generous enough | |
| 32 | - | /// to cover any real sync log at any batch size. | |
| 33 | - | const MAX_ROTATION_ROUNDS: u32 = 100_000; | |
| 34 | - | ||
| 35 | - | impl SyncKitClient { | |
| 36 | - | /// Rotate the master encryption key. | |
| 37 | - | /// | |
| 38 | - | /// Generates a new 256-bit key, re-encrypts all sync log entries in batches, | |
| 39 | - | /// and commits the new key to the server. | |
| 40 | - | /// | |
| 41 | - | /// Genuinely idempotent: if a previous call was interrupted, the server still | |
| 42 | - | /// holds the rotation's `pending_key`. This call detects that and **resumes | |
| 43 | - | /// with the already-committed key** rather than minting a second new key, | |
| 44 | - | /// the latter would orphan any entries already re-encrypted under the first | |
| 45 | - | /// key. Only when there is no pending rotation is a fresh key generated. | |
| 46 | - | /// | |
| 47 | - | /// Requires the encryption password (to wrap the new key) and a device_id | |
| 48 | - | /// (the device performing the rotation). Only one device can rotate at a time. | |
| 49 | - | /// | |
| 50 | - | /// After completion, all devices will receive the new key on their next | |
| 51 | - | /// `GET /keys` call. During rotation, both old and new keys are available | |
| 52 | - | /// so pulls continue to work. | |
| 53 | - | #[instrument(skip(self, password))] | |
| 54 | - | pub async fn rotate_key(&self, device_id: DeviceId, password: &str) -> Result<()> { | |
| 55 | - | // 1. Verify password against the still-active key, and learn whether a | |
| 56 | - | // rotation is already pending for this user. | |
| 57 | - | let key_state = self.get_server_key_full().await?; | |
| 58 | - | let key_version = key_state.key_version.unwrap_or(0); | |
| 59 | - | let old_key = crypto::verify_password_against_envelope(&key_state.encrypted_key, password)?; | |
| 60 | - | let old_key = crypto::ZeroizeOnDrop(old_key); | |
| 61 | - | ||
| 62 | - | // 2. Resume an interrupted rotation by adopting its committed pending key, | |
| 63 | - | // or start fresh. Either way `new_envelope` is the key the server will | |
| 64 | - | // promote on completion, so re-encryption uses the same key it commits. | |
| 65 | - | let (new_master_key, new_envelope) = | |
| 66 | - | Self::rotation_key_material(key_state.pending_key, password)?; | |
| 67 | - | // Wrap immediately so the new key is zeroized on every exit path, an | |
| 68 | - | // error in the re-encrypt or complete loops below would otherwise drop | |
| 69 | - | // the bare array without scrubbing it. | |
| 70 | - | let new_master_key = crypto::ZeroizeOnDrop(new_master_key); | |
| 71 | - | ||
| 72 | - | // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the | |
| 73 | - | // same device: it returns the existing rotation, ignoring `new_envelope` | |
| 74 | - | // on resume, which is why step 2 must adopt the committed key. | |
| 75 | - | let begin_resp = self | |
| 76 | - | .begin_rotation(device_id, &new_envelope, key_version) | |
| 77 | - | .await?; | |
| 78 | - | let rotation_id = begin_resp.rotation_id; | |
| 79 | - | let new_key_id = begin_resp.new_key_id; | |
| 80 | - | ||
| 81 | - | tracing::info!( | |
| 82 | - | rotation_id = %rotation_id, | |
| 83 | - | target_seq = begin_resp.target_seq, | |
| 84 | - | new_key_id = new_key_id, | |
| 85 | - | "Key rotation started", | |
| 86 | - | ); | |
| 87 | - | ||
| 88 | - | // 4. Re-encrypt loop (bounded, see reencrypt_until_done). | |
| 89 | - | self.reencrypt_until_done(rotation_id, &old_key, &new_master_key) | |
| 90 | - | .await?; | |
| 91 | - | ||
| 92 | - | // 5. Complete, retry if stragglers arrived from concurrent pushes. Both | |
| 93 | - | // the straggler retries and each re-encrypt pass are capped so a server | |
| 94 | - | // that keeps returning 409 (a stall, or a hostile peer racing pushes) | |
| 95 | - | // cannot spin this loop forever. | |
| 96 | - | let mut straggler_rounds = 0u32; | |
| 97 | - | loop { | |
| 98 | - | match self.complete_rotation(rotation_id).await { | |
| 99 | - | Ok(()) => break, | |
| 100 | - | Err(crate::error::SyncKitError::Server { status: 409, .. }) => { | |
| 101 | - | straggler_rounds += 1; | |
| 102 | - | if straggler_rounds >= MAX_ROTATION_ROUNDS { | |
| 103 | - | return Err(crate::error::SyncKitError::Internal( | |
| 104 | - | "key rotation did not converge: server kept reporting stragglers past the round cap".into(), | |
| 105 | - | )); | |
| 106 | - | } | |
| 107 | - | tracing::debug!( | |
| 108 | - | round = straggler_rounds, | |
| 109 | - | "Stragglers detected, re-encrypting remaining entries" | |
| 110 | - | ); | |
| 111 | - | self.reencrypt_until_done(rotation_id, &old_key, &new_master_key) | |
| 112 | - | .await?; | |
| 113 | - | } | |
| 114 | - | Err(e) => return Err(e), | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // 6. Update local state | |
| 119 | - | let (app_id, user_id) = self.require_session_ids()?; | |
| 120 | - | crate::keystore::store_key(app_id, user_id, &new_master_key)?; | |
| 121 | - | *self.master_key.write() = Some(new_master_key); | |
| 122 | - | *self.master_key_id.write() = new_key_id; | |
| 123 | - | *self.pending_key.write() = None; | |
| 124 | - | ||
| 125 | - | tracing::info!("Key rotation completed"); | |
| 126 | - | Ok(()) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | /// Decide the rotation's new key material: adopt the server's committed | |
| 130 | - | /// `pending_key` when resuming an interrupted rotation, otherwise mint a | |
| 131 | - | /// fresh key. Returns `(new_master_key, new_envelope)` where the envelope is | |
| 132 | - | /// what the server promotes on completion, so the re-encryption key always | |
| 133 | - | /// matches the committed envelope. Pure (no IO) so it is unit-testable. | |
| 134 | - | fn rotation_key_material( | |
| 135 | - | pending: Option<PendingKeyInfo>, | |
| 136 | - | password: &str, | |
| 137 | - | ) -> Result<([u8; 32], String)> { | |
| 138 | - | match pending { | |
| 139 | - | Some(pending) => { | |
| 140 | - | let key = crypto::unwrap_master_key(&pending.encrypted_key, password)?; | |
| 141 | - | Ok((key, pending.encrypted_key)) | |
| 142 | - | } | |
| 143 | - | None => { | |
| 144 | - | // Wrap before the fallible `wrap_master_key` so a wrap error | |
| 145 | - | // scrubs the fresh key instead of dropping a bare array. | |
| 146 | - | let key = crypto::ZeroizeOnDrop(crypto::generate_master_key()); | |
| 147 | - | let envelope = crypto::wrap_master_key(&key.0, password)?; | |
| 148 | - | Ok((key.0, envelope)) | |
| 149 | - | } | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | /// Run [`reencrypt_batch`](Self::reencrypt_batch) until the server reports no | |
| 154 | - | /// entries left, bounded by [`MAX_ROTATION_ROUNDS`]. The batch set shrinks | |
| 155 | - | /// every round under an honest server; the cap is the backstop against a | |
| 156 | - | /// server that never reports it done, so the client fails with an error | |
| 157 | - | /// instead of spinning forever. | |
| 158 | - | async fn reencrypt_until_done( | |
| 159 | - | &self, | |
| 160 | - | rotation_id: Uuid, | |
| 161 | - | old_key: &[u8; 32], | |
| 162 | - | new_key: &[u8; 32], | |
| 163 | - | ) -> Result<()> { | |
| 164 | - | for _ in 0..MAX_ROTATION_ROUNDS { | |
| 165 | - | if self.reencrypt_batch(rotation_id, old_key, new_key).await? { | |
| 166 | - | return Ok(()); | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | Err(crate::error::SyncKitError::Internal( | |
| 170 | - | "key rotation did not converge: re-encrypt exceeded the round cap without the entry set draining".into(), | |
| 171 | - | )) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | /// Pull a batch of entries needing re-encryption, re-encrypt them, and push back. | |
| 175 | - | /// Returns true if there are no more entries to process. | |
| 176 | - | async fn reencrypt_batch( | |
| 177 | - | &self, | |
| 178 | - | rotation_id: Uuid, | |
| 179 | - | old_key: &[u8; 32], | |
| 180 | - | new_key: &[u8; 32], | |
| 181 | - | ) -> Result<bool> { | |
| 182 | - | // Pull entries needing re-encryption (starting from seq 0 each time, | |
| 183 | - | // since the server filters by key_id != new_key_id) | |
| 184 | - | let entries_resp = self.rotation_entries(rotation_id, 0).await?; | |
| 185 | - | ||
| 186 | - | if entries_resp.entries.is_empty() { | |
| 187 | - | return Ok(true); | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | let has_more = entries_resp.has_more; | |
| 191 | - | ||
| 192 | - | // Re-encrypt each entry | |
| 193 | - | let reencrypted: Vec<RotationBatchEntry> = entries_resp | |
| 194 | - | .entries | |
| 195 | - | .into_iter() | |
| 196 | - | .map(|entry| { | |
| 197 | - | let new_data = match entry.data { | |
| 198 | - | Some(ref encrypted_value) => { | |
| 199 | - | // Decrypt with old key, re-encrypt with new key, binding the | |
| 200 | - | // same (table, row_id) AAD on both ends. A legacy untagged | |
| 201 | - | // entry decrypts with empty AAD and re-emits as a v2 tagged, | |
| 202 | - | // position-bound payload (opportunistic upgrade). | |
| 203 | - | let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id); | |
| 204 | - | let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &ctx)?; | |
| 205 | - | let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &ctx)?; | |
| 206 | - | Some(reencrypted) | |
| 207 | - | } | |
| 208 | - | None => None, // DELETE entries have no data | |
| 209 | - | }; | |
| 210 | - | Ok(RotationBatchEntry { | |
| 211 | - | seq: entry.seq, | |
| 212 | - | data: new_data, | |
| 213 | - | }) | |
| 214 | - | }) | |
| 215 | - | .collect::<Result<Vec<_>>>()?; | |
| 216 | - | ||
| 217 | - | // Push re-encrypted batch | |
| 218 | - | let count = reencrypted.len(); | |
| 219 | - | self.rotation_batch(rotation_id, reencrypted).await?; | |
| 220 | - | ||
| 221 | - | tracing::debug!(count, "Re-encrypted batch submitted"); | |
| 222 | - | ||
| 223 | - | Ok(!has_more) | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // ── HTTP helpers ── | |
| 227 | - | ||
| 228 | - | async fn begin_rotation( | |
| 229 | - | &self, | |
| 230 | - | device_id: DeviceId, | |
| 231 | - | new_encrypted_key: &str, | |
| 232 | - | expected_key_version: i32, | |
| 233 | - | ) -> Result<BeginRotationResponse> { | |
| 234 | - | let token = self.require_token()?; | |
| 235 | - | let url = format!( | |
| 236 | - | "{}/api/v1/sync/keys/rotate", | |
| 237 | - | self.config.server_url.trim_end_matches('/') | |
| 238 | - | ); | |
| 239 | - | ||
| 240 | - | let body = Bytes::from(serde_json::to_vec(&BeginRotationRequest { | |
| 241 | - | device_id, | |
| 242 | - | new_encrypted_key: new_encrypted_key.to_string(), | |
| 243 | - | expected_key_version, | |
| 244 | - | })?); | |
| 245 | - | ||
| 246 | - | self.retry_request_json( | |
| 247 | - | Idempotency::IdempotentWrite { | |
| 248 | - | on: "one rotation per (app_id, user_id), migration 089", | |
| 249 | - | }, | |
| 250 | - | || { | |
| 251 | - | let req = self | |
| 252 | - | .http | |
| 253 | - | .post(&url) | |
| 254 | - | .bearer_auth(&token) | |
| 255 | - | .header("content-type", "application/json") | |
| 256 | - | .body(body.clone()); | |
| 257 | - | async move { check_response(req.send().await?).await } | |
| 258 | - | }, | |
| 259 | - | ) | |
| 260 | - | .await | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | async fn rotation_entries( | |
| 264 | - | &self, | |
| 265 | - | rotation_id: Uuid, | |
| 266 | - | after_seq: i64, | |
| 267 | - | ) -> Result<RotationEntriesResponse> { | |
| 268 | - | let token = self.require_token()?; | |
| 269 | - | let url = format!( | |
| 270 | - | "{}/api/v1/sync/keys/rotate/entries", | |
| 271 | - | self.config.server_url.trim_end_matches('/') | |
| 272 | - | ); | |
| 273 | - | ||
| 274 | - | let body = Bytes::from(serde_json::to_vec(&RotationEntriesRequest { | |
| 275 | - | rotation_id, | |
| 276 | - | after_seq, | |
| 277 | - | })?); | |
| 278 | - | ||
| 279 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 280 | - | let req = self | |
| 281 | - | .http | |
| 282 | - | .post(&url) | |
| 283 | - | .bearer_auth(&token) | |
| 284 | - | .header("content-type", "application/json") | |
| 285 | - | .body(body.clone()); | |
| 286 | - | async move { check_response(req.send().await?).await } | |
| 287 | - | }) | |
| 288 | - | .await | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | async fn rotation_batch( | |
| 292 | - | &self, | |
| 293 | - | rotation_id: Uuid, | |
| 294 | - | entries: Vec<RotationBatchEntry>, | |
| 295 | - | ) -> Result<RotationBatchResponse> { | |
| 296 | - | let token = self.require_token()?; | |
| 297 | - | let url = format!( | |
| 298 | - | "{}/api/v1/sync/keys/rotate/batch", | |
| 299 | - | self.config.server_url.trim_end_matches('/') | |
| 300 | - | ); | |
| 301 | - | ||
| 302 | - | let body = Bytes::from(serde_json::to_vec(&RotationBatchRequest { | |
| 303 | - | rotation_id, | |
| 304 | - | entries, | |
| 305 | - | })?); | |
| 306 | - | ||
| 307 | - | self.retry_request_json( | |
| 308 | - | Idempotency::IdempotentWrite { | |
| 309 | - | on: "seq (re-encrypt overwrites the same row)", | |
| 310 | - | }, | |
| 311 | - | || { | |
| 312 | - | let req = self | |
| 313 | - | .http | |
| 314 | - | .post(&url) | |
| 315 | - | .bearer_auth(&token) | |
| 316 | - | .header("content-type", "application/json") | |
| 317 | - | .body(body.clone()); | |
| 318 | - | async move { check_response(req.send().await?).await } | |
| 319 | - | }, | |
| 320 | - | ) | |
| 321 | - | .await | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | async fn complete_rotation(&self, rotation_id: Uuid) -> Result<()> { | |
| 325 | - | let token = self.require_token()?; | |
| 326 | - | let url = format!( | |
| 327 | - | "{}/api/v1/sync/keys/rotate/complete", | |
| 328 | - | self.config.server_url.trim_end_matches('/') | |
| 329 | - | ); | |
| 330 | - | ||
| 331 | - | let body = Bytes::from(serde_json::to_vec(&CompleteRotationRequest { | |
| 332 | - | rotation_id, | |
| 333 | - | })?); | |
| 334 | - | ||
| 335 | - | self.retry_request(Idempotency::IdempotentWrite { on: "rotation_id" }, || { | |
| 336 | - | let req = self | |
| 337 | - | .http | |
| 338 | - | .post(&url) | |
| 339 | - | .bearer_auth(&token) | |
| 340 | - | .header("content-type", "application/json") | |
| 341 | - | .body(body.clone()); | |
| 342 | - | async move { check_response(req.send().await?).await } | |
| 343 | - | }) | |
| 344 | - | .await?; | |
| 345 | - | ||
| 346 | - | Ok(()) | |
| 347 | - | } | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | #[cfg(test)] | |
| 351 | - | mod tests { | |
| 352 | - | use super::*; | |
| 353 | - | use crate::types::{GetKeyResponse, PullChangeEntry}; | |
| 354 | - | ||
| 355 | - | #[test] | |
| 356 | - | fn rotation_resume_reuses_committed_pending_key() { | |
| 357 | - | // Simulate an interrupted rotation: the server holds a pending envelope | |
| 358 | - | // wrapping key K. Resuming must recover K (not mint a fresh key), or | |
| 359 | - | // entries already re-encrypted under K become undecryptable. | |
| 360 | - | let committed_key = crypto::generate_master_key(); | |
| 361 | - | let pending_envelope = crypto::wrap_master_key(&committed_key, "pw").unwrap(); | |
| 362 | - | let pending = Some(PendingKeyInfo { | |
| 363 | - | encrypted_key: pending_envelope.clone(), | |
| 364 | - | key_id: 2, | |
| 365 | - | }); | |
| 366 | - | ||
| 367 | - | let (resumed_key, envelope) = SyncKitClient::rotation_key_material(pending, "pw").unwrap(); | |
| 368 | - | assert_eq!( | |
| 369 | - | resumed_key, committed_key, | |
| 370 | - | "resume must adopt the committed key" | |
| 371 | - | ); | |
| 372 | - | assert_eq!( | |
| 373 | - | envelope, pending_envelope, | |
| 374 | - | "resume must keep the committed envelope" | |
| 375 | - | ); | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | #[test] | |
| 379 | - | fn rotation_fresh_start_mints_unwrappable_key() { | |
| 380 | - | // No pending rotation: a fresh key is generated and its envelope unwraps | |
| 381 | - | // back to that same key under the password. | |
| 382 | - | let (fresh_key, envelope) = SyncKitClient::rotation_key_material(None, "pw").unwrap(); | |
| 383 | - | let recovered = crypto::unwrap_master_key(&envelope, "pw").unwrap(); | |
| 384 | - | assert_eq!(recovered, fresh_key); | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | #[test] | |
| 388 | - | fn reencrypt_preserves_plaintext() { | |
| 389 | - | // Simulate re-encryption: encrypt with old key, decrypt, re-encrypt with new key | |
| 390 | - | let old_key = crypto::generate_master_key(); | |
| 391 | - | let new_key = crypto::generate_master_key(); | |
| 392 | - | let original = serde_json::json!({"title": "Test task", "priority": 3}); | |
| 393 | - | ||
| 394 | - | // Encrypt with old key (simulates existing sync_log entry) | |
| 395 | - | let encrypted_old = crypto::encrypt_json(&original, &old_key).unwrap(); | |
| 396 | - | ||
| 397 | - | // Re-encrypt: decrypt with old, encrypt with new | |
| 398 | - | let plaintext = crypto::decrypt_json(&encrypted_old, &old_key).unwrap(); | |
| 399 | - | let encrypted_new = crypto::encrypt_json(&plaintext, &new_key).unwrap(); | |
| 400 | - | ||
| 401 | - | // Verify: new key can decrypt to original data | |
| 402 | - | let recovered = crypto::decrypt_json(&encrypted_new, &new_key).unwrap(); | |
| 403 | - | assert_eq!(recovered, original); | |
| 404 | - | ||
| 405 | - | // Verify: old key cannot decrypt re-encrypted data | |
| 406 | - | assert!(crypto::decrypt_json(&encrypted_new, &old_key).is_err()); | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | #[test] | |
| 410 | - | fn reencrypt_null_data_stays_null() { | |
| 411 | - | // DELETE entries have no data, rotation should preserve this | |
| 412 | - | let old_key = crypto::generate_master_key(); | |
| 413 | - | let new_key = crypto::generate_master_key(); | |
| 414 | - | ||
| 415 | - | // No data to re-encrypt | |
| 416 | - | let data: Option<serde_json::Value> = None; | |
| 417 | - | let reencrypted = match data { | |
| 418 | - | Some(ref val) => { | |
| 419 | - | let plaintext = crypto::decrypt_json(val, &old_key).unwrap(); | |
| 420 | - | Some(crypto::encrypt_json(&plaintext, &new_key).unwrap()) | |
| 421 | - | } | |
| 422 | - | None => None, | |
| 423 | - | }; | |
| 424 | - | ||
| 425 | - | assert!(reencrypted.is_none()); | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | #[test] | |
| 429 | - | fn rotation_request_types_serialize() { | |
| 430 | - | let req = BeginRotationRequest { | |
| 431 | - | device_id: DeviceId::new(Uuid::new_v4()), | |
| 432 | - | new_encrypted_key: "envelope-json".to_string(), | |
| 433 | - | expected_key_version: 1, | |
| 434 | - | }; | |
| 435 | - | let json = serde_json::to_string(&req).unwrap(); | |
| 436 | - | assert!(json.contains("expected_key_version")); | |
| 437 | - | ||
| 438 | - | let req = RotationBatchRequest { | |
| 439 | - | rotation_id: Uuid::new_v4(), | |
| 440 | - | entries: vec![ | |
| 441 | - | RotationBatchEntry { | |
| 442 | - | seq: 1, | |
| 443 | - | data: Some(serde_json::json!("encrypted")), | |
| 444 | - | }, | |
| 445 | - | RotationBatchEntry { seq: 2, data: None }, | |
| 446 | - | ], | |
| 447 | - | }; | |
| 448 | - | let json = serde_json::to_string(&req).unwrap(); | |
| 449 | - | assert!(json.contains("rotation_id")); | |
| 450 | - | assert!(json.contains("\"seq\":1")); | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | #[test] | |
| 454 | - | fn rotation_response_types_deserialize() { | |
| 455 | - | let json = r#"{"rotation_id": "550e8400-e29b-41d4-a716-446655440000", "target_seq": 100, "new_key_id": 2}"#; | |
| 456 | - | let resp: BeginRotationResponse = serde_json::from_str(json).unwrap(); | |
| 457 | - | assert_eq!(resp.target_seq, 100); | |
| 458 | - | assert_eq!(resp.new_key_id, 2); | |
| 459 | - | ||
| 460 | - | let json = r#"{"entries": [{"seq": 1, "table": "tasks", "row_id": "r1", "data": "encrypted"}, {"seq": 2, "table": "tasks", "row_id": "r2", "data": null}], "has_more": true}"#; | |
| 461 | - | let resp: RotationEntriesResponse = serde_json::from_str(json).unwrap(); | |
| 462 | - | assert_eq!(resp.entries.len(), 2); | |
| 463 | - | assert!(resp.has_more); | |
| 464 | - | assert!(resp.entries[0].data.is_some()); | |
| 465 | - | assert_eq!(resp.entries[0].table, "tasks"); | |
| 466 | - | assert_eq!(resp.entries[0].row_id, "r1"); | |
| 467 | - | assert!(resp.entries[1].data.is_none()); | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | #[test] | |
| 471 | - | fn pending_key_info_deserializes() { | |
| 472 | - | let json = r#"{ | |
| 473 | - | "encrypted_key": "envelope", | |
| 474 | - | "key_version": 2, | |
| 475 | - | "key_id": 1, | |
| 476 | - | "pending_key": {"encrypted_key": "new-envelope", "key_id": 2} | |
| 477 | - | }"#; | |
| 478 | - | let resp: GetKeyResponse = serde_json::from_str(json).unwrap(); | |
| 479 | - | assert!(resp.pending_key.is_some()); | |
| 480 | - | let pending = resp.pending_key.unwrap(); | |
| 481 | - | assert_eq!(pending.key_id, 2); | |
| 482 | - | assert_eq!(pending.encrypted_key, "new-envelope"); | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | #[test] | |
| 486 | - | fn get_key_response_without_pending_key() { | |
| 487 | - | let json = r#"{"encrypted_key": "envelope", "key_version": 1}"#; | |
| 488 | - | let resp: GetKeyResponse = serde_json::from_str(json).unwrap(); | |
| 489 | - | assert!(resp.pending_key.is_none()); | |
| 490 | - | assert_eq!(resp.key_id, None); // backward compat | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | #[test] | |
| 494 | - | fn pull_change_entry_with_key_id() { | |
| 495 | - | let device_id = Uuid::new_v4(); | |
| 496 | - | let json = format!( | |
| 497 | - | r#"{{"seq": 1, "device_id": "{device_id}", "table": "t", "op": "INSERT", "row_id": "r", "timestamp": "2025-06-01T12:00:00Z", "data": null, "key_id": 2}}"# | |
| 498 | - | ); | |
| 499 | - | let entry: PullChangeEntry = serde_json::from_str(&json).unwrap(); | |
| 500 | - | assert_eq!(entry.key_id, Some(2)); |
Lines truncated
| @@ -1,316 +0,0 @@ | |||
| 1 | - | //! SSE push notification stream for real-time sync notifications. | |
| 2 | - | //! | |
| 3 | - | //! The server sends zero-data `event: changed` events over SSE whenever | |
| 4 | - | //! another device pushes changes. The client should pull on each event. | |
| 5 | - | //! The stream auto-reconnects with backoff across transient drops; it only | |
| 6 | - | //! ends (`next_change` returns `None`) on a fatal condition, the server | |
| 7 | - | //! rejecting the reconnect for auth reasons, or a protocol violation. | |
| 8 | - | ||
| 9 | - | use std::sync::Arc; | |
| 10 | - | use std::time::Duration; | |
| 11 | - | ||
| 12 | - | use rand::RngExt; | |
| 13 | - | ||
| 14 | - | use crate::error::{Result, SyncKitError}; | |
| 15 | - | ||
| 16 | - | use super::{SecretToken, SyncKitClient}; | |
| 17 | - | ||
| 18 | - | /// A stream of SSE "changed" notifications from the SyncKit server. | |
| 19 | - | /// | |
| 20 | - | /// Created by [`SyncKitClient::subscribe`]. Wraps a raw HTTP response byte | |
| 21 | - | /// stream and parses SSE events line by line, yielding `Some(())` for each | |
| 22 | - | /// `event: changed`. When the underlying connection drops, it transparently | |
| 23 | - | /// reconnects (exponential backoff, capped, jittered) rather than ending, | |
| 24 | - | /// SSE `changed` events are contentless pokes, so a reconnect needs no cursor. | |
| 25 | - | /// `next_change` returns `None` only when reconnection is futile: the server | |
| 26 | - | /// rejects the request with `401`/`403` (the caller must re-authenticate), or | |
| 27 | - | /// the stream violates the protocol (invalid UTF-8, oversized block). | |
| 28 | - | pub struct SyncNotifyStream { | |
| 29 | - | buffer: String, | |
| 30 | - | /// `None` between a drop and the next successful reconnect. | |
| 31 | - | response: Option<reqwest::Response>, | |
| 32 | - | http: reqwest::Client, | |
| 33 | - | url: String, | |
| 34 | - | token: Arc<SecretToken>, | |
| 35 | - | reconnect_attempt: u32, | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | /// Maximum SSE buffer size (1 MB). If the server sends data without a | |
| 39 | - | /// block terminator (`\n\n`) exceeding this limit, we treat it as an error | |
| 40 | - | /// and close the stream to prevent unbounded memory growth. | |
| 41 | - | const MAX_SSE_BUFFER: usize = 1024 * 1024; | |
| 42 | - | ||
| 43 | - | /// Cap on the reconnect backoff delay. | |
| 44 | - | const MAX_RECONNECT_DELAY: Duration = Duration::from_mins(1); | |
| 45 | - | ||
| 46 | - | /// Maximum *consecutive* failed reconnect attempts before the stream gives up. | |
| 47 | - | /// A single success resets the counter, so this only fires when the server has | |
| 48 | - | /// been unreachable or erroring for a sustained stretch (with the 60s backoff | |
| 49 | - | /// cap, several minutes). Without it, a permanently-degraded non-auth server | |
| 50 | - | /// (persistent 500s, connection refused) is retried silently forever, the | |
| 51 | - | /// caller is better told the stream is dead so it can re-subscribe later. | |
| 52 | - | const MAX_RECONNECT_ATTEMPTS: u32 = 10; | |
| 53 | - | ||
| 54 | - | /// Reconnect backoff: 1s, 2s, 4s, ... capped at [`MAX_RECONNECT_DELAY`], with | |
| 55 | - | /// +/-20% jitter so a fleet of clients doesn't reconnect in lockstep. | |
| 56 | - | fn reconnect_delay(attempt: u32) -> Duration { | |
| 57 | - | let base = Duration::from_secs(1) | |
| 58 | - | .saturating_mul(2u32.saturating_pow(attempt.min(6))) | |
| 59 | - | .min(MAX_RECONNECT_DELAY); | |
| 60 | - | let millis = base.as_millis() as u64; | |
| 61 | - | let span = millis / 5; | |
| 62 | - | let delta = rand::rng().random_range(0..=2 * span.max(1)); | |
| 63 | - | Duration::from_millis(millis.saturating_sub(span).saturating_add(delta)) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | impl SyncNotifyStream { | |
| 67 | - | pub(crate) fn new( | |
| 68 | - | http: reqwest::Client, | |
| 69 | - | url: String, | |
| 70 | - | token: Arc<SecretToken>, | |
| 71 | - | response: reqwest::Response, | |
| 72 | - | ) -> Self { | |
| 73 | - | Self { | |
| 74 | - | buffer: String::new(), | |
| 75 | - | response: Some(response), | |
| 76 | - | http, | |
| 77 | - | url, | |
| 78 | - | token, | |
| 79 | - | reconnect_attempt: 0, | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | /// Re-establish the SSE connection after a drop. Loops with backoff on | |
| 84 | - | /// transient failures; returns `false` (fatal) if the server rejects the | |
| 85 | - | /// request for auth reasons (caller re-authenticates) or after | |
| 86 | - | /// [`MAX_RECONNECT_ATTEMPTS`] consecutive failures (caller re-subscribes | |
| 87 | - | /// later) rather than retrying a dead server forever. | |
| 88 | - | async fn reconnect(&mut self) -> bool { | |
| 89 | - | loop { | |
| 90 | - | self.reconnect_attempt += 1; | |
| 91 | - | if self.reconnect_attempt > MAX_RECONNECT_ATTEMPTS { | |
| 92 | - | tracing::warn!( | |
| 93 | - | attempts = self.reconnect_attempt - 1, | |
| 94 | - | "SSE reconnect gave up after too many consecutive failures; stream closing" | |
| 95 | - | ); | |
| 96 | - | return false; | |
| 97 | - | } | |
| 98 | - | tokio::time::sleep(reconnect_delay(self.reconnect_attempt)).await; | |
| 99 | - | ||
| 100 | - | match self | |
| 101 | - | .http | |
| 102 | - | .get(&self.url) | |
| 103 | - | .bearer_auth(&*self.token) | |
| 104 | - | .send() | |
| 105 | - | .await | |
| 106 | - | { | |
| 107 | - | Ok(resp) => { | |
| 108 | - | let status = resp.status().as_u16(); | |
| 109 | - | if status == 401 || status == 403 { | |
| 110 | - | tracing::warn!(status, "SSE reconnect rejected (auth); stream closing"); | |
| 111 | - | return false; | |
| 112 | - | } | |
| 113 | - | if status >= 400 { | |
| 114 | - | tracing::debug!( | |
| 115 | - | status, | |
| 116 | - | attempt = self.reconnect_attempt, | |
| 117 | - | "SSE reconnect got error status; backing off" | |
| 118 | - | ); | |
| 119 | - | continue; | |
| 120 | - | } | |
| 121 | - | tracing::debug!(attempt = self.reconnect_attempt, "SSE reconnected"); | |
| 122 | - | self.reconnect_attempt = 0; | |
| 123 | - | self.buffer.clear(); | |
| 124 | - | self.response = Some(resp); | |
| 125 | - | return true; | |
| 126 | - | } | |
| 127 | - | Err(e) => { | |
| 128 | - | tracing::debug!( | |
| 129 | - | error = %e, | |
| 130 | - | attempt = self.reconnect_attempt, | |
| 131 | - | "SSE reconnect failed; backing off" | |
| 132 | - | ); | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | /// Wait for the next "changed" notification. | |
| 139 | - | /// | |
| 140 | - | /// Transparently reconnects across transient stream drops. Returns `Some(())` | |
| 141 | - | /// when the server signals new changes. Returns `None` only on a fatal | |
| 142 | - | /// condition: the reconnect is rejected for auth, or the stream violates the | |
| 143 | - | /// SSE protocol (invalid UTF-8 / oversized block). Keepalive comments (lines | |
| 144 | - | /// starting with `:`) are silently ignored. | |
| 145 | - | pub async fn next_change(&mut self) -> Option<()> { | |
| 146 | - | loop { | |
| 147 | - | if self.response.is_none() && !self.reconnect().await { | |
| 148 | - | return None; | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | // Try to extract a complete SSE block from the buffer | |
| 152 | - | if let Some(pos) = self.buffer.find("\n\n") { | |
| 153 | - | let block = self.buffer[..pos].to_string(); | |
| 154 | - | self.buffer = self.buffer[pos + 2..].to_string(); | |
| 155 | - | ||
| 156 | - | if parse_sse_block_is_changed(&block) { | |
| 157 | - | return Some(()); | |
| 158 | - | } | |
| 159 | - | // Not a "changed" event, skip and try next block | |
| 160 | - | continue; | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Need more data from the stream | |
| 164 | - | let chunk = self | |
| 165 | - | .response | |
| 166 | - | .as_mut() | |
| 167 | - | .expect("connected above") | |
| 168 | - | .chunk() | |
| 169 | - | .await; | |
| 170 | - | match chunk { | |
| 171 | - | Ok(Some(chunk)) => { | |
| 172 | - | match std::str::from_utf8(&chunk) { | |
| 173 | - | Ok(text) => self.buffer.push_str(text), | |
| 174 | - | Err(_) => { | |
| 175 | - | tracing::warn!("SSE stream contained invalid UTF-8, closing"); | |
| 176 | - | return None; | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | if self.buffer.len() > MAX_SSE_BUFFER { | |
| 180 | - | tracing::warn!( | |
| 181 | - | "SSE buffer exceeded {MAX_SSE_BUFFER} bytes, closing stream" | |
| 182 | - | ); | |
| 183 | - | return None; | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | // Stream ended or errored: drop it and reconnect on the next loop | |
| 187 | - | // instead of ending, this is the auto-reconnect. | |
| 188 | - | Ok(None) => { | |
| 189 | - | tracing::debug!("SSE stream ended; will reconnect"); | |
| 190 | - | self.response = None; | |
| 191 | - | } | |
| 192 | - | Err(e) => { | |
| 193 | - | tracing::debug!(error = %e, "SSE stream error; will reconnect"); | |
| 194 | - | self.response = None; | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | /// Parse an SSE block and return `true` if it contains `event: changed`. | |
| 202 | - | /// | |
| 203 | - | /// An SSE block is one or more lines separated by `\n`, terminated by `\n\n`. | |
| 204 | - | /// Lines starting with `:` are comments (keepalive). The `event:` field | |
| 205 | - | /// specifies the event type, `data:` the payload (ignored here). | |
| 206 | - | fn parse_sse_block_is_changed(block: &str) -> bool { | |
| 207 | - | for line in block.lines() { | |
| 208 | - | let trimmed = line.trim(); | |
| 209 | - | // Skip comments | |
| 210 | - | if trimmed.starts_with(':') { | |
| 211 | - | continue; | |
| 212 | - | } | |
| 213 | - | if let Some(value) = trimmed.strip_prefix("event:") | |
| 214 | - | && value.trim() == "changed" | |
| 215 | - | { | |
| 216 | - | return true; | |
| 217 | - | } | |
| 218 | - | } | |
| 219 | - | false | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | impl SyncKitClient { | |
| 223 | - | /// Open an SSE connection for real-time push notifications. | |
| 224 | - | /// | |
| 225 | - | /// Returns a [`SyncNotifyStream`] that yields `Some(())` each time the | |
| 226 | - | /// server signals new changes are available. The caller should pull after | |
| 227 | - | /// each notification to get the actual changes. | |
| 228 | - | /// | |
| 229 | - | /// The returned stream auto-reconnects with backoff across transient drops; | |
| 230 | - | /// `next_change()` returns `None` only on a fatal condition (auth rejected on | |
| 231 | - | /// reconnect, or a protocol violation), at which point the caller should | |
| 232 | - | /// re-authenticate and subscribe again. | |
| 233 | - | #[tracing::instrument(skip(self))] | |
| 234 | - | pub async fn subscribe(&self) -> Result<SyncNotifyStream> { | |
| 235 | - | let token = self.require_token()?; | |
| 236 | - | let (app_id, _user_id) = self.require_session_ids()?; | |
| 237 | - | let url = format!("{}?app_id={}", self.endpoints.subscribe, app_id); | |
| 238 | - | ||
| 239 | - | let resp = self | |
| 240 | - | .http_stream | |
| 241 | - | .get(&url) | |
| 242 | - | .bearer_auth(&*token) | |
| 243 | - | .send() | |
| 244 | - | .await | |
| 245 | - | .map_err(SyncKitError::Http)?; | |
| 246 | - | ||
| 247 | - | let status = resp.status().as_u16(); | |
| 248 | - | if status >= 400 { | |
| 249 | - | let message = crate::client::helpers::read_text_capped( | |
| 250 | - | resp, | |
| 251 | - | crate::client::helpers::MAX_CONTROL_BODY_BYTES, | |
| 252 | - | ) | |
| 253 | - | .await; | |
| 254 | - | return Err(SyncKitError::Server { | |
| 255 | - | status, | |
| 256 | - | message, | |
| 257 | - | retry_after_secs: None, | |
| 258 | - | }); | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // Hand the stream everything it needs to reconnect itself. | |
| 262 | - | Ok(SyncNotifyStream::new( | |
| 263 | - | self.http_stream.clone(), | |
| 264 | - | url, | |
| 265 | - | token, | |
| 266 | - | resp, | |
| 267 | - | )) | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | #[cfg(test)] | |
| 272 | - | mod tests { | |
| 273 | - | use super::*; | |
| 274 | - | ||
| 275 | - | #[test] | |
| 276 | - | fn parse_changed_event() { | |
| 277 | - | let block = "event: changed\ndata: {}"; | |
| 278 | - | assert!(parse_sse_block_is_changed(block)); | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | #[test] | |
| 282 | - | fn parse_ignores_keepalive_comments() { | |
| 283 | - | let block = ": keepalive"; | |
| 284 | - | assert!(!parse_sse_block_is_changed(block)); | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | #[test] | |
| 288 | - | fn parse_handles_malformed_lines() { | |
| 289 | - | let block = "garbled nonsense"; | |
| 290 | - | assert!(!parse_sse_block_is_changed(block)); | |
| 291 | - | ||
| 292 | - | let block2 = ""; | |
| 293 | - | assert!(!parse_sse_block_is_changed(block2)); | |
| 294 | - | ||
| 295 | - | let block3 = "event:other\ndata: test"; | |
| 296 | - | assert!(!parse_sse_block_is_changed(block3)); | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | #[test] | |
| 300 | - | fn parse_changed_with_extra_whitespace() { | |
| 301 | - | let block = "event: changed \ndata: {}"; | |
| 302 | - | assert!(parse_sse_block_is_changed(block)); | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | #[test] | |
| 306 | - | fn reconnect_delay_grows_then_caps() { | |
| 307 | - | // Within +/-20% jitter, the delay grows with the attempt and never | |
| 308 | - | // exceeds the cap plus jitter headroom. | |
| 309 | - | let d1 = reconnect_delay(1); | |
| 310 | - | let d6 = reconnect_delay(6); | |
| 311 | - | assert!(d1 >= Duration::from_millis(1600) && d1 <= Duration::from_millis(2400)); | |
| 312 | - | assert!(d6 <= MAX_RECONNECT_DELAY + Duration::from_secs(12)); | |
| 313 | - | // Deep attempts stay capped (exponent is clamped). | |
| 314 | - | assert!(reconnect_delay(100) <= MAX_RECONNECT_DELAY + Duration::from_secs(12)); | |
| 315 | - | } | |
| 316 | - | } |
| @@ -1,442 +0,0 @@ | |||
| 1 | - | //! Subscription status, pricing-formula quoting, checkout, and storage-cap | |
| 2 | - | //! management for end-user SyncKit subscriptions. | |
| 3 | - | //! | |
| 4 | - | //! The server uses a formula-driven pricing model: there are no fixed tiers, | |
| 5 | - | //! the user picks any storage cap (within a min/max) and the server quotes a | |
| 6 | - | //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`] | |
| 7 | - | //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`] | |
| 8 | - | //! once the user clicks subscribe. | |
| 9 | - | ||
| 10 | - | use bytes::Bytes; | |
| 11 | - | use serde::{Deserialize, Deserializer, Serialize, Serializer}; | |
| 12 | - | ||
| 13 | - | use super::SyncKitClient; | |
| 14 | - | use super::helpers::{Idempotency, check_response}; | |
| 15 | - | use crate::error::Result; | |
| 16 | - | ||
| 17 | - | /// A monetary amount in whole cents. | |
| 18 | - | /// | |
| 19 | - | /// All pricing arithmetic flows through this newtype so the saturating | |
| 20 | - | /// discipline lives in one place rather than being re-derived at each call | |
| 21 | - | /// site: the inputs are server-supplied and a hostile or buggy value must | |
| 22 | - | /// never panic (debug overflow) or wrap to a negative price (release). It is | |
| 23 | - | /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64` | |
| 24 | - | /// it replaces. | |
| 25 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] | |
| 26 | - | #[serde(transparent)] | |
| 27 | - | pub struct Cents(pub i64); | |
| 28 | - | ||
| 29 | - | impl Cents { | |
| 30 | - | /// The underlying whole-cent count. | |
| 31 | - | pub const fn get(self) -> i64 { | |
| 32 | - | self.0 | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing. | |
| 36 | - | #[must_use] | |
| 37 | - | pub fn saturating_add(self, other: Cents) -> Cents { | |
| 38 | - | Cents(self.0.saturating_add(other.0)) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | /// Scale by an integer factor (e.g. a months multiplier), saturating. | |
| 42 | - | #[must_use] | |
| 43 | - | pub fn saturating_mul(self, factor: i64) -> Cents { | |
| 44 | - | Cents(self.0.saturating_mul(factor)) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | /// The larger of two amounts (e.g. applying a price floor). | |
| 48 | - | #[must_use] | |
| 49 | - | pub fn max(self, other: Cents) -> Cents { | |
| 50 | - | Cents(self.0.max(other.0)) | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | impl std::fmt::Display for Cents { | |
| 55 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 56 | - | write!(f, "{}", self.0) | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | /// Subscription status as returned by the server. | |
| 61 | - | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 62 | - | #[non_exhaustive] | |
| 63 | - | pub struct SubscriptionStatus { | |
| 64 | - | /// Whether the user has an active sync subscription for this app. | |
| 65 | - | pub active: bool, | |
| 66 | - | /// Billing interval. `None` if no subscription. | |
| 67 | - | /// Kept under the legacy field name `tier` on the wire for SDK compat. | |
| 68 | - | #[serde(rename = "tier")] | |
| 69 | - | pub interval: Option<BillingInterval>, | |
| 70 | - | /// Subscription status string (e.g. "active", "past_due", "canceled"). | |
| 71 | - | pub status: Option<String>, | |
| 72 | - | /// Current blob storage cap, in bytes. | |
| 73 | - | pub storage_limit_bytes: Option<i64>, | |
| 74 | - | /// A queued cap change that applies at the next billing cycle. `None` | |
| 75 | - | /// when no change is pending. | |
| 76 | - | #[serde(default)] | |
| 77 | - | pub pending_storage_limit_bytes: Option<i64>, | |
| 78 | - | /// Blob storage currently in use, in bytes (when the server has the info). | |
| 79 | - | pub storage_used_bytes: Option<i64>, | |
| 80 | - | /// ISO 8601 end of current billing period. | |
| 81 | - | pub current_period_end: Option<String>, | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | /// Pricing-formula constants for an app. Clients use these to quote a price | |
| 85 | - | /// locally as a slider moves; the server enforces the same formula at | |
| 86 | - | /// checkout so client-computed prices are advisory only. | |
| 87 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 88 | - | #[non_exhaustive] | |
| 89 | - | pub struct AppPricing { | |
| 90 | - | pub app_name: String, | |
| 91 | - | /// Minimum charge (applies to both monthly and annual). | |
| 92 | - | pub min_charge_cents: Cents, | |
| 93 | - | /// Storage rate per GiB per month, in tenths of a cent. Convert with | |
| 94 | - | /// `(gib * per_gb_tenths + 9) / 10` to get cents. | |
| 95 | - | pub per_gb_tenths_of_cent_per_month: i64, | |
| 96 | - | /// Annual price is monthly × this multiplier. | |
| 97 | - | pub annual_multiplier: i64, | |
| 98 | - | pub min_cap_bytes: i64, | |
| 99 | - | pub max_cap_bytes: i64, | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | impl AppPricing { | |
| 103 | - | /// Compute the price in cents for a given cap and interval. Mirrors the | |
| 104 | - | /// server-side formula so client-side previews match what the user will | |
| 105 | - | /// actually be charged. | |
| 106 | - | /// | |
| 107 | - | /// All arithmetic saturates: the inputs are server-supplied `i64` values, and | |
| 108 | - | /// a hostile or buggy server returning huge rates must not panic the client | |
| 109 | - | /// (debug overflow) or wrap to a negative price (release). The server is | |
| 110 | - | /// authoritative at checkout, so this is an advisory preview regardless. | |
| 111 | - | pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents { | |
| 112 | - | let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes); | |
| 113 | - | let gib = cap_bytes_to_gib_ceil(cap_bytes); | |
| 114 | - | // The storage rate is tenths-of-a-cent per GiB; convert to whole cents, | |
| 115 | - | // rounding up. Intermediate stays i64 (a rate, not money) until the | |
| 116 | - | // result becomes a `Cents` amount. | |
| 117 | - | let storage_monthly = Cents( | |
| 118 | - | gib.saturating_mul(self.per_gb_tenths_of_cent_per_month) | |
| 119 | - | .saturating_add(9) | |
| 120 | - | / 10, | |
| 121 | - | ); | |
| 122 | - | let monthly = storage_monthly.max(self.min_charge_cents); | |
| 123 | - | match interval { | |
| 124 | - | BillingInterval::Monthly => monthly, | |
| 125 | - | BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier), | |
| 126 | - | } | |
| 127 | - | } | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 { | |
| 131 | - | const GIB: i64 = 1024 * 1024 * 1024; | |
| 132 | - | cap_bytes.saturating_add(GIB - 1) / GIB | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | /// Billing interval for SyncKit subscriptions. | |
| 136 | - | /// | |
| 137 | - | /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization | |
| 138 | - | /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps | |
| 139 | - | /// to `Monthly` rather than erroring, so a future server-side interval cannot | |
| 140 | - | /// break an older client's status parse. | |
| 141 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 142 | - | pub enum BillingInterval { | |
| 143 | - | Monthly, | |
| 144 | - | Annual, | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | impl Serialize for BillingInterval { | |
| 148 | - | fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> { | |
| 149 | - | serializer.serialize_str(self.as_str()) | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | impl<'de> Deserialize<'de> for BillingInterval { | |
| 154 | - | fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> { | |
| 155 | - | let s = String::deserialize(deserializer)?; | |
| 156 | - | Ok(BillingInterval::from_wire(&s)) | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | impl BillingInterval { | |
| 161 | - | pub fn as_str(self) -> &'static str { | |
| 162 | - | match self { | |
| 163 | - | Self::Monthly => "monthly", | |
| 164 | - | Self::Annual => "annual", | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | /// Parse from the server wire string. Falls back to `Monthly` for unknown | |
| 169 | - | /// values rather than erroring, defensive against a future interval tag. | |
| 170 | - | /// Named `from_wire` (not `from_str`) because it is infallible and lenient, | |
| 171 | - | /// unlike `std::str::FromStr`. | |
| 172 | - | pub fn from_wire(s: &str) -> Self { | |
| 173 | - | match s { | |
| 174 | - | "annual" => Self::Annual, | |
| 175 | - | other => { | |
| 176 | - | if !other.is_empty() && other != "monthly" { | |
| 177 | - | tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly"); | |
| 178 | - | } | |
| 179 | - | Self::Monthly | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | /// Response from creating a checkout session. | |
| 186 | - | #[derive(Debug, Deserialize)] | |
| 187 | - | #[non_exhaustive] | |
| 188 | - | pub struct CheckoutResponse { | |
| 189 | - | /// URL to redirect the user to for Stripe Checkout. | |
| 190 | - | pub checkout_url: String, | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | /// Account identifiers for the authenticated MNW user. | |
| 194 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 195 | - | #[non_exhaustive] | |
| 196 | - | pub struct AccountInfo { | |
| 197 | - | /// The authenticated user's email address. | |
| 198 | - | pub email: String, | |
| 199 | - | /// The authenticated user's username. | |
| 200 | - | pub username: String, | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | #[derive(Debug, Serialize, Deserialize)] | |
| 204 | - | #[non_exhaustive] | |
| 205 | - | pub struct PriceQuote { | |
| 206 | - | pub cap_bytes: i64, | |
| 207 | - | pub interval: BillingInterval, | |
| 208 | - | pub price_cents: Cents, | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | #[derive(Debug, Serialize)] | |
| 212 | - | struct AppPricingRequest<'a> { | |
| 213 | - | api_key: &'a str, | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | #[derive(Debug, Serialize)] | |
| 217 | - | struct QuoteRequest { | |
| 218 | - | cap_bytes: i64, | |
| 219 | - | interval: &'static str, | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | #[derive(Debug, Serialize)] | |
| 223 | - | struct CheckoutRequest { | |
| 224 | - | cap_bytes: i64, | |
| 225 | - | interval: &'static str, | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | #[derive(Debug, Serialize)] | |
| 229 | - | struct CapChangeRequest { | |
| 230 | - | cap_bytes: i64, | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | impl SyncKitClient { | |
| 234 | - | /// Fetch the pricing-formula constants for this app. No JWT needed; the | |
| 235 | - | /// app's API key is sent in the body. Safe to call before login so the | |
| 236 | - | /// UI can show pricing on the marketing/onboarding view. | |
| 237 | - | #[tracing::instrument(skip(self))] | |
| 238 | - | pub async fn get_app_pricing(&self) -> Result<AppPricing> { | |
| 239 | - | let body = Bytes::from(serde_json::to_vec(&AppPricingRequest { | |
| 240 | - | api_key: &self.config.api_key, | |
| 241 | - | })?); | |
| 242 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 243 | - | let req = self | |
| 244 | - | .http | |
| 245 | - | .post(&self.endpoints.app_pricing) | |
| 246 | - | .header("content-type", "application/json") | |
| 247 | - | .body(body.clone()); | |
| 248 | - | async move { check_response(req.send().await?).await } | |
| 249 | - | }) | |
| 250 | - | .await | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | /// Server-side price quote for a (cap, interval). Use this to confirm | |
| 254 | - | /// the number you display matches what Stripe will charge; the result is | |
| 255 | - | /// authoritative. | |
| 256 | - | #[tracing::instrument(skip(self))] | |
| 257 | - | pub async fn quote_price( | |
| 258 | - | &self, | |
| 259 | - | cap_bytes: i64, | |
| 260 | - | interval: BillingInterval, | |
| 261 | - | ) -> Result<PriceQuote> { | |
| 262 | - | let token = self.require_token()?; | |
| 263 | - | let body = Bytes::from(serde_json::to_vec(&QuoteRequest { | |
| 264 | - | cap_bytes, | |
| 265 | - | interval: interval.as_str(), | |
| 266 | - | })?); | |
| 267 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 268 | - | let req = self | |
| 269 | - | .http | |
| 270 | - | .post(&self.endpoints.subscription_quote) | |
| 271 | - | .bearer_auth(token.as_str()) | |
| 272 | - | .header("content-type", "application/json") | |
| 273 | - | .body(body.clone()); | |
| 274 | - | async move { check_response(req.send().await?).await } | |
| 275 | - | }) | |
| 276 | - | .await | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | /// Fetch the authenticated user's email and username. | |
| 280 | - | /// | |
| 281 | - | /// Used by apps to display "logged in as ..." in their cloud-sync UI. | |
| 282 | - | #[tracing::instrument(skip(self))] | |
| 283 | - | pub async fn get_account_info(&self) -> Result<AccountInfo> { | |
| 284 | - | let token = self.require_token()?; | |
| 285 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 286 | - | let req = self | |
| 287 | - | .http | |
| 288 | - | .get(&self.endpoints.account) | |
| 289 | - | .bearer_auth(token.as_str()); | |
| 290 | - | async move { check_response(req.send().await?).await } | |
| 291 | - | }) | |
| 292 | - | .await | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | /// Check the subscription status for this authenticated user + app. | |
| 296 | - | /// | |
| 297 | - | /// Returns `SubscriptionStatus` with `active: true` if sync is allowed, | |
| 298 | - | /// or `active: false` if the user needs to subscribe. | |
| 299 | - | #[tracing::instrument(skip(self))] | |
| 300 | - | pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> { | |
| 301 | - | let token = self.require_token()?; | |
| 302 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 303 | - | let req = self | |
| 304 | - | .http | |
| 305 | - | .get(&self.endpoints.subscription) | |
| 306 | - | .bearer_auth(token.as_str()); | |
| 307 | - | async move { check_response(req.send().await?).await } | |
| 308 | - | }) | |
| 309 | - | .await | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | /// Create a Stripe Checkout session for subscribing to cloud sync at the | |
| 313 | - | /// chosen storage cap. Returns a URL that should be opened in the user's | |
| 314 | - | /// browser. | |
| 315 | - | #[tracing::instrument(skip(self))] | |
| 316 | - | pub async fn create_subscription_checkout( | |
| 317 | - | &self, | |
| 318 | - | cap_bytes: i64, | |
| 319 | - | interval: BillingInterval, | |
| 320 | - | ) -> Result<CheckoutResponse> { | |
| 321 | - | let token = self.require_token()?; | |
| 322 | - | let body = Bytes::from(serde_json::to_vec(&CheckoutRequest { | |
| 323 | - | cap_bytes, | |
| 324 | - | interval: interval.as_str(), | |
| 325 | - | })?); | |
| 326 | - | // Not idempotent: a blind auto-retry could mint a second Stripe Checkout | |
| 327 | - | // session. Attempt once; the user re-initiates on a transient failure. | |
| 328 | - | self.retry_request_json(Idempotency::Unsafe, || { | |
| 329 | - | let req = self | |
| 330 | - | .http | |
| 331 | - | .post(&self.endpoints.subscription_checkout) | |
| 332 | - | .bearer_auth(token.as_str()) | |
| 333 | - | .header("content-type", "application/json") | |
| 334 | - | .body(body.clone()); | |
| 335 | - | async move { check_response(req.send().await?).await } | |
| 336 | - | }) | |
| 337 | - | .await | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | /// Queue a storage-cap change that takes effect at the next billing | |
| 341 | - | /// cycle. Returns the updated subscription status with the new cap in | |
| 342 | - | /// `pending_storage_limit_bytes`. | |
| 343 | - | #[tracing::instrument(skip(self))] | |
| 344 | - | pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> { | |
| 345 | - | let token = self.require_token()?; | |
| 346 | - | let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?); | |
| 347 | - | self.retry_request_json( | |
| 348 | - | Idempotency::IdempotentWrite { | |
| 349 | - | on: "pending cap change (upsert)", | |
| 350 | - | }, | |
| 351 | - | || { | |
| 352 | - | let req = self | |
| 353 | - | .http | |
| 354 | - | .post(&self.endpoints.subscription_storage_cap) | |
| 355 | - | .bearer_auth(token.as_str()) | |
| 356 | - | .header("content-type", "application/json") | |
| 357 | - | .body(body.clone()); | |
| 358 | - | async move { check_response(req.send().await?).await } | |
| 359 | - | }, | |
| 360 | - | ) | |
| 361 | - | .await | |
| 362 | - | } | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | #[cfg(test)] | |
| 366 | - | mod tests { | |
| 367 | - | use super::*; | |
| 368 | - | ||
| 369 | - | fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing { | |
| 370 | - | AppPricing { | |
| 371 | - | app_name: "test".into(), | |
| 372 | - | min_charge_cents: Cents(min_charge), | |
| 373 | - | per_gb_tenths_of_cent_per_month: per_gb_tenths, | |
| 374 | - | annual_multiplier: annual_mult, | |
| 375 | - | min_cap_bytes: 0, | |
| 376 | - | max_cap_bytes: i64::MAX, | |
| 377 | - | } | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | #[test] | |
| 381 | - | fn quote_cents_applies_floor_and_annual_multiplier() { | |
| 382 | - | // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the | |
| 383 | - | // 16-cent floor; annual = 10x. | |
| 384 | - | let p = pricing(16, 50, 10); | |
| 385 | - | let ten_gib = 10 * 1024 * 1024 * 1024; | |
| 386 | - | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50)); | |
| 387 | - | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500)); | |
| 388 | - | // Tiny cap falls back to the minimum charge. | |
| 389 | - | assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16)); | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | #[test] | |
| 393 | - | fn quote_cents_saturates_on_hostile_server_numbers() { | |
| 394 | - | // A server returning an absurd per-GiB rate and multiplier must not panic | |
| 395 | - | // (debug) or wrap negative (release), it saturates. | |
| 396 | - | let p = pricing(0, i64::MAX, i64::MAX); | |
| 397 | - | let big = p.quote_cents(i64::MAX, BillingInterval::Annual); | |
| 398 | - | assert!(big.get() >= 0, "price must never wrap negative, got {big}"); | |
| 399 | - | assert_eq!(big, Cents(i64::MAX)); | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | #[test] | |
| 403 | - | fn billing_interval_from_wire_defaults_to_monthly() { | |
| 404 | - | assert_eq!( | |
| 405 | - | BillingInterval::from_wire("annual"), | |
| 406 | - | BillingInterval::Annual | |
| 407 | - | ); | |
| 408 | - | assert_eq!( | |
| 409 | - | BillingInterval::from_wire("monthly"), | |
| 410 | - | BillingInterval::Monthly | |
| 411 | - | ); | |
| 412 | - | assert_eq!( | |
| 413 | - | BillingInterval::from_wire("weekly"), | |
| 414 | - | BillingInterval::Monthly | |
| 415 | - | ); | |
| 416 | - | assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly); | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | #[test] | |
| 420 | - | fn billing_interval_serde_roundtrips_as_wire_string() { | |
| 421 | - | // Serializes to the bare wire string and parses back leniently. | |
| 422 | - | assert_eq!( | |
| 423 | - | serde_json::to_string(&BillingInterval::Annual).unwrap(), | |
| 424 | - | "\"annual\"" | |
| 425 | - | ); | |
| 426 | - | assert_eq!( | |
| 427 | - | serde_json::from_str::<BillingInterval>("\"monthly\"").unwrap(), | |
| 428 | - | BillingInterval::Monthly | |
| 429 | - | ); | |
| 430 | - | // Unknown future tag falls back to Monthly, not a parse error. | |
| 431 | - | assert_eq!( | |
| 432 | - | serde_json::from_str::<BillingInterval>("\"weekly\"").unwrap(), | |
| 433 | - | BillingInterval::Monthly | |
| 434 | - | ); | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | #[test] | |
| 438 | - | fn cents_is_wire_transparent_to_i64() { | |
| 439 | - | assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234"); | |
| 440 | - | assert_eq!(serde_json::from_str::<Cents>("1234").unwrap(), Cents(1234)); | |
| 441 | - | } | |
| 442 | - | } |
| @@ -1,506 +0,0 @@ | |||
| 1 | - | //! Device registration and the core encrypted push/pull. | |
| 2 | - | //! | |
| 3 | - | //! Registers a device, lists the account's devices, and moves change entries | |
| 4 | - | //! to and from the server. Rows are encrypted before push and decrypted after | |
| 5 | - | //! pull, so the transport only ever carries ciphertext; the `*_filtered` and | |
| 6 | - | //! `*_rich` variants narrow the pull or return per-row sync metadata. | |
| 7 | - | ||
| 8 | - | use bytes::Bytes; | |
| 9 | - | use tracing::instrument; | |
| 10 | - | use uuid::Uuid; | |
| 11 | - | ||
| 12 | - | use crate::{ | |
| 13 | - | crypto, | |
| 14 | - | error::Result, | |
| 15 | - | ids::DeviceId, | |
| 16 | - | types::{ | |
| 17 | - | ChangeEntry, Device, FilteredPullRequest, PullChangeEntry, PullFilter, PullRequest, | |
| 18 | - | PullResponse, PulledChange, PushResponse, RegisterDeviceRequest, SyncStatus, | |
| 19 | - | WirePushRequest, | |
| 20 | - | }, | |
| 21 | - | }; | |
| 22 | - | ||
| 23 | - | use super::SyncKitClient; | |
| 24 | - | use super::helpers::{Idempotency, check_response}; | |
| 25 | - | ||
| 26 | - | impl SyncKitClient { | |
| 27 | - | // ── Devices ── | |
| 28 | - | ||
| 29 | - | /// Register a device for sync. | |
| 30 | - | /// | |
| 31 | - | /// If a device with the same name already exists for this user/app, the | |
| 32 | - | /// server upserts: it updates the existing device's platform and | |
| 33 | - | /// `last_seen_at` rather than creating a duplicate. | |
| 34 | - | #[instrument(skip(self))] | |
| 35 | - | pub async fn register_device(&self, device_name: &str, platform: &str) -> Result<Device> { | |
| 36 | - | let token = self.require_token()?; | |
| 37 | - | ||
| 38 | - | let body = Bytes::from(serde_json::to_vec(&RegisterDeviceRequest { | |
| 39 | - | device_name: device_name.to_string(), | |
| 40 | - | platform: platform.to_string(), | |
| 41 | - | })?); | |
| 42 | - | ||
| 43 | - | self.retry_request_json( | |
| 44 | - | Idempotency::IdempotentWrite { | |
| 45 | - | on: "device registration (server-deduped)", | |
| 46 | - | }, | |
| 47 | - | || { | |
| 48 | - | let req = self | |
| 49 | - | .http | |
| 50 | - | .post(&self.endpoints.devices) | |
| 51 | - | .bearer_auth(&token) | |
| 52 | - | .header("content-type", "application/json") | |
| 53 | - | .body(body.clone()); | |
| 54 | - | async move { check_response(req.send().await?).await } | |
| 55 | - | }, | |
| 56 | - | ) | |
| 57 | - | .await | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | /// List all devices for the current user. | |
| 61 | - | #[instrument(skip(self))] | |
| 62 | - | pub async fn list_devices(&self) -> Result<Vec<Device>> { | |
| 63 | - | let token = self.require_token()?; | |
| 64 | - | ||
| 65 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 66 | - | let req = self.http.get(&self.endpoints.devices).bearer_auth(&token); | |
| 67 | - | async move { check_response(req.send().await?).await } | |
| 68 | - | }) | |
| 69 | - | .await | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // ── Push / Pull ── | |
| 73 | - | ||
| 74 | - | /// Push changes to the server. Encrypts `data` fields automatically. | |
| 75 | - | /// Returns the server cursor after the push. | |
| 76 | - | /// | |
| 77 | - | /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff. | |
| 78 | - | #[instrument(skip(self, changes))] | |
| 79 | - | pub async fn push(&self, device_id: DeviceId, changes: Vec<ChangeEntry>) -> Result<i64> { | |
| 80 | - | let token = self.require_token()?; | |
| 81 | - | ||
| 82 | - | // Every change now seals an HLC envelope (Deletes included), so the master | |
| 83 | - | // key is needed whenever there is anything to push. | |
| 84 | - | let key_holder = if changes.is_empty() { | |
| 85 | - | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 86 | - | } else { | |
| 87 | - | self.require_master_key()? | |
| 88 | - | }; | |
| 89 | - | let master_key: &[u8; 32] = &key_holder; | |
| 90 | - | let wire_changes = changes | |
| 91 | - | .into_iter() | |
| 92 | - | .map(|c| Self::encrypt_change_with_key(c, master_key)) | |
| 93 | - | .collect::<Result<Vec<_>>>()?; | |
| 94 | - | ||
| 95 | - | let body = Bytes::from(serde_json::to_vec(&WirePushRequest { | |
| 96 | - | device_id, | |
| 97 | - | batch_id: Uuid::new_v4(), | |
| 98 | - | changes: wire_changes, | |
| 99 | - | })?); | |
| 100 | - | ||
| 101 | - | let push_resp: PushResponse = self | |
| 102 | - | .retry_request_json(Idempotency::Keyed, || { | |
| 103 | - | let req = self | |
| 104 | - | .http | |
| 105 | - | .post(&self.endpoints.push) | |
| 106 | - | .bearer_auth(&token) | |
| 107 | - | .header("content-type", "application/json") | |
| 108 | - | .body(body.clone()); | |
| 109 | - | async move { check_response(req.send().await?).await } | |
| 110 | - | }) | |
| 111 | - | .await?; | |
| 112 | - | Ok(push_resp.cursor) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | /// Pull changes from the server since the given cursor. | |
| 116 | - | /// Decrypts `data` fields automatically. | |
| 117 | - | /// Returns (changes, new_cursor, has_more). | |
| 118 | - | /// | |
| 119 | - | /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff. | |
| 120 | - | /// | |
| 121 | - | /// **Contract, persist the cursor only after applying.** The returned cursor | |
| 122 | - | /// must be written to durable storage *after* the returned changes are | |
| 123 | - | /// applied, in the same transaction where possible. Persisting the cursor | |
| 124 | - | /// first and crashing before apply silently drops a batch; the SDK does not | |
| 125 | - | /// dedup re-delivered changes, so apply must also be idempotent per | |
| 126 | - | /// `(table, row_id, hlc)`. | |
| 127 | - | #[instrument(skip(self))] | |
| 128 | - | pub async fn pull( | |
| 129 | - | &self, | |
| 130 | - | device_id: DeviceId, | |
| 131 | - | cursor: i64, | |
| 132 | - | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| 133 | - | let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?); | |
| 134 | - | self.pull_inner(body, Self::decrypt_change_with_key).await | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | /// Pull changes from the server with optional table and timestamp filters. | |
| 138 | - | /// Decrypts `data` fields automatically. | |
| 139 | - | /// Returns (changes, new_cursor, has_more). | |
| 140 | - | /// | |
| 141 | - | /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default. | |
| 142 | - | #[instrument(skip(self, filter))] | |
| 143 | - | pub async fn pull_filtered( | |
| 144 | - | &self, | |
| 145 | - | device_id: DeviceId, | |
| 146 | - | cursor: i64, | |
| 147 | - | filter: PullFilter, | |
| 148 | - | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| 149 | - | let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest { | |
| 150 | - | device_id, | |
| 151 | - | cursor, | |
| 152 | - | tables: filter.tables, | |
| 153 | - | since: filter.since, | |
| 154 | - | })?); | |
| 155 | - | self.pull_inner(body, Self::decrypt_change_with_key).await | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | /// Pull changes from the server, preserving `device_id` and `seq` metadata. | |
| 159 | - | /// | |
| 160 | - | /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns | |
| 161 | - | /// [`PulledChange`] wrappers that retain server metadata needed for conflict | |
| 162 | - | /// detection. Returns (changes, new_cursor, has_more). | |
| 163 | - | #[instrument(skip(self))] | |
| 164 | - | pub async fn pull_rich( | |
| 165 | - | &self, | |
| 166 | - | device_id: DeviceId, | |
| 167 | - | cursor: i64, | |
| 168 | - | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| 169 | - | let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?); | |
| 170 | - | self.pull_inner(body, Self::decrypt_change_to_pulled).await | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | /// Pull changes with filters, preserving `device_id` and `seq` metadata. | |
| 174 | - | /// | |
| 175 | - | /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp | |
| 176 | - | /// filtering support. Returns (changes, new_cursor, has_more). | |
| 177 | - | #[instrument(skip(self, filter))] | |
| 178 | - | pub async fn pull_filtered_rich( | |
| 179 | - | &self, | |
| 180 | - | device_id: DeviceId, | |
| 181 | - | cursor: i64, | |
| 182 | - | filter: PullFilter, | |
| 183 | - | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| 184 | - | let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest { | |
| 185 | - | device_id, | |
| 186 | - | cursor, | |
| 187 | - | tables: filter.tables, | |
| 188 | - | since: filter.since, | |
| 189 | - | })?); | |
| 190 | - | self.pull_inner(body, Self::decrypt_change_to_pulled).await | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | /// Shared pull implementation: sends the request, extracts the master key, | |
| 194 | - | /// and decrypts each change using the provided function. | |
| 195 | - | /// | |
| 196 | - | /// If a pending rotation key is cached on the client, entries are decrypted | |
| 197 | - | /// with key selection based on each entry's `key_id` field. | |
| 198 | - | async fn pull_inner<T, F>(&self, body: Bytes, decrypt_fn: F) -> Result<(Vec<T>, i64, bool)> | |
| 199 | - | where | |
| 200 | - | F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>, | |
| 201 | - | { | |
| 202 | - | let token = self.require_token()?; | |
| 203 | - | ||
| 204 | - | let pull_resp: PullResponse = self | |
| 205 | - | .retry_request_json(Idempotency::ReadOnly, || { | |
| 206 | - | let req = self | |
| 207 | - | .http | |
| 208 | - | .post(&self.endpoints.pull) | |
| 209 | - | .bearer_auth(&token) | |
| 210 | - | .header("content-type", "application/json") | |
| 211 | - | .body(body.clone()); | |
| 212 | - | async move { check_response(req.send().await?).await } | |
| 213 | - | }) | |
| 214 | - | .await?; | |
| 215 | - | ||
| 216 | - | // Extract key once for the entire batch (only needed if any entry has data) | |
| 217 | - | let has_data = pull_resp.changes.iter().any(|c| c.data.is_some()); | |
| 218 | - | let key_holder = if has_data { | |
| 219 | - | self.require_master_key()? | |
| 220 | - | } else { | |
| 221 | - | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 222 | - | }; | |
| 223 | - | let master_key: &[u8; 32] = &key_holder; | |
| 224 | - | ||
| 225 | - | // Check for pending rotation key (multi-key decryption) | |
| 226 | - | let pending_guard = self.pending_key.read(); | |
| 227 | - | let has_pending = pending_guard.is_some() && has_data; | |
| 228 | - | ||
| 229 | - | let changes = if has_pending { | |
| 230 | - | let pending = pending_guard.as_ref().unwrap(); | |
| 231 | - | let primary_key_id = *self.master_key_id.read(); | |
| 232 | - | pull_resp | |
| 233 | - | .changes | |
| 234 | - | .into_iter() | |
| 235 | - | .map(|c| { | |
| 236 | - | Self::decrypt_with_rotation_keys( | |
| 237 | - | c, | |
| 238 | - | master_key, | |
| 239 | - | primary_key_id, | |
| 240 | - | &pending.key, | |
| 241 | - | pending.key_id, | |
| 242 | - | &decrypt_fn, | |
| 243 | - | ) | |
| 244 | - | }) | |
| 245 | - | .collect::<Result<Vec<_>>>()? | |
| 246 | - | } else { | |
| 247 | - | drop(pending_guard); | |
| 248 | - | pull_resp | |
| 249 | - | .changes | |
| 250 | - | .into_iter() | |
| 251 | - | .map(|c| decrypt_fn(c, master_key)) | |
| 252 | - | .collect::<Result<Vec<_>>>()? | |
| 253 | - | }; | |
| 254 | - | ||
| 255 | - | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | /// Get sync status (total changes, latest cursor). | |
| 259 | - | #[instrument(skip(self))] | |
| 260 | - | pub async fn status(&self) -> Result<SyncStatus> { | |
| 261 | - | let token = self.require_token()?; | |
| 262 | - | ||
| 263 | - | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 264 | - | let req = self.http.get(&self.endpoints.status).bearer_auth(&token); | |
| 265 | - | async move { check_response(req.send().await?).await } | |
| 266 | - | }) | |
| 267 | - | .await | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | #[cfg(test)] | |
| 272 | - | mod tests { | |
| 273 | - | use chrono::Utc; | |
| 274 | - | use uuid::Uuid; | |
| 275 | - | ||
| 276 | - | use crate::ids::{AppId, DeviceId, UserId}; | |
| 277 | - | use crate::types::*; | |
| 278 | - | ||
| 279 | - | // ── Type serialization / deserialization ── | |
| 280 | - | ||
| 281 | - | #[test] | |
| 282 | - | fn change_entry_serialization_roundtrip() { | |
| 283 | - | let entry = ChangeEntry { | |
| 284 | - | table: "tasks".to_string(), | |
| 285 | - | op: ChangeOp::Insert, | |
| 286 | - | row_id: Uuid::new_v4().to_string(), | |
| 287 | - | timestamp: Utc::now(), | |
| 288 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 289 | - | data: Some(serde_json::json!({"title": "Test task", "done": false})), | |
| 290 | - | extra: serde_json::Map::default(), | |
| 291 | - | }; | |
| 292 | - | ||
| 293 | - | let json = serde_json::to_string(&entry).unwrap(); | |
| 294 | - | let deserialized: ChangeEntry = serde_json::from_str(&json).unwrap(); | |
| 295 | - | ||
| 296 | - | assert_eq!(deserialized.table, entry.table); | |
| 297 | - | assert_eq!(deserialized.op, entry.op); | |
| 298 | - | assert_eq!(deserialized.row_id, entry.row_id); | |
| 299 | - | assert_eq!(deserialized.data, entry.data); | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | #[test] | |
| 303 | - | fn change_entry_with_none_data_omits_field() { | |
| 304 | - | let entry = ChangeEntry { | |
| 305 | - | table: "tasks".to_string(), | |
| 306 | - | op: ChangeOp::Delete, | |
| 307 | - | row_id: "abc-123".to_string(), | |
| 308 | - | timestamp: Utc::now(), | |
| 309 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 310 | - | data: None, | |
| 311 | - | extra: serde_json::Map::default(), | |
| 312 | - | }; | |
| 313 | - | ||
| 314 | - | let json = serde_json::to_string(&entry).unwrap(); | |
| 315 | - | assert!(!json.contains("\"data\"")); | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | #[test] | |
| 319 | - | fn change_entry_deserialization_with_missing_data() { | |
| 320 | - | let json = r#"{ | |
| 321 | - | "table": "events", | |
| 322 | - | "op": "DELETE", | |
| 323 | - | "row_id": "evt-1", | |
| 324 | - | "timestamp": "2025-01-15T10:00:00Z" | |
| 325 | - | }"#; | |
| 326 | - | ||
| 327 | - | let entry: ChangeEntry = serde_json::from_str(json).unwrap(); | |
| 328 | - | assert_eq!(entry.table, "events"); | |
| 329 | - | assert_eq!(entry.op, ChangeOp::Delete); | |
| 330 | - | assert!(entry.data.is_none()); | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | #[test] | |
| 334 | - | fn device_serialization_roundtrip() { | |
| 335 | - | let device = Device { | |
| 336 | - | id: DeviceId::new(Uuid::new_v4()), | |
| 337 | - | app_id: AppId::new(Uuid::new_v4()), | |
| 338 | - | user_id: UserId::new(Uuid::new_v4()), | |
| 339 | - | device_name: "MacBook Pro".to_string(), | |
| 340 | - | platform: "macos".to_string(), | |
| 341 | - | last_seen_at: Utc::now(), | |
| 342 | - | created_at: Utc::now(), | |
| 343 | - | }; | |
| 344 | - | ||
| 345 | - | let json = serde_json::to_string(&device).unwrap(); | |
| 346 | - | let deserialized: Device = serde_json::from_str(&json).unwrap(); | |
| 347 | - | ||
| 348 | - | assert_eq!(deserialized.id, device.id); | |
| 349 | - | assert_eq!(deserialized.device_name, device.device_name); | |
| 350 | - | assert_eq!(deserialized.platform, device.platform); | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | #[test] | |
| 354 | - | fn sync_status_deserialization() { | |
| 355 | - | let json = r#"{"total_changes": 42, "latest_cursor": 100}"#; | |
| 356 | - | let status: SyncStatus = serde_json::from_str(json).unwrap(); | |
| 357 | - | assert_eq!(status.total_changes, 42); | |
| 358 | - | assert_eq!(status.latest_cursor, Some(100)); | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | #[test] | |
| 362 | - | fn sync_status_with_null_cursor() { | |
| 363 | - | let json = r#"{"total_changes": 0, "latest_cursor": null}"#; | |
| 364 | - | let status: SyncStatus = serde_json::from_str(json).unwrap(); | |
| 365 | - | assert_eq!(status.total_changes, 0); | |
| 366 | - | assert_eq!(status.latest_cursor, None); | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | #[test] | |
| 370 | - | fn register_device_request_serialization() { | |
| 371 | - | let req = RegisterDeviceRequest { | |
| 372 | - | device_name: "iPhone 15".to_string(), | |
| 373 | - | platform: "ios".to_string(), | |
| 374 | - | }; | |
| 375 | - | ||
| 376 | - | let json = serde_json::to_string(&req).unwrap(); | |
| 377 | - | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | |
| 378 | - | assert_eq!(parsed["device_name"], "iPhone 15"); | |
| 379 | - | assert_eq!(parsed["platform"], "ios"); | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | // ── Wire types ── | |
| 383 | - | ||
| 384 | - | #[test] | |
| 385 | - | fn wire_push_request_serialization() { | |
| 386 | - | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 387 | - | let req = WirePushRequest { | |
| 388 | - | device_id, | |
| 389 | - | batch_id: Uuid::new_v4(), | |
| 390 | - | changes: vec![WireChangeEntry { | |
| 391 | - | table: "tasks".to_string(), | |
| 392 | - | op: ChangeOp::Insert, | |
| 393 | - | row_id: "r1".to_string(), | |
| 394 | - | timestamp: Utc::now(), | |
| 395 | - | data: Some(serde_json::json!("encrypted-blob")), | |
| 396 | - | }], | |
| 397 | - | }; | |
| 398 | - | ||
| 399 | - | let json = serde_json::to_string(&req).unwrap(); | |
| 400 | - | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | |
| 401 | - | assert_eq!(parsed["device_id"], device_id.to_string()); | |
| 402 | - | assert_eq!(parsed["changes"].as_array().unwrap().len(), 1); | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | #[test] | |
| 406 | - | fn pull_request_serialization() { | |
| 407 | - | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 408 | - | let req = PullRequest { | |
| 409 | - | device_id, | |
| 410 | - | cursor: 42, | |
| 411 | - | }; | |
| 412 | - | ||
| 413 | - | let json = serde_json::to_string(&req).unwrap(); | |
| 414 | - | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | |
| 415 | - | assert_eq!(parsed["device_id"], device_id.to_string()); | |
| 416 | - | assert_eq!(parsed["cursor"], 42); | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | #[test] | |
| 420 | - | fn pull_response_deserialization() { | |
| 421 | - | let device_id = Uuid::new_v4(); | |
| 422 | - | let json = format!( | |
| 423 | - | r#"{{ | |
| 424 | - | "changes": [ | |
| 425 | - | {{ | |
| 426 | - | "seq": 1, | |
| 427 | - | "device_id": "{device_id}", | |
| 428 | - | "table": "tasks", | |
| 429 | - | "op": "INSERT", | |
| 430 | - | "row_id": "r1", | |
| 431 | - | "timestamp": "2025-06-01T12:00:00Z", | |
| 432 | - | "data": "encrypted" | |
| 433 | - | }} | |
| 434 | - | ], | |
| 435 | - | "cursor": 5, | |
| 436 | - | "has_more": true | |
| 437 | - | }}"# | |
| 438 | - | ); | |
| 439 | - | ||
| 440 | - | let resp: PullResponse = serde_json::from_str(&json).unwrap(); | |
| 441 | - | assert_eq!(resp.changes.len(), 1); | |
| 442 | - | assert_eq!(resp.cursor, 5); | |
| 443 | - | assert!(resp.has_more); | |
| 444 | - | assert_eq!(resp.changes[0].seq, 1); | |
| 445 | - | assert_eq!(resp.changes[0].table, "tasks"); | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | #[test] | |
| 449 | - | fn pull_response_empty_changes() { | |
| 450 | - | let json = r#"{"changes": [], "cursor": 0, "has_more": false}"#; | |
| 451 | - | let resp: PullResponse = serde_json::from_str(json).unwrap(); | |
| 452 | - | assert!(resp.changes.is_empty()); | |
| 453 | - | assert_eq!(resp.cursor, 0); | |
| 454 | - | assert!(!resp.has_more); | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | #[test] | |
| 458 | - | fn push_response_deserialization() { | |
| 459 | - | let json = r#"{"cursor": 99}"#; | |
| 460 | - | let resp: PushResponse = serde_json::from_str(json).unwrap(); | |
| 461 | - | assert_eq!(resp.cursor, 99); | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | // ── ChangeOp display and parsing ── | |
| 465 | - | ||
| 466 | - | #[test] | |
| 467 | - | fn change_op_display() { | |
| 468 | - | assert_eq!(ChangeOp::Insert.to_string(), "INSERT"); | |
| 469 | - | assert_eq!(ChangeOp::Update.to_string(), "UPDATE"); | |
| 470 | - | assert_eq!(ChangeOp::Delete.to_string(), "DELETE"); | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | #[test] | |
| 474 | - | fn change_op_from_str_valid() { | |
| 475 | - | assert_eq!(ChangeOp::from_str_opt("INSERT"), Some(ChangeOp::Insert)); | |
| 476 | - | assert_eq!(ChangeOp::from_str_opt("UPDATE"), Some(ChangeOp::Update)); | |
| 477 | - | assert_eq!(ChangeOp::from_str_opt("DELETE"), Some(ChangeOp::Delete)); | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | #[test] | |
| 481 | - | fn change_op_from_str_invalid() { | |
| 482 | - | assert_eq!(ChangeOp::from_str_opt("insert"), None); | |
| 483 | - | assert_eq!(ChangeOp::from_str_opt("UPSERT"), None); | |
| 484 | - | assert_eq!(ChangeOp::from_str_opt(""), None); | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | // ── Malformed response types ── | |
| 488 | - | ||
| 489 | - | #[test] | |
| 490 | - | fn pull_response_missing_changes_fails() { | |
| 491 | - | let json = r#"{"cursor": 0, "has_more": false}"#; | |
| 492 | - | assert!(serde_json::from_str::<PullResponse>(json).is_err()); | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | #[test] | |
| 496 | - | fn pull_response_missing_cursor_fails() { | |
| 497 | - | let json = r#"{"changes": [], "has_more": false}"#; | |
| 498 | - | assert!(serde_json::from_str::<PullResponse>(json).is_err()); | |
| 499 | - | } | |
| 500 | - |
Lines truncated