Skip to main content

max / synckit-client

Audit remediation: testing push, perf upgrade, adversarial fixes, observability Multi-round audit improvements: - Performance A: pre-built endpoint URLs (Endpoints struct), Arc<String> session token (O(1) clone), cached JWT expiry, Bytes for retry bodies, batch key extract - Adversarial fixes: change_password bypass (always verify old password against server envelope), Unicode password normalization (NFC), empty password rejection, password length limits (1024 bytes), comprehensive tamper detection tests - Testing A+: 297 tests (197 unit + 99 integration + 1 doctest). Full coverage of types.rs, error.rs, client.rs constructors, retry exhaustion, malformed responses, session edge cases, blob roundtrips, concurrency stress, timeout handling - Observability A: 16 #[instrument] annotations on all pub async methods - Concurrency A: parking_lot RwLock replacing std::sync, poison-free locking - Security: blob E2E encryption, random Argon2 salt, ZeroizeOnDrop, pub(crate) visibility - README and integration test suite added Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:35 UTC
Commit: 37a725376d59fac19314f4ee2f3c67538fbabd7d
Parent: 1229974
9 files changed, +1766 insertions, -140 deletions
M Cargo.lock +243 -1
@@ -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"
M Cargo.toml +11
@@ -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"
A README.md +78
@@ -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
M src/client.rs +236 -93
@@ -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
M src/crypto.rs +370 -39
@@ -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
M src/error.rs +135
@@ -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 + }
M src/keystore.rs +16 -2
@@ -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 }
M src/types.rs +177 -5
@@ -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