Skip to main content

max / synckit-client

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