max / synckit-client
8 files changed,
+1784 insertions,
-0 deletions
| @@ -0,0 +1,2155 @@ | |||
| 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.5.2" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 10 | + | dependencies = [ | |
| 11 | + | "crypto-common", | |
| 12 | + | "generic-array", | |
| 13 | + | ] | |
| 14 | + | ||
| 15 | + | [[package]] | |
| 16 | + | name = "android_system_properties" | |
| 17 | + | version = "0.1.5" | |
| 18 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 19 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 20 | + | dependencies = [ | |
| 21 | + | "libc", | |
| 22 | + | ] | |
| 23 | + | ||
| 24 | + | [[package]] | |
| 25 | + | name = "anyhow" | |
| 26 | + | version = "1.0.102" | |
| 27 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 28 | + | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | |
| 29 | + | ||
| 30 | + | [[package]] | |
| 31 | + | name = "argon2" | |
| 32 | + | version = "0.5.3" | |
| 33 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 34 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 35 | + | dependencies = [ | |
| 36 | + | "base64ct", | |
| 37 | + | "blake2", | |
| 38 | + | "cpufeatures", | |
| 39 | + | "password-hash", | |
| 40 | + | ] | |
| 41 | + | ||
| 42 | + | [[package]] | |
| 43 | + | name = "atomic-waker" | |
| 44 | + | version = "1.1.2" | |
| 45 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 46 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 47 | + | ||
| 48 | + | [[package]] | |
| 49 | + | name = "autocfg" | |
| 50 | + | version = "1.5.0" | |
| 51 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 52 | + | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | |
| 53 | + | ||
| 54 | + | [[package]] | |
| 55 | + | name = "base64" | |
| 56 | + | version = "0.22.1" | |
| 57 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 58 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 59 | + | ||
| 60 | + | [[package]] | |
| 61 | + | name = "base64ct" | |
| 62 | + | version = "1.8.3" | |
| 63 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 65 | + | ||
| 66 | + | [[package]] | |
| 67 | + | name = "bitflags" | |
| 68 | + | version = "2.11.0" | |
| 69 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 70 | + | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" | |
| 71 | + | ||
| 72 | + | [[package]] | |
| 73 | + | name = "blake2" | |
| 74 | + | version = "0.10.6" | |
| 75 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 76 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 77 | + | dependencies = [ | |
| 78 | + | "digest", | |
| 79 | + | ] | |
| 80 | + | ||
| 81 | + | [[package]] | |
| 82 | + | name = "block-buffer" | |
| 83 | + | version = "0.10.4" | |
| 84 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 85 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 86 | + | dependencies = [ | |
| 87 | + | "generic-array", | |
| 88 | + | ] | |
| 89 | + | ||
| 90 | + | [[package]] | |
| 91 | + | name = "bumpalo" | |
| 92 | + | version = "3.20.2" | |
| 93 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 94 | + | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" | |
| 95 | + | ||
| 96 | + | [[package]] | |
| 97 | + | name = "bytes" | |
| 98 | + | version = "1.11.1" | |
| 99 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 100 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 101 | + | ||
| 102 | + | [[package]] | |
| 103 | + | name = "cc" | |
| 104 | + | version = "1.2.56" | |
| 105 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 106 | + | checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" | |
| 107 | + | dependencies = [ | |
| 108 | + | "find-msvc-tools", | |
| 109 | + | "shlex", | |
| 110 | + | ] | |
| 111 | + | ||
| 112 | + | [[package]] | |
| 113 | + | name = "cfg-if" | |
| 114 | + | version = "1.0.4" | |
| 115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 116 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 117 | + | ||
| 118 | + | [[package]] | |
| 119 | + | name = "chacha20" | |
| 120 | + | version = "0.9.1" | |
| 121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | + | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | |
| 123 | + | dependencies = [ | |
| 124 | + | "cfg-if", | |
| 125 | + | "cipher", | |
| 126 | + | "cpufeatures", | |
| 127 | + | ] | |
| 128 | + | ||
| 129 | + | [[package]] | |
| 130 | + | name = "chacha20poly1305" | |
| 131 | + | version = "0.10.1" | |
| 132 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 133 | + | checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" | |
| 134 | + | dependencies = [ | |
| 135 | + | "aead", | |
| 136 | + | "chacha20", | |
| 137 | + | "cipher", | |
| 138 | + | "poly1305", | |
| 139 | + | "zeroize", | |
| 140 | + | ] | |
| 141 | + | ||
| 142 | + | [[package]] | |
| 143 | + | name = "chrono" | |
| 144 | + | version = "0.4.44" | |
| 145 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 146 | + | checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" | |
| 147 | + | dependencies = [ | |
| 148 | + | "iana-time-zone", | |
| 149 | + | "js-sys", | |
| 150 | + | "num-traits", | |
| 151 | + | "serde", | |
| 152 | + | "wasm-bindgen", | |
| 153 | + | "windows-link", | |
| 154 | + | ] | |
| 155 | + | ||
| 156 | + | [[package]] | |
| 157 | + | name = "cipher" | |
| 158 | + | version = "0.4.4" | |
| 159 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 160 | + | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 161 | + | dependencies = [ | |
| 162 | + | "crypto-common", | |
| 163 | + | "inout", | |
| 164 | + | "zeroize", | |
| 165 | + | ] | |
| 166 | + | ||
| 167 | + | [[package]] | |
| 168 | + | name = "core-foundation" | |
| 169 | + | version = "0.9.4" | |
| 170 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 171 | + | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | |
| 172 | + | dependencies = [ | |
| 173 | + | "core-foundation-sys", | |
| 174 | + | "libc", | |
| 175 | + | ] | |
| 176 | + | ||
| 177 | + | [[package]] | |
| 178 | + | name = "core-foundation" | |
| 179 | + | version = "0.10.1" | |
| 180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 182 | + | dependencies = [ | |
| 183 | + | "core-foundation-sys", | |
| 184 | + | "libc", | |
| 185 | + | ] | |
| 186 | + | ||
| 187 | + | [[package]] | |
| 188 | + | name = "core-foundation-sys" | |
| 189 | + | version = "0.8.7" | |
| 190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 191 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 192 | + | ||
| 193 | + | [[package]] | |
| 194 | + | name = "cpufeatures" | |
| 195 | + | version = "0.2.17" | |
| 196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 197 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 198 | + | dependencies = [ | |
| 199 | + | "libc", | |
| 200 | + | ] | |
| 201 | + | ||
| 202 | + | [[package]] | |
| 203 | + | name = "crypto-common" | |
| 204 | + | version = "0.1.7" | |
| 205 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 206 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 207 | + | dependencies = [ | |
| 208 | + | "generic-array", | |
| 209 | + | "rand_core", | |
| 210 | + | "typenum", | |
| 211 | + | ] | |
| 212 | + | ||
| 213 | + | [[package]] | |
| 214 | + | name = "digest" | |
| 215 | + | version = "0.10.7" | |
| 216 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 217 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 218 | + | dependencies = [ | |
| 219 | + | "block-buffer", | |
| 220 | + | "crypto-common", | |
| 221 | + | "subtle", | |
| 222 | + | ] | |
| 223 | + | ||
| 224 | + | [[package]] | |
| 225 | + | name = "displaydoc" | |
| 226 | + | version = "0.2.5" | |
| 227 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 228 | + | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" | |
| 229 | + | dependencies = [ | |
| 230 | + | "proc-macro2", | |
| 231 | + | "quote", | |
| 232 | + | "syn", | |
| 233 | + | ] | |
| 234 | + | ||
| 235 | + | [[package]] | |
| 236 | + | name = "encoding_rs" | |
| 237 | + | version = "0.8.35" | |
| 238 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 239 | + | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" | |
| 240 | + | dependencies = [ | |
| 241 | + | "cfg-if", | |
| 242 | + | ] | |
| 243 | + | ||
| 244 | + | [[package]] | |
| 245 | + | name = "equivalent" | |
| 246 | + | version = "1.0.2" | |
| 247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 248 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 249 | + | ||
| 250 | + | [[package]] | |
| 251 | + | name = "errno" | |
| 252 | + | version = "0.3.14" | |
| 253 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 254 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 255 | + | dependencies = [ | |
| 256 | + | "libc", | |
| 257 | + | "windows-sys 0.61.2", | |
| 258 | + | ] | |
| 259 | + | ||
| 260 | + | [[package]] | |
| 261 | + | name = "fastrand" | |
| 262 | + | version = "2.3.0" | |
| 263 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 264 | + | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" | |
| 265 | + | ||
| 266 | + | [[package]] | |
| 267 | + | name = "find-msvc-tools" | |
| 268 | + | version = "0.1.9" | |
| 269 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 270 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 271 | + | ||
| 272 | + | [[package]] | |
| 273 | + | name = "fnv" | |
| 274 | + | version = "1.0.7" | |
| 275 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 276 | + | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 277 | + | ||
| 278 | + | [[package]] | |
| 279 | + | name = "foldhash" | |
| 280 | + | version = "0.1.5" | |
| 281 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 282 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 283 | + | ||
| 284 | + | [[package]] | |
| 285 | + | name = "foreign-types" | |
| 286 | + | version = "0.3.2" | |
| 287 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 288 | + | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | |
| 289 | + | dependencies = [ | |
| 290 | + | "foreign-types-shared", | |
| 291 | + | ] | |
| 292 | + | ||
| 293 | + | [[package]] | |
| 294 | + | name = "foreign-types-shared" | |
| 295 | + | version = "0.1.1" | |
| 296 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 297 | + | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | |
| 298 | + | ||
| 299 | + | [[package]] | |
| 300 | + | name = "form_urlencoded" | |
| 301 | + | version = "1.2.2" | |
| 302 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 303 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 304 | + | dependencies = [ | |
| 305 | + | "percent-encoding", | |
| 306 | + | ] | |
| 307 | + | ||
| 308 | + | [[package]] | |
| 309 | + | name = "futures-channel" | |
| 310 | + | version = "0.3.32" | |
| 311 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 312 | + | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" | |
| 313 | + | dependencies = [ | |
| 314 | + | "futures-core", | |
| 315 | + | ] | |
| 316 | + | ||
| 317 | + | [[package]] | |
| 318 | + | name = "futures-core" | |
| 319 | + | version = "0.3.32" | |
| 320 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 321 | + | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | |
| 322 | + | ||
| 323 | + | [[package]] | |
| 324 | + | name = "futures-sink" | |
| 325 | + | version = "0.3.32" | |
| 326 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 327 | + | checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" | |
| 328 | + | ||
| 329 | + | [[package]] | |
| 330 | + | name = "futures-task" | |
| 331 | + | version = "0.3.32" | |
| 332 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 333 | + | checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" | |
| 334 | + | ||
| 335 | + | [[package]] | |
| 336 | + | name = "futures-util" | |
| 337 | + | version = "0.3.32" | |
| 338 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 339 | + | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | |
| 340 | + | dependencies = [ | |
| 341 | + | "futures-core", | |
| 342 | + | "futures-task", | |
| 343 | + | "pin-project-lite", | |
| 344 | + | "slab", | |
| 345 | + | ] | |
| 346 | + | ||
| 347 | + | [[package]] | |
| 348 | + | name = "generic-array" | |
| 349 | + | version = "0.14.7" | |
| 350 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 351 | + | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | |
| 352 | + | dependencies = [ | |
| 353 | + | "typenum", | |
| 354 | + | "version_check", | |
| 355 | + | ] | |
| 356 | + | ||
| 357 | + | [[package]] | |
| 358 | + | name = "getrandom" | |
| 359 | + | version = "0.2.17" | |
| 360 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 361 | + | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 362 | + | dependencies = [ | |
| 363 | + | "cfg-if", | |
| 364 | + | "libc", | |
| 365 | + | "wasi", | |
| 366 | + | ] | |
| 367 | + | ||
| 368 | + | [[package]] | |
| 369 | + | name = "getrandom" | |
| 370 | + | version = "0.4.1" | |
| 371 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 372 | + | checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" | |
| 373 | + | dependencies = [ | |
| 374 | + | "cfg-if", | |
| 375 | + | "libc", | |
| 376 | + | "r-efi", | |
| 377 | + | "wasip2", | |
| 378 | + | "wasip3", | |
| 379 | + | ] | |
| 380 | + | ||
| 381 | + | [[package]] | |
| 382 | + | name = "h2" | |
| 383 | + | version = "0.4.13" | |
| 384 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 385 | + | checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" | |
| 386 | + | dependencies = [ | |
| 387 | + | "atomic-waker", | |
| 388 | + | "bytes", | |
| 389 | + | "fnv", | |
| 390 | + | "futures-core", | |
| 391 | + | "futures-sink", | |
| 392 | + | "http", | |
| 393 | + | "indexmap", | |
| 394 | + | "slab", | |
| 395 | + | "tokio", | |
| 396 | + | "tokio-util", | |
| 397 | + | "tracing", | |
| 398 | + | ] | |
| 399 | + | ||
| 400 | + | [[package]] | |
| 401 | + | name = "hashbrown" | |
| 402 | + | version = "0.15.5" | |
| 403 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 404 | + | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 405 | + | dependencies = [ | |
| 406 | + | "foldhash", | |
| 407 | + | ] | |
| 408 | + | ||
| 409 | + | [[package]] | |
| 410 | + | name = "hashbrown" | |
| 411 | + | version = "0.16.1" | |
| 412 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 413 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 414 | + | ||
| 415 | + | [[package]] | |
| 416 | + | name = "heck" | |
| 417 | + | version = "0.5.0" | |
| 418 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 419 | + | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 420 | + | ||
| 421 | + | [[package]] | |
| 422 | + | name = "http" | |
| 423 | + | version = "1.4.0" | |
| 424 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 425 | + | checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" | |
| 426 | + | dependencies = [ | |
| 427 | + | "bytes", | |
| 428 | + | "itoa", | |
| 429 | + | ] | |
| 430 | + | ||
| 431 | + | [[package]] | |
| 432 | + | name = "http-body" | |
| 433 | + | version = "1.0.1" | |
| 434 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 435 | + | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" | |
| 436 | + | dependencies = [ | |
| 437 | + | "bytes", | |
| 438 | + | "http", | |
| 439 | + | ] | |
| 440 | + | ||
| 441 | + | [[package]] | |
| 442 | + | name = "http-body-util" | |
| 443 | + | version = "0.1.3" | |
| 444 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 445 | + | checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" | |
| 446 | + | dependencies = [ | |
| 447 | + | "bytes", | |
| 448 | + | "futures-core", | |
| 449 | + | "http", | |
| 450 | + | "http-body", | |
| 451 | + | "pin-project-lite", | |
| 452 | + | ] | |
| 453 | + | ||
| 454 | + | [[package]] | |
| 455 | + | name = "httparse" | |
| 456 | + | version = "1.10.1" | |
| 457 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | + | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 459 | + | ||
| 460 | + | [[package]] | |
| 461 | + | name = "hyper" | |
| 462 | + | version = "1.8.1" | |
| 463 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 464 | + | checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" | |
| 465 | + | dependencies = [ | |
| 466 | + | "atomic-waker", | |
| 467 | + | "bytes", | |
| 468 | + | "futures-channel", | |
| 469 | + | "futures-core", | |
| 470 | + | "h2", | |
| 471 | + | "http", | |
| 472 | + | "http-body", | |
| 473 | + | "httparse", | |
| 474 | + | "itoa", | |
| 475 | + | "pin-project-lite", | |
| 476 | + | "pin-utils", | |
| 477 | + | "smallvec", | |
| 478 | + | "tokio", | |
| 479 | + | "want", | |
| 480 | + | ] | |
| 481 | + | ||
| 482 | + | [[package]] | |
| 483 | + | name = "hyper-rustls" | |
| 484 | + | version = "0.27.7" | |
| 485 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 486 | + | checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" | |
| 487 | + | dependencies = [ | |
| 488 | + | "http", | |
| 489 | + | "hyper", | |
| 490 | + | "hyper-util", | |
| 491 | + | "rustls", | |
| 492 | + | "rustls-pki-types", | |
| 493 | + | "tokio", | |
| 494 | + | "tokio-rustls", | |
| 495 | + | "tower-service", | |
| 496 | + | ] | |
| 497 | + | ||
| 498 | + | [[package]] | |
| 499 | + | name = "hyper-tls" | |
| 500 | + | version = "0.6.0" |
Lines truncated
| @@ -0,0 +1,37 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "synckit-client" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2021" | |
| 5 | + | description = "SyncKit client SDK with end-to-end encryption" | |
| 6 | + | ||
| 7 | + | [features] | |
| 8 | + | default = ["keychain"] | |
| 9 | + | keychain = ["dep:keyring"] | |
| 10 | + | ||
| 11 | + | [dependencies] | |
| 12 | + | # Encryption | |
| 13 | + | chacha20poly1305 = "0.10" | |
| 14 | + | argon2 = "0.5" | |
| 15 | + | sha2 = "0.10" | |
| 16 | + | rand = "0.8" | |
| 17 | + | base64 = "0.22" | |
| 18 | + | ||
| 19 | + | # HTTP | |
| 20 | + | reqwest = { version = "0.12", features = ["json", "native-tls"] } | |
| 21 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros"] } | |
| 22 | + | ||
| 23 | + | # Serialization | |
| 24 | + | serde = { version = "1", features = ["derive"] } | |
| 25 | + | serde_json = "1" | |
| 26 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 27 | + | uuid = { version = "1", features = ["v4", "serde"] } | |
| 28 | + | ||
| 29 | + | # OS keychain (optional) | |
| 30 | + | keyring = { version = "3", optional = true } | |
| 31 | + | ||
| 32 | + | # URL encoding | |
| 33 | + | urlencoding = "2" | |
| 34 | + | ||
| 35 | + | # Error handling & logging | |
| 36 | + | thiserror = "1" | |
| 37 | + | tracing = "0.1" |
| @@ -0,0 +1,534 @@ | |||
| 1 | + | //! SyncKitClient: HTTP transport + high-level API with transparent encryption. | |
| 2 | + | ||
| 3 | + | use reqwest::Client; | |
| 4 | + | use std::sync::Mutex; | |
| 5 | + | use uuid::Uuid; | |
| 6 | + | ||
| 7 | + | use crate::{ | |
| 8 | + | crypto, | |
| 9 | + | error::{Result, SyncKitError}, | |
| 10 | + | keystore, | |
| 11 | + | types::*, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | /// Configuration for the SyncKit client. | |
| 15 | + | #[derive(Debug, Clone)] | |
| 16 | + | pub struct SyncKitConfig { | |
| 17 | + | /// Base URL of the MNW server (e.g. "https://makenot.work"). | |
| 18 | + | pub server_url: String, | |
| 19 | + | /// App API key (obtained from MNW dashboard). | |
| 20 | + | pub api_key: String, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Session state obtained after authentication. | |
| 24 | + | struct Session { | |
| 25 | + | token: String, | |
| 26 | + | user_id: Uuid, | |
| 27 | + | app_id: Uuid, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Public session info returned by `session_info()`. | |
| 31 | + | pub struct SessionInfo { | |
| 32 | + | pub token: String, | |
| 33 | + | pub user_id: Uuid, | |
| 34 | + | pub app_id: Uuid, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// The SyncKit client. Handles authentication, encryption, and HTTP transport. | |
| 38 | + | pub struct SyncKitClient { | |
| 39 | + | config: SyncKitConfig, | |
| 40 | + | http: Client, | |
| 41 | + | session: Mutex<Option<Session>>, | |
| 42 | + | master_key: Mutex<Option<crypto::ZeroizeOnDrop>>, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl SyncKitClient { | |
| 46 | + | /// Create a new client with the given configuration. | |
| 47 | + | pub fn new(config: SyncKitConfig) -> Self { | |
| 48 | + | Self { | |
| 49 | + | config, | |
| 50 | + | http: Client::new(), | |
| 51 | + | session: Mutex::new(None), | |
| 52 | + | master_key: Mutex::new(None), | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ── Auth ── | |
| 57 | + | ||
| 58 | + | /// Authenticate with the MNW server. Returns (user_id, app_id). | |
| 59 | + | pub async fn authenticate( | |
| 60 | + | &self, | |
| 61 | + | email: &str, | |
| 62 | + | password: &str, | |
| 63 | + | ) -> Result<(Uuid, Uuid)> { | |
| 64 | + | let url = format!("{}/api/sync/auth", self.config.server_url); | |
| 65 | + | ||
| 66 | + | let resp = self | |
| 67 | + | .http | |
| 68 | + | .post(&url) | |
| 69 | + | .json(&AuthRequest { | |
| 70 | + | email: email.to_string(), | |
| 71 | + | password: password.to_string(), | |
| 72 | + | api_key: self.config.api_key.clone(), | |
| 73 | + | }) | |
| 74 | + | .send() | |
| 75 | + | .await?; | |
| 76 | + | ||
| 77 | + | let resp = check_response(resp).await?; | |
| 78 | + | let auth: AuthResponse = resp.json().await?; | |
| 79 | + | ||
| 80 | + | let user_id = auth.user_id; | |
| 81 | + | let app_id = auth.app_id; | |
| 82 | + | ||
| 83 | + | *self.session.lock().unwrap() = Some(Session { | |
| 84 | + | token: auth.token, | |
| 85 | + | user_id, | |
| 86 | + | app_id, | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | tracing::info!("Authenticated as user {user_id} for app {app_id}"); | |
| 90 | + | Ok((user_id, app_id)) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Returns the client configuration. | |
| 94 | + | pub fn config(&self) -> &SyncKitConfig { | |
| 95 | + | &self.config | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Returns whether the master encryption key is loaded and ready. | |
| 99 | + | pub fn has_master_key(&self) -> bool { | |
| 100 | + | self.master_key.lock().unwrap().is_some() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Returns the current session info, if authenticated. | |
| 104 | + | pub fn session_info(&self) -> Option<SessionInfo> { | |
| 105 | + | let guard = self.session.lock().unwrap(); | |
| 106 | + | guard.as_ref().map(|s| SessionInfo { | |
| 107 | + | token: s.token.clone(), | |
| 108 | + | user_id: s.user_id, | |
| 109 | + | app_id: s.app_id, | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Restore a session from previously stored credentials (e.g. OS keychain). | |
| 114 | + | /// | |
| 115 | + | /// Sets the internal session state without making any HTTP calls. | |
| 116 | + | /// Used on app startup to restore from stored credentials without re-authenticating. | |
| 117 | + | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) { | |
| 118 | + | *self.session.lock().unwrap() = Some(Session { | |
| 119 | + | token: token.to_string(), | |
| 120 | + | user_id, | |
| 121 | + | app_id, | |
| 122 | + | }); | |
| 123 | + | tracing::info!("Session restored for user {user_id}, app {app_id}"); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // ── OAuth ── | |
| 127 | + | ||
| 128 | + | /// Build the authorization URL for the OAuth2 PKCE flow. | |
| 129 | + | /// | |
| 130 | + | /// The caller is responsible for generating the PKCE verifier/challenge, | |
| 131 | + | /// starting the localhost callback server, and opening the browser. | |
| 132 | + | pub fn build_authorize_url( | |
| 133 | + | &self, | |
| 134 | + | redirect_port: u16, | |
| 135 | + | state: &str, | |
| 136 | + | code_challenge: &str, | |
| 137 | + | ) -> String { | |
| 138 | + | format!( | |
| 139 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 140 | + | self.config.server_url, | |
| 141 | + | urlencoding::encode(&self.config.api_key), | |
| 142 | + | urlencoding::encode(&format!("http://127.0.0.1:{}/", redirect_port)), | |
| 143 | + | urlencoding::encode(state), | |
| 144 | + | urlencoding::encode(code_challenge), | |
| 145 | + | ) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Exchange an OAuth2 authorization code for a SyncKit JWT. | |
| 149 | + | /// | |
| 150 | + | /// Call this after receiving the code from the localhost callback server. | |
| 151 | + | /// On success, stores the session internally (same as `authenticate()`). | |
| 152 | + | pub async fn authenticate_with_code( | |
| 153 | + | &self, | |
| 154 | + | code: &str, | |
| 155 | + | code_verifier: &str, | |
| 156 | + | redirect_port: u16, | |
| 157 | + | ) -> Result<(Uuid, Uuid)> { | |
| 158 | + | let url = format!("{}/oauth/token", self.config.server_url); | |
| 159 | + | let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port); | |
| 160 | + | ||
| 161 | + | let resp = self | |
| 162 | + | .http | |
| 163 | + | .post(&url) | |
| 164 | + | .json(&OAuthTokenRequest { | |
| 165 | + | grant_type: "authorization_code".to_string(), | |
| 166 | + | code: code.to_string(), | |
| 167 | + | redirect_uri, | |
| 168 | + | code_verifier: code_verifier.to_string(), | |
| 169 | + | client_id: self.config.api_key.clone(), | |
| 170 | + | }) | |
| 171 | + | .send() | |
| 172 | + | .await?; | |
| 173 | + | ||
| 174 | + | let resp = check_response(resp).await?; | |
| 175 | + | let token_resp: OAuthTokenResponse = resp.json().await?; | |
| 176 | + | ||
| 177 | + | let user_id = token_resp.user_id; | |
| 178 | + | let app_id = token_resp.app_id; | |
| 179 | + | ||
| 180 | + | *self.session.lock().unwrap() = Some(Session { | |
| 181 | + | token: token_resp.access_token, | |
| 182 | + | user_id, | |
| 183 | + | app_id, | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}"); | |
| 187 | + | Ok((user_id, app_id)) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // ── Encryption setup ── | |
| 191 | + | ||
| 192 | + | /// Check if the server has an encrypted master key for this user. | |
| 193 | + | pub async fn has_server_key(&self) -> Result<bool> { | |
| 194 | + | let (url, token) = self.key_url_and_token()?; | |
| 195 | + | ||
| 196 | + | let resp = self | |
| 197 | + | .http | |
| 198 | + | .get(&url) | |
| 199 | + | .bearer_auth(&token) | |
| 200 | + | .send() | |
| 201 | + | .await?; | |
| 202 | + | ||
| 203 | + | match resp.status().as_u16() { | |
| 204 | + | 200 => Ok(true), | |
| 205 | + | 404 => Ok(false), | |
| 206 | + | status => { | |
| 207 | + | let message = resp.text().await.unwrap_or_default(); | |
| 208 | + | Err(SyncKitError::Server { status, message }) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// First device: generate a new master key, encrypt it, push to server, cache in keychain. | |
| 214 | + | pub async fn setup_encryption_new(&self, password: &str) -> Result<()> { | |
| 215 | + | let (app_id, user_id) = self.require_session_ids()?; | |
| 216 | + | ||
| 217 | + | let master_key = crypto::generate_master_key(); | |
| 218 | + | let wrapping_key = crypto::derive_wrapping_key(password, app_id, user_id)?; | |
| 219 | + | let envelope = crypto::wrap_master_key(&master_key, &wrapping_key, app_id, user_id)?; | |
| 220 | + | ||
| 221 | + | // Push to server | |
| 222 | + | self.put_server_key(&envelope).await?; | |
| 223 | + | ||
| 224 | + | // Cache in OS keychain | |
| 225 | + | keystore::store_key(app_id, user_id, &master_key)?; | |
| 226 | + | ||
| 227 | + | // Store in memory | |
| 228 | + | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 229 | + | ||
| 230 | + | tracing::info!("New master key generated and stored"); | |
| 231 | + | Ok(()) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Second device: pull encrypted master key from server, decrypt with password, cache. | |
| 235 | + | pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> { | |
| 236 | + | let (app_id, user_id) = self.require_session_ids()?; | |
| 237 | + | ||
| 238 | + | let envelope_json = self.get_server_key().await?; | |
| 239 | + | let wrapping_key = crypto::derive_wrapping_key(password, app_id, user_id)?; | |
| 240 | + | let master_key = crypto::unwrap_master_key(&envelope_json, &wrapping_key)?; | |
| 241 | + | ||
| 242 | + | // Cache in OS keychain | |
| 243 | + | keystore::store_key(app_id, user_id, &master_key)?; | |
| 244 | + | ||
| 245 | + | // Store in memory | |
| 246 | + | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 247 | + | ||
| 248 | + | tracing::info!("Master key recovered from server"); | |
| 249 | + | Ok(()) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// Subsequent launches: try to load the master key from the OS keychain. | |
| 253 | + | /// Returns true if a key was found. | |
| 254 | + | pub fn try_load_key_from_keychain(&self) -> Result<bool> { | |
| 255 | + | let (app_id, user_id) = self.require_session_ids()?; | |
| 256 | + | ||
| 257 | + | if let Some(key) = keystore::load_key(app_id, user_id)? { | |
| 258 | + | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(key)); | |
| 259 | + | tracing::info!("Master key loaded from keychain"); | |
| 260 | + | Ok(true) | |
| 261 | + | } else { | |
| 262 | + | Ok(false) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Change the encryption password. Decrypts master key with old password, | |
| 267 | + | /// re-encrypts with new password, and pushes to server. | |
| 268 | + | pub async fn change_password( | |
| 269 | + | &self, | |
| 270 | + | old_password: &str, | |
| 271 | + | new_password: &str, | |
| 272 | + | ) -> Result<()> { | |
| 273 | + | let (app_id, user_id) = self.require_session_ids()?; | |
| 274 | + | ||
| 275 | + | // Get the current master key (from memory or re-derive from old password) | |
| 276 | + | let master_key = { | |
| 277 | + | let guard = self.master_key.lock().unwrap(); | |
| 278 | + | if let Some(ref key) = *guard { | |
| 279 | + | **key | |
| 280 | + | } else { | |
| 281 | + | // Fall back to decrypting from server | |
| 282 | + | let envelope_json = self.get_server_key().await?; | |
| 283 | + | let wrapping_key = | |
| 284 | + | crypto::derive_wrapping_key(old_password, app_id, user_id)?; | |
| 285 | + | crypto::unwrap_master_key(&envelope_json, &wrapping_key)? | |
| 286 | + | } | |
| 287 | + | }; | |
| 288 | + | ||
| 289 | + | // Re-wrap with new password | |
| 290 | + | let new_wrapping_key = | |
| 291 | + | crypto::derive_wrapping_key(new_password, app_id, user_id)?; | |
| 292 | + | let new_envelope = | |
| 293 | + | crypto::wrap_master_key(&master_key, &new_wrapping_key, app_id, user_id)?; | |
| 294 | + | ||
| 295 | + | self.put_server_key(&new_envelope).await?; | |
| 296 | + | ||
| 297 | + | tracing::info!("Encryption password changed"); | |
| 298 | + | Ok(()) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // ── Devices ── | |
| 302 | + | ||
| 303 | + | /// Register a device for sync. | |
| 304 | + | pub async fn register_device( | |
| 305 | + | &self, | |
| 306 | + | device_name: &str, | |
| 307 | + | platform: &str, | |
| 308 | + | ) -> Result<Device> { | |
| 309 | + | let url = format!("{}/api/sync/devices", self.config.server_url); | |
| 310 | + | let token = self.require_token()?; | |
| 311 | + | ||
| 312 | + | let resp = self | |
| 313 | + | .http | |
| 314 | + | .post(&url) | |
| 315 | + | .bearer_auth(&token) | |
| 316 | + | .json(&RegisterDeviceRequest { | |
| 317 | + | device_name: device_name.to_string(), | |
| 318 | + | platform: platform.to_string(), | |
| 319 | + | }) | |
| 320 | + | .send() | |
| 321 | + | .await?; | |
| 322 | + | ||
| 323 | + | let resp = check_response(resp).await?; | |
| 324 | + | Ok(resp.json().await?) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// List all devices for the current user. | |
| 328 | + | pub async fn list_devices(&self) -> Result<Vec<Device>> { | |
| 329 | + | let url = format!("{}/api/sync/devices", self.config.server_url); | |
| 330 | + | let token = self.require_token()?; | |
| 331 | + | ||
| 332 | + | let resp = self | |
| 333 | + | .http | |
| 334 | + | .get(&url) | |
| 335 | + | .bearer_auth(&token) | |
| 336 | + | .send() | |
| 337 | + | .await?; | |
| 338 | + | ||
| 339 | + | let resp = check_response(resp).await?; | |
| 340 | + | Ok(resp.json().await?) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // ── Push / Pull ── | |
| 344 | + | ||
| 345 | + | /// Push changes to the server. Encrypts `data` fields automatically. | |
| 346 | + | /// Returns the server cursor after the push. | |
| 347 | + | pub async fn push( | |
| 348 | + | &self, | |
| 349 | + | device_id: Uuid, | |
| 350 | + | changes: Vec<ChangeEntry>, | |
| 351 | + | ) -> Result<i64> { | |
| 352 | + | let url = format!("{}/api/sync/push", self.config.server_url); | |
| 353 | + | let token = self.require_token()?; | |
| 354 | + | ||
| 355 | + | let wire_changes = changes | |
| 356 | + | .into_iter() | |
| 357 | + | .map(|c| self.encrypt_change(c)) | |
| 358 | + | .collect::<Result<Vec<_>>>()?; | |
| 359 | + | ||
| 360 | + | let resp = self | |
| 361 | + | .http | |
| 362 | + | .post(&url) | |
| 363 | + | .bearer_auth(&token) | |
| 364 | + | .json(&WirePushRequest { | |
| 365 | + | device_id, | |
| 366 | + | changes: wire_changes, | |
| 367 | + | }) | |
| 368 | + | .send() | |
| 369 | + | .await?; | |
| 370 | + | ||
| 371 | + | let resp = check_response(resp).await?; | |
| 372 | + | let push_resp: PushResponse = resp.json().await?; | |
| 373 | + | Ok(push_resp.cursor) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | /// Pull changes from the server since the given cursor. | |
| 377 | + | /// Decrypts `data` fields automatically. | |
| 378 | + | /// Returns (changes, new_cursor, has_more). | |
| 379 | + | pub async fn pull( | |
| 380 | + | &self, | |
| 381 | + | device_id: Uuid, | |
| 382 | + | cursor: i64, | |
| 383 | + | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| 384 | + | let url = format!("{}/api/sync/pull", self.config.server_url); | |
| 385 | + | let token = self.require_token()?; | |
| 386 | + | ||
| 387 | + | let resp = self | |
| 388 | + | .http | |
| 389 | + | .post(&url) | |
| 390 | + | .bearer_auth(&token) | |
| 391 | + | .json(&PullRequest { device_id, cursor }) | |
| 392 | + | .send() | |
| 393 | + | .await?; | |
| 394 | + | ||
| 395 | + | let resp = check_response(resp).await?; | |
| 396 | + | let pull_resp: PullResponse = resp.json().await?; | |
| 397 | + | ||
| 398 | + | let changes = pull_resp | |
| 399 | + | .changes | |
| 400 | + | .into_iter() | |
| 401 | + | .map(|c| self.decrypt_change(c)) | |
| 402 | + | .collect::<Result<Vec<_>>>()?; | |
| 403 | + | ||
| 404 | + | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /// Get sync status (total changes, latest cursor). | |
| 408 | + | pub async fn status(&self) -> Result<SyncStatus> { | |
| 409 | + | let url = format!("{}/api/sync/status", self.config.server_url); | |
| 410 | + | let token = self.require_token()?; | |
| 411 | + | ||
| 412 | + | let resp = self | |
| 413 | + | .http | |
| 414 | + | .get(&url) | |
| 415 | + | .bearer_auth(&token) | |
| 416 | + | .send() | |
| 417 | + | .await?; | |
| 418 | + | ||
| 419 | + | let resp = check_response(resp).await?; | |
| 420 | + | Ok(resp.json().await?) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | // ── Internal helpers ── | |
| 424 | + | ||
| 425 | + | fn require_token(&self) -> Result<String> { | |
| 426 | + | let guard = self.session.lock().unwrap(); | |
| 427 | + | guard | |
| 428 | + | .as_ref() | |
| 429 | + | .map(|s| s.token.clone()) | |
| 430 | + | .ok_or(SyncKitError::NotAuthenticated) | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | fn require_session_ids(&self) -> Result<(Uuid, Uuid)> { | |
| 434 | + | let guard = self.session.lock().unwrap(); | |
| 435 | + | guard | |
| 436 | + | .as_ref() | |
| 437 | + | .map(|s| (s.app_id, s.user_id)) | |
| 438 | + | .ok_or(SyncKitError::NotAuthenticated) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | fn require_master_key(&self) -> Result<[u8; 32]> { | |
| 442 | + | let guard = self.master_key.lock().unwrap(); | |
| 443 | + | guard | |
| 444 | + | .as_ref() | |
| 445 | + | .map(|k| **k) | |
| 446 | + | .ok_or(SyncKitError::NoMasterKey) | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | fn key_url_and_token(&self) -> Result<(String, String)> { | |
| 450 | + | let url = format!("{}/api/sync/keys", self.config.server_url); | |
| 451 | + | let token = self.require_token()?; | |
| 452 | + | Ok((url, token)) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | async fn put_server_key(&self, envelope_json: &str) -> Result<()> { | |
| 456 | + | let (url, token) = self.key_url_and_token()?; | |
| 457 | + | ||
| 458 | + | let resp = self | |
| 459 | + | .http | |
| 460 | + | .put(&url) | |
| 461 | + | .bearer_auth(&token) | |
| 462 | + | .json(&PutKeyRequest { | |
| 463 | + | encrypted_key: envelope_json.to_string(), | |
| 464 | + | }) | |
| 465 | + | .send() | |
| 466 | + | .await?; | |
| 467 | + | ||
| 468 | + | check_response(resp).await?; | |
| 469 | + | Ok(()) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | async fn get_server_key(&self) -> Result<String> { | |
| 473 | + | let (url, token) = self.key_url_and_token()?; | |
| 474 | + | ||
| 475 | + | let resp = self | |
| 476 | + | .http | |
| 477 | + | .get(&url) | |
| 478 | + | .bearer_auth(&token) | |
| 479 | + | .send() | |
| 480 | + | .await?; | |
| 481 | + | ||
| 482 | + | let resp = check_response(resp).await?; | |
| 483 | + | let key_resp: GetKeyResponse = resp.json().await?; | |
| 484 | + | Ok(key_resp.encrypted_key) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | /// Encrypt the data field of a change entry for the wire. | |
| 488 | + | fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> { | |
| 489 | + | let encrypted_data = match entry.data { | |
| 490 | + | Some(ref value) => { | |
| 491 | + | let master_key = self.require_master_key()?; | |
| 492 | + | Some(crypto::encrypt_json(value, &master_key)?) | |
| 493 | + | } | |
| 494 | + | None => None, | |
| 495 | + | }; | |
| 496 | + | ||
| 497 | + | Ok(WireChangeEntry { | |
| 498 | + | table: entry.table, | |
| 499 | + | op: entry.op, | |
| 500 | + | row_id: entry.row_id, |
Lines truncated
| @@ -0,0 +1,416 @@ | |||
| 1 | + | //! Encryption engine: key derivation, wrapping, and per-entry encrypt/decrypt. | |
| 2 | + | //! | |
| 3 | + | //! Key hierarchy: | |
| 4 | + | //! password + (app_id, user_id) → Argon2id → wrapping_key | |
| 5 | + | //! wrapping_key encrypts/decrypts the random master_key | |
| 6 | + | //! master_key encrypts/decrypts individual data entries | |
| 7 | + | //! | |
| 8 | + | //! All encryption uses XChaCha20-Poly1305 (192-bit nonces, safe for random generation). | |
| 9 | + | ||
| 10 | + | use argon2::{Argon2, Algorithm, Version, Params}; | |
| 11 | + | use base64::{engine::general_purpose::STANDARD as B64, Engine}; | |
| 12 | + | use chacha20poly1305::{ | |
| 13 | + | aead::{Aead, KeyInit}, | |
| 14 | + | XChaCha20Poly1305, XNonce, | |
| 15 | + | }; | |
| 16 | + | use rand::RngCore; | |
| 17 | + | use serde::{Deserialize, Serialize}; | |
| 18 | + | use sha2::{Digest, Sha256}; | |
| 19 | + | use uuid::Uuid; | |
| 20 | + | ||
| 21 | + | use crate::error::{Result, SyncKitError}; | |
| 22 | + | ||
| 23 | + | /// Size of XChaCha20-Poly1305 nonce in bytes. | |
| 24 | + | const NONCE_SIZE: usize = 24; | |
| 25 | + | /// Size of the encryption key in bytes. | |
| 26 | + | const KEY_SIZE: usize = 32; | |
| 27 | + | ||
| 28 | + | /// Current envelope version. | |
| 29 | + | const ENVELOPE_VERSION: u8 = 1; | |
| 30 | + | ||
| 31 | + | /// Argon2id parameters: 64 MB memory, 3 iterations (OWASP interactive minimum). | |
| 32 | + | const ARGON2_MEM_COST_KB: u32 = 65_536; // 64 MB | |
| 33 | + | const ARGON2_TIME_COST: u32 = 3; | |
| 34 | + | const ARGON2_PARALLELISM: u32 = 1; | |
| 35 | + | ||
| 36 | + | /// Encrypted master key envelope stored on the server. | |
| 37 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 38 | + | pub struct KeyEnvelope { | |
| 39 | + | /// Envelope version (currently 1). | |
| 40 | + | pub v: u8, | |
| 41 | + | /// Argon2 salt (base64). | |
| 42 | + | pub salt: String, | |
| 43 | + | /// XChaCha20-Poly1305 nonce for the wrapping (base64). | |
| 44 | + | pub nonce: String, | |
| 45 | + | /// Encrypted master key (base64). | |
| 46 | + | pub ciphertext: String, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Generate a random 256-bit master key. | |
| 50 | + | pub fn generate_master_key() -> [u8; KEY_SIZE] { | |
| 51 | + | let mut key = [0u8; KEY_SIZE]; | |
| 52 | + | rand::thread_rng().fill_bytes(&mut key); | |
| 53 | + | key | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Derive a deterministic salt from app_id and user_id. | |
| 57 | + | /// salt = SHA256(app_id_bytes || user_id_bytes) | |
| 58 | + | fn derive_salt(app_id: Uuid, user_id: Uuid) -> [u8; 32] { | |
| 59 | + | let mut hasher = Sha256::new(); | |
| 60 | + | hasher.update(app_id.as_bytes()); | |
| 61 | + | hasher.update(user_id.as_bytes()); | |
| 62 | + | hasher.finalize().into() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Derive a wrapping key from a password using Argon2id. | |
| 66 | + | pub fn derive_wrapping_key( | |
| 67 | + | password: &str, | |
| 68 | + | app_id: Uuid, | |
| 69 | + | user_id: Uuid, | |
| 70 | + | ) -> Result<[u8; KEY_SIZE]> { | |
| 71 | + | let salt = derive_salt(app_id, user_id); | |
| 72 | + | ||
| 73 | + | let params = Params::new( | |
| 74 | + | ARGON2_MEM_COST_KB, | |
| 75 | + | ARGON2_TIME_COST, | |
| 76 | + | ARGON2_PARALLELISM, | |
| 77 | + | Some(KEY_SIZE), | |
| 78 | + | ) | |
| 79 | + | .map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?; | |
| 80 | + | ||
| 81 | + | let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); | |
| 82 | + | ||
| 83 | + | let mut wrapping_key = [0u8; KEY_SIZE]; | |
| 84 | + | argon2 | |
| 85 | + | .hash_password_into(password.as_bytes(), &salt, &mut wrapping_key) | |
| 86 | + | .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}")))?; | |
| 87 | + | ||
| 88 | + | Ok(wrapping_key) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Encrypt the master key with the wrapping key, producing a JSON envelope. | |
| 92 | + | pub fn wrap_master_key( | |
| 93 | + | master_key: &[u8; KEY_SIZE], | |
| 94 | + | wrapping_key: &[u8; KEY_SIZE], | |
| 95 | + | app_id: Uuid, | |
| 96 | + | user_id: Uuid, | |
| 97 | + | ) -> Result<String> { | |
| 98 | + | let salt = derive_salt(app_id, user_id); | |
| 99 | + | let mut nonce_bytes = [0u8; NONCE_SIZE]; | |
| 100 | + | rand::thread_rng().fill_bytes(&mut nonce_bytes); | |
| 101 | + | ||
| 102 | + | let cipher = XChaCha20Poly1305::new(wrapping_key.into()); | |
| 103 | + | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 104 | + | ||
| 105 | + | let ciphertext = cipher | |
| 106 | + | .encrypt(nonce, master_key.as_ref()) | |
| 107 | + | .map_err(|e| SyncKitError::Crypto(format!("wrap encrypt: {e}")))?; | |
| 108 | + | ||
| 109 | + | let envelope = KeyEnvelope { | |
| 110 | + | v: ENVELOPE_VERSION, | |
| 111 | + | salt: B64.encode(salt), | |
| 112 | + | nonce: B64.encode(nonce_bytes), | |
| 113 | + | ciphertext: B64.encode(ciphertext), | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | serde_json::to_string(&envelope).map_err(Into::into) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Decrypt the master key from a JSON envelope using the wrapping key. | |
| 120 | + | pub fn unwrap_master_key( | |
| 121 | + | envelope_json: &str, | |
| 122 | + | wrapping_key: &[u8; KEY_SIZE], | |
| 123 | + | ) -> Result<[u8; KEY_SIZE]> { | |
| 124 | + | let envelope: KeyEnvelope = | |
| 125 | + | serde_json::from_str(envelope_json).map_err(|e| { | |
| 126 | + | SyncKitError::InvalidEnvelope(format!("JSON parse: {e}")) | |
| 127 | + | })?; | |
| 128 | + | ||
| 129 | + | if envelope.v != ENVELOPE_VERSION { | |
| 130 | + | return Err(SyncKitError::InvalidEnvelope(format!( | |
| 131 | + | "unsupported version {}", | |
| 132 | + | envelope.v | |
| 133 | + | ))); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | let nonce_bytes = B64.decode(&envelope.nonce)?; | |
| 137 | + | let ciphertext = B64.decode(&envelope.ciphertext)?; | |
| 138 | + | ||
| 139 | + | if nonce_bytes.len() != NONCE_SIZE { | |
| 140 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 141 | + | "invalid nonce length".into(), | |
| 142 | + | )); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | let cipher = XChaCha20Poly1305::new(wrapping_key.into()); | |
| 146 | + | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 147 | + | ||
| 148 | + | let plaintext = cipher | |
| 149 | + | .decrypt(nonce, ciphertext.as_ref()) | |
| 150 | + | .map_err(|_| SyncKitError::DecryptionFailed)?; | |
| 151 | + | ||
| 152 | + | if plaintext.len() != KEY_SIZE { | |
| 153 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 154 | + | "decrypted key has wrong length".into(), | |
| 155 | + | )); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | let mut key = [0u8; KEY_SIZE]; | |
| 159 | + | key.copy_from_slice(&plaintext); | |
| 160 | + | Ok(key) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Encrypt a data entry with the master key. | |
| 164 | + | /// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). | |
| 165 | + | pub fn encrypt_data( | |
| 166 | + | plaintext: &[u8], | |
| 167 | + | master_key: &[u8; KEY_SIZE], | |
| 168 | + | ) -> Result<String> { | |
| 169 | + | let mut nonce_bytes = [0u8; NONCE_SIZE]; | |
| 170 | + | rand::thread_rng().fill_bytes(&mut nonce_bytes); | |
| 171 | + | ||
| 172 | + | let cipher = XChaCha20Poly1305::new(master_key.into()); | |
| 173 | + | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 174 | + | ||
| 175 | + | let ciphertext = cipher | |
| 176 | + | .encrypt(nonce, plaintext) | |
| 177 | + | .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?; | |
| 178 | + | ||
| 179 | + | // Wire format: nonce || ciphertext (which includes poly1305 tag) | |
| 180 | + | let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); | |
| 181 | + | blob.extend_from_slice(&nonce_bytes); | |
| 182 | + | blob.extend_from_slice(&ciphertext); | |
| 183 | + | ||
| 184 | + | Ok(B64.encode(blob)) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Decrypt a data entry with the master key. | |
| 188 | + | /// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]). | |
| 189 | + | pub fn decrypt_data( | |
| 190 | + | encoded: &str, | |
| 191 | + | master_key: &[u8; KEY_SIZE], | |
| 192 | + | ) -> Result<Vec<u8>> { | |
| 193 | + | let blob = B64.decode(encoded)?; | |
| 194 | + | ||
| 195 | + | if blob.len() < NONCE_SIZE + 16 { | |
| 196 | + | // Minimum: nonce + poly1305 tag (empty plaintext) | |
| 197 | + | return Err(SyncKitError::Crypto( | |
| 198 | + | "ciphertext too short".into(), | |
| 199 | + | )); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE); | |
| 203 | + | let cipher = XChaCha20Poly1305::new(master_key.into()); | |
| 204 | + | let nonce = XNonce::from_slice(nonce_bytes); | |
| 205 | + | ||
| 206 | + | cipher | |
| 207 | + | .decrypt(nonce, ciphertext) | |
| 208 | + | .map_err(|_| SyncKitError::DecryptionFailed) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Encrypt a JSON value, returning a JSON string suitable for the `data` field. | |
| 212 | + | pub fn encrypt_json( | |
| 213 | + | value: &serde_json::Value, | |
| 214 | + | master_key: &[u8; KEY_SIZE], | |
| 215 | + | ) -> Result<serde_json::Value> { | |
| 216 | + | let plaintext = serde_json::to_vec(value)?; | |
| 217 | + | let encrypted = encrypt_data(&plaintext, master_key)?; | |
| 218 | + | Ok(serde_json::Value::String(encrypted)) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// Decrypt a JSON string from the `data` field back into the original value. | |
| 222 | + | pub fn decrypt_json( | |
| 223 | + | encrypted_value: &serde_json::Value, | |
| 224 | + | master_key: &[u8; KEY_SIZE], | |
| 225 | + | ) -> Result<serde_json::Value> { | |
| 226 | + | let encoded = encrypted_value | |
| 227 | + | .as_str() | |
| 228 | + | .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?; | |
| 229 | + | ||
| 230 | + | let plaintext = decrypt_data(encoded, master_key)?; | |
| 231 | + | serde_json::from_slice(&plaintext).map_err(Into::into) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Zero out a key on drop (best-effort memory defense). | |
| 235 | + | pub struct ZeroizeOnDrop(pub [u8; KEY_SIZE]); | |
| 236 | + | ||
| 237 | + | impl Drop for ZeroizeOnDrop { | |
| 238 | + | fn drop(&mut self) { | |
| 239 | + | // Volatile write to prevent optimization | |
| 240 | + | for byte in self.0.iter_mut() { | |
| 241 | + | unsafe { | |
| 242 | + | std::ptr::write_volatile(byte, 0); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | impl std::ops::Deref for ZeroizeOnDrop { | |
| 249 | + | type Target = [u8; KEY_SIZE]; | |
| 250 | + | fn deref(&self) -> &Self::Target { | |
| 251 | + | &self.0 | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[cfg(test)] | |
| 256 | + | mod tests { | |
| 257 | + | use super::*; | |
| 258 | + | ||
| 259 | + | fn test_ids() -> (Uuid, Uuid) { | |
| 260 | + | ( | |
| 261 | + | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 262 | + | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 263 | + | ) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn master_key_generation_is_random() { | |
| 268 | + | let k1 = generate_master_key(); | |
| 269 | + | let k2 = generate_master_key(); | |
| 270 | + | assert_ne!(k1, k2, "Two generated keys must differ"); | |
| 271 | + | assert_eq!(k1.len(), 32); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[test] | |
| 275 | + | fn wrapping_key_derivation_is_deterministic() { | |
| 276 | + | let (app_id, user_id) = test_ids(); | |
| 277 | + | let k1 = derive_wrapping_key("password123", app_id, user_id).unwrap(); | |
| 278 | + | let k2 = derive_wrapping_key("password123", app_id, user_id).unwrap(); | |
| 279 | + | assert_eq!(k1, k2, "Same inputs must produce same wrapping key"); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn different_passwords_produce_different_keys() { | |
| 284 | + | let (app_id, user_id) = test_ids(); | |
| 285 | + | let k1 = derive_wrapping_key("password1", app_id, user_id).unwrap(); | |
| 286 | + | let k2 = derive_wrapping_key("password2", app_id, user_id).unwrap(); | |
| 287 | + | assert_ne!(k1, k2); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn wrap_unwrap_roundtrip() { | |
| 292 | + | let (app_id, user_id) = test_ids(); | |
| 293 | + | let master_key = generate_master_key(); | |
| 294 | + | let wrapping_key = derive_wrapping_key("mypassword", app_id, user_id).unwrap(); | |
| 295 | + | ||
| 296 | + | let envelope = wrap_master_key(&master_key, &wrapping_key, app_id, user_id).unwrap(); | |
| 297 | + | let recovered = unwrap_master_key(&envelope, &wrapping_key).unwrap(); | |
| 298 | + | ||
| 299 | + | assert_eq!(master_key, recovered); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[test] | |
| 303 | + | fn wrong_password_fails_unwrap() { | |
| 304 | + | let (app_id, user_id) = test_ids(); | |
| 305 | + | let master_key = generate_master_key(); | |
| 306 | + | let wrapping_key = derive_wrapping_key("correct", app_id, user_id).unwrap(); | |
| 307 | + | let wrong_key = derive_wrapping_key("wrong", app_id, user_id).unwrap(); | |
| 308 | + | ||
| 309 | + | let envelope = wrap_master_key(&master_key, &wrapping_key, app_id, user_id).unwrap(); | |
| 310 | + | let result = unwrap_master_key(&envelope, &wrong_key); | |
| 311 | + | ||
| 312 | + | assert!(result.is_err()); | |
| 313 | + | assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn data_encrypt_decrypt_roundtrip() { | |
| 318 | + | let master_key = generate_master_key(); | |
| 319 | + | let plaintext = b"Hello, world! This is sensitive data."; | |
| 320 | + | ||
| 321 | + | let encrypted = encrypt_data(plaintext, &master_key).unwrap(); | |
| 322 | + | let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); | |
| 323 | + | ||
| 324 | + | assert_eq!(decrypted, plaintext); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn same_plaintext_different_ciphertext() { | |
| 329 | + | let master_key = generate_master_key(); | |
| 330 | + | let plaintext = b"same data"; | |
| 331 | + | ||
| 332 | + | let e1 = encrypt_data(plaintext, &master_key).unwrap(); | |
| 333 | + | let e2 = encrypt_data(plaintext, &master_key).unwrap(); | |
| 334 | + | ||
| 335 | + | assert_ne!(e1, e2, "Random nonces must produce different ciphertext"); | |
| 336 | + | ||
| 337 | + | // But both decrypt to the same plaintext | |
| 338 | + | assert_eq!(decrypt_data(&e1, &master_key).unwrap(), plaintext); | |
| 339 | + | assert_eq!(decrypt_data(&e2, &master_key).unwrap(), plaintext); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | #[test] | |
| 343 | + | fn wrong_key_fails_decrypt() { | |
| 344 | + | let key1 = generate_master_key(); | |
| 345 | + | let key2 = generate_master_key(); | |
| 346 | + | let plaintext = b"secret"; | |
| 347 | + | ||
| 348 | + | let encrypted = encrypt_data(plaintext, &key1).unwrap(); | |
| 349 | + | let result = decrypt_data(&encrypted, &key2); | |
| 350 | + | ||
| 351 | + | assert!(result.is_err()); | |
| 352 | + | assert!(matches!(result.unwrap_err(), SyncKitError::DecryptionFailed)); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn envelope_version_check() { | |
| 357 | + | let (app_id, user_id) = test_ids(); | |
| 358 | + | let master_key = generate_master_key(); | |
| 359 | + | let wrapping_key = derive_wrapping_key("pass", app_id, user_id).unwrap(); | |
| 360 | + | ||
| 361 | + | let envelope_json = wrap_master_key(&master_key, &wrapping_key, app_id, user_id).unwrap(); | |
| 362 | + | ||
| 363 | + | // Tamper with version | |
| 364 | + | let mut envelope: KeyEnvelope = serde_json::from_str(&envelope_json).unwrap(); | |
| 365 | + | envelope.v = 99; | |
| 366 | + | let tampered = serde_json::to_string(&envelope).unwrap(); | |
| 367 | + | ||
| 368 | + | let result = unwrap_master_key(&tampered, &wrapping_key); | |
| 369 | + | assert!(result.is_err()); | |
| 370 | + | assert!(matches!( | |
| 371 | + | result.unwrap_err(), | |
| 372 | + | SyncKitError::InvalidEnvelope(_) | |
| 373 | + | )); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | #[test] | |
| 377 | + | fn truncated_ciphertext_rejected() { | |
| 378 | + | let master_key = generate_master_key(); | |
| 379 | + | let encrypted = encrypt_data(b"data", &master_key).unwrap(); | |
| 380 | + | ||
| 381 | + | // Decode, truncate, re-encode | |
| 382 | + | let mut blob = B64.decode(&encrypted).unwrap(); | |
| 383 | + | blob.truncate(10); // Way too short | |
| 384 | + | let truncated = B64.encode(&blob); | |
| 385 | + | ||
| 386 | + | let result = decrypt_data(&truncated, &master_key); | |
| 387 | + | assert!(result.is_err()); | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | #[test] | |
| 391 | + | fn json_encrypt_decrypt_roundtrip() { | |
| 392 | + | let master_key = generate_master_key(); | |
| 393 | + | let original = serde_json::json!({ | |
| 394 | + | "title": "Buy milk", | |
| 395 | + | "priority": 3, | |
| 396 | + | "tags": ["groceries", "urgent"] | |
| 397 | + | }); | |
| 398 | + | ||
| 399 | + | let encrypted = encrypt_json(&original, &master_key).unwrap(); | |
| 400 | + | assert!(encrypted.is_string(), "Encrypted JSON should be a string"); | |
| 401 | + | ||
| 402 | + | let decrypted = decrypt_json(&encrypted, &master_key).unwrap(); | |
| 403 | + | assert_eq!(decrypted, original); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn zeroize_on_drop() { | |
| 408 | + | let key = generate_master_key(); | |
| 409 | + | let guarded = ZeroizeOnDrop(key); | |
| 410 | + | // Verify we can use it | |
| 411 | + | assert_eq!(guarded.len(), 32); | |
| 412 | + | // Drop happens automatically — we can't easily test memory zeroing | |
| 413 | + | // but we verify the API works without panic. | |
| 414 | + | drop(guarded); | |
| 415 | + | } | |
| 416 | + | } |
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //! Error types for the SyncKit client SDK. | |
| 2 | + | ||
| 3 | + | use thiserror::Error; | |
| 4 | + | ||
| 5 | + | /// All errors that can occur in the SyncKit client. | |
| 6 | + | #[derive(Debug, Error)] | |
| 7 | + | pub enum SyncKitError { | |
| 8 | + | #[error("HTTP request failed: {0}")] | |
| 9 | + | Http(#[from] reqwest::Error), | |
| 10 | + | ||
| 11 | + | #[error("Server returned {status}: {message}")] | |
| 12 | + | Server { status: u16, message: String }, | |
| 13 | + | ||
| 14 | + | #[error("JSON serialization error: {0}")] | |
| 15 | + | Json(#[from] serde_json::Error), | |
| 16 | + | ||
| 17 | + | #[error("Encryption not initialized — call setup_encryption first")] | |
| 18 | + | NoMasterKey, | |
| 19 | + | ||
| 20 | + | #[error("Wrong password or corrupted key envelope")] | |
| 21 | + | DecryptionFailed, | |
| 22 | + | ||
| 23 | + | #[error("Invalid key envelope: {0}")] | |
| 24 | + | InvalidEnvelope(String), | |
| 25 | + | ||
| 26 | + | #[error("Encryption error: {0}")] | |
| 27 | + | Crypto(String), | |
| 28 | + | ||
| 29 | + | #[error("Base64 decode error: {0}")] | |
| 30 | + | Base64(#[from] base64::DecodeError), | |
| 31 | + | ||
| 32 | + | #[error("Not authenticated — call authenticate first")] | |
| 33 | + | NotAuthenticated, | |
| 34 | + | ||
| 35 | + | #[cfg(feature = "keychain")] | |
| 36 | + | #[error("Keychain error: {0}")] | |
| 37 | + | Keychain(String), | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Convenience alias. | |
| 41 | + | pub type Result<T> = std::result::Result<T, SyncKitError>; |
| @@ -0,0 +1,94 @@ | |||
| 1 | + | //! OS keychain integration for caching the master key. | |
| 2 | + | //! | |
| 3 | + | //! Feature-gated behind `keychain` (enabled by default). | |
| 4 | + | //! Falls back gracefully when the keychain is unavailable. | |
| 5 | + | ||
| 6 | + | use crate::error::{Result, SyncKitError}; | |
| 7 | + | use base64::{engine::general_purpose::STANDARD as B64, Engine}; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | const SERVICE_PREFIX: &str = "synckit"; | |
| 11 | + | ||
| 12 | + | /// Build the keychain service name: "synckit:<app_id>" | |
| 13 | + | fn service_name(app_id: Uuid) -> String { | |
| 14 | + | format!("{SERVICE_PREFIX}:{app_id}") | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Build the keychain user key: the user_id as a string. | |
| 18 | + | fn user_key(user_id: Uuid) -> String { | |
| 19 | + | user_id.to_string() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Store the master key in the OS keychain. | |
| 23 | + | #[cfg(feature = "keychain")] | |
| 24 | + | pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> { | |
| 25 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 26 | + | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 27 | + | ||
| 28 | + | let encoded = B64.encode(master_key); | |
| 29 | + | entry | |
| 30 | + | .set_password(&encoded) | |
| 31 | + | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 32 | + | ||
| 33 | + | tracing::debug!("Master key stored in OS keychain"); | |
| 34 | + | Ok(()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Load the master key from the OS keychain. | |
| 38 | + | /// Returns None if no key is stored (not an error). | |
| 39 | + | #[cfg(feature = "keychain")] | |
| 40 | + | pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> { | |
| 41 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 42 | + | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 43 | + | ||
| 44 | + | match entry.get_password() { | |
| 45 | + | Ok(encoded) => { | |
| 46 | + | let bytes = B64.decode(&encoded)?; | |
| 47 | + | if bytes.len() != 32 { | |
| 48 | + | return Err(SyncKitError::Keychain( | |
| 49 | + | "stored key has wrong length".into(), | |
| 50 | + | )); | |
| 51 | + | } | |
| 52 | + | let mut key = [0u8; 32]; | |
| 53 | + | key.copy_from_slice(&bytes); | |
| 54 | + | tracing::debug!("Master key loaded from OS keychain"); | |
| 55 | + | Ok(Some(key)) | |
| 56 | + | } | |
| 57 | + | Err(keyring::Error::NoEntry) => Ok(None), | |
| 58 | + | Err(e) => Err(SyncKitError::Keychain(e.to_string())), | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Delete the master key from the OS keychain. | |
| 63 | + | #[cfg(feature = "keychain")] | |
| 64 | + | pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { | |
| 65 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 66 | + | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 67 | + | ||
| 68 | + | match entry.delete_credential() { | |
| 69 | + | Ok(()) => { | |
| 70 | + | tracing::debug!("Master key deleted from OS keychain"); | |
| 71 | + | Ok(()) | |
| 72 | + | } | |
| 73 | + | Err(keyring::Error::NoEntry) => Ok(()), // Already gone | |
| 74 | + | Err(e) => Err(SyncKitError::Keychain(e.to_string())), | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ── No-op stubs when keychain feature is disabled ── | |
| 79 | + | ||
| 80 | + | #[cfg(not(feature = "keychain"))] | |
| 81 | + | pub fn store_key(_app_id: Uuid, _user_id: Uuid, _master_key: &[u8; 32]) -> Result<()> { | |
| 82 | + | tracing::warn!("Keychain support disabled — master key not persisted"); | |
| 83 | + | Ok(()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[cfg(not(feature = "keychain"))] | |
| 87 | + | pub fn load_key(_app_id: Uuid, _user_id: Uuid) -> Result<Option<[u8; 32]>> { | |
| 88 | + | Ok(None) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[cfg(not(feature = "keychain"))] | |
| 92 | + | pub fn delete_key(_app_id: Uuid, _user_id: Uuid) -> Result<()> { | |
| 93 | + | Ok(()) | |
| 94 | + | } |
| @@ -0,0 +1,53 @@ | |||
| 1 | + | //! SyncKit Client SDK — end-to-end encrypted cloud sync. | |
| 2 | + | //! | |
| 3 | + | //! All row data is encrypted client-side before leaving the device. | |
| 4 | + | //! The server only ever sees ciphertext. | |
| 5 | + | //! | |
| 6 | + | //! # Quick start | |
| 7 | + | //! | |
| 8 | + | //! ```no_run | |
| 9 | + | //! use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry}; | |
| 10 | + | //! use chrono::Utc; | |
| 11 | + | //! | |
| 12 | + | //! # async fn example() -> synckit_client::Result<()> { | |
| 13 | + | //! let client = SyncKitClient::new(SyncKitConfig { | |
| 14 | + | //! server_url: "https://makenot.work".into(), | |
| 15 | + | //! api_key: "your-api-key".into(), | |
| 16 | + | //! }); | |
| 17 | + | //! | |
| 18 | + | //! // Authenticate | |
| 19 | + | //! let (user_id, app_id) = client.authenticate("user@example.com", "password").await?; | |
| 20 | + | //! | |
| 21 | + | //! // Set up encryption (first device) | |
| 22 | + | //! client.setup_encryption_new("password").await?; | |
| 23 | + | //! | |
| 24 | + | //! // Register this device | |
| 25 | + | //! let device = client.register_device("MacBook Pro", "macos").await?; | |
| 26 | + | //! | |
| 27 | + | //! // Push encrypted data | |
| 28 | + | //! let cursor = client.push(device.id, vec![ | |
| 29 | + | //! ChangeEntry { | |
| 30 | + | //! table: "tasks".into(), | |
| 31 | + | //! op: "INSERT".into(), | |
| 32 | + | //! row_id: uuid::Uuid::new_v4().to_string(), | |
| 33 | + | //! timestamp: Utc::now(), | |
| 34 | + | //! data: Some(serde_json::json!({"title": "Buy milk"})), | |
| 35 | + | //! }, | |
| 36 | + | //! ]).await?; | |
| 37 | + | //! | |
| 38 | + | //! // Pull and auto-decrypt | |
| 39 | + | //! let (changes, cursor, has_more) = client.pull(device.id, 0).await?; | |
| 40 | + | //! # Ok(()) | |
| 41 | + | //! # } | |
| 42 | + | //! ``` | |
| 43 | + | ||
| 44 | + | pub mod client; | |
| 45 | + | pub mod crypto; | |
| 46 | + | pub mod error; | |
| 47 | + | pub mod keystore; | |
| 48 | + | pub mod types; | |
| 49 | + | ||
| 50 | + | // Re-exports for convenience | |
| 51 | + | pub use client::{SessionInfo, SyncKitClient, SyncKitConfig}; | |
| 52 | + | pub use error::{Result, SyncKitError}; | |
| 53 | + | pub use types::{ChangeEntry, Device, SyncStatus}; |
| @@ -0,0 +1,143 @@ | |||
| 1 | + | //! Request/response types matching the MNW SyncKit server API. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use uuid::Uuid; | |
| 6 | + | ||
| 7 | + | // ── Auth ── | |
| 8 | + | ||
| 9 | + | #[derive(Serialize)] | |
| 10 | + | pub struct AuthRequest { | |
| 11 | + | pub email: String, | |
| 12 | + | pub password: String, | |
| 13 | + | pub api_key: String, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | #[derive(Deserialize)] | |
| 17 | + | pub struct AuthResponse { | |
| 18 | + | pub token: String, | |
| 19 | + | pub user_id: Uuid, | |
| 20 | + | pub app_id: Uuid, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ── Devices ── | |
| 24 | + | ||
| 25 | + | #[derive(Serialize)] | |
| 26 | + | pub struct RegisterDeviceRequest { | |
| 27 | + | pub device_name: String, | |
| 28 | + | pub platform: String, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 32 | + | pub struct Device { | |
| 33 | + | pub id: Uuid, | |
| 34 | + | pub app_id: Uuid, | |
| 35 | + | pub user_id: Uuid, | |
| 36 | + | pub device_name: String, | |
| 37 | + | pub platform: String, | |
| 38 | + | pub last_seen_at: DateTime<Utc>, | |
| 39 | + | pub created_at: DateTime<Utc>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ── Push / Pull ── | |
| 43 | + | ||
| 44 | + | /// A change entry for pushing to the server. | |
| 45 | + | /// `data` is plaintext here — the client encrypts it before sending. | |
| 46 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 47 | + | pub struct ChangeEntry { | |
| 48 | + | pub table: String, | |
| 49 | + | pub op: String, | |
| 50 | + | pub row_id: String, | |
| 51 | + | pub timestamp: DateTime<Utc>, | |
| 52 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 53 | + | pub data: Option<serde_json::Value>, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Wire format sent to server (data is already encrypted). | |
| 57 | + | #[derive(Serialize)] | |
| 58 | + | pub(crate) struct WirePushRequest { | |
| 59 | + | pub device_id: Uuid, | |
| 60 | + | pub changes: Vec<WireChangeEntry>, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[derive(Serialize)] | |
| 64 | + | pub(crate) struct WireChangeEntry { | |
| 65 | + | pub table: String, | |
| 66 | + | pub op: String, | |
| 67 | + | pub row_id: String, | |
| 68 | + | pub timestamp: DateTime<Utc>, | |
| 69 | + | pub data: Option<serde_json::Value>, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[derive(Deserialize)] | |
| 73 | + | pub(crate) struct PushResponse { | |
| 74 | + | pub cursor: i64, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[derive(Serialize)] | |
| 78 | + | pub(crate) struct PullRequest { | |
| 79 | + | pub device_id: Uuid, | |
| 80 | + | pub cursor: i64, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[derive(Deserialize)] | |
| 84 | + | pub(crate) struct PullResponse { | |
| 85 | + | pub changes: Vec<PullChangeEntry>, | |
| 86 | + | pub cursor: i64, | |
| 87 | + | pub has_more: bool, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[derive(Deserialize)] | |
| 91 | + | pub(crate) struct PullChangeEntry { | |
| 92 | + | #[allow(dead_code)] | |
| 93 | + | pub seq: i64, | |
| 94 | + | #[allow(dead_code)] | |
| 95 | + | pub device_id: Uuid, | |
| 96 | + | pub table: String, | |
| 97 | + | pub op: String, | |
| 98 | + | pub row_id: String, | |
| 99 | + | pub timestamp: DateTime<Utc>, | |
| 100 | + | pub data: Option<serde_json::Value>, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // ── Keys ── | |
| 104 | + | ||
| 105 | + | #[derive(Serialize)] | |
| 106 | + | pub(crate) struct PutKeyRequest { | |
| 107 | + | pub encrypted_key: String, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[derive(Deserialize)] | |
| 111 | + | pub(crate) struct GetKeyResponse { | |
| 112 | + | pub encrypted_key: String, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // ── OAuth ── | |
| 116 | + | ||
| 117 | + | #[derive(Serialize)] | |
| 118 | + | pub(crate) struct OAuthTokenRequest { | |
| 119 | + | pub grant_type: String, | |
| 120 | + | pub code: String, | |
| 121 | + | pub redirect_uri: String, | |
| 122 | + | pub code_verifier: String, | |
| 123 | + | pub client_id: String, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[derive(Deserialize)] | |
| 127 | + | pub(crate) struct OAuthTokenResponse { | |
| 128 | + | pub access_token: String, | |
| 129 | + | #[allow(dead_code)] | |
| 130 | + | pub token_type: String, | |
| 131 | + | #[allow(dead_code)] | |
| 132 | + | pub expires_in: i64, | |
| 133 | + | pub user_id: Uuid, | |
| 134 | + | pub app_id: Uuid, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // ── Status ── | |
| 138 | + | ||
| 139 | + | #[derive(Debug, Deserialize)] | |
| 140 | + | pub struct SyncStatus { | |
| 141 | + | pub total_changes: i64, | |
| 142 | + | pub latest_cursor: Option<i64>, | |
| 143 | + | } |