max / synckit-client
9 files changed,
+1766 insertions,
-140 deletions
| @@ -13,6 +13,15 @@ dependencies = [ | |||
| 13 | 13 | ] | |
| 14 | 14 | ||
| 15 | 15 | [[package]] | |
| 16 | + | name = "aho-corasick" | |
| 17 | + | version = "1.1.4" | |
| 18 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 19 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 20 | + | dependencies = [ | |
| 21 | + | "memchr", | |
| 22 | + | ] | |
| 23 | + | ||
| 24 | + | [[package]] | |
| 16 | 25 | name = "android_system_properties" | |
| 17 | 26 | version = "0.1.5" | |
| 18 | 27 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -40,6 +49,16 @@ dependencies = [ | |||
| 40 | 49 | ] | |
| 41 | 50 | ||
| 42 | 51 | [[package]] | |
| 52 | + | name = "assert-json-diff" | |
| 53 | + | version = "2.0.2" | |
| 54 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 55 | + | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" | |
| 56 | + | dependencies = [ | |
| 57 | + | "serde", | |
| 58 | + | "serde_json", | |
| 59 | + | ] | |
| 60 | + | ||
| 61 | + | [[package]] | |
| 43 | 62 | name = "atomic-waker" | |
| 44 | 63 | version = "1.1.2" | |
| 45 | 64 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -211,6 +230,24 @@ dependencies = [ | |||
| 211 | 230 | ] | |
| 212 | 231 | ||
| 213 | 232 | [[package]] | |
| 233 | + | name = "deadpool" | |
| 234 | + | version = "0.12.3" | |
| 235 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 236 | + | checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" | |
| 237 | + | dependencies = [ | |
| 238 | + | "deadpool-runtime", | |
| 239 | + | "lazy_static", | |
| 240 | + | "num_cpus", | |
| 241 | + | "tokio", | |
| 242 | + | ] | |
| 243 | + | ||
| 244 | + | [[package]] | |
| 245 | + | name = "deadpool-runtime" | |
| 246 | + | version = "0.1.4" | |
| 247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 248 | + | checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" | |
| 249 | + | ||
| 250 | + | [[package]] | |
| 214 | 251 | name = "digest" | |
| 215 | 252 | version = "0.10.7" | |
| 216 | 253 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -306,12 +343,28 @@ dependencies = [ | |||
| 306 | 343 | ] | |
| 307 | 344 | ||
| 308 | 345 | [[package]] | |
| 346 | + | name = "futures" | |
| 347 | + | version = "0.3.32" | |
| 348 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 349 | + | checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" | |
| 350 | + | dependencies = [ | |
| 351 | + | "futures-channel", | |
| 352 | + | "futures-core", | |
| 353 | + | "futures-executor", | |
| 354 | + | "futures-io", | |
| 355 | + | "futures-sink", | |
| 356 | + | "futures-task", | |
| 357 | + | "futures-util", | |
| 358 | + | ] | |
| 359 | + | ||
| 360 | + | [[package]] | |
| 309 | 361 | name = "futures-channel" | |
| 310 | 362 | version = "0.3.32" | |
| 311 | 363 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 312 | 364 | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" | |
| 313 | 365 | dependencies = [ | |
| 314 | 366 | "futures-core", | |
| 367 | + | "futures-sink", | |
| 315 | 368 | ] | |
| 316 | 369 | ||
| 317 | 370 | [[package]] | |
| @@ -321,6 +374,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 321 | 374 | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | |
| 322 | 375 | ||
| 323 | 376 | [[package]] | |
| 377 | + | name = "futures-executor" | |
| 378 | + | version = "0.3.32" | |
| 379 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | + | checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" | |
| 381 | + | dependencies = [ | |
| 382 | + | "futures-core", | |
| 383 | + | "futures-task", | |
| 384 | + | "futures-util", | |
| 385 | + | ] | |
| 386 | + | ||
| 387 | + | [[package]] | |
| 388 | + | name = "futures-io" | |
| 389 | + | version = "0.3.32" | |
| 390 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 391 | + | checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" | |
| 392 | + | ||
| 393 | + | [[package]] | |
| 394 | + | name = "futures-macro" | |
| 395 | + | version = "0.3.32" | |
| 396 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 397 | + | checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" | |
| 398 | + | dependencies = [ | |
| 399 | + | "proc-macro2", | |
| 400 | + | "quote", | |
| 401 | + | "syn", | |
| 402 | + | ] | |
| 403 | + | ||
| 404 | + | [[package]] | |
| 324 | 405 | name = "futures-sink" | |
| 325 | 406 | version = "0.3.32" | |
| 326 | 407 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -338,8 +419,13 @@ version = "0.3.32" | |||
| 338 | 419 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 339 | 420 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | |
| 340 | 421 | dependencies = [ | |
| 422 | + | "futures-channel", | |
| 341 | 423 | "futures-core", | |
| 424 | + | "futures-io", | |
| 425 | + | "futures-macro", | |
| 426 | + | "futures-sink", | |
| 342 | 427 | "futures-task", | |
| 428 | + | "memchr", | |
| 343 | 429 | "pin-project-lite", | |
| 344 | 430 | "slab", | |
| 345 | 431 | ] | |
| @@ -419,6 +505,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 419 | 505 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 420 | 506 | ||
| 421 | 507 | [[package]] | |
| 508 | + | name = "hermit-abi" | |
| 509 | + | version = "0.5.2" | |
| 510 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 511 | + | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 512 | + | ||
| 513 | + | [[package]] | |
| 422 | 514 | name = "http" | |
| 423 | 515 | version = "1.4.0" | |
| 424 | 516 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -458,6 +550,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 458 | 550 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 459 | 551 | ||
| 460 | 552 | [[package]] | |
| 553 | + | name = "httpdate" | |
| 554 | + | version = "1.0.3" | |
| 555 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 556 | + | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | |
| 557 | + | ||
| 558 | + | [[package]] | |
| 461 | 559 | name = "hyper" | |
| 462 | 560 | version = "1.8.1" | |
| 463 | 561 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -471,6 +569,7 @@ dependencies = [ | |||
| 471 | 569 | "http", | |
| 472 | 570 | "http-body", | |
| 473 | 571 | "httparse", | |
| 572 | + | "httpdate", | |
| 474 | 573 | "itoa", | |
| 475 | 574 | "pin-project-lite", | |
| 476 | 575 | "pin-utils", | |
| @@ -732,6 +831,12 @@ dependencies = [ | |||
| 732 | 831 | ] | |
| 733 | 832 | ||
| 734 | 833 | [[package]] | |
| 834 | + | name = "lazy_static" | |
| 835 | + | version = "1.5.0" | |
| 836 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 837 | + | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | |
| 838 | + | ||
| 839 | + | [[package]] | |
| 735 | 840 | name = "leb128fmt" | |
| 736 | 841 | version = "0.1.0" | |
| 737 | 842 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -756,6 +861,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 756 | 861 | checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" | |
| 757 | 862 | ||
| 758 | 863 | [[package]] | |
| 864 | + | name = "lock_api" | |
| 865 | + | version = "0.4.14" | |
| 866 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 867 | + | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | |
| 868 | + | dependencies = [ | |
| 869 | + | "scopeguard", | |
| 870 | + | ] | |
| 871 | + | ||
| 872 | + | [[package]] | |
| 759 | 873 | name = "log" | |
| 760 | 874 | version = "0.4.29" | |
| 761 | 875 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -811,6 +925,16 @@ dependencies = [ | |||
| 811 | 925 | ] | |
| 812 | 926 | ||
| 813 | 927 | [[package]] | |
| 928 | + | name = "num_cpus" | |
| 929 | + | version = "1.17.0" | |
| 930 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 931 | + | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | |
| 932 | + | dependencies = [ | |
| 933 | + | "hermit-abi", | |
| 934 | + | "libc", | |
| 935 | + | ] | |
| 936 | + | ||
| 937 | + | [[package]] | |
| 814 | 938 | name = "once_cell" | |
| 815 | 939 | version = "1.21.3" | |
| 816 | 940 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -867,6 +991,29 @@ dependencies = [ | |||
| 867 | 991 | ] | |
| 868 | 992 | ||
| 869 | 993 | [[package]] | |
| 994 | + | name = "parking_lot" | |
| 995 | + | version = "0.12.5" | |
| 996 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 997 | + | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | |
| 998 | + | dependencies = [ | |
| 999 | + | "lock_api", | |
| 1000 | + | "parking_lot_core", | |
| 1001 | + | ] | |
| 1002 | + | ||
| 1003 | + | [[package]] | |
| 1004 | + | name = "parking_lot_core" | |
| 1005 | + | version = "0.9.12" | |
| 1006 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1007 | + | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | |
| 1008 | + | dependencies = [ | |
| 1009 | + | "cfg-if", | |
| 1010 | + | "libc", | |
| 1011 | + | "redox_syscall", | |
| 1012 | + | "smallvec", | |
| 1013 | + | "windows-link", | |
| 1014 | + | ] | |
| 1015 | + | ||
| 1016 | + | [[package]] | |
| 870 | 1017 | name = "password-hash" | |
| 871 | 1018 | version = "0.5.0" | |
| 872 | 1019 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -995,6 +1142,44 @@ dependencies = [ | |||
| 995 | 1142 | ] | |
| 996 | 1143 | ||
| 997 | 1144 | [[package]] | |
| 1145 | + | name = "redox_syscall" | |
| 1146 | + | version = "0.5.18" | |
| 1147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1148 | + | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | |
| 1149 | + | dependencies = [ | |
| 1150 | + | "bitflags", | |
| 1151 | + | ] | |
| 1152 | + | ||
| 1153 | + | [[package]] | |
| 1154 | + | name = "regex" | |
| 1155 | + | version = "1.12.3" | |
| 1156 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1157 | + | checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" | |
| 1158 | + | dependencies = [ | |
| 1159 | + | "aho-corasick", | |
| 1160 | + | "memchr", | |
| 1161 | + | "regex-automata", | |
| 1162 | + | "regex-syntax", | |
| 1163 | + | ] | |
| 1164 | + | ||
| 1165 | + | [[package]] | |
| 1166 | + | name = "regex-automata" | |
| 1167 | + | version = "0.4.14" | |
| 1168 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1169 | + | checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" | |
| 1170 | + | dependencies = [ | |
| 1171 | + | "aho-corasick", | |
| 1172 | + | "memchr", | |
| 1173 | + | "regex-syntax", | |
| 1174 | + | ] | |
| 1175 | + | ||
| 1176 | + | [[package]] | |
| 1177 | + | name = "regex-syntax" | |
| 1178 | + | version = "0.8.10" | |
| 1179 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1180 | + | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" | |
| 1181 | + | ||
| 1182 | + | [[package]] | |
| 998 | 1183 | name = "reqwest" | |
| 999 | 1184 | version = "0.12.28" | |
| 1000 | 1185 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1116,6 +1301,12 @@ dependencies = [ | |||
| 1116 | 1301 | ] | |
| 1117 | 1302 | ||
| 1118 | 1303 | [[package]] | |
| 1304 | + | name = "scopeguard" | |
| 1305 | + | version = "1.2.0" | |
| 1306 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1307 | + | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 1308 | + | ||
| 1309 | + | [[package]] | |
| 1119 | 1310 | name = "security-framework" | |
| 1120 | 1311 | version = "3.7.0" | |
| 1121 | 1312 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1272,13 +1463,15 @@ dependencies = [ | |||
| 1272 | 1463 | ||
| 1273 | 1464 | [[package]] | |
| 1274 | 1465 | name = "synckit-client" | |
| 1275 | - | version = "0.1.0" | |
| 1466 | + | version = "0.2.1" | |
| 1276 | 1467 | dependencies = [ | |
| 1277 | 1468 | "argon2", | |
| 1278 | 1469 | "base64", | |
| 1470 | + | "bytes", | |
| 1279 | 1471 | "chacha20poly1305", | |
| 1280 | 1472 | "chrono", | |
| 1281 | 1473 | "keyring", | |
| 1474 | + | "parking_lot", | |
| 1282 | 1475 | "rand", | |
| 1283 | 1476 | "reqwest", | |
| 1284 | 1477 | "serde", | |
| @@ -1287,8 +1480,10 @@ dependencies = [ | |||
| 1287 | 1480 | "thiserror", | |
| 1288 | 1481 | "tokio", | |
| 1289 | 1482 | "tracing", | |
| 1483 | + | "unicode-normalization", | |
| 1290 | 1484 | "urlencoding", | |
| 1291 | 1485 | "uuid", | |
| 1486 | + | "wiremock", | |
| 1292 | 1487 | ] | |
| 1293 | 1488 | ||
| 1294 | 1489 | [[package]] | |
| @@ -1367,6 +1562,21 @@ dependencies = [ | |||
| 1367 | 1562 | ] | |
| 1368 | 1563 | ||
| 1369 | 1564 | [[package]] | |
| 1565 | + | name = "tinyvec" | |
| 1566 | + | version = "1.10.0" | |
| 1567 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1568 | + | checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" | |
| 1569 | + | dependencies = [ | |
| 1570 | + | "tinyvec_macros", | |
| 1571 | + | ] | |
| 1572 | + | ||
| 1573 | + | [[package]] | |
| 1574 | + | name = "tinyvec_macros" | |
| 1575 | + | version = "0.1.1" | |
| 1576 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1577 | + | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | |
| 1578 | + | ||
| 1579 | + | [[package]] | |
| 1370 | 1580 | name = "tokio" | |
| 1371 | 1581 | version = "1.49.0" | |
| 1372 | 1582 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1520,6 +1730,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1520 | 1730 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 1521 | 1731 | ||
| 1522 | 1732 | [[package]] | |
| 1733 | + | name = "unicode-normalization" | |
| 1734 | + | version = "0.1.25" | |
| 1735 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1736 | + | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | |
| 1737 | + | dependencies = [ | |
| 1738 | + | "tinyvec", | |
| 1739 | + | ] | |
| 1740 | + | ||
| 1741 | + | [[package]] | |
| 1523 | 1742 | name = "unicode-xid" | |
| 1524 | 1743 | version = "0.2.6" | |
| 1525 | 1744 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1952,6 +2171,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1952 | 2171 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" | |
| 1953 | 2172 | ||
| 1954 | 2173 | [[package]] | |
| 2174 | + | name = "wiremock" | |
| 2175 | + | version = "0.6.5" | |
| 2176 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2177 | + | checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" | |
| 2178 | + | dependencies = [ | |
| 2179 | + | "assert-json-diff", | |
| 2180 | + | "base64", | |
| 2181 | + | "deadpool", | |
| 2182 | + | "futures", | |
| 2183 | + | "http", | |
| 2184 | + | "http-body-util", | |
| 2185 | + | "hyper", | |
| 2186 | + | "hyper-util", | |
| 2187 | + | "log", | |
| 2188 | + | "once_cell", | |
| 2189 | + | "regex", | |
| 2190 | + | "serde", | |
| 2191 | + | "serde_json", | |
| 2192 | + | "tokio", | |
| 2193 | + | "url", | |
| 2194 | + | ] | |
| 2195 | + | ||
| 2196 | + | [[package]] | |
| 1955 | 2197 | name = "wit-bindgen" | |
| 1956 | 2198 | version = "0.51.0" | |
| 1957 | 2199 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3,6 +3,7 @@ name = "synckit-client" | |||
| 3 | 3 | version = "0.2.1" | |
| 4 | 4 | edition = "2021" | |
| 5 | 5 | description = "SyncKit client SDK with end-to-end encryption" | |
| 6 | + | license-file = "LICENSE" | |
| 6 | 7 | ||
| 7 | 8 | [features] | |
| 8 | 9 | default = ["keychain"] | |
| @@ -18,6 +19,7 @@ base64 = "0.22" | |||
| 18 | 19 | ||
| 19 | 20 | # HTTP | |
| 20 | 21 | reqwest = { version = "0.12", features = ["json", "native-tls"] } | |
| 22 | + | bytes = "1" | |
| 21 | 23 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } | |
| 22 | 24 | ||
| 23 | 25 | # Serialization | |
| @@ -32,6 +34,15 @@ keyring = { version = "3", optional = true } | |||
| 32 | 34 | # URL encoding | |
| 33 | 35 | urlencoding = "2" | |
| 34 | 36 | ||
| 37 | + | # Unicode | |
| 38 | + | unicode-normalization = "0.1" | |
| 39 | + | ||
| 40 | + | # Synchronization | |
| 41 | + | parking_lot = "0.12" | |
| 42 | + | ||
| 35 | 43 | # Error handling & logging | |
| 36 | 44 | thiserror = "1" | |
| 37 | 45 | tracing = "0.1" | |
| 46 | + | ||
| 47 | + | [dev-dependencies] | |
| 48 | + | wiremock = "0.6" |
| @@ -0,0 +1,78 @@ | |||
| 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 | |
| 28 | + | let (user_id, app_id) = client.authenticate("user@example.com", "password").await?; | |
| 29 | + | ||
| 30 | + | // Set up encryption (first device) | |
| 31 | + | client.setup_encryption_new("password").await?; | |
| 32 | + | ||
| 33 | + | // Register this device | |
| 34 | + | let device = client.register_device("MacBook Pro", "macos").await?; | |
| 35 | + | ||
| 36 | + | // Push encrypted data | |
| 37 | + | let cursor = client.push(device.id, vec![ | |
| 38 | + | ChangeEntry { | |
| 39 | + | table: "tasks".into(), | |
| 40 | + | op: ChangeOp::Insert, | |
| 41 | + | row_id: uuid::Uuid::new_v4().to_string(), | |
| 42 | + | timestamp: Utc::now(), | |
| 43 | + | data: Some(serde_json::json!({"title": "Buy milk"})), | |
| 44 | + | }, | |
| 45 | + | ]).await?; | |
| 46 | + | ||
| 47 | + | // Pull and auto-decrypt | |
| 48 | + | let (changes, cursor, has_more) = client.pull(device.id, 0).await?; | |
| 49 | + | ``` | |
| 50 | + | ||
| 51 | + | ## Crate Structure | |
| 52 | + | ||
| 53 | + | | File | Role | | |
| 54 | + | |------|------| | |
| 55 | + | | `lib.rs` | Crate root, re-exports, doc example | | |
| 56 | + | | `client.rs` | `SyncKitClient` -- HTTP methods, retry logic, token expiry detection | | |
| 57 | + | | `crypto.rs` | Key derivation (Argon2id), key wrapping, per-entry and per-blob encrypt/decrypt | | |
| 58 | + | | `error.rs` | `SyncKitError` enum (10 variants: HTTP, server, JSON, crypto, keychain, auth) | | |
| 59 | + | | `keystore.rs` | OS keychain read/write/delete, feature-gated with no-op stubs | | |
| 60 | + | | `types.rs` | Wire protocol types (`ChangeEntry`, `ChangeOp`, `Device`, `SyncStatus`) | | |
| 61 | + | ||
| 62 | + | ## Feature Flags | |
| 63 | + | ||
| 64 | + | | Flag | Default | Description | | |
| 65 | + | |------|---------|-------------| | |
| 66 | + | | `keychain` | on | OS keychain storage via the `keyring` crate. Disable with `default-features = false` for headless/CI environments. | | |
| 67 | + | ||
| 68 | + | ## Security Properties | |
| 69 | + | ||
| 70 | + | - **Server-zero-knowledge** -- the server never receives the plaintext master key or user data | |
| 71 | + | - **Key zeroization** -- volatile writes clear the master key from memory on drop | |
| 72 | + | - **Random salt per wrap** -- re-wrapping with the same password produces a different envelope | |
| 73 | + | - **Minimum ciphertext validation** -- decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag) | |
| 74 | + | - **No key material in logs** -- tracing events never include key bytes or ciphertext | |
| 75 | + | ||
| 76 | + | ## License | |
| 77 | + | ||
| 78 | + | PolyForm Noncommercial 1.0.0 |
| @@ -1,8 +1,60 @@ | |||
| 1 | - | //! SyncKitClient: HTTP transport + high-level API with transparent encryption. | |
| 2 | - | ||
| 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_url`](SyncKitClient::blob_upload_url), | |
| 22 | + | //! [`blob_upload`](SyncKitClient::blob_upload), [`blob_confirm`](SyncKitClient::blob_confirm), | |
| 23 | + | //! [`blob_download_url`](SyncKitClient::blob_download_url), | |
| 24 | + | //! [`blob_download`](SyncKitClient::blob_download). | |
| 25 | + | //! | |
| 26 | + | //! ## Internal state | |
| 27 | + | //! | |
| 28 | + | //! The client holds two `RwLock`-wrapped fields: the authenticated session | |
| 29 | + | //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both | |
| 30 | + | //! start as `None` and are populated by the authentication and encryption | |
| 31 | + | //! setup methods respectively. | |
| 32 | + | //! | |
| 33 | + | //! ## Thread safety | |
| 34 | + | //! | |
| 35 | + | //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public | |
| 36 | + | //! methods take `&self`, acquiring the internal locks only briefly to read | |
| 37 | + | //! or update state. The locks are never held across `.await` points. | |
| 38 | + | //! | |
| 39 | + | //! ## Retry strategy | |
| 40 | + | //! | |
| 41 | + | //! All HTTP operations retry transient failures (network errors, 5xx, | |
| 42 | + | //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors | |
| 43 | + | //! (4xx except 429) are permanent and returned immediately. | |
| 44 | + | //! | |
| 45 | + | //! ## Token handling | |
| 46 | + | //! | |
| 47 | + | //! The client decodes the JWT `exp` claim (without signature verification) | |
| 48 | + | //! and applies a 30-second expiry buffer. If the token is about to expire, | |
| 49 | + | //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller | |
| 50 | + | //! can re-authenticate before the request fails on the server. | |
| 51 | + | ||
| 52 | + | use bytes::Bytes; | |
| 53 | + | use parking_lot::RwLock; | |
| 3 | 54 | use reqwest::Client; | |
| 4 | - | use std::sync::Mutex; | |
| 55 | + | use std::sync::Arc; | |
| 5 | 56 | use std::time::Duration; | |
| 57 | + | use tracing::instrument; | |
| 6 | 58 | use uuid::Uuid; | |
| 7 | 59 | ||
| 8 | 60 | use base64::Engine; | |
| @@ -33,17 +85,53 @@ pub struct SyncKitConfig { | |||
| 33 | 85 | pub api_key: String, | |
| 34 | 86 | } | |
| 35 | 87 | ||
| 88 | + | /// Pre-built endpoint URLs, computed once at client construction. | |
| 89 | + | struct Endpoints { | |
| 90 | + | auth: String, | |
| 91 | + | oauth_token: String, | |
| 92 | + | devices: String, | |
| 93 | + | push: String, | |
| 94 | + | pull: String, | |
| 95 | + | status: String, | |
| 96 | + | keys: String, | |
| 97 | + | blobs_upload: String, | |
| 98 | + | blobs_confirm: String, | |
| 99 | + | blobs_download: String, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | impl Endpoints { | |
| 103 | + | fn new(base: &str) -> Self { | |
| 104 | + | Self { | |
| 105 | + | auth: format!("{base}/api/sync/auth"), | |
| 106 | + | oauth_token: format!("{base}/oauth/token"), | |
| 107 | + | devices: format!("{base}/api/sync/devices"), | |
| 108 | + | push: format!("{base}/api/sync/push"), | |
| 109 | + | pull: format!("{base}/api/sync/pull"), | |
| 110 | + | status: format!("{base}/api/sync/status"), | |
| 111 | + | keys: format!("{base}/api/sync/keys"), | |
| 112 | + | blobs_upload: format!("{base}/api/sync/blobs/upload"), | |
| 113 | + | blobs_confirm: format!("{base}/api/sync/blobs/confirm"), | |
| 114 | + | blobs_download: format!("{base}/api/sync/blobs/download"), | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 36 | 119 | /// Session state obtained after authentication. | |
| 37 | 120 | struct Session { | |
| 38 | - | token: String, | |
| 121 | + | token: Arc<String>, | |
| 122 | + | /// Cached `exp` claim from the JWT, extracted once at session creation. | |
| 123 | + | token_exp: Option<i64>, | |
| 39 | 124 | user_id: Uuid, | |
| 40 | 125 | app_id: Uuid, | |
| 41 | 126 | } | |
| 42 | 127 | ||
| 43 | 128 | /// Public session info returned by `session_info()`. | |
| 44 | 129 | pub struct SessionInfo { | |
| 130 | + | /// The JWT bearer token for API requests. | |
| 45 | 131 | pub token: String, | |
| 132 | + | /// The authenticated user's UUID. | |
| 46 | 133 | pub user_id: Uuid, | |
| 134 | + | /// The SyncKit app UUID this session belongs to. | |
| 47 | 135 | pub app_id: Uuid, | |
| 48 | 136 | } | |
| 49 | 137 | ||
| @@ -51,56 +139,83 @@ pub struct SessionInfo { | |||
| 51 | 139 | pub struct SyncKitClient { | |
| 52 | 140 | config: SyncKitConfig, | |
| 53 | 141 | http: Client, | |
| 54 | - | session: Mutex<Option<Session>>, | |
| 55 | - | master_key: Mutex<Option<crypto::ZeroizeOnDrop>>, | |
| 142 | + | endpoints: Endpoints, | |
| 143 | + | session: RwLock<Option<Session>>, | |
| 144 | + | master_key: RwLock<Option<crypto::ZeroizeOnDrop>>, | |
| 56 | 145 | } | |
| 57 | 146 | ||
| 58 | 147 | impl SyncKitClient { | |
| 59 | 148 | /// Create a new client with the given configuration. | |
| 60 | 149 | pub fn new(config: SyncKitConfig) -> Self { | |
| 61 | 150 | let http = Client::builder() | |
| 62 | - | .timeout(std::time::Duration::from_secs(30)) | |
| 63 | - | .connect_timeout(std::time::Duration::from_secs(10)) | |
| 151 | + | .timeout(Duration::from_secs(30)) | |
| 152 | + | .connect_timeout(Duration::from_secs(10)) | |
| 153 | + | .pool_max_idle_per_host(5) | |
| 154 | + | .pool_idle_timeout(Duration::from_secs(90)) | |
| 64 | 155 | .build() | |
| 65 | 156 | .expect("failed to build HTTP client"); | |
| 66 | 157 | ||
| 158 | + | let endpoints = Endpoints::new(&config.server_url); | |
| 67 | 159 | Self { | |
| 68 | 160 | config, | |
| 69 | 161 | http, | |
| 70 | - | session: Mutex::new(None), | |
| 71 | - | master_key: Mutex::new(None), | |
| 162 | + | endpoints, | |
| 163 | + | session: RwLock::new(None), | |
| 164 | + | master_key: RwLock::new(None), | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// Create a new client with a custom HTTP client (for testing with custom timeouts). | |
| 169 | + | #[doc(hidden)] | |
| 170 | + | pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self { | |
| 171 | + | let endpoints = Endpoints::new(&config.server_url); | |
| 172 | + | Self { | |
| 173 | + | config, | |
| 174 | + | http, | |
| 175 | + | endpoints, | |
| 176 | + | session: RwLock::new(None), | |
| 177 | + | master_key: RwLock::new(None), | |
| 72 | 178 | } | |
| 73 | 179 | } | |
| 74 | 180 | ||
| 75 | 181 | // ── Auth ── | |
| 76 | 182 | ||
| 77 | 183 | /// Authenticate with the MNW server. Returns (user_id, app_id). | |
| 184 | + | /// | |
| 185 | + | /// # Errors | |
| 186 | + | /// | |
| 187 | + | /// Returns `Server { status: 401, .. }` for wrong credentials. | |
| 188 | + | #[instrument(skip(self, email, password))] | |
| 78 | 189 | pub async fn authenticate( | |
| 79 | 190 | &self, | |
| 80 | 191 | email: &str, | |
| 81 | 192 | password: &str, | |
| 82 | 193 | ) -> Result<(Uuid, Uuid)> { | |
| 83 | - | let url = format!("{}/api/sync/auth", self.config.server_url); | |
| 194 | + | let body = Bytes::from(serde_json::to_vec(&AuthRequest { | |
| 195 | + | email: email.to_string(), | |
| 196 | + | password: password.to_string(), | |
| 197 | + | api_key: self.config.api_key.clone(), | |
| 198 | + | })?); | |
| 84 | 199 | ||
| 85 | 200 | let resp = self | |
| 86 | - | .http | |
| 87 | - | .post(&url) | |
| 88 | - | .json(&AuthRequest { | |
| 89 | - | email: email.to_string(), | |
| 90 | - | password: password.to_string(), | |
| 91 | - | api_key: self.config.api_key.clone(), | |
| 201 | + | .retry_request(|| { | |
| 202 | + | let req = self | |
| 203 | + | .http | |
| 204 | + | .post(&self.endpoints.auth) | |
| 205 | + | .header("content-type", "application/json") | |
| 206 | + | .body(body.clone()); | |
| 207 | + | async move { check_response(req.send().await?).await } | |
| 92 | 208 | }) | |
| 93 | - | .send() | |
| 94 | 209 | .await?; | |
| 95 | - | ||
| 96 | - | let resp = check_response(resp).await?; | |
| 97 | 210 | let auth: AuthResponse = resp.json().await?; | |
| 98 | 211 | ||
| 99 | 212 | let user_id = auth.user_id; | |
| 100 | 213 | let app_id = auth.app_id; | |
| 214 | + | let token_exp = jwt_exp(&auth.token); | |
| 101 | 215 | ||
| 102 | - | *self.session.lock().unwrap() = Some(Session { | |
| 103 | - | token: auth.token, | |
| 216 | + | *self.session.write() = Some(Session { | |
| 217 | + | token: Arc::new(auth.token), | |
| 218 | + | token_exp, | |
| 104 | 219 | user_id, | |
| 105 | 220 | app_id, | |
| 106 | 221 | }); | |
| @@ -115,31 +230,46 @@ impl SyncKitClient { | |||
| 115 | 230 | } | |
| 116 | 231 | ||
| 117 | 232 | /// Returns whether the master encryption key is loaded and ready. | |
| 118 | - | pub fn has_master_key(&self) -> bool { | |
| 119 | - | self.master_key.lock().unwrap().is_some() | |
| 233 | + | pub fn has_master_key(&self) -> Result<bool> { | |
| 234 | + | Ok(self.master_key.read().is_some()) | |
| 120 | 235 | } | |
| 121 | 236 | ||
| 122 | 237 | /// Returns the current session info, if authenticated. | |
| 123 | - | pub fn session_info(&self) -> Option<SessionInfo> { | |
| 124 | - | let guard = self.session.lock().unwrap(); | |
| 125 | - | guard.as_ref().map(|s| SessionInfo { | |
| 126 | - | token: s.token.clone(), | |
| 238 | + | pub fn session_info(&self) -> Result<Option<SessionInfo>> { | |
| 239 | + | let guard = self.session.read(); | |
| 240 | + | Ok(guard.as_ref().map(|s| SessionInfo { | |
| 241 | + | token: (*s.token).clone(), | |
| 127 | 242 | user_id: s.user_id, | |
| 128 | 243 | app_id: s.app_id, | |
| 129 | - | }) | |
| 244 | + | })) | |
| 130 | 245 | } | |
| 131 | 246 | ||
| 132 | 247 | /// Restore a session from previously stored credentials (e.g. OS keychain). | |
| 133 | 248 | /// | |
| 134 | 249 | /// Sets the internal session state without making any HTTP calls. | |
| 135 | 250 | /// Used on app startup to restore from stored credentials without re-authenticating. | |
| 136 | - | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) { | |
| 137 | - | *self.session.lock().unwrap() = Some(Session { | |
| 138 | - | token: token.to_string(), | |
| 251 | + | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) -> Result<()> { | |
| 252 | + | let token_exp = jwt_exp(token); | |
| 253 | + | *self.session.write() = Some(Session { | |
| 254 | + | token: Arc::new(token.to_string()), | |
| 255 | + | token_exp, | |
| 139 | 256 | user_id, | |
| 140 | 257 | app_id, | |
| 141 | 258 | }); | |
| 142 | 259 | tracing::info!("Session restored for user {user_id}, app {app_id}"); | |
| 260 | + | Ok(()) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Clear the in-memory session and master key. | |
| 264 | + | /// | |
| 265 | + | /// After calling this, the client will need to re-authenticate and set up | |
| 266 | + | /// encryption again. Does not affect OS keychain storage — call | |
| 267 | + | /// `keystore::delete_master_key` separately if needed. | |
| 268 | + | pub fn clear_session(&self) -> Result<()> { | |
| 269 | + | *self.session.write() = None; | |
| 270 | + | *self.master_key.write() = None; | |
| 271 | + | tracing::info!("Session and master key cleared"); | |
| 272 | + | Ok(()) | |
| 143 | 273 | } | |
| 144 | 274 | ||
| 145 | 275 | /// Check whether the current session token has expired (or will expire | |
| @@ -147,12 +277,18 @@ impl SyncKitClient { | |||
| 147 | 277 | /// if the token's `exp` claim is in the past. Returns `false` if the | |
| 148 | 278 | /// token cannot be decoded (assumes not expired — the server will reject | |
| 149 | 279 | /// it with a 401 if it actually is). | |
| 150 | - | pub fn is_token_expired(&self) -> bool { | |
| 151 | - | let guard = self.session.lock().unwrap(); | |
| 280 | + | pub fn is_token_expired(&self) -> Result<bool> { | |
| 281 | + | let guard = self.session.read(); | |
| 152 | 282 | let Some(session) = guard.as_ref() else { | |
| 153 | - | return true; | |
| 283 | + | return Ok(true); | |
| 154 | 284 | }; | |
| 155 | - | token_is_expired(&session.token) | |
| 285 | + | match session.token_exp { | |
| 286 | + | Some(exp) => { | |
| 287 | + | let now = chrono::Utc::now().timestamp(); | |
| 288 | + | Ok(now >= exp - TOKEN_EXPIRY_BUFFER_SECS) | |
| 289 | + | } | |
| 290 | + | None => Ok(false), | |
| 291 | + | } | |
| 156 | 292 | } | |
| 157 | 293 | ||
| 158 | 294 | // ── OAuth ── | |
| @@ -181,36 +317,42 @@ impl SyncKitClient { | |||
| 181 | 317 | /// | |
| 182 | 318 | /// Call this after receiving the code from the localhost callback server. | |
| 183 | 319 | /// On success, stores the session internally (same as `authenticate()`). | |
| 320 | + | #[instrument(skip(self, code, code_verifier))] | |
| 184 | 321 | pub async fn authenticate_with_code( | |
| 185 | 322 | &self, | |
| 186 | 323 | code: &str, | |
| 187 | 324 | code_verifier: &str, | |
| 188 | 325 | redirect_port: u16, | |
| 189 | 326 | ) -> Result<(Uuid, Uuid)> { | |
| 190 | - | let url = format!("{}/oauth/token", self.config.server_url); | |
| 191 | 327 | let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port); | |
| 192 | 328 | ||
| 329 | + | let body = Bytes::from(serde_json::to_vec(&OAuthTokenRequest { | |
| 330 | + | grant_type: "authorization_code".to_string(), | |
| 331 | + | code: code.to_string(), | |
| 332 | + | redirect_uri, | |
| 333 | + | code_verifier: code_verifier.to_string(), | |
| 334 | + | client_id: self.config.api_key.clone(), | |
| 335 | + | })?); | |
| 336 | + | ||
| 193 | 337 | let resp = self | |
| 194 | - | .http | |
| 195 | - | .post(&url) | |
| 196 | - | .json(&OAuthTokenRequest { | |
| 197 | - | grant_type: "authorization_code".to_string(), | |
| 198 | - | code: code.to_string(), | |
| 199 | - | redirect_uri, | |
| 200 | - | code_verifier: code_verifier.to_string(), | |
| 201 | - | client_id: self.config.api_key.clone(), | |
| 338 | + | .retry_request(|| { | |
| 339 | + | let req = self | |
| 340 | + | .http | |
| 341 | + | .post(&self.endpoints.oauth_token) | |
| 342 | + | .header("content-type", "application/json") | |
| 343 | + | .body(body.clone()); | |
| 344 | + | async move { check_response(req.send().await?).await } | |
| 202 | 345 | }) | |
| 203 | - | .send() | |
| 204 | 346 | .await?; | |
| 205 | - | ||
| 206 | - | let resp = check_response(resp).await?; | |
| 207 | 347 | let token_resp: OAuthTokenResponse = resp.json().await?; | |
| 208 | 348 | ||
| 209 | 349 | let user_id = token_resp.user_id; | |
| 210 | 350 | let app_id = token_resp.app_id; | |
| 351 | + | let token_exp = jwt_exp(&token_resp.access_token); | |
| 211 | 352 | ||
| 212 | - | *self.session.lock().unwrap() = Some(Session { | |
| 213 | - | token: token_resp.access_token, | |
| 353 | + | *self.session.write() = Some(Session { | |
| 354 | + | token: Arc::new(token_resp.access_token), | |
| 355 | + | token_exp, | |
| 214 | 356 | user_id, | |
| 215 | 357 | app_id, | |
| 216 | 358 | }); | |
| @@ -222,33 +364,36 @@ impl SyncKitClient { | |||
| 222 | 364 | // ── Encryption setup ── | |
| 223 | 365 | ||
| 224 | 366 | /// Check if the server has an encrypted master key for this user. | |
| 367 | + | #[instrument(skip(self))] | |
| 225 | 368 | pub async fn has_server_key(&self) -> Result<bool> { | |
| 226 | 369 | let (url, token) = self.key_url_and_token()?; | |
| 227 | 370 | ||
| 228 | - | let resp = self | |
| 229 | - | .http | |
| 230 | - | .get(&url) | |
| 231 | - | .bearer_auth(&token) | |
| 232 | - | .send() | |
| 371 | + | let result = self | |
| 372 | + | .retry_request(|| { | |
| 373 | + | let req = self.http.get(url).bearer_auth(&token); | |
| 374 | + | async move { | |
| 375 | + | let resp = req.send().await?; | |
| 376 | + | match resp.status().as_u16() { | |
| 377 | + | 200 | 404 => Ok(resp), | |
| 378 | + | status => { | |
| 379 | + | let message = resp.text().await.unwrap_or_default(); | |
| 380 | + | Err(SyncKitError::Server { status, message }) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | }) | |
| 233 | 385 | .await?; | |
| 234 | 386 | ||
| 235 | - | match resp.status().as_u16() { | |
| 236 | - | 200 => Ok(true), | |
| 237 | - | 404 => Ok(false), | |
| 238 | - | status => { | |
| 239 | - | let message = resp.text().await.unwrap_or_default(); | |
| 240 | - | Err(SyncKitError::Server { status, message }) | |
| 241 | - | } | |
| 242 | - | } | |
| 387 | + | Ok(result.status().as_u16() == 200) | |
| 243 | 388 | } | |
| 244 | 389 | ||
| 245 | 390 | /// First device: generate a new master key, encrypt it, push to server, cache in keychain. | |
| 391 | + | #[instrument(skip(self, password))] | |
| 246 | 392 | pub async fn setup_encryption_new(&self, password: &str) -> Result<()> { | |
| 247 | 393 | let (app_id, user_id) = self.require_session_ids()?; | |
| 248 | 394 | ||
| 249 | 395 | let master_key = crypto::generate_master_key(); | |
| 250 | - | let wrapping_key = crypto::derive_wrapping_key(password, app_id, user_id)?; | |
| 251 | - | let envelope = crypto::wrap_master_key(&master_key, &wrapping_key, app_id, user_id)?; | |
| 396 | + | let envelope = crypto::wrap_master_key(&master_key, password)?; | |
| 252 | 397 | ||
| 253 | 398 | // Push to server | |
| 254 | 399 | self.put_server_key(&envelope).await?; | |
| @@ -257,25 +402,25 @@ impl SyncKitClient { | |||
| 257 | 402 | keystore::store_key(app_id, user_id, &master_key)?; | |
| 258 | 403 | ||
| 259 | 404 | // Store in memory | |
| 260 | - | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 405 | + | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 261 | 406 | ||
| 262 | 407 | tracing::info!("New master key generated and stored"); | |
| 263 | 408 | Ok(()) | |
| 264 | 409 | } | |
| 265 | 410 | ||
| 266 | 411 | /// Second device: pull encrypted master key from server, decrypt with password, cache. | |
| 412 | + | #[instrument(skip(self, password))] | |
| 267 | 413 | pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> { | |
| 268 | 414 | let (app_id, user_id) = self.require_session_ids()?; | |
| 269 | 415 | ||
| 270 | 416 | let envelope_json = self.get_server_key().await?; | |
| 271 | - | let wrapping_key = crypto::derive_wrapping_key(password, app_id, user_id)?; | |
| 272 | - | let master_key = crypto::unwrap_master_key(&envelope_json, &wrapping_key)?; | |
| 417 | + | let master_key = crypto::unwrap_master_key(&envelope_json, password)?; | |
| 273 | 418 | ||
| 274 | 419 | // Cache in OS keychain | |
| 275 | 420 | keystore::store_key(app_id, user_id, &master_key)?; | |
| 276 | 421 | ||
| 277 | 422 | // Store in memory | |
| 278 | - | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 423 | + | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 279 | 424 | ||
| 280 | 425 | tracing::info!("Master key recovered from server"); | |
| 281 | 426 | Ok(()) | |
| @@ -287,7 +432,7 @@ impl SyncKitClient { | |||
| 287 | 432 | let (app_id, user_id) = self.require_session_ids()?; | |
| 288 | 433 | ||
| 289 | 434 | if let Some(key) = keystore::load_key(app_id, user_id)? { | |
| 290 | - | *self.master_key.lock().unwrap() = Some(crypto::ZeroizeOnDrop(key)); | |
| 435 | + | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); | |
| 291 | 436 | tracing::info!("Master key loaded from keychain"); | |
| 292 | 437 | Ok(true) | |
| 293 | 438 | } else { | |
| @@ -295,36 +440,31 @@ impl SyncKitClient { | |||
| 295 | 440 | } | |
| 296 | 441 | } | |
| 297 | 442 | ||
| 298 | - | /// Change the encryption password. Decrypts master key with old password, | |
| 299 | - | /// re-encrypts with new password, and pushes to server. | |
| 443 | + | /// Change the encryption password. Always validates the old password | |
| 444 | + | /// against the server envelope before re-encrypting with the new password. | |
| 445 | + | /// | |
| 446 | + | /// Even when the master key is cached in memory (normal case -- user is | |
| 447 | + | /// logged in), the old password is verified by attempting to unwrap the | |
| 448 | + | /// server envelope. This prevents an attacker with session access from | |
| 449 | + | /// changing the password without knowing the current one. | |
| 450 | + | #[instrument(skip(self, old_password, new_password))] | |
| 300 | 451 | pub async fn change_password( | |
| 301 | 452 | &self, | |
| 302 | 453 | old_password: &str, | |
| 303 | 454 | new_password: &str, | |
| 304 | 455 | ) -> Result<()> { | |
| 305 | - | let (app_id, user_id) = self.require_session_ids()?; | |
| 456 | + | // Always fetch the envelope from the server and verify old_password | |
| 457 | + | // can decrypt it, regardless of whether we have a cached key. | |
| 458 | + | let envelope_json = self.get_server_key().await?; | |
| 459 | + | let verified_key = crypto::verify_password_against_envelope( | |
| 460 | + | &envelope_json, | |
| 461 | + | old_password, | |
| 462 | + | )?; | |
| 306 | 463 | ||
| 307 | - | // Get the current master key (from memory or re-derive from old password). | |
| 308 | - | // Check the in-memory cache first, dropping the lock before any .await. | |
| 309 | - | let cached = { | |
| 310 | - | let guard = self.master_key.lock().unwrap(); | |
| 311 | - | guard.as_ref().map(|key| **key) | |
| 312 | - | }; | |
| 464 | + | let master_key = crypto::ZeroizeOnDrop(verified_key); | |
| 313 | 465 | ||
| 314 | - | let master_key = if let Some(key) = cached { | |
| 315 | - | key | |
| 316 | - | } else { | |
| 317 | - | let envelope_json = self.get_server_key().await?; | |
| 318 | - | let wrapping_key = | |
| 319 | - | crypto::derive_wrapping_key(old_password, app_id, user_id)?; | |
| 320 | - | crypto::unwrap_master_key(&envelope_json, &wrapping_key)? | |
| 321 | - | }; | |
| 322 | - | ||
| 323 | - | // Re-wrap with new password | |
| 324 | - | let new_wrapping_key = | |
| 325 | - | crypto::derive_wrapping_key(new_password, app_id, user_id)?; | |
| 326 | - | let new_envelope = | |
| 327 | - | crypto::wrap_master_key(&master_key, &new_wrapping_key, app_id, user_id)?; | |
| 466 | + | // Re-wrap with new password (generates fresh random salt) | |
| 467 | + | let new_envelope = crypto::wrap_master_key(&master_key, new_password)?; | |
| 328 | 468 | ||
| 329 | 469 | self.put_server_key(&new_envelope).await?; | |
| 330 | 470 | ||
| @@ -335,42 +475,48 @@ impl SyncKitClient { | |||
| 335 | 475 | // ── Devices ── | |
| 336 | 476 | ||
| 337 | 477 | /// Register a device for sync. | |
| 478 | + | /// | |
| 479 | + | /// If a device with the same name already exists for this user/app, the | |
| 480 | + | /// server upserts: it updates the existing device's platform and |
Lines truncated
| @@ -15,8 +15,7 @@ use chacha20poly1305::{ | |||
| 15 | 15 | }; | |
| 16 | 16 | use rand::RngCore; | |
| 17 | 17 | use serde::{Deserialize, Serialize}; | |
| 18 | - | use sha2::{Digest, Sha256}; | |
| 19 | - | use uuid::Uuid; | |
| 18 | + | use unicode_normalization::UnicodeNormalization; | |
| 20 | 19 | ||
| 21 | 20 | use crate::error::{Result, SyncKitError}; | |
| 22 | 21 | ||
| @@ -35,7 +34,7 @@ const ARGON2_PARALLELISM: u32 = 1; | |||
| 35 | 34 | ||
| 36 | 35 | /// Encrypted master key envelope stored on the server. | |
| 37 | 36 | #[derive(Debug, Serialize, Deserialize)] | |
| 38 | - | pub struct KeyEnvelope { | |
| 37 | + | pub(crate) struct KeyEnvelope { | |
| 39 | 38 | /// Envelope version (currently 1). | |
| 40 | 39 | pub v: u8, | |
| 41 | 40 | /// Argon2 salt (base64). | |
| @@ -53,22 +52,39 @@ pub fn generate_master_key() -> [u8; KEY_SIZE] { | |||
| 53 | 52 | key | |
| 54 | 53 | } | |
| 55 | 54 | ||
| 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() | |
| 55 | + | /// Maximum password length in bytes. Passwords longer than this are rejected | |
| 56 | + | /// to prevent denial-of-service via extreme Argon2 input sizes. | |
| 57 | + | const MAX_PASSWORD_BYTES: usize = 1024; | |
| 58 | + | ||
| 59 | + | /// Normalize a password to NFC form and validate constraints. | |
| 60 | + | /// | |
| 61 | + | /// Returns the NFC-normalized password string. Rejects empty passwords | |
| 62 | + | /// and passwords exceeding [`MAX_PASSWORD_BYTES`]. | |
| 63 | + | fn normalize_password(password: &str) -> Result<String> { | |
| 64 | + | if password.is_empty() { | |
| 65 | + | return Err(SyncKitError::Crypto("password must not be empty".into())); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | let normalized: String = password.nfc().collect(); | |
| 69 | + | ||
| 70 | + | if normalized.len() > MAX_PASSWORD_BYTES { | |
| 71 | + | return Err(SyncKitError::Crypto(format!( | |
| 72 | + | "password exceeds maximum length of {MAX_PASSWORD_BYTES} bytes" | |
| 73 | + | ))); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | Ok(normalized) | |
| 63 | 77 | } | |
| 64 | 78 | ||
| 65 | - | /// Derive a wrapping key from a password using Argon2id. | |
| 66 | - | pub fn derive_wrapping_key( | |
| 79 | + | /// Derive a wrapping key from a password and salt using Argon2id. | |
| 80 | + | /// | |
| 81 | + | /// The password is NFC-normalized before derivation to ensure consistent | |
| 82 | + | /// keys across platforms with different default Unicode normalization forms. | |
| 83 | + | fn derive_wrapping_key( | |
| 67 | 84 | password: &str, | |
| 68 | - | app_id: Uuid, | |
| 69 | - | user_id: Uuid, | |
| 85 | + | salt: &[u8; 32], | |
| 70 | 86 | ) -> Result<[u8; KEY_SIZE]> { | |
| 71 | - | let salt = derive_salt(app_id, user_id); | |
| 87 | + | let normalized = normalize_password(password)?; | |
| 72 | 88 | ||
| 73 | 89 | let params = Params::new( | |
| 74 | 90 | ARGON2_MEM_COST_KB, | |
| @@ -82,24 +98,44 @@ pub fn derive_wrapping_key( | |||
| 82 | 98 | ||
| 83 | 99 | let mut wrapping_key = [0u8; KEY_SIZE]; | |
| 84 | 100 | argon2 | |
| 85 | - | .hash_password_into(password.as_bytes(), &salt, &mut wrapping_key) | |
| 101 | + | .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key) | |
| 86 | 102 | .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}")))?; | |
| 87 | 103 | ||
| 88 | 104 | Ok(wrapping_key) | |
| 89 | 105 | } | |
| 90 | 106 | ||
| 91 | - | /// Encrypt the master key with the wrapping key, producing a JSON envelope. | |
| 107 | + | /// Verify that a password correctly derives to the given master key by | |
| 108 | + | /// attempting to unwrap the envelope with it. | |
| 109 | + | /// | |
| 110 | + | /// This is used by `change_password` to validate the old password before | |
| 111 | + | /// allowing a re-wrap with the new password. The cached key may still be | |
| 112 | + | /// used for the actual re-encryption, but the old password must be proven | |
| 113 | + | /// correct first. | |
| 114 | + | pub fn verify_password_against_envelope( | |
| 115 | + | envelope_json: &str, | |
| 116 | + | password: &str, | |
| 117 | + | ) -> Result<[u8; KEY_SIZE]> { | |
| 118 | + | unwrap_master_key(envelope_json, password) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Encrypt the master key with a password, producing a JSON envelope. | |
| 122 | + | /// | |
| 123 | + | /// Generates a random 32-byte salt for Argon2id key derivation and stores it | |
| 124 | + | /// in the envelope. Each wrap operation uses a unique salt, preventing | |
| 125 | + | /// precomputation attacks. | |
| 92 | 126 | pub fn wrap_master_key( | |
| 93 | 127 | master_key: &[u8; KEY_SIZE], | |
| 94 | - | wrapping_key: &[u8; KEY_SIZE], | |
| 95 | - | app_id: Uuid, | |
| 96 | - | user_id: Uuid, | |
| 128 | + | password: &str, | |
| 97 | 129 | ) -> Result<String> { | |
| 98 | - | let salt = derive_salt(app_id, user_id); | |
| 130 | + | let mut salt = [0u8; 32]; | |
| 131 | + | rand::thread_rng().fill_bytes(&mut salt); | |
| 132 | + | ||
| 133 | + | let wrapping_key = derive_wrapping_key(password, &salt)?; | |
| 134 | + | ||
| 99 | 135 | let mut nonce_bytes = [0u8; NONCE_SIZE]; | |
| 100 | 136 | rand::thread_rng().fill_bytes(&mut nonce_bytes); | |
| 101 | 137 | ||
| 102 | - | let cipher = XChaCha20Poly1305::new(wrapping_key.into()); | |
| 138 | + | let cipher = XChaCha20Poly1305::new((&wrapping_key).into()); | |
| 103 | 139 | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 104 | 140 | ||
| 105 | 141 | let ciphertext = cipher | |
| @@ -116,10 +152,13 @@ pub fn wrap_master_key( | |||
| 116 | 152 | serde_json::to_string(&envelope).map_err(Into::into) | |
| 117 | 153 | } | |
| 118 | 154 | ||
| 119 | - | /// Decrypt the master key from a JSON envelope using the wrapping key. | |
| 155 | + | /// Decrypt the master key from a JSON envelope using a password. | |
| 156 | + | /// | |
| 157 | + | /// Reads the random salt from the envelope, derives the wrapping key via | |
| 158 | + | /// Argon2id, then decrypts the master key. | |
| 120 | 159 | pub fn unwrap_master_key( | |
| 121 | 160 | envelope_json: &str, | |
| 122 | - | wrapping_key: &[u8; KEY_SIZE], | |
| 161 | + | password: &str, | |
| 123 | 162 | ) -> Result<[u8; KEY_SIZE]> { | |
| 124 | 163 | let envelope: KeyEnvelope = | |
| 125 | 164 | serde_json::from_str(envelope_json).map_err(|e| { | |
| @@ -133,16 +172,27 @@ pub fn unwrap_master_key( | |||
| 133 | 172 | ))); | |
| 134 | 173 | } | |
| 135 | 174 | ||
| 175 | + | let salt_bytes = B64.decode(&envelope.salt)?; | |
| 136 | 176 | let nonce_bytes = B64.decode(&envelope.nonce)?; | |
| 137 | 177 | let ciphertext = B64.decode(&envelope.ciphertext)?; | |
| 138 | 178 | ||
| 179 | + | if salt_bytes.len() != 32 { | |
| 180 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 181 | + | "invalid salt length".into(), | |
| 182 | + | )); | |
| 183 | + | } | |
| 184 | + | ||
| 139 | 185 | if nonce_bytes.len() != NONCE_SIZE { | |
| 140 | 186 | return Err(SyncKitError::InvalidEnvelope( | |
| 141 | 187 | "invalid nonce length".into(), | |
| 142 | 188 | )); | |
| 143 | 189 | } | |
| 144 | 190 | ||
| 145 | - | let cipher = XChaCha20Poly1305::new(wrapping_key.into()); | |
| 191 | + | let mut salt = [0u8; 32]; | |
| 192 | + | salt.copy_from_slice(&salt_bytes); | |
| 193 | + | let wrapping_key = derive_wrapping_key(password, &salt)?; | |
| 194 | + | ||
| 195 | + | let cipher = XChaCha20Poly1305::new((&wrapping_key).into()); | |
| 146 | 196 | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 147 | 197 | ||
| 148 | 198 | let plaintext = cipher | |
| @@ -208,6 +258,54 @@ pub fn decrypt_data( | |||
| 208 | 258 | .map_err(|_| SyncKitError::DecryptionFailed) | |
| 209 | 259 | } | |
| 210 | 260 | ||
| 261 | + | /// Encrypt raw bytes with the master key. | |
| 262 | + | /// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64). | |
| 263 | + | /// Use this for blob data where base64 overhead is undesirable. | |
| 264 | + | pub fn encrypt_bytes( | |
| 265 | + | plaintext: &[u8], | |
| 266 | + | master_key: &[u8; KEY_SIZE], | |
| 267 | + | ) -> Result<Vec<u8>> { | |
| 268 | + | let mut nonce_bytes = [0u8; NONCE_SIZE]; | |
| 269 | + | rand::thread_rng().fill_bytes(&mut nonce_bytes); | |
| 270 | + | ||
| 271 | + | let cipher = XChaCha20Poly1305::new(master_key.into()); | |
| 272 | + | let nonce = XNonce::from_slice(&nonce_bytes); | |
| 273 | + | ||
| 274 | + | let ciphertext = cipher | |
| 275 | + | .encrypt(nonce, plaintext) | |
| 276 | + | .map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?; | |
| 277 | + | ||
| 278 | + | let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); | |
| 279 | + | blob.extend_from_slice(&nonce_bytes); | |
| 280 | + | blob.extend_from_slice(&ciphertext); | |
| 281 | + | ||
| 282 | + | Ok(blob) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// Decrypt raw bytes with the master key. | |
| 286 | + | /// Input is `nonce[24] || ciphertext || poly1305_tag[16]`. | |
| 287 | + | pub fn decrypt_bytes( | |
| 288 | + | encrypted: &[u8], | |
| 289 | + | master_key: &[u8; KEY_SIZE], | |
| 290 | + | ) -> Result<Vec<u8>> { | |
| 291 | + | if encrypted.len() < NONCE_SIZE + 16 { | |
| 292 | + | return Err(SyncKitError::Crypto( | |
| 293 | + | "ciphertext too short".into(), | |
| 294 | + | )); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE); | |
| 298 | + | let cipher = XChaCha20Poly1305::new(master_key.into()); | |
| 299 | + | let nonce = XNonce::from_slice(nonce_bytes); | |
| 300 | + | ||
| 301 | + | cipher | |
| 302 | + | .decrypt(nonce, ciphertext) | |
| 303 | + | .map_err(|_| SyncKitError::DecryptionFailed) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | /// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag). | |
| 307 | + | pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16; | |
| 308 | + | ||
| 211 | 309 | /// Encrypt a JSON value, returning a JSON string suitable for the `data` field. | |
| 212 | 310 | pub fn encrypt_json( | |
| 213 | 311 | value: &serde_json::Value, | |
| @@ -232,7 +330,7 @@ pub fn decrypt_json( | |||
| 232 | 330 | } | |
| 233 | 331 | ||
| 234 | 332 | /// Zero out a key on drop (best-effort memory defense). | |
| 235 | - | pub struct ZeroizeOnDrop(pub [u8; KEY_SIZE]); | |
| 333 | + | pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]); | |
| 236 | 334 | ||
| 237 | 335 | impl Drop for ZeroizeOnDrop { | |
| 238 | 336 | fn drop(&mut self) { | |
| @@ -256,13 +354,6 @@ impl std::ops::Deref for ZeroizeOnDrop { | |||
| 256 | 354 | mod tests { | |
| 257 | 355 | use super::*; | |
| 258 | 356 | ||
| 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 | 357 | #[test] | |
| 267 | 358 | fn master_key_generation_is_random() { | |
| 268 | 359 | let k1 = generate_master_key(); | |
| @@ -273,41 +364,483 @@ mod tests { | |||
| 273 | 364 | ||
| 274 | 365 | #[test] | |
| 275 | 366 | 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(); | |
| 367 | + | let salt = [42u8; 32]; | |
| 368 | + | let k1 = derive_wrapping_key("password123", &salt).unwrap(); | |
| 369 | + | let k2 = derive_wrapping_key("password123", &salt).unwrap(); | |
| 279 | 370 | assert_eq!(k1, k2, "Same inputs must produce same wrapping key"); | |
| 280 | 371 | } | |
| 281 | 372 | ||
| 282 | 373 | #[test] | |
| 283 | 374 | 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(); | |
| 375 | + | let salt = [42u8; 32]; | |
| 376 | + | let k1 = derive_wrapping_key("password1", &salt).unwrap(); | |
| 377 | + | let k2 = derive_wrapping_key("password2", &salt).unwrap(); | |
| 378 | + | assert_ne!(k1, k2); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | #[test] | |
| 382 | + | fn different_salts_produce_different_keys() { | |
| 383 | + | let salt1 = [1u8; 32]; | |
| 384 | + | let salt2 = [2u8; 32]; | |
| 385 | + | let k1 = derive_wrapping_key("password", &salt1).unwrap(); | |
| 386 | + | let k2 = derive_wrapping_key("password", &salt2).unwrap(); | |
| 287 | 387 | assert_ne!(k1, k2); | |
| 288 | 388 | } | |
| 289 | 389 | ||
| 390 | + | // ── Password normalization (NFC/NFD) ── | |
| 391 | + | ||
| 392 | + | #[test] | |
| 393 | + | fn nfc_and_nfd_passwords_derive_same_key() { | |
| 394 | + | // "e" + combining acute accent (NFD form of e-acute) | |
| 395 | + | let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent | |
| 396 | + | // Pre-composed e-acute (NFC form) | |
| 397 | + | let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent | |
| 398 | + | ||
| 399 | + | // Verify they are actually different byte sequences | |
| 400 | + | assert_ne!( | |
| 401 | + | nfd_password.as_bytes(), | |
| 402 | + | nfc_password.as_bytes(), | |
| 403 | + | "NFD and NFC should have different raw bytes" | |
| 404 | + | ); | |
| 405 | + | ||
| 406 | + | let salt = [99u8; 32]; | |
| 407 | + | let k1 = derive_wrapping_key(nfd_password, &salt).unwrap(); | |
| 408 | + | let k2 = derive_wrapping_key(nfc_password, &salt).unwrap(); | |
| 409 | + | assert_eq!( | |
| 410 | + | k1, k2, | |
| 411 | + | "Same password in NFC and NFD forms must derive the same key" | |
| 412 | + | ); | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | #[test] | |
| 416 | + | fn nfc_nfd_wrap_unwrap_roundtrip() { | |
| 417 | + | let master_key = generate_master_key(); | |
| 418 | + | // Wrap with NFC form | |
| 419 | + | let nfc_password = "caf\u{00e9}"; | |
| 420 | + | let envelope = wrap_master_key(&master_key, nfc_password).unwrap(); | |
| 421 | + | ||
| 422 | + | // Unwrap with NFD form | |
| 423 | + | let nfd_password = "caf\u{0065}\u{0301}"; | |
| 424 | + | let recovered = unwrap_master_key(&envelope, nfd_password).unwrap(); | |
| 425 | + | assert_eq!(master_key, recovered); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn nfd_wrap_nfc_unwrap_roundtrip() { | |
| 430 | + | let master_key = generate_master_key(); | |
| 431 | + | // Wrap with NFD form | |
| 432 | + | let nfd_password = "caf\u{0065}\u{0301}"; | |
| 433 | + | let envelope = wrap_master_key(&master_key, nfd_password).unwrap(); | |
| 434 | + | ||
| 435 | + | // Unwrap with NFC form | |
| 436 | + | let nfc_password = "caf\u{00e9}"; | |
| 437 | + | let recovered = unwrap_master_key(&envelope, nfc_password).unwrap(); | |
| 438 | + | assert_eq!(master_key, recovered); | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | #[test] | |
| 442 | + | fn normalize_password_converts_to_nfc() { | |
| 443 | + | let nfd = "caf\u{0065}\u{0301}"; | |
| 444 | + | let nfc = "caf\u{00e9}"; | |
| 445 | + | let normalized = normalize_password(nfd).unwrap(); | |
| 446 | + | assert_eq!(normalized, nfc); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // ── Empty password rejection ── | |
| 450 | + | ||
| 451 | + | #[test] | |
| 452 | + | fn empty_password_rejected_by_normalize() { | |
| 453 | + | let result = normalize_password(""); | |
| 454 | + | assert!(result.is_err()); | |
| 455 | + | let msg = result.unwrap_err().to_string(); | |
| 456 | + | assert!(msg.contains("empty"), "Error should mention empty: {msg}"); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn empty_password_rejected_by_derive() { | |
| 461 | + | let salt = [0u8; 32]; | |
| 462 | + | let result = derive_wrapping_key("", &salt); | |
| 463 | + | assert!(result.is_err()); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | #[test] | |
| 467 | + | fn empty_password_rejected_by_wrap() { | |
| 468 | + | let master_key = generate_master_key(); | |
| 469 | + | let result = wrap_master_key(&master_key, ""); | |
| 470 | + | assert!(result.is_err()); | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | #[test] | |
| 474 | + | fn empty_password_rejected_by_unwrap() { | |
| 475 | + | let master_key = generate_master_key(); | |
| 476 | + | let envelope = wrap_master_key(&master_key, "valid").unwrap(); | |
| 477 | + | let result = unwrap_master_key(&envelope, ""); | |
| 478 | + | assert!(result.is_err()); | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | // ── Password length limit ── | |
| 482 | + | ||
| 483 | + | #[test] | |
| 484 | + | fn very_long_password_rejected() { | |
| 485 | + | let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1); | |
| 486 | + | let result = normalize_password(&long_password); | |
| 487 | + | assert!(result.is_err()); | |
| 488 | + | let msg = result.unwrap_err().to_string(); | |
| 489 | + | assert!( | |
| 490 | + | msg.contains("maximum length"), | |
| 491 | + | "Error should mention max length: {msg}" | |
| 492 | + | ); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | #[test] | |
| 496 | + | fn password_at_max_length_accepted() { | |
| 497 | + | let max_password = "a".repeat(MAX_PASSWORD_BYTES); | |
| 498 | + | let result = normalize_password(&max_password); | |
| 499 | + | assert!(result.is_ok()); | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | #[test] | |
| 503 | + | fn password_just_under_max_length_accepted() { | |
| 504 | + | let password = "a".repeat(MAX_PASSWORD_BYTES - 1); | |
| 505 | + | let result = normalize_password(&password); | |
| 506 | + | assert!(result.is_ok()); | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // ── Salt reuse detection ── | |
| 510 | + | ||
| 511 | + | #[test] | |
| 512 | + | fn two_wraps_use_different_salts() { | |
| 513 | + | let master_key = generate_master_key(); | |
| 514 | + | let e1_json = wrap_master_key(&master_key, "pass").unwrap(); | |
| 515 | + | let e2_json = wrap_master_key(&master_key, "pass").unwrap(); | |
| 516 | + | ||
| 517 | + | let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap(); | |
| 518 | + | let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap(); | |
| 519 | + | ||
| 520 | + | assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt"); | |
| 521 | + | assert_ne!(e1.nonce, e2.nonce, "Each wrap must use a unique random nonce"); | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // ── Key derivation determinism ── | |
| 525 | + | ||
| 526 | + | #[test] | |
| 527 | + | fn key_derivation_deterministic_multiple_calls() { | |
| 528 | + | let salt = [77u8; 32]; | |
| 529 | + | let password = "deterministic-test-password"; | |
| 530 | + | ||
| 531 | + | let k1 = derive_wrapping_key(password, &salt).unwrap(); | |
| 532 | + | let k2 = derive_wrapping_key(password, &salt).unwrap(); | |
| 533 | + | let k3 = derive_wrapping_key(password, &salt).unwrap(); | |
| 534 | + | ||
| 535 | + | assert_eq!(k1, k2); | |
| 536 | + | assert_eq!(k2, k3); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // ── Key rotation: re-wrap with new password, old data still readable ── | |
| 540 | + | ||
| 541 | + | #[test] | |
| 542 | + | fn key_rotation_preserves_data_access() { | |
| 543 | + | let master_key = generate_master_key(); | |
| 544 | + | let plaintext = b"encrypted before password change"; | |
| 545 | + | ||
| 546 | + | // Encrypt data with the master key | |
| 547 | + | let encrypted = encrypt_data(plaintext, &master_key).unwrap(); | |
| 548 | + | ||
| 549 | + | // Wrap master key with old password | |
| 550 | + | let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap(); | |
| 551 | + | ||
| 552 | + | // Simulate password change: unwrap with old, re-wrap with new | |
| 553 | + | let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap(); | |
| 554 | + | assert_eq!(recovered_key, master_key); | |
| 555 | + | ||
| 556 | + | let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap(); | |
| 557 | + | ||
| 558 | + | // Verify: unwrap with new password gives same key | |
| 559 | + | let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap(); | |
| 560 | + | assert_eq!(key_from_new, master_key); | |
| 561 | + | ||
| 562 | + | // Verify: old encrypted data can still be decrypted | |
| 563 | + | let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap(); | |
| 564 | + | assert_eq!(decrypted, plaintext); | |
| 565 | + | ||
| 566 | + | // Verify: old password no longer works on new envelope | |
| 567 | + | let result = unwrap_master_key(&new_envelope, "old-pass"); | |
| 568 | + | assert!(result.is_err()); | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // ── Encryption roundtrip with various data sizes ── | |
| 572 | + | ||
| 573 | + | #[test] | |
| 574 | + | fn encrypt_decrypt_empty_data() { | |
| 575 | + | let master_key = generate_master_key(); | |
| 576 | + | let encrypted = encrypt_data(b"", &master_key).unwrap(); | |
| 577 | + | let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); | |
| 578 | + | assert!(decrypted.is_empty()); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | #[test] | |
| 582 | + | fn encrypt_decrypt_single_byte() { | |
| 583 | + | let master_key = generate_master_key(); | |
| 584 | + | let encrypted = encrypt_data(&[42], &master_key).unwrap(); | |
| 585 | + | let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); | |
| 586 | + | assert_eq!(decrypted, vec![42]); | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | #[test] | |
| 590 | + | fn encrypt_decrypt_large_payload() { | |
| 591 | + | let master_key = generate_master_key(); | |
| 592 | + | // 1MB of data | |
| 593 | + | let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect(); | |
| 594 | + | let encrypted = encrypt_data(&plaintext, &master_key).unwrap(); | |
| 595 | + | let decrypted = decrypt_data(&encrypted, &master_key).unwrap(); | |
| 596 | + | assert_eq!(decrypted, plaintext); | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // ── Wrong key gives error, not garbage ── | |
| 600 | + | ||
| 601 | + | #[test] | |
| 602 | + | fn wrong_key_gives_decryption_error_not_garbage() { | |
| 603 | + | let key1 = generate_master_key(); | |
| 604 | + | let key2 = generate_master_key(); | |
| 605 | + | let plaintext = b"this should fail cleanly with wrong key"; | |
| 606 | + | ||
| 607 | + | let encrypted = encrypt_data(plaintext, &key1).unwrap(); | |
| 608 | + | let result = decrypt_data(&encrypted, &key2); | |
| 609 | + | ||
| 610 | + | // Must be an error, not a successful decryption to garbage | |
| 611 | + | assert!(result.is_err()); | |
| 612 | + | assert!( | |
| 613 | + | matches!(result.unwrap_err(), SyncKitError::DecryptionFailed), | |
| 614 | + | "Wrong key must produce DecryptionFailed, not garbage output" | |
| 615 | + | ); | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | #[test] | |
| 619 | + | fn wrong_key_bytes_gives_decryption_error_not_garbage() { | |
| 620 | + | let key1 = generate_master_key(); |
Lines truncated
| @@ -5,36 +5,56 @@ use thiserror::Error; | |||
| 5 | 5 | /// All errors that can occur in the SyncKit client. | |
| 6 | 6 | #[derive(Debug, Error)] | |
| 7 | 7 | pub enum SyncKitError { | |
| 8 | + | /// Network-level failure: connection refused, timeout, DNS resolution, TLS handshake. | |
| 8 | 9 | #[error("HTTP request failed: {0}")] | |
| 9 | 10 | Http(#[from] reqwest::Error), | |
| 10 | 11 | ||
| 12 | + | /// Server returned a non-success HTTP status (4xx or 5xx). Status and message | |
| 13 | + | /// extracted from response. | |
| 11 | 14 | #[error("Server returned {status}: {message}")] | |
| 12 | 15 | Server { status: u16, message: String }, | |
| 13 | 16 | ||
| 17 | + | /// Response body could not be parsed as expected JSON type. | |
| 14 | 18 | #[error("JSON serialization error: {0}")] | |
| 15 | 19 | Json(#[from] serde_json::Error), | |
| 16 | 20 | ||
| 21 | + | /// Encryption method called before `setup_encryption_new` or | |
| 22 | + | /// `setup_encryption_existing`. | |
| 17 | 23 | #[error("Encryption not initialized — call setup_encryption first")] | |
| 18 | 24 | NoMasterKey, | |
| 19 | 25 | ||
| 26 | + | /// `unwrap_master_key` or `decrypt_data`/`decrypt_bytes` failed. Wrong | |
| 27 | + | /// password or corrupted ciphertext. | |
| 20 | 28 | #[error("Wrong password or corrupted key envelope")] | |
| 21 | 29 | DecryptionFailed, | |
| 22 | 30 | ||
| 31 | + | /// Key envelope JSON has an unrecognized version or missing fields. | |
| 23 | 32 | #[error("Invalid key envelope: {0}")] | |
| 24 | 33 | InvalidEnvelope(String), | |
| 25 | 34 | ||
| 35 | + | /// Argon2 key derivation or AEAD encryption/decryption failed (corrupt | |
| 36 | + | /// data, wrong parameters). | |
| 26 | 37 | #[error("Encryption error: {0}")] | |
| 27 | 38 | Crypto(String), | |
| 28 | 39 | ||
| 40 | + | /// Base64 decoding of encrypted payloads failed. | |
| 29 | 41 | #[error("Base64 decode error: {0}")] | |
| 30 | 42 | Base64(#[from] base64::DecodeError), | |
| 31 | 43 | ||
| 44 | + | /// API method called before `authenticate` or `restore_session`. | |
| 32 | 45 | #[error("Not authenticated — call authenticate first")] | |
| 33 | 46 | NotAuthenticated, | |
| 34 | 47 | ||
| 48 | + | /// JWT `exp` claim is within 30 seconds of current time. Caller should | |
| 49 | + | /// re-authenticate. | |
| 35 | 50 | #[error("Token expired — re-authenticate to continue syncing")] | |
| 36 | 51 | TokenExpired, | |
| 37 | 52 | ||
| 53 | + | /// Internal error that should not occur in normal operation. | |
| 54 | + | #[error("Internal error: {0}")] | |
| 55 | + | Internal(String), | |
| 56 | + | ||
| 57 | + | /// OS keychain operation failed (store, load, or delete). Platform-specific. | |
| 38 | 58 | #[cfg(feature = "keychain")] | |
| 39 | 59 | #[error("Keychain error: {0}")] | |
| 40 | 60 | Keychain(String), | |
| @@ -42,3 +62,118 @@ pub enum SyncKitError { | |||
| 42 | 62 | ||
| 43 | 63 | /// Convenience alias. | |
| 44 | 64 | pub type Result<T> = std::result::Result<T, SyncKitError>; | |
| 65 | + | ||
| 66 | + | #[cfg(test)] | |
| 67 | + | mod tests { | |
| 68 | + | use super::*; | |
| 69 | + | use std::error::Error; | |
| 70 | + | ||
| 71 | + | #[test] | |
| 72 | + | fn error_is_send_and_sync() { | |
| 73 | + | fn assert_send_sync<T: Send + Sync>() {} | |
| 74 | + | assert_send_sync::<SyncKitError>(); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[test] | |
| 78 | + | fn display_all_variants() { | |
| 79 | + | let cases: Vec<(SyncKitError, &str)> = vec![ | |
| 80 | + | ( | |
| 81 | + | SyncKitError::Server { status: 500, message: "boom".into() }, | |
| 82 | + | "Server returned 500: boom", | |
| 83 | + | ), | |
| 84 | + | (SyncKitError::NoMasterKey, "Encryption not initialized"), | |
| 85 | + | (SyncKitError::DecryptionFailed, "Wrong password"), | |
| 86 | + | (SyncKitError::InvalidEnvelope("bad".into()), "Invalid key envelope: bad"), | |
| 87 | + | (SyncKitError::Crypto("aead".into()), "Encryption error: aead"), | |
| 88 | + | (SyncKitError::NotAuthenticated, "Not authenticated"), | |
| 89 | + | (SyncKitError::TokenExpired, "Token expired"), | |
| 90 | + | (SyncKitError::Internal("oops".into()), "Internal error: oops"), | |
| 91 | + | ]; | |
| 92 | + | for (err, expected_substring) in cases { | |
| 93 | + | let msg = err.to_string(); | |
| 94 | + | assert!( | |
| 95 | + | msg.contains(expected_substring), | |
| 96 | + | "Expected '{expected_substring}' in '{msg}'" | |
| 97 | + | ); | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn debug_format_no_panic() { | |
| 103 | + | let variants: Vec<SyncKitError> = vec![ | |
| 104 | + | SyncKitError::Server { status: 500, message: "err".into() }, | |
| 105 | + | SyncKitError::NoMasterKey, | |
| 106 | + | SyncKitError::DecryptionFailed, | |
| 107 | + | SyncKitError::InvalidEnvelope("v".into()), | |
| 108 | + | SyncKitError::Crypto("c".into()), | |
| 109 | + | SyncKitError::NotAuthenticated, | |
| 110 | + | SyncKitError::TokenExpired, | |
| 111 | + | SyncKitError::Internal("i".into()), | |
| 112 | + | ]; | |
| 113 | + | for v in variants { | |
| 114 | + | let debug = format!("{v:?}"); | |
| 115 | + | assert!(!debug.is_empty()); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn source_json_error() { | |
| 121 | + | let inner = serde_json::from_str::<serde_json::Value>("bad").unwrap_err(); | |
| 122 | + | let err = SyncKitError::Json(inner); | |
| 123 | + | assert!(err.source().is_some(), "Json variant should chain source"); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn source_base64_error() { | |
| 128 | + | use base64::Engine; | |
| 129 | + | let inner = base64::engine::general_purpose::STANDARD | |
| 130 | + | .decode("!!!invalid!!!") | |
| 131 | + | .unwrap_err(); | |
| 132 | + | let err = SyncKitError::Base64(inner); | |
| 133 | + | assert!(err.source().is_some(), "Base64 variant should chain source"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn source_none_for_leaf_variants() { | |
| 138 | + | assert!(SyncKitError::NoMasterKey.source().is_none()); | |
| 139 | + | assert!(SyncKitError::DecryptionFailed.source().is_none()); | |
| 140 | + | assert!(SyncKitError::NotAuthenticated.source().is_none()); | |
| 141 | + | assert!(SyncKitError::TokenExpired.source().is_none()); | |
| 142 | + | assert!(SyncKitError::InvalidEnvelope("x".into()).source().is_none()); | |
| 143 | + | assert!(SyncKitError::Crypto("x".into()).source().is_none()); | |
| 144 | + | assert!(SyncKitError::Internal("x".into()).source().is_none()); | |
| 145 | + | let server = SyncKitError::Server { status: 500, message: "x".into() }; | |
| 146 | + | assert!(server.source().is_none()); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn server_error_empty_message() { | |
| 151 | + | let err = SyncKitError::Server { status: 503, message: String::new() }; | |
| 152 | + | let msg = err.to_string(); | |
| 153 | + | assert!(msg.contains("503")); | |
| 154 | + | assert!(msg.contains(": "), "Should have colon separator even with empty message"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn server_error_very_long_message() { | |
| 159 | + | let long = "x".repeat(1_000_000); | |
| 160 | + | let err = SyncKitError::Server { status: 500, message: long }; | |
| 161 | + | let msg = err.to_string(); | |
| 162 | + | assert!(msg.contains("500")); | |
| 163 | + | assert!(msg.len() > 1_000_000); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn invalid_envelope_preserves_detail() { | |
| 168 | + | let detail = "unsupported version 99"; | |
| 169 | + | let err = SyncKitError::InvalidEnvelope(detail.into()); | |
| 170 | + | assert!(err.to_string().contains(detail)); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn internal_preserves_detail() { | |
| 175 | + | let detail = "unexpected state in push handler"; | |
| 176 | + | let err = SyncKitError::Internal(detail.into()); | |
| 177 | + | assert!(err.to_string().contains(detail)); | |
| 178 | + | } | |
| 179 | + | } |
| @@ -2,6 +2,14 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! Feature-gated behind `keychain` (enabled by default). | |
| 4 | 4 | //! Falls back gracefully when the keychain is unavailable. | |
| 5 | + | //! | |
| 6 | + | //! ## Platform backends | |
| 7 | + | //! | |
| 8 | + | //! - **macOS**: Keychain (via Security framework). | |
| 9 | + | //! - **Linux**: secret-service (D-Bus). Requires a running keyring daemon such | |
| 10 | + | //! as gnome-keyring. Without a secret-service provider, `store_key` and | |
| 11 | + | //! `load_key` will return a `Keychain` error. | |
| 12 | + | //! - **Windows**: Credential Manager. | |
| 5 | 13 | ||
| 6 | 14 | use crate::error::{Result, SyncKitError}; | |
| 7 | 15 | use base64::{engine::general_purpose::STANDARD as B64, Engine}; | |
| @@ -9,12 +17,18 @@ use uuid::Uuid; | |||
| 9 | 17 | ||
| 10 | 18 | const SERVICE_PREFIX: &str = "synckit"; | |
| 11 | 19 | ||
| 12 | - | /// Build the keychain service name: "synckit:<app_id>" | |
| 20 | + | /// Build the keychain service name: `"synckit:<app_id>"`. | |
| 21 | + | /// | |
| 22 | + | /// Each SyncKit app gets its own keychain namespace so that keys from | |
| 23 | + | /// different apps never collide. | |
| 13 | 24 | fn service_name(app_id: Uuid) -> String { | |
| 14 | 25 | format!("{SERVICE_PREFIX}:{app_id}") | |
| 15 | 26 | } | |
| 16 | 27 | ||
| 17 | - | /// Build the keychain user key: the user_id as a string. | |
| 28 | + | /// Build the keychain user key: the `user_id` as a hyphenated UUID string. | |
| 29 | + | /// | |
| 30 | + | /// Combined with `service_name`, this uniquely identifies the keychain entry | |
| 31 | + | /// for a given (app, user) pair. | |
| 18 | 32 | fn user_key(user_id: Uuid) -> String { | |
| 19 | 33 | user_id.to_string() | |
| 20 | 34 | } |
| @@ -47,14 +47,14 @@ impl ChangeOp { | |||
| 47 | 47 | // ── Auth ── | |
| 48 | 48 | ||
| 49 | 49 | #[derive(Serialize)] | |
| 50 | - | pub struct AuthRequest { | |
| 50 | + | pub(crate) struct AuthRequest { | |
| 51 | 51 | pub email: String, | |
| 52 | 52 | pub password: String, | |
| 53 | 53 | pub api_key: String, | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | #[derive(Deserialize)] | |
| 57 | - | pub struct AuthResponse { | |
| 57 | + | pub(crate) struct AuthResponse { | |
| 58 | 58 | pub token: String, | |
| 59 | 59 | pub user_id: Uuid, | |
| 60 | 60 | pub app_id: Uuid, | |
| @@ -63,19 +63,26 @@ pub struct AuthResponse { | |||
| 63 | 63 | // ── Devices ── | |
| 64 | 64 | ||
| 65 | 65 | #[derive(Serialize)] | |
| 66 | - | pub struct RegisterDeviceRequest { | |
| 66 | + | pub(crate) struct RegisterDeviceRequest { | |
| 67 | 67 | pub device_name: String, | |
| 68 | 68 | pub platform: String, | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | 71 | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 72 | 72 | pub struct Device { | |
| 73 | + | /// Server-assigned device UUID. | |
| 73 | 74 | pub id: Uuid, | |
| 75 | + | /// The SyncKit app this device belongs to. | |
| 74 | 76 | pub app_id: Uuid, | |
| 77 | + | /// The user who owns this device. | |
| 75 | 78 | pub user_id: Uuid, | |
| 79 | + | /// Human-readable name (e.g., "MacBook Pro"). | |
| 76 | 80 | pub device_name: String, | |
| 81 | + | /// OS identifier (e.g., "macos", "linux", "windows"). | |
| 77 | 82 | pub platform: String, | |
| 83 | + | /// Last time this device synced. | |
| 78 | 84 | pub last_seen_at: DateTime<Utc>, | |
| 85 | + | /// When this device was first registered. | |
| 79 | 86 | pub created_at: DateTime<Utc>, | |
| 80 | 87 | } | |
| 81 | 88 | ||
| @@ -85,10 +92,16 @@ pub struct Device { | |||
| 85 | 92 | /// `data` is plaintext here — the client encrypts it before sending. | |
| 86 | 93 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 87 | 94 | pub struct ChangeEntry { | |
| 95 | + | /// The source table name (opaque to server, meaningful to the app). | |
| 88 | 96 | pub table: String, | |
| 97 | + | /// Insert, Update, or Delete. | |
| 89 | 98 | pub op: ChangeOp, | |
| 99 | + | /// App-assigned row identifier (typically a UUID string). | |
| 90 | 100 | pub row_id: String, | |
| 101 | + | /// When this change was made on the originating device. | |
| 91 | 102 | pub timestamp: DateTime<Utc>, | |
| 103 | + | /// The row payload as JSON. `None` for Delete operations. | |
| 104 | + | /// Serialized as absent (not null) when `None`. | |
| 92 | 105 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 93 | 106 | pub data: Option<serde_json::Value>, | |
| 94 | 107 | } | |
| @@ -96,7 +109,9 @@ pub struct ChangeEntry { | |||
| 96 | 109 | /// Wire format sent to server (data is already encrypted). | |
| 97 | 110 | #[derive(Serialize)] | |
| 98 | 111 | pub(crate) struct WirePushRequest { | |
| 112 | + | /// The device sending the changes. | |
| 99 | 113 | pub device_id: Uuid, | |
| 114 | + | /// Encrypted change entries ready for the wire. | |
| 100 | 115 | pub changes: Vec<WireChangeEntry>, | |
| 101 | 116 | } | |
| 102 | 117 | ||
| @@ -127,6 +142,11 @@ pub(crate) struct PullResponse { | |||
| 127 | 142 | pub has_more: bool, | |
| 128 | 143 | } | |
| 129 | 144 | ||
| 145 | + | /// A change entry received from the server during a pull. | |
| 146 | + | /// | |
| 147 | + | /// `seq` and `device_id` are present in the server response and parsed for | |
| 148 | + | /// completeness, but not used by the SDK — consumers track cursors, not | |
| 149 | + | /// individual sequence numbers. | |
| 130 | 150 | #[derive(Deserialize)] | |
| 131 | 151 | pub(crate) struct PullChangeEntry { | |
| 132 | 152 | #[allow(dead_code)] | |
| @@ -178,26 +198,30 @@ pub(crate) struct OAuthTokenResponse { | |||
| 178 | 198 | ||
| 179 | 199 | #[derive(Debug, Deserialize)] | |
| 180 | 200 | pub struct SyncStatus { | |
| 201 | + | /// Number of changelog entries on the server for this app/user. | |
| 181 | 202 | pub total_changes: i64, | |
| 203 | + | /// Sequence number of the most recent change. `None` if no changes exist. | |
| 182 | 204 | pub latest_cursor: Option<i64>, | |
| 183 | 205 | } | |
| 184 | 206 | ||
| 185 | 207 | // ── Blobs ── | |
| 186 | 208 | ||
| 187 | 209 | #[derive(Serialize)] | |
| 188 | - | pub struct BlobUploadUrlRequest { | |
| 210 | + | pub(crate) struct BlobUploadUrlRequest { | |
| 189 | 211 | pub hash: String, | |
| 190 | 212 | pub size_bytes: i64, | |
| 191 | 213 | } | |
| 192 | 214 | ||
| 193 | 215 | #[derive(Deserialize)] | |
| 194 | 216 | pub struct BlobUploadUrlResponse { | |
| 217 | + | /// Presigned S3 PUT URL. Empty string when `already_exists` is true. | |
| 195 | 218 | pub upload_url: String, | |
| 219 | + | /// True if the server already has a blob with this hash (skip upload). | |
| 196 | 220 | pub already_exists: bool, | |
| 197 | 221 | } | |
| 198 | 222 | ||
| 199 | 223 | #[derive(Serialize)] | |
| 200 | - | pub struct BlobConfirmRequest { | |
| 224 | + | pub(crate) struct BlobConfirmRequest { | |
| 201 | 225 | pub hash: String, | |
| 202 | 226 | pub size_bytes: i64, | |
| 203 | 227 | } | |
| @@ -211,3 +235,151 @@ pub(crate) struct BlobDownloadUrlRequest { | |||
| 211 | 235 | pub(crate) struct BlobDownloadUrlResponse { | |
| 212 | 236 | pub download_url: String, | |
| 213 | 237 | } | |
| 238 | + | ||
| 239 | + | #[cfg(test)] | |
| 240 | + | mod tests { | |
| 241 | + | use super::*; | |
| 242 | + | use serde_json::json; | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn change_op_serde_roundtrip() { | |
| 246 | + | for (variant, expected_str) in [ | |
| 247 | + | (ChangeOp::Insert, "\"INSERT\""), | |
| 248 | + | (ChangeOp::Update, "\"UPDATE\""), | |
| 249 | + | (ChangeOp::Delete, "\"DELETE\""), | |
| 250 | + | ] { | |
| 251 | + | let serialized = serde_json::to_string(&variant).unwrap(); | |
| 252 | + | assert_eq!(serialized, expected_str); | |
| 253 | + | let deserialized: ChangeOp = serde_json::from_str(&serialized).unwrap(); | |
| 254 | + | assert_eq!(deserialized, variant); | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn change_op_display_matches_serde() { | |
| 260 | + | for variant in [ChangeOp::Insert, ChangeOp::Update, ChangeOp::Delete] { | |
| 261 | + | let display = variant.to_string(); | |
| 262 | + | let serde_str = serde_json::to_string(&variant).unwrap(); | |
| 263 | + | // serde wraps in quotes, Display does not | |
| 264 | + | assert_eq!(format!("\"{display}\""), serde_str); | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn change_op_from_str_opt_rejects_lowercase() { | |
| 270 | + | assert_eq!(ChangeOp::from_str_opt("insert"), None); | |
| 271 | + | assert_eq!(ChangeOp::from_str_opt("update"), None); | |
| 272 | + | assert_eq!(ChangeOp::from_str_opt("delete"), None); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn change_op_from_str_opt_rejects_unknown() { | |
| 277 | + | assert_eq!(ChangeOp::from_str_opt("UPSERT"), None); | |
| 278 | + | assert_eq!(ChangeOp::from_str_opt(""), None); | |
| 279 | + | assert_eq!(ChangeOp::from_str_opt("MERGE"), None); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn change_op_is_copy_and_eq() { | |
| 284 | + | let op = ChangeOp::Insert; | |
| 285 | + | let copied = op; // Copy | |
| 286 | + | assert_eq!(op, copied); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn change_op_hash_works() { | |
| 291 | + | use std::collections::HashSet; | |
| 292 | + | let mut set = HashSet::new(); | |
| 293 | + | set.insert(ChangeOp::Insert); | |
| 294 | + | set.insert(ChangeOp::Update); | |
| 295 | + | set.insert(ChangeOp::Delete); | |
| 296 | + | set.insert(ChangeOp::Insert); // duplicate | |
| 297 | + | assert_eq!(set.len(), 3); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn change_entry_serialization_omits_none_data() { | |
| 302 | + | let entry = ChangeEntry { | |
| 303 | + | table: "t".into(), | |
| 304 | + | op: ChangeOp::Delete, | |
| 305 | + | row_id: "r".into(), | |
| 306 | + | timestamp: chrono::Utc::now(), | |
| 307 | + | data: None, | |
| 308 | + | }; | |
| 309 | + | let json = serde_json::to_string(&entry).unwrap(); | |
| 310 | + | assert!(!json.contains("\"data\""), "None data should be omitted: {json}"); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[test] | |
| 314 | + | fn change_entry_serialization_includes_some_data() { | |
| 315 | + | let entry = ChangeEntry { | |
| 316 | + | table: "t".into(), | |
| 317 | + | op: ChangeOp::Insert, | |
| 318 | + | row_id: "r".into(), | |
| 319 | + | timestamp: chrono::Utc::now(), | |
| 320 | + | data: Some(json!({"k": "v"})), | |
| 321 | + | }; | |
| 322 | + | let json = serde_json::to_string(&entry).unwrap(); | |
| 323 | + | assert!(json.contains("\"data\""), "Some data should be present: {json}"); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn change_entry_deserialization_ignores_extra_fields() { | |
| 328 | + | let json = r#"{ | |
| 329 | + | "table": "tasks", | |
| 330 | + | "op": "INSERT", | |
| 331 | + | "row_id": "r1", | |
| 332 | + | "timestamp": "2025-01-15T10:00:00Z", | |
| 333 | + | "data": {"title": "test"}, | |
| 334 | + | "extra_field": "should be ignored", | |
| 335 | + | "another_unknown": 42 | |
| 336 | + | }"#; | |
| 337 | + | let entry: ChangeEntry = serde_json::from_str(json).unwrap(); | |
| 338 | + | assert_eq!(entry.table, "tasks"); | |
| 339 | + | assert_eq!(entry.op, ChangeOp::Insert); | |
| 340 | + | assert_eq!(entry.data.unwrap()["title"], "test"); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn device_deserialization_with_iso_timestamps() { | |
| 345 | + | let json = r#"{ | |
| 346 | + | "id": "550e8400-e29b-41d4-a716-446655440000", | |
| 347 | + | "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", | |
| 348 | + | "user_id": "550e8400-e29b-41d4-a716-446655440001", | |
| 349 | + | "device_name": "MacBook Pro", | |
| 350 | + | "platform": "macos", | |
| 351 | + | "last_seen_at": "2025-06-15T14:30:00.123Z", | |
| 352 | + | "created_at": "2025-01-01T00:00:00Z" | |
| 353 | + | }"#; | |
| 354 | + | let device: Device = serde_json::from_str(json).unwrap(); | |
| 355 | + | assert_eq!(device.device_name, "MacBook Pro"); | |
| 356 | + | assert_eq!(device.platform, "macos"); | |
| 357 | + | assert_eq!( | |
| 358 | + | device.id, | |
| 359 | + | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() | |
| 360 | + | ); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn sync_status_with_zero_total_changes() { | |
| 365 | + | let json = r#"{"total_changes": 0, "latest_cursor": null}"#; | |
| 366 | + | let status: SyncStatus = serde_json::from_str(json).unwrap(); | |
| 367 | + | assert_eq!(status.total_changes, 0); | |
| 368 | + | assert!(status.latest_cursor.is_none()); | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | #[test] | |
| 372 | + | fn blob_upload_url_response_already_exists() { | |
| 373 | + | let json = r#"{"upload_url": "", "already_exists": true}"#; | |
| 374 | + | let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap(); | |
| 375 | + | assert!(resp.already_exists); | |
| 376 | + | assert!(resp.upload_url.is_empty()); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[test] | |
| 380 | + | fn change_op_debug_format() { | |
| 381 | + | assert_eq!(format!("{:?}", ChangeOp::Insert), "Insert"); | |
| 382 | + | assert_eq!(format!("{:?}", ChangeOp::Update), "Update"); | |
| 383 | + | assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete"); | |
| 384 | + | } | |
| 385 | + | } |
| @@ -0,0 +1,2749 @@ | |||
| 1 | + | //! Integration tests using wiremock to simulate the MNW SyncKit server. | |
| 2 | + | //! | |
| 3 | + | //! These tests verify the full HTTP round-trip including retry behavior, | |
| 4 | + | //! encryption/decryption, and error classification. | |
| 5 | + | ||
| 6 | + | use std::sync::Arc; | |
| 7 | + | ||
| 8 | + | use base64::Engine; | |
| 9 | + | use chrono::Utc; | |
| 10 | + | use serde_json::json; | |
| 11 | + | use uuid::Uuid; | |
| 12 | + | use wiremock::matchers::{method, path}; | |
| 13 | + | use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 14 | + | ||
| 15 | + | use std::time::Duration; | |
| 16 | + | use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient, SyncKitConfig, SyncKitError}; | |
| 17 | + | ||
| 18 | + | // ── Helpers ── | |
| 19 | + | ||
| 20 | + | fn fake_jwt(exp: i64) -> String { | |
| 21 | + | let header = | |
| 22 | + | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#); | |
| 23 | + | let payload = json!({ | |
| 24 | + | "sub": "550e8400-e29b-41d4-a716-446655440000", | |
| 25 | + | "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", | |
| 26 | + | "exp": exp, | |
| 27 | + | "iat": exp - 3600, | |
| 28 | + | }); | |
| 29 | + | let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 30 | + | .encode(payload.to_string().as_bytes()); | |
| 31 | + | let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature"); | |
| 32 | + | format!("{header}.{payload_b64}.{sig}") | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | fn fresh_token() -> String { | |
| 36 | + | fake_jwt(Utc::now().timestamp() + 3600) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn test_ids() -> (Uuid, Uuid) { | |
| 40 | + | ( | |
| 41 | + | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 42 | + | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 43 | + | ) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn client_for(server: &MockServer) -> SyncKitClient { | |
| 47 | + | SyncKitClient::new(SyncKitConfig { | |
| 48 | + | server_url: server.uri(), | |
| 49 | + | api_key: "test-api-key".to_string(), | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn authed_client(server: &MockServer) -> SyncKitClient { | |
| 54 | + | let client = client_for(server); | |
| 55 | + | let (user_id, app_id) = test_ids(); | |
| 56 | + | client | |
| 57 | + | .restore_session(&fresh_token(), user_id, app_id) | |
| 58 | + | .unwrap(); | |
| 59 | + | client | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn auth_response_json() -> serde_json::Value { | |
| 63 | + | let (user_id, app_id) = test_ids(); | |
| 64 | + | json!({ | |
| 65 | + | "token": fresh_token(), | |
| 66 | + | "user_id": user_id, | |
| 67 | + | "app_id": app_id, | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | fn device_json() -> serde_json::Value { | |
| 72 | + | let (user_id, app_id) = test_ids(); | |
| 73 | + | json!({ | |
| 74 | + | "id": Uuid::new_v4(), | |
| 75 | + | "app_id": app_id, | |
| 76 | + | "user_id": user_id, | |
| 77 | + | "device_name": "Test Device", | |
| 78 | + | "platform": "test", | |
| 79 | + | "last_seen_at": "2025-01-01T00:00:00Z", | |
| 80 | + | "created_at": "2025-01-01T00:00:00Z", | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // ── Auth flow ── | |
| 85 | + | ||
| 86 | + | #[tokio::test] | |
| 87 | + | async fn authenticate_success_stores_session() { | |
| 88 | + | let server = MockServer::start().await; | |
| 89 | + | ||
| 90 | + | Mock::given(method("POST")) | |
| 91 | + | .and(path("/api/sync/auth")) | |
| 92 | + | .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json())) | |
| 93 | + | .mount(&server) | |
| 94 | + | .await; | |
| 95 | + | ||
| 96 | + | let client = client_for(&server); | |
| 97 | + | let (user_id, app_id) = client | |
| 98 | + | .authenticate("user@test.com", "password") | |
| 99 | + | .await | |
| 100 | + | .unwrap(); | |
| 101 | + | ||
| 102 | + | let info = client.session_info().unwrap().expect("session stored"); | |
| 103 | + | assert_eq!(info.user_id, user_id); | |
| 104 | + | assert_eq!(info.app_id, app_id); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[tokio::test] | |
| 108 | + | async fn authenticate_wrong_password_no_retry() { | |
| 109 | + | let server = MockServer::start().await; | |
| 110 | + | ||
| 111 | + | Mock::given(method("POST")) | |
| 112 | + | .and(path("/api/sync/auth")) | |
| 113 | + | .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) | |
| 114 | + | .expect(1) // Must be called exactly once (no retry) | |
| 115 | + | .mount(&server) | |
| 116 | + | .await; | |
| 117 | + | ||
| 118 | + | let client = client_for(&server); | |
| 119 | + | let err = client | |
| 120 | + | .authenticate("user@test.com", "wrong") | |
| 121 | + | .await | |
| 122 | + | .unwrap_err(); | |
| 123 | + | ||
| 124 | + | assert!( | |
| 125 | + | matches!(err, SyncKitError::Server { status: 401, .. }), | |
| 126 | + | "Expected 401 error, got: {err:?}" | |
| 127 | + | ); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[tokio::test] | |
| 131 | + | async fn authenticate_retries_on_503() { | |
| 132 | + | let server = MockServer::start().await; | |
| 133 | + | ||
| 134 | + | Mock::given(method("POST")) | |
| 135 | + | .and(path("/api/sync/auth")) | |
| 136 | + | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 137 | + | .up_to_n_times(1) | |
| 138 | + | .mount(&server) | |
| 139 | + | .await; | |
| 140 | + | ||
| 141 | + | Mock::given(method("POST")) | |
| 142 | + | .and(path("/api/sync/auth")) | |
| 143 | + | .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json())) | |
| 144 | + | .mount(&server) | |
| 145 | + | .await; | |
| 146 | + | ||
| 147 | + | let client = client_for(&server); | |
| 148 | + | let result = client.authenticate("user@test.com", "password").await; | |
| 149 | + | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[tokio::test] | |
| 153 | + | async fn authenticate_with_code_success() { | |
| 154 | + | let server = MockServer::start().await; | |
| 155 | + | ||
| 156 | + | let (user_id, app_id) = test_ids(); | |
| 157 | + | Mock::given(method("POST")) | |
| 158 | + | .and(path("/oauth/token")) | |
| 159 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 160 | + | "access_token": fresh_token(), | |
| 161 | + | "token_type": "Bearer", | |
| 162 | + | "expires_in": 3600, | |
| 163 | + | "user_id": user_id, | |
| 164 | + | "app_id": app_id, | |
| 165 | + | }))) | |
| 166 | + | .mount(&server) | |
| 167 | + | .await; | |
| 168 | + | ||
| 169 | + | let client = client_for(&server); | |
| 170 | + | let (uid, aid) = client | |
| 171 | + | .authenticate_with_code("auth-code", "verifier", 8080) | |
| 172 | + | .await | |
| 173 | + | .unwrap(); | |
| 174 | + | ||
| 175 | + | assert_eq!(uid, user_id); | |
| 176 | + | assert_eq!(aid, app_id); | |
| 177 | + | assert!(client.session_info().unwrap().is_some()); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // ── Device management ── | |
| 181 | + | ||
| 182 | + | #[tokio::test] | |
| 183 | + | async fn register_device_success() { | |
| 184 | + | let server = MockServer::start().await; | |
| 185 | + | ||
| 186 | + | Mock::given(method("POST")) | |
| 187 | + | .and(path("/api/sync/devices")) | |
| 188 | + | .respond_with(ResponseTemplate::new(200).set_body_json(device_json())) | |
| 189 | + | .mount(&server) | |
| 190 | + | .await; | |
| 191 | + | ||
| 192 | + | let client = authed_client(&server); | |
| 193 | + | let device = client.register_device("MacBook", "macos").await.unwrap(); | |
| 194 | + | assert_eq!(device.device_name, "Test Device"); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[tokio::test] | |
| 198 | + | async fn register_device_retries_on_transient() { | |
| 199 | + | let server = MockServer::start().await; | |
| 200 | + | ||
| 201 | + | Mock::given(method("POST")) | |
| 202 | + | .and(path("/api/sync/devices")) | |
| 203 | + | .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) | |
| 204 | + | .up_to_n_times(1) | |
| 205 | + | .mount(&server) | |
| 206 | + | .await; | |
| 207 | + | ||
| 208 | + | Mock::given(method("POST")) | |
| 209 | + | .and(path("/api/sync/devices")) | |
| 210 | + | .respond_with(ResponseTemplate::new(200).set_body_json(device_json())) | |
| 211 | + | .mount(&server) | |
| 212 | + | .await; | |
| 213 | + | ||
| 214 | + | let client = authed_client(&server); | |
| 215 | + | let result = client.register_device("MacBook", "macos").await; | |
| 216 | + | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[tokio::test] | |
| 220 | + | async fn list_devices_success() { | |
| 221 | + | let server = MockServer::start().await; | |
| 222 | + | ||
| 223 | + | Mock::given(method("GET")) | |
| 224 | + | .and(path("/api/sync/devices")) | |
| 225 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!([device_json()]))) | |
| 226 | + | .mount(&server) | |
| 227 | + | .await; | |
| 228 | + | ||
| 229 | + | let client = authed_client(&server); | |
| 230 | + | let devices = client.list_devices().await.unwrap(); | |
| 231 | + | assert_eq!(devices.len(), 1); | |
| 232 | + | assert_eq!(devices[0].device_name, "Test Device"); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // ── Push / Pull with encryption ── | |
| 236 | + | ||
| 237 | + | #[tokio::test] | |
| 238 | + | async fn push_encrypts_data() { | |
| 239 | + | let server = MockServer::start().await; | |
| 240 | + | ||
| 241 | + | Mock::given(method("POST")) | |
| 242 | + | .and(path("/api/sync/push")) | |
| 243 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 244 | + | .mount(&server) | |
| 245 | + | .await; | |
| 246 | + | ||
| 247 | + | let client = authed_client(&server); | |
| 248 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 249 | + | client.set_master_key_raw(key).unwrap(); | |
| 250 | + | ||
| 251 | + | let device_id = Uuid::new_v4(); | |
| 252 | + | let cursor = client | |
| 253 | + | .push( | |
| 254 | + | device_id, | |
| 255 | + | vec![ChangeEntry { | |
| 256 | + | table: "tasks".into(), | |
| 257 | + | op: ChangeOp::Insert, | |
| 258 | + | row_id: "row-1".into(), | |
| 259 | + | timestamp: Utc::now(), | |
| 260 | + | data: Some(json!({"title": "Secret task"})), | |
| 261 | + | }], | |
| 262 | + | ) | |
| 263 | + | .await | |
| 264 | + | .unwrap(); | |
| 265 | + | ||
| 266 | + | assert_eq!(cursor, 1); | |
| 267 | + | ||
| 268 | + | // Verify the request body was sent with encrypted data (not plaintext) | |
| 269 | + | let requests = server.received_requests().await.unwrap(); | |
| 270 | + | let push_req = requests | |
| 271 | + | .iter() | |
| 272 | + | .find(|r| r.url.path() == "/api/sync/push") | |
| 273 | + | .unwrap(); | |
| 274 | + | let body: serde_json::Value = serde_json::from_slice(&push_req.body).unwrap(); | |
| 275 | + | let wire_data = body["changes"][0]["data"].as_str().unwrap(); | |
| 276 | + | assert!( | |
| 277 | + | !wire_data.contains("Secret task"), | |
| 278 | + | "Plaintext should not appear on the wire" | |
| 279 | + | ); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[tokio::test] | |
| 283 | + | async fn pull_decrypts_data() { | |
| 284 | + | let server = MockServer::start().await; | |
| 285 | + | ||
| 286 | + | let client = authed_client(&server); | |
| 287 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 288 | + | client.set_master_key_raw(key).unwrap(); | |
| 289 | + | ||
| 290 | + | // Encrypt a value to simulate what the server would return | |
| 291 | + | let plaintext = json!({"title": "Decrypted task"}); | |
| 292 | + | let encrypted = synckit_client::crypto::encrypt_json(&plaintext, &key).unwrap(); | |
| 293 | + | ||
| 294 | + | let device_id = Uuid::new_v4(); | |
| 295 | + | Mock::given(method("POST")) | |
| 296 | + | .and(path("/api/sync/pull")) | |
| 297 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 298 | + | "changes": [{ | |
| 299 | + | "seq": 1, | |
| 300 | + | "device_id": device_id, | |
| 301 | + | "table": "tasks", | |
| 302 | + | "op": "INSERT", | |
| 303 | + | "row_id": "row-1", | |
| 304 | + | "timestamp": "2025-06-01T12:00:00Z", | |
| 305 | + | "data": encrypted, | |
| 306 | + | }], | |
| 307 | + | "cursor": 1, | |
| 308 | + | "has_more": false, | |
| 309 | + | }))) | |
| 310 | + | .mount(&server) | |
| 311 | + | .await; | |
| 312 | + | ||
| 313 | + | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| 314 | + | assert_eq!(changes.len(), 1); | |
| 315 | + | assert_eq!(cursor, 1); | |
| 316 | + | assert!(!has_more); | |
| 317 | + | assert_eq!(changes[0].data.as_ref().unwrap(), &plaintext); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[tokio::test] | |
| 321 | + | async fn push_retries_on_503() { | |
| 322 | + | let server = MockServer::start().await; | |
| 323 | + | ||
| 324 | + | Mock::given(method("POST")) | |
| 325 | + | .and(path("/api/sync/push")) | |
| 326 | + | .respond_with(ResponseTemplate::new(503)) | |
| 327 | + | .up_to_n_times(1) | |
| 328 | + | .mount(&server) | |
| 329 | + | .await; | |
| 330 | + | ||
| 331 | + | Mock::given(method("POST")) | |
| 332 | + | .and(path("/api/sync/push")) | |
| 333 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 5}))) | |
| 334 | + | .mount(&server) | |
| 335 | + | .await; | |
| 336 | + | ||
| 337 | + | let client = authed_client(&server); | |
| 338 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 339 | + | client.set_master_key_raw(key).unwrap(); | |
| 340 | + | ||
| 341 | + | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 342 | + | assert_eq!(cursor, 5); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[tokio::test] | |
| 346 | + | async fn push_fails_immediately_on_401() { | |
| 347 | + | let server = MockServer::start().await; | |
| 348 | + | ||
| 349 | + | Mock::given(method("POST")) | |
| 350 | + | .and(path("/api/sync/push")) | |
| 351 | + | .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) | |
| 352 | + | .expect(1) | |
| 353 | + | .mount(&server) | |
| 354 | + | .await; | |
| 355 | + | ||
| 356 | + | let client = authed_client(&server); | |
| 357 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 358 | + | client.set_master_key_raw(key).unwrap(); | |
| 359 | + | ||
| 360 | + | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 361 | + | assert!(matches!(err, SyncKitError::Server { status: 401, .. })); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[tokio::test] | |
| 365 | + | async fn pull_with_has_more_pagination() { | |
| 366 | + | let server = MockServer::start().await; | |
| 367 | + | ||
| 368 | + | let client = authed_client(&server); | |
| 369 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 370 | + | client.set_master_key_raw(key).unwrap(); | |
| 371 | + | ||
| 372 | + | let device_id = Uuid::new_v4(); | |
| 373 | + | ||
| 374 | + | // First pull: has_more = true | |
| 375 | + | Mock::given(method("POST")) | |
| 376 | + | .and(path("/api/sync/pull")) | |
| 377 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 378 | + | "changes": [], | |
| 379 | + | "cursor": 50, | |
| 380 | + | "has_more": true, | |
| 381 | + | }))) | |
| 382 | + | .up_to_n_times(1) | |
| 383 | + | .mount(&server) | |
| 384 | + | .await; | |
| 385 | + | ||
| 386 | + | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| 387 | + | assert!(changes.is_empty()); | |
| 388 | + | assert_eq!(cursor, 50); | |
| 389 | + | assert!(has_more); | |
| 390 | + | ||
| 391 | + | // Second pull from cursor 50: has_more = false | |
| 392 | + | Mock::given(method("POST")) | |
| 393 | + | .and(path("/api/sync/pull")) | |
| 394 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 395 | + | "changes": [], | |
| 396 | + | "cursor": 100, | |
| 397 | + | "has_more": false, | |
| 398 | + | }))) | |
| 399 | + | .mount(&server) | |
| 400 | + | .await; | |
| 401 | + | ||
| 402 | + | let (_, cursor2, has_more2) = client.pull(device_id, 50).await.unwrap(); | |
| 403 | + | assert_eq!(cursor2, 100); | |
| 404 | + | assert!(!has_more2); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // ── Blob operations ── | |
| 408 | + | ||
| 409 | + | #[tokio::test] | |
| 410 | + | async fn blob_upload_url_success() { | |
| 411 | + | let server = MockServer::start().await; | |
| 412 | + | ||
| 413 | + | Mock::given(method("POST")) | |
| 414 | + | .and(path("/api/sync/blobs/upload")) | |
| 415 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 416 | + | "upload_url": "https://s3.example.com/put", | |
| 417 | + | "already_exists": false, | |
| 418 | + | }))) | |
| 419 | + | .mount(&server) | |
| 420 | + | .await; | |
| 421 | + | ||
| 422 | + | let client = authed_client(&server); | |
| 423 | + | let resp = client.blob_upload_url("sha256-abc", 1024).await.unwrap(); | |
| 424 | + | assert_eq!(resp.upload_url, "https://s3.example.com/put"); | |
| 425 | + | assert!(!resp.already_exists); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[tokio::test] | |
| 429 | + | async fn blob_upload_encrypts_data() { | |
| 430 | + | let server = MockServer::start().await; | |
| 431 | + | ||
| 432 | + | let upload_path = "/s3/upload"; | |
| 433 | + | Mock::given(method("PUT")) | |
| 434 | + | .and(path(upload_path)) | |
| 435 | + | .respond_with(ResponseTemplate::new(200)) | |
| 436 | + | .mount(&server) | |
| 437 | + | .await; | |
| 438 | + | ||
| 439 | + | let client = authed_client(&server); | |
| 440 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 441 | + | client.set_master_key_raw(key).unwrap(); | |
| 442 | + | ||
| 443 | + | let plaintext = b"hello blob data"; | |
| 444 | + | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 445 | + | client | |
| 446 | + | .blob_upload(&presigned, plaintext.to_vec()) | |
| 447 | + | .await | |
| 448 | + | .unwrap(); | |
| 449 | + | ||
| 450 | + | // Verify uploaded body is encrypted (not plaintext) | |
| 451 | + | let requests = server.received_requests().await.unwrap(); | |
| 452 | + | let upload_req = requests | |
| 453 | + | .iter() | |
| 454 | + | .find(|r| r.url.path() == upload_path) | |
| 455 | + | .unwrap(); | |
| 456 | + | assert!( | |
| 457 | + | !upload_req | |
| 458 | + | .body | |
| 459 | + | .windows(plaintext.len()) | |
| 460 | + | .any(|w| w == plaintext), | |
| 461 | + | "Plaintext should not appear in uploaded body" | |
| 462 | + | ); | |
| 463 | + | // Encrypted blob should be larger due to nonce + tag overhead | |
| 464 | + | assert!(upload_req.body.len() > plaintext.len()); | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | #[tokio::test] | |
| 468 | + | async fn blob_download_decrypts_data() { | |
| 469 | + | let server = MockServer::start().await; | |
| 470 | + | ||
| 471 | + | let client = authed_client(&server); | |
| 472 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 473 | + | client.set_master_key_raw(key).unwrap(); | |
| 474 | + | ||
| 475 | + | // Encrypt data to simulate what S3 would return | |
| 476 | + | let plaintext = b"decrypted blob content"; | |
| 477 | + | let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); | |
| 478 | + | ||
| 479 | + | let download_path = "/s3/download"; | |
| 480 | + | Mock::given(method("GET")) | |
| 481 | + | .and(path(download_path)) | |
| 482 | + | .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted)) | |
| 483 | + | .mount(&server) | |
| 484 | + | .await; | |
| 485 | + | ||
| 486 | + | let presigned = format!("{}{}", server.uri(), download_path); | |
| 487 | + | let result = client.blob_download(&presigned).await.unwrap(); | |
| 488 | + | assert_eq!(result, plaintext); | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | #[tokio::test] | |
| 492 | + | async fn blob_upload_retries_on_503() { | |
| 493 | + | let server = MockServer::start().await; | |
| 494 | + | ||
| 495 | + | let upload_path = "/s3/retry-upload"; | |
| 496 | + | Mock::given(method("PUT")) | |
| 497 | + | .and(path(upload_path)) | |
| 498 | + | .respond_with(ResponseTemplate::new(503)) | |
| 499 | + | .up_to_n_times(1) | |
| 500 | + | .mount(&server) |
Lines truncated