Skip to main content

max / synckit-client

Split client.rs into client/ directory module (v0.2.2) Break 2362-line client.rs into client/{mod,auth,encryption,sync,blob,helpers}.rs. Each method group lives in its own file with co-located tests. Same public API, zero behavior changes. Also includes crypto improvements from prior work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 02:07 UTC
Commit: 7f689125be8642798ad7f57c296020148931fa25
Parent: 37a7253
10 files changed, +2262 insertions, -542 deletions
M Cargo.lock +5 -16
@@ -1391,17 +1391,6 @@ dependencies = [
1391 1391 ]
1392 1392
1393 1393 [[package]]
1394 - name = "sha2"
1395 - version = "0.10.9"
1396 - source = "registry+https://github.com/rust-lang/crates.io-index"
1397 - checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1398 - dependencies = [
1399 - "cfg-if",
1400 - "cpufeatures",
1401 - "digest",
1402 - ]
1403 -
1404 - [[package]]
1405 1394 name = "shlex"
1406 1395 version = "1.3.0"
1407 1396 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1476,7 +1465,6 @@ dependencies = [
1476 1465 "reqwest",
1477 1466 "serde",
1478 1467 "serde_json",
1479 - "sha2",
1480 1468 "thiserror",
1481 1469 "tokio",
1482 1470 "tracing",
@@ -1484,6 +1472,7 @@ dependencies = [
1484 1472 "urlencoding",
1485 1473 "uuid",
1486 1474 "wiremock",
1475 + "zeroize",
1487 1476 ]
1488 1477
1489 1478 [[package]]
@@ -1533,18 +1522,18 @@ dependencies = [
1533 1522
1534 1523 [[package]]
1535 1524 name = "thiserror"
1536 - version = "1.0.69"
1525 + version = "2.0.18"
1537 1526 source = "registry+https://github.com/rust-lang/crates.io-index"
1538 - checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1527 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1539 1528 dependencies = [
1540 1529 "thiserror-impl",
1541 1530 ]
1542 1531
1543 1532 [[package]]
1544 1533 name = "thiserror-impl"
1545 - version = "1.0.69"
1534 + version = "2.0.18"
1546 1535 source = "registry+https://github.com/rust-lang/crates.io-index"
1547 - checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1536 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1548 1537 dependencies = [
1549 1538 "proc-macro2",
1550 1539 "quote",
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.2.1"
3 + version = "0.2.2"
4 4 edition = "2021"
5 5 description = "SyncKit client SDK with end-to-end encryption"
6 6 license-file = "LICENSE"
@@ -13,9 +13,9 @@ keychain = ["dep:keyring"]
13 13 # Encryption
14 14 chacha20poly1305 = "0.10"
15 15 argon2 = "0.5"
16 - sha2 = "0.10"
17 16 rand = "0.8"
18 17 base64 = "0.22"
18 + zeroize = "1"
19 19
20 20 # HTTP
21 21 reqwest = { version = "0.12", features = ["json", "native-tls"] }
@@ -41,7 +41,7 @@ unicode-normalization = "0.1"
41 41 parking_lot = "0.12"
42 42
43 43 # Error handling & logging
44 - thiserror = "1"
44 + thiserror = "2"
45 45 tracing = "0.1"
46 46
47 47 [dev-dependencies]
D src/client.rs -500
@@ -1,2365 +0,0 @@
1 - //! HTTP transport and high-level API with transparent end-to-end encryption.
2 - //!
3 - //! This module provides [`SyncKitClient`], the primary interface to the MNW
4 - //! SyncKit server. All encryption and decryption happens transparently inside
5 - //! the client -- callers work with plaintext [`ChangeEntry`] values and never
6 - //! handle ciphertext directly.
7 - //!
8 - //! ## Method groups
9 - //!
10 - //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password),
11 - //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE),
12 - //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session).
13 - //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device),
14 - //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices),
15 - //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain),
16 - //! [`change_password`](SyncKitClient::change_password).
17 - //! - **Device management**: [`register_device`](SyncKitClient::register_device),
18 - //! [`list_devices`](SyncKitClient::list_devices).
19 - //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull),
20 - //! [`status`](SyncKitClient::status).
21 - //! - **Blob storage**: [`blob_upload_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;
54 - use reqwest::Client;
55 - use std::sync::Arc;
56 - use std::time::Duration;
57 - use tracing::instrument;
58 - use uuid::Uuid;
59 -
60 - use base64::Engine;
61 -
62 - use crate::{
63 - crypto,
64 - error::{Result, SyncKitError},
65 - keystore,
66 - types::*,
67 - };
68 -
69 - /// Maximum number of retry attempts for transient failures.
70 - const MAX_RETRIES: u32 = 3;
71 -
72 - /// Base delay for exponential backoff (1s, 2s, 4s).
73 - const BASE_DELAY: Duration = Duration::from_secs(1);
74 -
75 - /// Seconds before actual expiry to consider the token expired.
76 - /// Avoids sending a request with a token that expires mid-flight.
77 - const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
78 -
79 - /// Configuration for the SyncKit client.
80 - #[derive(Debug, Clone)]
81 - pub struct SyncKitConfig {
82 - /// Base URL of the MNW server (e.g. "https://makenot.work").
83 - pub server_url: String,
84 - /// App API key (obtained from MNW dashboard).
85 - pub api_key: String,
86 - }
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 -
119 - /// Session state obtained after authentication.
120 - struct Session {
121 - token: Arc<String>,
122 - /// Cached `exp` claim from the JWT, extracted once at session creation.
123 - token_exp: Option<i64>,
124 - user_id: Uuid,
125 - app_id: Uuid,
126 - }
127 -
128 - /// Public session info returned by `session_info()`.
129 - pub struct SessionInfo {
130 - /// The JWT bearer token for API requests.
131 - pub token: String,
132 - /// The authenticated user's UUID.
133 - pub user_id: Uuid,
134 - /// The SyncKit app UUID this session belongs to.
135 - pub app_id: Uuid,
136 - }
137 -
138 - /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
139 - pub struct SyncKitClient {
140 - config: SyncKitConfig,
141 - http: Client,
142 - endpoints: Endpoints,
143 - session: RwLock<Option<Session>>,
144 - master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
145 - }
146 -
147 - impl SyncKitClient {
148 - /// Create a new client with the given configuration.
149 - pub fn new(config: SyncKitConfig) -> Self {
150 - let http = Client::builder()
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))
155 - .build()
156 - .expect("failed to build HTTP client");
157 -
158 - let endpoints = Endpoints::new(&config.server_url);
159 - Self {
160 - config,
161 - http,
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),
178 - }
179 - }
180 -
181 - // ── Auth ──
182 -
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))]
189 - pub async fn authenticate(
190 - &self,
191 - email: &str,
192 - password: &str,
193 - ) -> Result<(Uuid, Uuid)> {
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 - })?);
199 -
200 - let resp = self
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 }
208 - })
209 - .await?;
210 - let auth: AuthResponse = resp.json().await?;
211 -
212 - let user_id = auth.user_id;
213 - let app_id = auth.app_id;
214 - let token_exp = jwt_exp(&auth.token);
215 -
216 - *self.session.write() = Some(Session {
217 - token: Arc::new(auth.token),
218 - token_exp,
219 - user_id,
220 - app_id,
221 - });
222 -
223 - tracing::info!("Authenticated as user {user_id} for app {app_id}");
224 - Ok((user_id, app_id))
225 - }
226 -
227 - /// Returns the client configuration.
228 - pub fn config(&self) -> &SyncKitConfig {
229 - &self.config
230 - }
231 -
232 - /// Returns whether the master encryption key is loaded and ready.
233 - pub fn has_master_key(&self) -> Result<bool> {
234 - Ok(self.master_key.read().is_some())
235 - }
236 -
237 - /// Returns the current session info, if authenticated.
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(),
242 - user_id: s.user_id,
243 - app_id: s.app_id,
244 - }))
245 - }
246 -
247 - /// Restore a session from previously stored credentials (e.g. OS keychain).
248 - ///
249 - /// Sets the internal session state without making any HTTP calls.
250 - /// Used on app startup to restore from stored credentials without re-authenticating.
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,
256 - user_id,
257 - app_id,
258 - });
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(())
273 - }
274 -
275 - /// Check whether the current session token has expired (or will expire
276 - /// within a 30-second buffer). Returns `true` if there is no session or
277 - /// if the token's `exp` claim is in the past. Returns `false` if the
278 - /// token cannot be decoded (assumes not expired — the server will reject
279 - /// it with a 401 if it actually is).
280 - pub fn is_token_expired(&self) -> Result<bool> {
281 - let guard = self.session.read();
282 - let Some(session) = guard.as_ref() else {
283 - return Ok(true);
284 - };
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 - }
292 - }
293 -
294 - // ── OAuth ──
295 -
296 - /// Build the authorization URL for the OAuth2 PKCE flow.
297 - ///
298 - /// The caller is responsible for generating the PKCE verifier/challenge,
299 - /// starting the localhost callback server, and opening the browser.
300 - pub fn build_authorize_url(
301 - &self,
302 - redirect_port: u16,
303 - state: &str,
304 - code_challenge: &str,
305 - ) -> String {
306 - format!(
307 - "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
308 - self.config.server_url,
309 - urlencoding::encode(&self.config.api_key),
310 - urlencoding::encode(&format!("http://127.0.0.1:{}/", redirect_port)),
311 - urlencoding::encode(state),
312 - urlencoding::encode(code_challenge),
313 - )
314 - }
315 -
316 - /// Exchange an OAuth2 authorization code for a SyncKit JWT.
317 - ///
318 - /// Call this after receiving the code from the localhost callback server.
319 - /// On success, stores the session internally (same as `authenticate()`).
320 - #[instrument(skip(self, code, code_verifier))]
321 - pub async fn authenticate_with_code(
322 - &self,
323 - code: &str,
324 - code_verifier: &str,
325 - redirect_port: u16,
326 - ) -> Result<(Uuid, Uuid)> {
327 - let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port);
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 -
337 - let resp = self
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 }
345 - })
346 - .await?;
347 - let token_resp: OAuthTokenResponse = resp.json().await?;
348 -
349 - let user_id = token_resp.user_id;
350 - let app_id = token_resp.app_id;
351 - let token_exp = jwt_exp(&token_resp.access_token);
352 -
353 - *self.session.write() = Some(Session {
354 - token: Arc::new(token_resp.access_token),
355 - token_exp,
356 - user_id,
357 - app_id,
358 - });
359 -
360 - tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
361 - Ok((user_id, app_id))
362 - }
363 -
364 - // ── Encryption setup ──
365 -
366 - /// Check if the server has an encrypted master key for this user.
367 - #[instrument(skip(self))]
368 - pub async fn has_server_key(&self) -> Result<bool> {
369 - let (url, token) = self.key_url_and_token()?;
370 -
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 - })
385 - .await?;
386 -
387 - Ok(result.status().as_u16() == 200)
388 - }
389 -
390 - /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
391 - #[instrument(skip(self, password))]
392 - pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
393 - let (app_id, user_id) = self.require_session_ids()?;
394 -
395 - let master_key = crypto::generate_master_key();
396 - let envelope = crypto::wrap_master_key(&master_key, password)?;
397 -
398 - // Push to server
399 - self.put_server_key(&envelope).await?;
400 -
401 - // Cache in OS keychain
402 - keystore::store_key(app_id, user_id, &master_key)?;
403 -
404 - // Store in memory
405 - *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key));
406 -
407 - tracing::info!("New master key generated and stored");
408 - Ok(())
409 - }
410 -
411 - /// Second device: pull encrypted master key from server, decrypt with password, cache.
412 - #[instrument(skip(self, password))]
413 - pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
414 - let (app_id, user_id) = self.require_session_ids()?;
415 -
416 - let envelope_json = self.get_server_key().await?;
417 - let master_key = crypto::unwrap_master_key(&envelope_json, password)?;
418 -
419 - // Cache in OS keychain
420 - keystore::store_key(app_id, user_id, &master_key)?;
421 -
422 - // Store in memory
423 - *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key));
424 -
425 - tracing::info!("Master key recovered from server");
426 - Ok(())
427 - }
428 -
429 - /// Subsequent launches: try to load the master key from the OS keychain.
430 - /// Returns true if a key was found.
431 - pub fn try_load_key_from_keychain(&self) -> Result<bool> {
432 - let (app_id, user_id) = self.require_session_ids()?;
433 -
434 - if let Some(key) = keystore::load_key(app_id, user_id)? {
435 - *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
436 - tracing::info!("Master key loaded from keychain");
437 - Ok(true)
438 - } else {
439 - Ok(false)
440 - }
441 - }
442 -
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))]
451 - pub async fn change_password(
452 - &self,
453 - old_password: &str,
454 - new_password: &str,
455 - ) -> Result<()> {
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 - )?;
463 -
464 - let master_key = crypto::ZeroizeOnDrop(verified_key);
465 -
466 - // Re-wrap with new password (generates fresh random salt)
467 - let new_envelope = crypto::wrap_master_key(&master_key, new_password)?;
468 -
469 - self.put_server_key(&new_envelope).await?;
470 -
471 - tracing::info!("Encryption password changed");
472 - Ok(())
473 - }
474 -
475 - // ── Devices ──
476 -
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
481 - /// `last_seen_at` rather than creating a duplicate.
482 - #[instrument(skip(self))]
483 - pub async fn register_device(
484 - &self,
485 - device_name: &str,
486 - platform: &str,
487 - ) -> Result<Device> {
488 - let token = self.require_token()?;
489 -
490 - let body = Bytes::from(serde_json::to_vec(&RegisterDeviceRequest {
491 - device_name: device_name.to_string(),
492 - platform: platform.to_string(),
493 - })?);
494 -
495 - let resp = self
496 - .retry_request(|| {
497 - let req = self
498 - .http
499 - .post(&self.endpoints.devices)
500 - .bearer_auth(&token)
Lines truncated
@@ -0,0 +1,424 @@
1 + use bytes::Bytes;
2 + use std::sync::Arc;
3 + use tracing::instrument;
4 + use uuid::Uuid;
5 +
6 + use crate::{
7 + error::Result,
8 + types::*,
9 + };
10 +
11 + use super::{Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS};
12 + use super::helpers::{check_response, jwt_exp};
13 +
14 + impl SyncKitClient {
15 + /// Authenticate with the MNW server. Returns (user_id, app_id).
16 + ///
17 + /// # Errors
18 + ///
19 + /// Returns `Server { status: 401, .. }` for wrong credentials.
20 + #[instrument(skip(self, email, password))]
21 + pub async fn authenticate(
22 + &self,
23 + email: &str,
24 + password: &str,
25 + ) -> Result<(Uuid, Uuid)> {
26 + let body = Bytes::from(serde_json::to_vec(&AuthRequest {
27 + email: email.to_string(),
28 + password: password.to_string(),
29 + api_key: self.config.api_key.clone(),
30 + })?);
31 +
32 + let resp = self
33 + .retry_request(|| {
34 + let req = self
35 + .http
36 + .post(&self.endpoints.auth)
37 + .header("content-type", "application/json")
38 + .body(body.clone());
39 + async move { check_response(req.send().await?).await }
40 + })
41 + .await?;
42 + let auth: AuthResponse = resp.json().await?;
43 +
44 + let user_id = auth.user_id;
45 + let app_id = auth.app_id;
46 + let token_exp = jwt_exp(&auth.token);
47 +
48 + *self.session.write() = Some(Session {
49 + token: Arc::new(auth.token),
50 + token_exp,
51 + user_id,
52 + app_id,
53 + });
54 +
55 + tracing::info!("Authenticated as user {user_id} for app {app_id}");
56 + Ok((user_id, app_id))
57 + }
58 +
59 + /// Restore a session from previously stored credentials (e.g. OS keychain).
60 + ///
61 + /// Sets the internal session state without making any HTTP calls.
62 + /// Used on app startup to restore from stored credentials without re-authenticating.
63 + pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) -> Result<()> {
64 + let token_exp = jwt_exp(token);
65 + *self.session.write() = Some(Session {
66 + token: Arc::new(token.to_string()),
67 + token_exp,
68 + user_id,
69 + app_id,
70 + });
71 + tracing::info!("Session restored for user {user_id}, app {app_id}");
72 + Ok(())
73 + }
74 +
75 + /// Clear the in-memory session and master key.
76 + ///
77 + /// After calling this, the client will need to re-authenticate and set up
78 + /// encryption again. Does not affect OS keychain storage — call
79 + /// `keystore::delete_master_key` separately if needed.
80 + pub fn clear_session(&self) -> Result<()> {
81 + *self.session.write() = None;
82 + *self.master_key.write() = None;
83 + tracing::info!("Session and master key cleared");
84 + Ok(())
85 + }
86 +
87 + /// Check whether the current session token has expired (or will expire
88 + /// within a 30-second buffer). Returns `true` if there is no session or
89 + /// if the token's `exp` claim is in the past. Returns `false` if the
90 + /// token cannot be decoded (assumes not expired — the server will reject
91 + /// it with a 401 if it actually is).
92 + pub fn is_token_expired(&self) -> Result<bool> {
93 + let guard = self.session.read();
94 + let Some(session) = guard.as_ref() else {
95 + return Ok(true);
96 + };
97 + match session.token_exp {
98 + Some(exp) => {
99 + let now = chrono::Utc::now().timestamp();
100 + Ok(now >= exp - TOKEN_EXPIRY_BUFFER_SECS)
101 + }
102 + None => Ok(false),
103 + }
104 + }
105 +
106 + // ── OAuth ──
107 +
108 + /// Build the authorization URL for the OAuth2 PKCE flow.
109 + ///
110 + /// The caller is responsible for generating the PKCE verifier/challenge,
111 + /// starting the localhost callback server, and opening the browser.
112 + pub fn build_authorize_url(
113 + &self,
114 + redirect_port: u16,
115 + state: &str,
116 + code_challenge: &str,
117 + ) -> String {
118 + format!(
119 + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
120 + self.config.server_url,
121 + urlencoding::encode(&self.config.api_key),
122 + urlencoding::encode(&format!("http://127.0.0.1:{}/", redirect_port)),
123 + urlencoding::encode(state),
124 + urlencoding::encode(code_challenge),
125 + )
126 + }
127 +
128 + /// Exchange an OAuth2 authorization code for a SyncKit JWT.
129 + ///
130 + /// Call this after receiving the code from the localhost callback server.
131 + /// On success, stores the session internally (same as `authenticate()`).
132 + #[instrument(skip(self, code, code_verifier))]
133 + pub async fn authenticate_with_code(
134 + &self,
135 + code: &str,
136 + code_verifier: &str,
137 + redirect_port: u16,
138 + ) -> Result<(Uuid, Uuid)> {
139 + let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port);
140 +
141 + let body = Bytes::from(serde_json::to_vec(&OAuthTokenRequest {
142 + grant_type: "authorization_code".to_string(),
143 + code: code.to_string(),
144 + redirect_uri,
145 + code_verifier: code_verifier.to_string(),
146 + client_id: self.config.api_key.clone(),
147 + })?);
148 +
149 + let resp = self
150 + .retry_request(|| {
151 + let req = self
152 + .http
153 + .post(&self.endpoints.oauth_token)
154 + .header("content-type", "application/json")
155 + .body(body.clone());
156 + async move { check_response(req.send().await?).await }
157 + })
158 + .await?;
159 + let token_resp: OAuthTokenResponse = resp.json().await?;
160 +
161 + let user_id = token_resp.user_id;
162 + let app_id = token_resp.app_id;
163 + let token_exp = jwt_exp(&token_resp.access_token);
164 +
165 + *self.session.write() = Some(Session {
166 + token: Arc::new(token_resp.access_token),
167 + token_exp,
168 + user_id,
169 + app_id,
170 + });
171 +
172 + tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
173 + Ok((user_id, app_id))
174 + }
175 + }
176 +
177 + #[cfg(test)]
178 + mod tests {
179 + use super::*;
180 + use base64::Engine;
181 + use chrono::Utc;
182 +
183 + use crate::error::SyncKitError;
184 +
185 + fn test_config() -> super::super::SyncKitConfig {
186 + super::super::SyncKitConfig {
187 + server_url: "https://example.com".to_string(),
188 + api_key: "test-api-key-123".to_string(),
189 + }
190 + }
191 +
192 + fn test_ids() -> (Uuid, Uuid) {
193 + (
194 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
195 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
196 + )
197 + }
198 +
199 + fn fake_jwt(exp: i64) -> String {
200 + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
201 + .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
202 + let payload_json = serde_json::json!({
203 + "sub": "550e8400-e29b-41d4-a716-446655440000",
204 + "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
205 + "exp": exp,
206 + "iat": exp - 3600,
207 + });
208 + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
209 + .encode(payload_json.to_string().as_bytes());
210 + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
211 + .encode(b"fake-signature");
212 + format!("{header}.{payload}.{signature}")
213 + }
214 +
215 + // ── restore_session ──
216 +
217 + #[test]
218 + fn restore_session_makes_client_authenticated() {
219 + let client = SyncKitClient::new(test_config());
220 + let (app_id, user_id) = test_ids();
221 +
222 + client.restore_session("fake-token", user_id, app_id).unwrap();
223 +
224 + let info = client.session_info().unwrap().expect("session should exist");
225 + assert_eq!(info.token, "fake-token");
226 + assert_eq!(info.user_id, user_id);
227 + assert_eq!(info.app_id, app_id);
228 + }
229 +
230 + #[test]
231 + fn restore_session_overwrites_previous_session() {
232 + let client = SyncKitClient::new(test_config());
233 + let (app_id, user_id) = test_ids();
234 +
235 + client.restore_session("first-token", user_id, app_id).unwrap();
236 + client.restore_session("second-token", user_id, app_id).unwrap();
237 +
238 + let info = client.session_info().unwrap().unwrap();
239 + assert_eq!(info.token, "second-token");
240 + }
241 +
242 + // ── build_authorize_url ──
243 +
244 + #[test]
245 + fn build_authorize_url_includes_all_params() {
246 + let client = SyncKitClient::new(test_config());
247 + let url = client.build_authorize_url(8080, "random-state", "challenge123");
248 +
249 + assert!(url.starts_with("https://example.com/oauth/authorize?"));
250 + assert!(url.contains("response_type=code"));
251 + assert!(url.contains("client_id=test-api-key-123"));
252 + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F"));
253 + assert!(url.contains("state=random-state"));
254 + assert!(url.contains("code_challenge=challenge123"));
255 + assert!(url.contains("code_challenge_method=S256"));
256 + }
257 +
258 + #[test]
259 + fn build_authorize_url_encodes_special_chars() {
260 + let config = super::super::SyncKitConfig {
261 + server_url: "https://example.com".to_string(),
262 + api_key: "key with spaces&special=chars".to_string(),
263 + };
264 + let client = SyncKitClient::new(config);
265 + let url = client.build_authorize_url(9090, "state/with/slashes", "ch+all=enge");
266 +
267 + assert!(url.contains("key%20with%20spaces%26special%3Dchars"));
268 + assert!(url.contains("state%2Fwith%2Fslashes"));
269 + assert!(url.contains("ch%2Ball%3Denge"));
270 + }
271 +
272 + #[test]
273 + fn build_authorize_url_different_ports() {
274 + let client = SyncKitClient::new(test_config());
275 +
276 + let url_low = client.build_authorize_url(1234, "s", "c");
277 + assert!(url_low.contains("127.0.0.1%3A1234"));
278 +
279 + let url_high = client.build_authorize_url(65535, "s", "c");
280 + assert!(url_high.contains("127.0.0.1%3A65535"));
281 + }
282 +
283 + // ── is_token_expired ──
284 +
285 + #[test]
286 + fn is_token_expired_true_without_session() {
287 + let client = SyncKitClient::new(test_config());
288 + assert!(client.is_token_expired().unwrap());
289 + }
290 +
291 + #[test]
292 + fn is_token_expired_true_with_expired_token() {
293 + let client = SyncKitClient::new(test_config());
294 + let (app_id, user_id) = test_ids();
295 + let token = fake_jwt(Utc::now().timestamp() - 3600);
296 + client.restore_session(&token, user_id, app_id).unwrap();
297 + assert!(client.is_token_expired().unwrap());
298 + }
299 +
300 + #[test]
301 + fn is_token_expired_false_with_fresh_token() {
302 + let client = SyncKitClient::new(test_config());
303 + let (app_id, user_id) = test_ids();
304 + let token = fake_jwt(Utc::now().timestamp() + 3600);
305 + client.restore_session(&token, user_id, app_id).unwrap();
306 + assert!(!client.is_token_expired().unwrap());
307 + }
308 +
309 + // ── require_token with expiry ──
310 +
311 + #[test]
312 + fn require_token_returns_token_expired_for_expired_token() {
313 + let client = SyncKitClient::new(test_config());
314 + let (app_id, user_id) = test_ids();
315 + let token = fake_jwt(Utc::now().timestamp() - 3600);
316 + client.restore_session(&token, user_id, app_id).unwrap();
317 +
318 + let err = client.require_token().unwrap_err();
319 + assert!(matches!(err, SyncKitError::TokenExpired));
320 + }
321 +
322 + #[test]
323 + fn require_token_succeeds_with_fresh_token() {
324 + let client = SyncKitClient::new(test_config());
325 + let (app_id, user_id) = test_ids();
326 + let token = fake_jwt(Utc::now().timestamp() + 3600);
327 + client.restore_session(&token, user_id, app_id).unwrap();
328 +
329 + assert!(client.require_token().is_ok());
330 + }
331 +
332 + // ── clear_session ──
333 +
334 + #[test]
335 + fn clear_session_clears_master_key() {
336 + let client = SyncKitClient::new(test_config());
337 + let (app_id, user_id) = test_ids();
338 + client.restore_session("token", user_id, app_id).unwrap();
339 + client.set_master_key_raw([42u8; 32]).unwrap();
340 +
341 + assert!(client.session_info().unwrap().is_some());
342 + assert!(client.has_master_key().unwrap());
343 +
344 + client.clear_session().unwrap();
345 +
346 + assert!(client.session_info().unwrap().is_none());
347 + assert!(!client.has_master_key().unwrap());
348 + }
349 +
350 + // ── OAuth types ──
351 +
352 + #[test]
353 + fn oauth_token_request_serialization() {
354 + let req = OAuthTokenRequest {
355 + grant_type: "authorization_code".to_string(),
356 + code: "auth-code-123".to_string(),
357 + redirect_uri: "http://127.0.0.1:8080/".to_string(),
358 + code_verifier: "verifier-abc".to_string(),
359 + client_id: "api-key".to_string(),
360 + };
361 +
362 + let json = serde_json::to_string(&req).unwrap();
363 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
364 + assert_eq!(parsed["grant_type"], "authorization_code");
365 + assert_eq!(parsed["code"], "auth-code-123");
366 + assert_eq!(parsed["code_verifier"], "verifier-abc");
367 + }
368 +
369 + #[test]
370 + fn oauth_token_response_deserialization() {
371 + let json = r#"{
372 + "access_token": "jwt-access-token",
373 + "token_type": "Bearer",
374 + "expires_in": 3600,
375 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
376 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
377 + }"#;
378 +
379 + let resp: OAuthTokenResponse = serde_json::from_str(json).unwrap();
380 + assert_eq!(resp.access_token, "jwt-access-token");
381 + assert_eq!(resp.token_type, "Bearer");
382 + assert_eq!(resp.expires_in, 3600);
383 + }
384 +
385 + // ── Auth types ──
386 +
387 + #[test]
388 + fn auth_request_serialization() {
389 + let req = AuthRequest {
390 + email: "user@example.com".to_string(),
391 + password: "secret123".to_string(),
392 + api_key: "ak_test".to_string(),
393 + };
394 +
395 + let json = serde_json::to_string(&req).unwrap();
396 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
397 + assert_eq!(parsed["email"], "user@example.com");
398 + assert_eq!(parsed["password"], "secret123");
399 + assert_eq!(parsed["api_key"], "ak_test");
400 + }
401 +
402 + #[test]
403 + fn auth_response_deserialization() {
404 + let json = r#"{
405 + "token": "jwt.token.here",
406 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
407 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
408 + }"#;
409 +
410 + let resp: AuthResponse = serde_json::from_str(json).unwrap();
411 + assert_eq!(resp.token, "jwt.token.here");
412 + assert_eq!(
413 + resp.user_id,
414 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
415 + );
416 + }
417 +
418 + #[test]
419 + fn auth_response_missing_token_fails() {
420 + let json = r#"{"user_id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}"#;
421 + let result = serde_json::from_str::<AuthResponse>(json);
422 + assert!(result.is_err());
423 + }
424 + }
@@ -0,0 +1,174 @@
1 + use bytes::Bytes;
2 + use tracing::instrument;
3 +
4 + use crate::{
5 + crypto,
6 + error::Result,
7 + types::*,
8 + };
9 +
10 + use super::SyncKitClient;
11 + use super::helpers::check_response;
12 +
13 + impl SyncKitClient {
14 + /// Request a presigned upload URL for a blob.
15 + /// Returns (upload_url, already_exists). If `already_exists` is true,
16 + /// the blob is already on the server and no upload is needed.
17 + #[instrument(skip(self))]
18 + pub async fn blob_upload_url(
19 + &self,
20 + hash: &str,
21 + size_bytes: i64,
22 + ) -> Result<BlobUploadUrlResponse> {
23 + let token = self.require_token()?;
24 +
25 + let body = Bytes::from(serde_json::to_vec(&BlobUploadUrlRequest {
26 + hash: hash.to_string(),
27 + size_bytes,
28 + })?);
29 +
30 + let resp = self
31 + .retry_request(|| {
32 + let req = self
33 + .http
34 + .post(&self.endpoints.blobs_upload)
35 + .bearer_auth(&token)
36 + .header("content-type", "application/json")
37 + .body(body.clone());
38 + async move { check_response(req.send().await?).await }
39 + })
40 + .await?;
41 + Ok(resp.json().await?)
42 + }
43 +
44 + /// Upload blob data directly to S3 via a presigned PUT URL.
45 + ///
46 + /// Encrypts the data with the master key before uploading. The plaintext
47 + /// is never sent to the server, preserving the E2E encryption guarantee.
48 + #[instrument(skip(self, presigned_url, data))]
49 + pub async fn blob_upload(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
50 + let master_key = self.require_master_key()?;
51 + let encrypted = Bytes::from(crypto::encrypt_bytes(&data, &master_key)?);
52 +
53 + self.retry_request(|| {
54 + let req = self
55 + .http
56 + .put(presigned_url)
57 + .header("content-type", "application/octet-stream")
58 + .body(encrypted.clone());
59 + async move { check_response(req.send().await?).await }
60 + })
61 + .await?;
62 +
63 + Ok(())
64 + }
65 +
66 + /// Confirm that a blob upload completed successfully.
67 + /// The server verifies the object exists in S3 and records it.
68 + #[instrument(skip(self))]
69 + pub async fn blob_confirm(&self, hash: &str, size_bytes: i64) -> Result<()> {
70 + let token = self.require_token()?;
71 +
72 + let body = Bytes::from(serde_json::to_vec(&BlobConfirmRequest {
73 + hash: hash.to_string(),
74 + size_bytes,
75 + })?);
76 +
77 + self.retry_request(|| {
78 + let req = self
79 + .http
80 + .post(&self.endpoints.blobs_confirm)
81 + .bearer_auth(&token)
82 + .header("content-type", "application/json")
83 + .body(body.clone());
84 + async move { check_response(req.send().await?).await }
85 + })
86 + .await?;
87 + Ok(())
88 + }
89 +
90 + /// Get a presigned download URL for a blob by hash.
91 + #[instrument(skip(self))]
92 + pub async fn blob_download_url(&self, hash: &str) -> Result<String> {
93 + let token = self.require_token()?;
94 +
95 + let body = Bytes::from(serde_json::to_vec(&BlobDownloadUrlRequest {
96 + hash: hash.to_string(),
97 + })?);
98 +
99 + let resp = self
100 + .retry_request(|| {
101 + let req = self
102 + .http
103 + .post(&self.endpoints.blobs_download)
104 + .bearer_auth(&token)
105 + .header("content-type", "application/json")
106 + .body(body.clone());
107 + async move { check_response(req.send().await?).await }
108 + })
109 + .await?;
110 + let download: BlobDownloadUrlResponse = resp.json().await?;
111 + Ok(download.download_url)
112 + }
113 +
114 + /// Download blob data from S3 via a presigned GET URL.
115 + ///
116 + /// Decrypts the data with the master key after downloading. The server
117 + /// only ever stores encrypted blobs, preserving the E2E encryption guarantee.
118 + #[instrument(skip(self, presigned_url))]
119 + pub async fn blob_download(&self, presigned_url: &str) -> Result<Vec<u8>> {
120 + let resp = self
121 + .retry_request(|| {
122 + let req = self.http.get(presigned_url);
123 + async move { check_response(req.send().await?).await }
124 + })
125 + .await?;
126 +
127 + let encrypted = resp.bytes().await?.to_vec();
128 + let master_key = self.require_master_key()?;
129 + crypto::decrypt_bytes(&encrypted, &master_key)
130 + }
131 + }
132 +
133 + #[cfg(test)]
134 + mod tests {
135 + use crate::types::*;
136 +
137 + #[test]
138 + fn blob_upload_url_response_deserialization() {
139 + let json = r#"{"upload_url": "https://s3.example.com/upload", "already_exists": false}"#;
140 + let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
141 + assert_eq!(resp.upload_url, "https://s3.example.com/upload");
142 + assert!(!resp.already_exists);
143 +
144 + let json = r#"{"upload_url": "", "already_exists": true}"#;
145 + let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
146 + assert!(resp.already_exists);
147 + }
148 +
149 + #[test]
150 + fn blob_upload_url_request_serialization() {
151 + let req = BlobUploadUrlRequest {
152 + hash: "sha256-abc123".to_string(),
153 + size_bytes: 1024,
154 + };
155 +
156 + let json = serde_json::to_string(&req).unwrap();
157 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
158 + assert_eq!(parsed["hash"], "sha256-abc123");
159 + assert_eq!(parsed["size_bytes"], 1024);
160 + }
161 +
162 + #[test]
163 + fn blob_confirm_request_serialization() {
164 + let req = BlobConfirmRequest {
165 + hash: "sha256-def456".to_string(),
166 + size_bytes: 2048,
167 + };
168 +
169 + let json = serde_json::to_string(&req).unwrap();
170 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
171 + assert_eq!(parsed["hash"], "sha256-def456");
172 + assert_eq!(parsed["size_bytes"], 2048);
173 + }
174 + }
@@ -0,0 +1,224 @@
1 + use bytes::Bytes;
2 + use std::sync::Arc;
3 + use tracing::instrument;
4 +
5 + use crate::{
6 + crypto,
7 + error::Result,
8 + keystore,
9 + types::*,
10 + };
11 +
12 + use super::SyncKitClient;
13 + use super::helpers::check_response;
14 +
15 + impl SyncKitClient {
16 + /// Check if the server has an encrypted master key for this user.
17 + #[instrument(skip(self))]
18 + pub async fn has_server_key(&self) -> Result<bool> {
19 + let (url, token) = self.key_url_and_token()?;
20 +
21 + let result = self
22 + .retry_request(|| {
23 + let req = self.http.get(url).bearer_auth(&token);
24 + async move {
25 + let resp = req.send().await?;
26 + match resp.status().as_u16() {
27 + 200 | 404 => Ok(resp),
28 + status => {
29 + let message = resp.text().await.unwrap_or_default();
30 + Err(crate::error::SyncKitError::Server { status, message })
31 + }
32 + }
33 + }
34 + })
35 + .await?;
36 +
37 + Ok(result.status().as_u16() == 200)
38 + }
39 +
40 + /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
41 + #[instrument(skip(self, password))]
42 + pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
43 + let (app_id, user_id) = self.require_session_ids()?;
44 +
45 + let master_key = crypto::generate_master_key();
46 + let envelope = crypto::wrap_master_key(&master_key, password)?;
47 +
48 + // Push to server
49 + self.put_server_key(&envelope).await?;
50 +
51 + // Cache in OS keychain
52 + keystore::store_key(app_id, user_id, &master_key)?;
53 +
54 + // Store in memory
55 + *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key));
56 +
57 + tracing::info!("New master key generated and stored");
58 + Ok(())
59 + }
60 +
61 + /// Second device: pull encrypted master key from server, decrypt with password, cache.
62 + #[instrument(skip(self, password))]
63 + pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
64 + let (app_id, user_id) = self.require_session_ids()?;
65 +
66 + let envelope_json = self.get_server_key().await?;
67 + let master_key = crypto::unwrap_master_key(&envelope_json, password)?;
68 +
69 + // Cache in OS keychain
70 + keystore::store_key(app_id, user_id, &master_key)?;
71 +
72 + // Store in memory
73 + *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key));
74 +
75 + tracing::info!("Master key recovered from server");
76 + Ok(())
77 + }
78 +
79 + /// Subsequent launches: try to load the master key from the OS keychain.
80 + /// Returns true if a key was found.
81 + pub fn try_load_key_from_keychain(&self) -> Result<bool> {
82 + let (app_id, user_id) = self.require_session_ids()?;
83 +
84 + if let Some(key) = keystore::load_key(app_id, user_id)? {
85 + *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
86 + tracing::info!("Master key loaded from keychain");
87 + Ok(true)
88 + } else {
89 + Ok(false)
90 + }
91 + }
92 +
93 + /// Change the encryption password. Always validates the old password
94 + /// against the server envelope before re-encrypting with the new password.
95 + ///
96 + /// Even when the master key is cached in memory (normal case -- user is
97 + /// logged in), the old password is verified by attempting to unwrap the
98 + /// server envelope. This prevents an attacker with session access from
99 + /// changing the password without knowing the current one.
100 + #[instrument(skip(self, old_password, new_password))]
101 + pub async fn change_password(
102 + &self,
103 + old_password: &str,
104 + new_password: &str,
105 + ) -> Result<()> {
106 + // Always fetch the envelope from the server and verify old_password
107 + // can decrypt it, regardless of whether we have a cached key.
108 + let envelope_json = self.get_server_key().await?;
109 + let verified_key = crypto::verify_password_against_envelope(
110 + &envelope_json,
111 + old_password,
112 + )?;
113 +
114 + let master_key = crypto::ZeroizeOnDrop(verified_key);
115 +
116 + // Re-wrap with new password (generates fresh random salt)
117 + let new_envelope = crypto::wrap_master_key(&master_key, new_password)?;
118 +
119 + self.put_server_key(&new_envelope).await?;
120 +
121 + tracing::info!("Encryption password changed");
122 + Ok(())
123 + }
124 +
125 + /// Build the key endpoint URL and extract the bearer token.
126 + pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<String>)> {
127 + let token = self.require_token()?;
128 + Ok((&self.endpoints.keys, token))
129 + }
130 +
131 + /// Upload the encrypted master key envelope to the server (PUT /api/sync/keys).
132 + pub(super) async fn put_server_key(&self, envelope_json: &str) -> Result<()> {
133 + let (url, token) = self.key_url_and_token()?;
134 +
135 + let body = Bytes::from(serde_json::to_vec(&PutKeyRequest {
136 + encrypted_key: envelope_json.to_string(),
137 + })?);
138 +
139 + self.retry_request(|| {
140 + let req = self
141 + .http
142 + .put(url)
143 + .bearer_auth(&token)
144 + .header("content-type", "application/json")
145 + .body(body.clone());
146 + async move { check_response(req.send().await?).await }
147 + })
148 + .await?;
149 + Ok(())
150 + }
151 +
152 + /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
153 + pub(super) async fn get_server_key(&self) -> Result<String> {
154 + let (url, token) = self.key_url_and_token()?;
155 +
156 + let resp = self
157 + .retry_request(|| {
158 + let req = self.http.get(url).bearer_auth(&token);
159 + async move { check_response(req.send().await?).await }
160 + })
161 + .await?;
162 + let key_resp: GetKeyResponse = resp.json().await?;
163 + Ok(key_resp.encrypted_key)
164 + }
165 + }
166 +
167 + #[cfg(test)]
168 + mod tests {
169 + use crate::error::SyncKitError;
170 +
171 + use super::*;
172 +
173 + fn test_config() -> super::super::SyncKitConfig {
174 + super::super::SyncKitConfig {
175 + server_url: "https://example.com".to_string(),
176 + api_key: "test-api-key-123".to_string(),
177 + }
178 + }
179 +
180 + fn test_ids() -> (uuid::Uuid, uuid::Uuid) {
181 + (
182 + uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
183 + uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
184 + )
185 + }
186 +
187 + #[test]
188 + fn key_url_and_token_builds_correct_url() {
189 + let client = SyncKitClient::new(test_config());
190 + let (app_id, user_id) = test_ids();
191 + client.restore_session("bearer-token", user_id, app_id).unwrap();
192 +
193 + let (url, token) = client.key_url_and_token().unwrap();
194 + assert_eq!(url, "https://example.com/api/sync/keys");
195 + assert_eq!(*token, "bearer-token");
196 + }
197 +
198 + #[test]
199 + fn key_url_and_token_fails_without_session() {
200 + let client = SyncKitClient::new(test_config());
201 + let err = client.key_url_and_token().unwrap_err();
202 + assert!(matches!(err, SyncKitError::NotAuthenticated));
203 + }
204 +
205 + // ── Key types ──
206 +
207 + #[test]
208 + fn put_key_request_serialization() {
209 + let req = PutKeyRequest {
210 + encrypted_key: "envelope-json-here".to_string(),
211 + };
212 +
213 + let json = serde_json::to_string(&req).unwrap();
214 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
215 + assert_eq!(parsed["encrypted_key"], "envelope-json-here");
216 + }
217 +
218 + #[test]
219 + fn get_key_response_deserialization() {
220 + let json = r#"{"encrypted_key": "{\"v\":1,\"salt\":\"...\",\"nonce\":\"...\",\"ciphertext\":\"...\"}"}"#;
221 + let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
222 + assert!(resp.encrypted_key.contains("\"v\":1"));
223 + }
224 + }
@@ -0,0 +1,647 @@
1 + use base64::Engine;
2 +
3 + use crate::{
4 + crypto,
5 + error::{Result, SyncKitError},
6 + types::*,
7 + };
8 +
9 + use super::{BASE_DELAY, MAX_RETRIES, SyncKitClient};
10 +
11 + impl SyncKitClient {
12 + /// Retry an async HTTP operation with exponential backoff.
13 + ///
14 + /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
15 + /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
16 + /// Client errors (4xx except 429) are considered permanent and returned immediately.
17 + pub(super) async fn retry_request<F, Fut>(&self, mut operation: F) -> Result<reqwest::Response>
18 + where
19 + F: FnMut() -> Fut,
20 + Fut: std::future::Future<Output = Result<reqwest::Response>>,
21 + {
22 + let mut last_err = None;
23 +
24 + for attempt in 0..=MAX_RETRIES {
25 + match operation().await {
26 + Ok(resp) => return Ok(resp),
27 + Err(err) => {
28 + if !is_transient(&err) {
29 + return Err(err);
30 + }
31 +
32 + if attempt < MAX_RETRIES {
33 + let delay = BASE_DELAY * 2u32.pow(attempt);
34 + tracing::debug!(
35 + attempt = attempt + 1,
36 + max_retries = MAX_RETRIES,
37 + delay_ms = delay.as_millis() as u64,
38 + error = %err,
39 + "Transient error, retrying after backoff",
40 + );
41 + tokio::time::sleep(delay).await;
42 + }
43 +
44 + last_err = Some(err);
45 + }
46 + }
47 + }
48 +
49 + Err(last_err.expect("loop ran at least once"))
50 + }
51 +
52 + /// Encrypt the data field of a change entry for the wire.
53 + #[cfg(test)]
54 + pub(super) fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
55 + if entry.data.is_some() {
56 + let master_key = self.require_master_key()?;
57 + Self::encrypt_change_with_key(entry, &master_key)
58 + } else {
59 + Self::encrypt_change_with_key(entry, &[0u8; 32])
60 + }
61 + }
62 +
63 + /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock acquisition.
64 + pub(super) fn encrypt_change_with_key(entry: ChangeEntry, master_key: &[u8; 32]) -> Result<WireChangeEntry> {
65 + let encrypted_data = match entry.data {
66 + Some(ref value) => Some(crypto::encrypt_json(value, master_key)?),
67 + None => None,
68 + };
69 +
70 + Ok(WireChangeEntry {
71 + table: entry.table,
72 + op: entry.op,
73 + row_id: entry.row_id,
74 + timestamp: entry.timestamp,
75 + data: encrypted_data,
76 + })
77 + }
78 +
79 + /// Decrypt the data field of a pulled change entry.
80 + #[cfg(test)]
81 + pub(super) fn decrypt_change(&self, entry: PullChangeEntry) -> Result<ChangeEntry> {
82 + if entry.data.is_some() {
83 + let master_key = self.require_master_key()?;
84 + Self::decrypt_change_with_key(entry, &master_key)
85 + } else {
86 + Self::decrypt_change_with_key(entry, &[0u8; 32])
87 + }
88 + }
89 +
90 + /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
91 + pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
92 + let decrypted_data = match entry.data {
93 + Some(ref value) => Some(crypto::decrypt_json(value, master_key)?),
94 + None => None,
95 + };
96 +
97 + Ok(ChangeEntry {
98 + table: entry.table,
99 + op: entry.op,
100 + row_id: entry.row_id,
101 + timestamp: entry.timestamp,
102 + data: decrypted_data,
103 + })
104 + }
105 + }
106 +
107 + /// Extract the `exp` claim from a JWT without verifying the signature.
108 + ///
109 + /// JWTs are `header.payload.signature` where the payload is base64url-encoded JSON.
110 + /// We decode the payload segment and read the `exp` field. Returns `None` if
111 + /// the token is malformed or `exp` is missing.
112 + pub(super) fn jwt_exp(token: &str) -> Option<i64> {
113 + let payload = token.split('.').nth(1)?;
114 + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
115 + .decode(payload)
116 + .ok()?;
117 + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
118 + claims["exp"].as_i64()
119 + }
120 +
121 + /// Returns `true` if the token's `exp` claim is within [`TOKEN_EXPIRY_BUFFER_SECS`]
122 + /// of the current time (or already past). Returns `false` if the token cannot
123 + /// be decoded — in that case, let the server decide.
124 + #[cfg(test)]
125 + pub(super) fn token_is_expired(token: &str) -> bool {
126 + let Some(exp) = jwt_exp(token) else {
127 + return false;
128 + };
129 + let now = chrono::Utc::now().timestamp();
130 + now >= exp - super::TOKEN_EXPIRY_BUFFER_SECS
131 + }
132 +
133 + /// Check an HTTP response for errors, returning the response on success.
134 + pub(super) async fn check_response(resp: reqwest::Response) -> Result<reqwest::Response> {
135 + let status = resp.status().as_u16();
136 + if status >= 400 {
137 + let message = resp.text().await.unwrap_or_default();
138 + return Err(SyncKitError::Server { status, message });
139 + }
140 + Ok(resp)
141 + }
142 +
143 + /// Returns true if the error is transient and worth retrying.
144 + ///
145 + /// Transient errors:
146 + /// - Network-level failures (connection refused, timeout, DNS, etc.)
147 + /// - Server errors (5xx)
148 + /// - Rate limiting (429)
149 + ///
150 + /// Permanent errors (not retried):
151 + /// - Client errors (4xx except 429) — bad request, auth failure, not found, etc.
152 + /// - Serialization errors, encryption errors, missing session, etc.
153 + pub(super) fn is_transient(err: &SyncKitError) -> bool {
154 + match err {
155 + SyncKitError::Http(e) => {
156 + // All reqwest transport errors are transient (timeout, connect, DNS, etc.)
157 + // except for builder errors which indicate programming mistakes.
158 + !e.is_builder()
159 + }
160 + SyncKitError::Server { status, .. } => {
161 + // 5xx = server error (transient), 429 = rate limited (transient)
162 + *status >= 500 || *status == 429
163 + }
164 + // Everything else (auth, crypto, serialization) is permanent
165 + _ => false,
166 + }
167 + }
168 +
169 + #[cfg(test)]
170 + mod tests {
171 + use super::*;
172 + use base64::Engine;
173 + use chrono::Utc;
174 + use std::time::Duration;
175 +
176 + use super::super::TOKEN_EXPIRY_BUFFER_SECS;
177 +
178 + fn test_config() -> super::super::SyncKitConfig {
179 + super::super::SyncKitConfig {
180 + server_url: "https://example.com".to_string(),
181 + api_key: "test-api-key-123".to_string(),
182 + }
183 + }
184 +
185 + /// Build a fake JWT with the given `exp` claim (no real signature).
186 + fn fake_jwt(exp: i64) -> String {
187 + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
188 + .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
189 + let payload_json = serde_json::json!({
190 + "sub": "550e8400-e29b-41d4-a716-446655440000",
191 + "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
192 + "exp": exp,
193 + "iat": exp - 3600,
194 + });
195 + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
196 + .encode(payload_json.to_string().as_bytes());
197 + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
198 + .encode(b"fake-signature");
199 + format!("{header}.{payload}.{signature}")
200 + }
201 +
202 + // ── encrypt_change / decrypt_change ──
203 +
204 + #[test]
205 + fn encrypt_change_with_no_data() {
206 + let client = SyncKitClient::new(test_config());
207 + let entry = ChangeEntry {
208 + table: "tasks".to_string(),
209 + op: ChangeOp::Delete,
210 + row_id: "row-1".to_string(),
211 + timestamp: Utc::now(),
212 + data: None,
213 + };
214 +
215 + let wire = client.encrypt_change(entry.clone()).unwrap();
216 + assert_eq!(wire.table, "tasks");
217 + assert_eq!(wire.op, ChangeOp::Delete);
218 + assert_eq!(wire.row_id, "row-1");
219 + assert!(wire.data.is_none());
220 + }
221 +
222 + #[test]
223 + fn encrypt_change_fails_without_master_key() {
224 + let client = SyncKitClient::new(test_config());
225 + let entry = ChangeEntry {
226 + table: "tasks".to_string(),
227 + op: ChangeOp::Insert,
228 + row_id: "row-1".to_string(),
229 + timestamp: Utc::now(),
230 + data: Some(serde_json::json!({"title": "test"})),
231 + };
232 +
233 + let err = client.encrypt_change(entry).unwrap_err();
234 + assert!(matches!(err, SyncKitError::NoMasterKey));
235 + }
236 +
237 + #[test]
238 + fn encrypt_change_produces_encrypted_data() {
239 + let client = SyncKitClient::new(test_config());
240 + let key = crypto::generate_master_key();
241 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
242 +
243 + let original_data = serde_json::json!({"title": "Buy milk", "priority": 3});
244 + let entry = ChangeEntry {
245 + table: "tasks".to_string(),
246 + op: ChangeOp::Insert,
247 + row_id: "row-1".to_string(),
248 + timestamp: Utc::now(),
249 + data: Some(original_data.clone()),
250 + };
251 +
252 + let wire = client.encrypt_change(entry).unwrap();
253 + assert!(wire.data.is_some());
254 + let encrypted = wire.data.unwrap();
255 + assert!(encrypted.is_string());
256 + assert_ne!(encrypted, original_data);
257 + }
258 +
259 + #[test]
260 + fn encrypt_decrypt_roundtrip() {
261 + let client = SyncKitClient::new(test_config());
262 + let key = crypto::generate_master_key();
263 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
264 +
265 + let original_data = serde_json::json!({
266 + "title": "Buy milk",
267 + "tags": ["groceries", "urgent"],
268 + "count": 42
269 + });
270 + let ts = Utc::now();
271 + let entry = ChangeEntry {
272 + table: "tasks".to_string(),
273 + op: ChangeOp::Update,
274 + row_id: "row-abc".to_string(),
275 + timestamp: ts,
276 + data: Some(original_data.clone()),
277 + };
278 +
279 + let wire = client.encrypt_change(entry).unwrap();
280 + let pull_entry = PullChangeEntry {
281 + seq: 1,
282 + device_id: uuid::Uuid::new_v4(),
283 + table: wire.table,
284 + op: wire.op,
285 + row_id: wire.row_id,
286 + timestamp: wire.timestamp,
287 + data: wire.data,
288 + };
289 +
290 + let decrypted = client.decrypt_change(pull_entry).unwrap();
291 + assert_eq!(decrypted.table, "tasks");
292 + assert_eq!(decrypted.op, ChangeOp::Update);
293 + assert_eq!(decrypted.row_id, "row-abc");
294 + assert_eq!(decrypted.data.unwrap(), original_data);
295 + }
296 +
297 + #[test]
298 + fn decrypt_change_with_no_data() {
299 + let client = SyncKitClient::new(test_config());
300 + let pull_entry = PullChangeEntry {
301 + seq: 5,
302 + device_id: uuid::Uuid::new_v4(),
303 + table: "events".to_string(),
304 + op: ChangeOp::Delete,
305 + row_id: "evt-1".to_string(),
306 + timestamp: Utc::now(),
307 + data: None,
308 + };
309 +
310 + let decrypted = client.decrypt_change(pull_entry).unwrap();
311 + assert_eq!(decrypted.table, "events");
312 + assert_eq!(decrypted.op, ChangeOp::Delete);
313 + assert!(decrypted.data.is_none());
314 + }
315 +
316 + #[test]
317 + fn decrypt_change_fails_without_master_key() {
318 + let client = SyncKitClient::new(test_config());
319 + let pull_entry = PullChangeEntry {
320 + seq: 1,
321 + device_id: uuid::Uuid::new_v4(),
322 + table: "tasks".to_string(),
323 + op: ChangeOp::Insert,
324 + row_id: "row-1".to_string(),
325 + timestamp: Utc::now(),
326 + data: Some(serde_json::json!("some-encrypted-string")),
327 + };
328 +
329 + let err = client.decrypt_change(pull_entry).unwrap_err();
330 + assert!(matches!(err, SyncKitError::NoMasterKey));
331 + }
332 +
333 + // ── is_transient error classification ──
334 +
335 + #[test]
336 + fn is_transient_server_5xx() {
337 + let err = SyncKitError::Server { status: 500, message: "Internal Server Error".to_string() };
338 + assert!(is_transient(&err));
339 + let err = SyncKitError::Server { status: 502, message: "Bad Gateway".to_string() };
340 + assert!(is_transient(&err));
341 + let err = SyncKitError::Server { status: 503, message: "Service Unavailable".to_string() };
342 + assert!(is_transient(&err));
343 + let err = SyncKitError::Server { status: 504, message: "Gateway Timeout".to_string() };
344 + assert!(is_transient(&err));
345 + }
346 +
347 + #[test]
348 + fn is_transient_rate_limited_429() {
349 + let err = SyncKitError::Server { status: 429, message: "Too Many Requests".to_string() };
350 + assert!(is_transient(&err));
351 + }
352 +
353 + #[test]
354 + fn is_not_transient_client_4xx() {
355 + let err = SyncKitError::Server { status: 400, message: "Bad Request".to_string() };
356 + assert!(!is_transient(&err));
357 + let err = SyncKitError::Server { status: 401, message: "Unauthorized".to_string() };
358 + assert!(!is_transient(&err));
359 + let err = SyncKitError::Server { status: 403, message: "Forbidden".to_string() };
360 + assert!(!is_transient(&err));
361 + let err = SyncKitError::Server { status: 404, message: "Not Found".to_string() };
362 + assert!(!is_transient(&err));
363 + let err = SyncKitError::Server { status: 409, message: "Conflict".to_string() };
364 + assert!(!is_transient(&err));
365 + let err = SyncKitError::Server { status: 422, message: "Unprocessable Entity".to_string() };
366 + assert!(!is_transient(&err));
367 + }
368 +
369 + #[test]
370 + fn is_not_transient_not_authenticated() {
371 + assert!(!is_transient(&SyncKitError::NotAuthenticated));
372 + }
373 +
374 + #[test]
375 + fn is_not_transient_no_master_key() {
376 + assert!(!is_transient(&SyncKitError::NoMasterKey));
377 + }
378 +
379 + #[test]
380 + fn is_not_transient_decryption_failed() {
381 + assert!(!is_transient(&SyncKitError::DecryptionFailed));
382 + }
383 +
384 + #[test]
385 + fn is_not_transient_invalid_envelope() {
386 + assert!(!is_transient(&SyncKitError::InvalidEnvelope("bad version".to_string())));
387 + }
388 +
389 + #[test]
390 + fn is_not_transient_crypto() {
391 + assert!(!is_transient(&SyncKitError::Crypto("encrypt failed".to_string())));
392 + }
393 +
394 + #[test]
395 + fn is_not_transient_json() {
396 + let err: SyncKitError = serde_json::from_str::<serde_json::Value>("not json")
397 + .unwrap_err()
398 + .into();
399 + assert!(!is_transient(&err));
400 + }
401 +
402 + #[test]
403 + fn is_not_transient_base64() {
404 + let err: SyncKitError = base64::engine::general_purpose::STANDARD
405 + .decode("!!!invalid!!!")
406 + .unwrap_err()
407 + .into();
408 + assert!(!is_transient(&err));
409 + }
410 +
411 + #[test]
412 + fn is_not_transient_token_expired() {
413 + assert!(!is_transient(&SyncKitError::TokenExpired));
414 + }
415 +
416 + #[test]
417 + fn is_not_transient_internal() {
418 + assert!(!is_transient(&SyncKitError::Internal("lock poisoned".to_string())));
419 + }
420 +
421 + // ── Retry constants ──
422 +
423 + #[test]
424 + fn retry_constants_are_sensible() {
425 + assert_eq!(MAX_RETRIES, 3);
426 + assert_eq!(BASE_DELAY, Duration::from_secs(1));
427 + }
428 +
429 + #[test]
430 + fn backoff_delays_are_exponential() {
431 + let delay_0 = BASE_DELAY * 2u32.pow(0);
432 + let delay_1 = BASE_DELAY * 2u32.pow(1);
433 + let delay_2 = BASE_DELAY * 2u32.pow(2);
434 +
435 + assert_eq!(delay_0, Duration::from_secs(1));
436 + assert_eq!(delay_1, Duration::from_secs(2));
437 + assert_eq!(delay_2, Duration::from_secs(4));
438 + }
439 +
440 + // ── is_transient boundary: 429 vs 428, 499 vs 500 ──
441 +
442 + #[test]
443 + fn is_transient_boundary_values() {
444 + assert!(!is_transient(&SyncKitError::Server { status: 428, message: String::new() }));
445 + assert!(is_transient(&SyncKitError::Server { status: 429, message: String::new() }));
446 + assert!(!is_transient(&SyncKitError::Server { status: 430, message: String::new() }));
447 + assert!(!is_transient(&SyncKitError::Server { status: 499, message: String::new() }));
448 + assert!(is_transient(&SyncKitError::Server { status: 500, message: String::new() }));
449 + }
450 +
451 + // ── Token expiry detection ──
452 +
453 + #[test]
454 + fn jwt_exp_extracts_expiry() {
455 + let exp = Utc::now().timestamp() + 3600;
456 + let token = fake_jwt(exp);
457 + assert_eq!(jwt_exp(&token), Some(exp));
458 + }
459 +
460 + #[test]
461 + fn jwt_exp_returns_none_for_garbage() {
462 + assert_eq!(jwt_exp("not-a-jwt"), None);
463 + assert_eq!(jwt_exp("a.b.c"), None);
464 + assert_eq!(jwt_exp(""), None);
465 + }
466 +
467 + #[test]
468 + fn token_is_expired_for_past_exp() {
469 + let token = fake_jwt(Utc::now().timestamp() - 3600);
470 + assert!(token_is_expired(&token));
471 + }
472 +
473 + #[test]
474 + fn token_is_expired_within_buffer() {
475 + let token = fake_jwt(Utc::now().timestamp() + 10);
476 + assert!(token_is_expired(&token));
477 + }
478 +
479 + #[test]
480 + fn token_is_not_expired_when_fresh() {
481 + let token = fake_jwt(Utc::now().timestamp() + 3600);
482 + assert!(!token_is_expired(&token));
483 + }
484 +
485 + #[test]
486 + fn token_is_not_expired_for_garbage() {
487 + assert!(!token_is_expired("garbage"));
488 + }
489 +
490 + #[test]
491 + fn token_expires_exactly_at_buffer_boundary() {
492 + let token = fake_jwt(Utc::now().timestamp() + TOKEN_EXPIRY_BUFFER_SECS);
493 + assert!(token_is_expired(&token));
494 + }
495 +
496 + #[test]
497 + fn token_expires_just_past_buffer() {
498 + let token = fake_jwt(Utc::now().timestamp() + TOKEN_EXPIRY_BUFFER_SECS + 1);
499 + assert!(!token_is_expired(&token));
500 + }
Lines truncated
@@ -0,0 +1,520 @@
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 + mod auth;
53 + mod blob;
54 + mod encryption;
55 + pub(crate) mod helpers;
56 + mod sync;
57 +
58 + use parking_lot::RwLock;
59 + use reqwest::Client;
60 + use std::sync::Arc;
61 + use std::time::Duration;
62 + use uuid::Uuid;
63 +
64 + use crate::{
65 + crypto,
66 + error::{Result, SyncKitError},
67 + };
68 +
69 + /// Maximum number of retry attempts for transient failures.
70 + const MAX_RETRIES: u32 = 3;
71 +
72 + /// Base delay for exponential backoff (1s, 2s, 4s).
73 + const BASE_DELAY: Duration = Duration::from_secs(1);
74 +
75 + /// Seconds before actual expiry to consider the token expired.
76 + /// Avoids sending a request with a token that expires mid-flight.
77 + const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
78 +
79 + /// Configuration for the SyncKit client.
80 + #[derive(Debug, Clone)]
81 + pub struct SyncKitConfig {
82 + /// Base URL of the MNW server (e.g. "https://makenot.work").
83 + pub server_url: String,
84 + /// App API key (obtained from MNW dashboard).
85 + pub api_key: String,
86 + }
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 +
119 + /// Session state obtained after authentication.
120 + struct Session {
121 + token: Arc<String>,
122 + /// Cached `exp` claim from the JWT, extracted once at session creation.
123 + token_exp: Option<i64>,
124 + user_id: Uuid,
125 + app_id: Uuid,
126 + }
127 +
128 + /// Public session info returned by `session_info()`.
129 + pub struct SessionInfo {
130 + /// The JWT bearer token for API requests.
131 + pub token: String,
132 + /// The authenticated user's UUID.
133 + pub user_id: Uuid,
134 + /// The SyncKit app UUID this session belongs to.
135 + pub app_id: Uuid,
136 + }
137 +
138 + /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
139 + pub struct SyncKitClient {
140 + config: SyncKitConfig,
141 + http: Client,
142 + endpoints: Endpoints,
143 + session: RwLock<Option<Session>>,
144 + master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
145 + }
146 +
147 + impl SyncKitClient {
148 + /// Create a new client with the given configuration.
149 + pub fn new(config: SyncKitConfig) -> Self {
150 + let http = Client::builder()
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))
155 + .build()
156 + .expect("failed to build HTTP client");
157 +
158 + let endpoints = Endpoints::new(&config.server_url);
159 + Self {
160 + config,
161 + http,
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),
178 + }
179 + }
180 +
181 + /// Returns the client configuration.
182 + pub fn config(&self) -> &SyncKitConfig {
183 + &self.config
184 + }
185 +
186 + /// Returns whether the master encryption key is loaded and ready.
187 + pub fn has_master_key(&self) -> Result<bool> {
188 + Ok(self.master_key.read().is_some())
189 + }
190 +
191 + /// Returns the current session info, if authenticated.
192 + pub fn session_info(&self) -> Result<Option<SessionInfo>> {
193 + let guard = self.session.read();
194 + Ok(guard.as_ref().map(|s| SessionInfo {
195 + token: (*s.token).clone(),
196 + user_id: s.user_id,
197 + app_id: s.app_id,
198 + }))
199 + }
200 +
201 + /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
202 + #[doc(hidden)]
203 + pub fn set_master_key_raw(&self, key: [u8; 32]) -> Result<()> {
204 + *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
205 + Ok(())
206 + }
207 +
208 + // ── Internal helpers ──
209 +
210 + /// Extract the bearer token from the current session.
211 + ///
212 + /// Returns `NotAuthenticated` if no session exists. Also checks token
213 + /// expiry and returns `TokenExpired` if the JWT `exp` claim is within
214 + /// 30 seconds of the current time.
215 + pub(crate) fn require_token(&self) -> Result<Arc<String>> {
216 + let guard = self.session.read();
217 + let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
218 +
219 + if let Some(exp) = session.token_exp {
220 + let now = chrono::Utc::now().timestamp();
221 + if now >= exp - TOKEN_EXPIRY_BUFFER_SECS {
222 + return Err(SyncKitError::TokenExpired);
223 + }
224 + }
225 +
226 + Ok(Arc::clone(&session.token))
227 + }
228 +
229 + /// Extract `(app_id, user_id)` from the current session.
230 + ///
231 + /// Returns `NotAuthenticated` if no session exists.
232 + pub(crate) fn require_session_ids(&self) -> Result<(Uuid, Uuid)> {
233 + let guard = self.session.read();
234 + guard
235 + .as_ref()
236 + .map(|s| (s.app_id, s.user_id))
237 + .ok_or(SyncKitError::NotAuthenticated)
238 + }
239 +
240 + /// Return a copy of the 256-bit master encryption key, wrapped in
241 + /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`.
242 + ///
243 + /// Returns `NoMasterKey` if encryption has not been set up yet.
244 + pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> {
245 + let guard = self.master_key.read();
246 + guard
247 + .as_ref()
248 + .map(|k| crypto::ZeroizeOnDrop(**k))
249 + .ok_or(SyncKitError::NoMasterKey)
250 + }
251 + }
252 +
253 + #[cfg(test)]
254 + mod tests {
255 + use super::*;
256 + use base64::Engine;
257 +
258 + fn test_config() -> SyncKitConfig {
259 + SyncKitConfig {
260 + server_url: "https://example.com".to_string(),
261 + api_key: "test-api-key-123".to_string(),
262 + }
263 + }
264 +
265 + fn test_ids() -> (Uuid, Uuid) {
266 + (
267 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
268 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
269 + )
270 + }
271 +
272 + // ── SyncKitClient::new() construction ──
273 +
274 + #[test]
275 + fn new_client_starts_unauthenticated() {
276 + let client = SyncKitClient::new(test_config());
277 + assert!(client.session_info().unwrap().is_none());
278 + }
279 +
280 + #[test]
281 + fn new_client_has_no_master_key() {
282 + let client = SyncKitClient::new(test_config());
283 + assert!(!client.has_master_key().unwrap());
284 + }
285 +
286 + #[test]
287 + fn config_returns_provided_values() {
288 + let client = SyncKitClient::new(test_config());
289 + assert_eq!(client.config().server_url, "https://example.com");
290 + assert_eq!(client.config().api_key, "test-api-key-123");
291 + }
292 +
293 + // ── SyncKitConfig ──
294 +
295 + #[test]
296 + fn config_clone() {
297 + let config = test_config();
298 + let cloned = config.clone();
299 + assert_eq!(cloned.server_url, config.server_url);
300 + assert_eq!(cloned.api_key, config.api_key);
301 + }
302 +
303 + #[test]
304 + fn config_debug() {
305 + let config = test_config();
306 + let debug = format!("{:?}", config);
307 + assert!(debug.contains("SyncKitConfig"));
308 + assert!(debug.contains("example.com"));
309 + }
310 +
311 + // ── require_token ──
312 +
313 + #[test]
314 + fn require_token_fails_without_session() {
315 + let client = SyncKitClient::new(test_config());
316 + let err = client.require_token().unwrap_err();
317 + assert!(matches!(err, SyncKitError::NotAuthenticated));
318 + }
319 +
320 + #[test]
321 + fn require_token_succeeds_with_session() {
322 + let client = SyncKitClient::new(test_config());
323 + let (app_id, user_id) = test_ids();
324 + client.restore_session("my-token", user_id, app_id).unwrap();
325 +
326 + let token = client.require_token().unwrap();
327 + assert_eq!(*token, "my-token");
328 + }
329 +
330 + // ── require_session_ids ──
331 +
332 + #[test]
333 + fn require_session_ids_fails_without_session() {
334 + let client = SyncKitClient::new(test_config());
335 + let err = client.require_session_ids().unwrap_err();
336 + assert!(matches!(err, SyncKitError::NotAuthenticated));
337 + }
338 +
339 + #[test]
340 + fn require_session_ids_returns_correct_ids() {
341 + let client = SyncKitClient::new(test_config());
342 + let (app_id, user_id) = test_ids();
343 + client.restore_session("token", user_id, app_id).unwrap();
344 +
345 + let (returned_app, returned_user) = client.require_session_ids().unwrap();
346 + assert_eq!(returned_app, app_id);
347 + assert_eq!(returned_user, user_id);
348 + }
349 +
350 + // ── require_master_key ──
351 +
352 + #[test]
353 + fn require_master_key_fails_without_key() {
354 + let client = SyncKitClient::new(test_config());
355 + let err = client.require_master_key().unwrap_err();
356 + assert!(matches!(err, SyncKitError::NoMasterKey));
357 + }
358 +
359 + #[test]
360 + fn require_master_key_succeeds_after_set() {
361 + let client = SyncKitClient::new(test_config());
362 + let test_key = [42u8; 32];
363 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(test_key));
364 +
365 + let key = client.require_master_key().unwrap();
366 + assert_eq!(*key, test_key);
367 + }
368 +
369 + // ── has_master_key ──
370 +
371 + #[test]
372 + fn has_master_key_false_initially() {
373 + let client = SyncKitClient::new(test_config());
374 + assert!(!client.has_master_key().unwrap());
375 + }
376 +
377 + #[test]
378 + fn has_master_key_true_after_set() {
379 + let client = SyncKitClient::new(test_config());
380 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32]));
381 + assert!(client.has_master_key().unwrap());
382 + }
383 +
384 + // ── set_master_key_raw ──
385 +
386 + #[test]
387 + fn set_master_key_raw_makes_key_available() {
388 + let client = SyncKitClient::new(test_config());
389 + assert!(!client.has_master_key().unwrap());
390 +
391 + let key = [99u8; 32];
392 + client.set_master_key_raw(key).unwrap();
393 +
394 + assert!(client.has_master_key().unwrap());
395 + assert_eq!(*client.require_master_key().unwrap(), key);
396 + }
397 +
398 + #[test]
399 + fn set_master_key_raw_overwrites_previous() {
400 + let client = SyncKitClient::new(test_config());
401 + let key1 = [1u8; 32];
402 + let key2 = [2u8; 32];
403 +
404 + client.set_master_key_raw(key1).unwrap();
405 + assert_eq!(*client.require_master_key().unwrap(), key1);
406 +
407 + client.set_master_key_raw(key2).unwrap();
408 + assert_eq!(*client.require_master_key().unwrap(), key2);
409 + }
410 +
411 + // ── with_http_client constructor ──
412 +
413 + #[test]
414 + fn with_http_client_starts_unauthenticated() {
415 + let http = Client::builder()
416 + .timeout(Duration::from_millis(100))
417 + .build()
418 + .unwrap();
419 + let client = SyncKitClient::with_http_client(test_config(), http);
420 + assert!(client.session_info().unwrap().is_none());
421 + assert!(!client.has_master_key().unwrap());
422 + }
423 +
424 + // ── Send + Sync assertions ──
425 +
426 + #[test]
427 + fn client_is_send_and_sync() {
428 + fn assert_send_sync<T: Send + Sync>() {}
429 + assert_send_sync::<SyncKitClient>();
430 + }
431 +
432 + // ── Config edge case ──
433 +
434 + #[test]
435 + fn config_with_trailing_slash_url() {
436 + let config = SyncKitConfig {
437 + server_url: "https://example.com/".to_string(),
438 + api_key: "key".to_string(),
439 + };
440 + let client = SyncKitClient::new(config);
441 + assert_eq!(client.config().server_url, "https://example.com/");
442 + }
443 +
444 + // ── SyncKitError Display ──
445 +
446 + #[test]
447 + fn error_display_not_authenticated() {
448 + let err = SyncKitError::NotAuthenticated;
449 + assert!(err.to_string().contains("Not authenticated"));
450 + }
451 +
452 + #[test]
453 + fn error_display_no_master_key() {
454 + let err = SyncKitError::NoMasterKey;
455 + assert!(err.to_string().contains("Encryption not initialized"));
456 + }
457 +
458 + #[test]
459 + fn error_display_server() {
460 + let err = SyncKitError::Server { status: 500, message: "boom".to_string() };
461 + let msg = err.to_string();
462 + assert!(msg.contains("500"));
463 + assert!(msg.contains("boom"));
464 + }
465 +
466 + #[test]
467 + fn error_display_decryption_failed() {
468 + let err = SyncKitError::DecryptionFailed;
469 + assert!(err.to_string().contains("Wrong password"));
470 + }
471 +
472 + #[test]
473 + fn error_display_invalid_envelope() {
474 + let err = SyncKitError::InvalidEnvelope("bad version".to_string());
475 + let msg = err.to_string();
476 + assert!(msg.contains("Invalid key envelope"));
477 + assert!(msg.contains("bad version"));
478 + }
479 +
480 + #[test]
481 + fn error_display_crypto() {
482 + let err = SyncKitError::Crypto("aead failed".to_string());
483 + let msg = err.to_string();
484 + assert!(msg.contains("Encryption error"));
485 + assert!(msg.contains("aead failed"));
486 + }
487 +
488 + #[test]
489 + fn error_display_token_expired() {
490 + let err = SyncKitError::TokenExpired;
491 + assert!(err.to_string().contains("Token expired"));
492 + }
493 +
494 + // ── SyncKitError conversions ──
495 +
496 + #[test]
497 + fn error_from_serde_json() {
498 + let err: SyncKitError = serde_json::from_str::<serde_json::Value>("{{bad}}")
499 + .unwrap_err()
500 + .into();
Lines truncated
@@ -0,0 +1,402 @@
1 + use bytes::Bytes;
2 + use tracing::instrument;
3 + use uuid::Uuid;
4 +
5 + use crate::{
6 + crypto,
7 + error::Result,
8 + types::*,
9 + };
10 +
11 + use super::SyncKitClient;
12 + use super::helpers::check_response;
13 +
14 + impl SyncKitClient {
15 + // ── Devices ──
16 +
17 + /// Register a device for sync.
18 + ///
19 + /// If a device with the same name already exists for this user/app, the
20 + /// server upserts: it updates the existing device's platform and
21 + /// `last_seen_at` rather than creating a duplicate.
22 + #[instrument(skip(self))]
23 + pub async fn register_device(
24 + &self,
25 + device_name: &str,
26 + platform: &str,
27 + ) -> Result<Device> {
28 + let token = self.require_token()?;
29 +
30 + let body = Bytes::from(serde_json::to_vec(&RegisterDeviceRequest {
31 + device_name: device_name.to_string(),
32 + platform: platform.to_string(),
33 + })?);
34 +
35 + let resp = self
36 + .retry_request(|| {
37 + let req = self
38 + .http
39 + .post(&self.endpoints.devices)
40 + .bearer_auth(&token)
41 + .header("content-type", "application/json")
42 + .body(body.clone());
43 + async move { check_response(req.send().await?).await }
44 + })
45 + .await?;
46 + Ok(resp.json().await?)
47 + }
48 +
49 + /// List all devices for the current user.
50 + #[instrument(skip(self))]
51 + pub async fn list_devices(&self) -> Result<Vec<Device>> {
52 + let token = self.require_token()?;
53 +
54 + let resp = self
55 + .retry_request(|| {
56 + let req = self.http.get(&self.endpoints.devices).bearer_auth(&token);
57 + async move { check_response(req.send().await?).await }
58 + })
59 + .await?;
60 + Ok(resp.json().await?)
61 + }
62 +
63 + // ── Push / Pull ──
64 +
65 + /// Push changes to the server. Encrypts `data` fields automatically.
66 + /// Returns the server cursor after the push.
67 + ///
68 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
69 + #[instrument(skip(self, changes))]
70 + pub async fn push(
71 + &self,
72 + device_id: Uuid,
73 + changes: Vec<ChangeEntry>,
74 + ) -> Result<i64> {
75 + let token = self.require_token()?;
76 +
77 + // Extract key once for the entire batch (only needed if any entry has data)
78 + let has_data = changes.iter().any(|c| c.data.is_some());
79 + let key_holder = if has_data {
80 + self.require_master_key()?
81 + } else {
82 + crypto::ZeroizeOnDrop([0u8; 32])
83 + };
84 + let master_key: &[u8; 32] = &key_holder;
85 + let wire_changes = changes
86 + .into_iter()
87 + .map(|c| Self::encrypt_change_with_key(c, master_key))
88 + .collect::<Result<Vec<_>>>()?;
89 +
90 + let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
91 + device_id,
92 + changes: wire_changes,
93 + })?);
94 +
95 + let resp = self
96 + .retry_request(|| {
97 + let req = self
98 + .http
99 + .post(&self.endpoints.push)
100 + .bearer_auth(&token)
101 + .header("content-type", "application/json")
102 + .body(body.clone());
103 + async move { check_response(req.send().await?).await }
104 + })
105 + .await?;
106 +
107 + let push_resp: PushResponse = resp.json().await?;
108 + Ok(push_resp.cursor)
109 + }
110 +
111 + /// Pull changes from the server since the given cursor.
112 + /// Decrypts `data` fields automatically.
113 + /// Returns (changes, new_cursor, has_more).
114 + ///
115 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
116 + #[instrument(skip(self))]
117 + pub async fn pull(
118 + &self,
119 + device_id: Uuid,
120 + cursor: i64,
121 + ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
122 + let token = self.require_token()?;
123 +
124 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
125 +
126 + let resp = self
127 + .retry_request(|| {
128 + let req = self
129 + .http
130 + .post(&self.endpoints.pull)
131 + .bearer_auth(&token)
132 + .header("content-type", "application/json")
133 + .body(body.clone());
134 + async move { check_response(req.send().await?).await }
135 + })
136 + .await?;
137 +
138 + let pull_resp: PullResponse = resp.json().await?;
139 +
140 + // Extract key once for the entire batch (only needed if any entry has data)
141 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
142 + let key_holder = if has_data {
143 + self.require_master_key()?
144 + } else {
145 + crypto::ZeroizeOnDrop([0u8; 32])
146 + };
147 + let master_key: &[u8; 32] = &key_holder;
148 + let changes = pull_resp
149 + .changes
150 + .into_iter()
151 + .map(|c| Self::decrypt_change_with_key(c, master_key))
152 + .collect::<Result<Vec<_>>>()?;
153 +
154 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
155 + }
156 +
157 + /// Get sync status (total changes, latest cursor).
158 + #[instrument(skip(self))]
159 + pub async fn status(&self) -> Result<SyncStatus> {
160 + let token = self.require_token()?;
161 +
162 + let resp = self
163 + .retry_request(|| {
164 + let req = self.http.get(&self.endpoints.status).bearer_auth(&token);
165 + async move { check_response(req.send().await?).await }
166 + })
167 + .await?;
168 + Ok(resp.json().await?)
169 + }
170 + }
171 +
172 + #[cfg(test)]
173 + mod tests {
174 + use chrono::Utc;
175 + use uuid::Uuid;
176 +
177 + use crate::types::*;
178 +
179 + // ── Type serialization / deserialization ──
180 +
181 + #[test]
182 + fn change_entry_serialization_roundtrip() {
183 + let entry = ChangeEntry {
184 + table: "tasks".to_string(),
185 + op: ChangeOp::Insert,
186 + row_id: Uuid::new_v4().to_string(),
187 + timestamp: Utc::now(),
188 + data: Some(serde_json::json!({"title": "Test task", "done": false})),
189 + };
190 +
191 + let json = serde_json::to_string(&entry).unwrap();
192 + let deserialized: ChangeEntry = serde_json::from_str(&json).unwrap();
193 +
194 + assert_eq!(deserialized.table, entry.table);
195 + assert_eq!(deserialized.op, entry.op);
196 + assert_eq!(deserialized.row_id, entry.row_id);
197 + assert_eq!(deserialized.data, entry.data);
198 + }
199 +
200 + #[test]
201 + fn change_entry_with_none_data_omits_field() {
202 + let entry = ChangeEntry {
203 + table: "tasks".to_string(),
204 + op: ChangeOp::Delete,
205 + row_id: "abc-123".to_string(),
206 + timestamp: Utc::now(),
207 + data: None,
208 + };
209 +
210 + let json = serde_json::to_string(&entry).unwrap();
211 + assert!(!json.contains("\"data\""));
212 + }
213 +
214 + #[test]
215 + fn change_entry_deserialization_with_missing_data() {
216 + let json = r#"{
217 + "table": "events",
218 + "op": "DELETE",
219 + "row_id": "evt-1",
220 + "timestamp": "2025-01-15T10:00:00Z"
221 + }"#;
222 +
223 + let entry: ChangeEntry = serde_json::from_str(json).unwrap();
224 + assert_eq!(entry.table, "events");
225 + assert_eq!(entry.op, ChangeOp::Delete);
226 + assert!(entry.data.is_none());
227 + }
228 +
229 + #[test]
230 + fn device_serialization_roundtrip() {
231 + let device = Device {
232 + id: Uuid::new_v4(),
233 + app_id: Uuid::new_v4(),
234 + user_id: Uuid::new_v4(),
235 + device_name: "MacBook Pro".to_string(),
236 + platform: "macos".to_string(),
237 + last_seen_at: Utc::now(),
238 + created_at: Utc::now(),
239 + };
240 +
241 + let json = serde_json::to_string(&device).unwrap();
242 + let deserialized: Device = serde_json::from_str(&json).unwrap();
243 +
244 + assert_eq!(deserialized.id, device.id);
245 + assert_eq!(deserialized.device_name, device.device_name);
246 + assert_eq!(deserialized.platform, device.platform);
247 + }
248 +
249 + #[test]
250 + fn sync_status_deserialization() {
251 + let json = r#"{"total_changes": 42, "latest_cursor": 100}"#;
252 + let status: SyncStatus = serde_json::from_str(json).unwrap();
253 + assert_eq!(status.total_changes, 42);
254 + assert_eq!(status.latest_cursor, Some(100));
255 + }
256 +
257 + #[test]
258 + fn sync_status_with_null_cursor() {
259 + let json = r#"{"total_changes": 0, "latest_cursor": null}"#;
260 + let status: SyncStatus = serde_json::from_str(json).unwrap();
261 + assert_eq!(status.total_changes, 0);
262 + assert_eq!(status.latest_cursor, None);
263 + }
264 +
265 + #[test]
266 + fn register_device_request_serialization() {
267 + let req = RegisterDeviceRequest {
268 + device_name: "iPhone 15".to_string(),
269 + platform: "ios".to_string(),
270 + };
271 +
272 + let json = serde_json::to_string(&req).unwrap();
273 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
274 + assert_eq!(parsed["device_name"], "iPhone 15");
275 + assert_eq!(parsed["platform"], "ios");
276 + }
277 +
278 + // ── Wire types ──
279 +
280 + #[test]
281 + fn wire_push_request_serialization() {
282 + let device_id = Uuid::new_v4();
283 + let req = WirePushRequest {
284 + device_id,
285 + changes: vec![WireChangeEntry {
286 + table: "tasks".to_string(),
287 + op: ChangeOp::Insert,
288 + row_id: "r1".to_string(),
289 + timestamp: Utc::now(),
290 + data: Some(serde_json::json!("encrypted-blob")),
291 + }],
292 + };
293 +
294 + let json = serde_json::to_string(&req).unwrap();
295 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
296 + assert_eq!(parsed["device_id"], device_id.to_string());
297 + assert_eq!(parsed["changes"].as_array().unwrap().len(), 1);
298 + }
299 +
300 + #[test]
301 + fn pull_request_serialization() {
302 + let device_id = Uuid::new_v4();
303 + let req = PullRequest {
304 + device_id,
305 + cursor: 42,
306 + };
307 +
308 + let json = serde_json::to_string(&req).unwrap();
309 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
310 + assert_eq!(parsed["device_id"], device_id.to_string());
311 + assert_eq!(parsed["cursor"], 42);
312 + }
313 +
314 + #[test]
315 + fn pull_response_deserialization() {
316 + let device_id = Uuid::new_v4();
317 + let json = format!(
318 + r#"{{
319 + "changes": [
320 + {{
321 + "seq": 1,
322 + "device_id": "{}",
323 + "table": "tasks",
324 + "op": "INSERT",
325 + "row_id": "r1",
326 + "timestamp": "2025-06-01T12:00:00Z",
327 + "data": "encrypted"
328 + }}
329 + ],
330 + "cursor": 5,
331 + "has_more": true
332 + }}"#,
333 + device_id
334 + );
335 +
336 + let resp: PullResponse = serde_json::from_str(&json).unwrap();
337 + assert_eq!(resp.changes.len(), 1);
338 + assert_eq!(resp.cursor, 5);
339 + assert!(resp.has_more);
340 + assert_eq!(resp.changes[0].seq, 1);
341 + assert_eq!(resp.changes[0].table, "tasks");
342 + }
343 +
344 + #[test]
345 + fn pull_response_empty_changes() {
346 + let json = r#"{"changes": [], "cursor": 0, "has_more": false}"#;
347 + let resp: PullResponse = serde_json::from_str(json).unwrap();
348 + assert!(resp.changes.is_empty());
349 + assert_eq!(resp.cursor, 0);
350 + assert!(!resp.has_more);
351 + }
352 +
353 + #[test]
354 + fn push_response_deserialization() {
355 + let json = r#"{"cursor": 99}"#;
356 + let resp: PushResponse = serde_json::from_str(json).unwrap();
357 + assert_eq!(resp.cursor, 99);
358 + }
359 +
360 + // ── ChangeOp display and parsing ──
361 +
362 + #[test]
363 + fn change_op_display() {
364 + assert_eq!(ChangeOp::Insert.to_string(), "INSERT");
365 + assert_eq!(ChangeOp::Update.to_string(), "UPDATE");
366 + assert_eq!(ChangeOp::Delete.to_string(), "DELETE");
367 + }
368 +
369 + #[test]
370 + fn change_op_from_str_valid() {
371 + assert_eq!(ChangeOp::from_str_opt("INSERT"), Some(ChangeOp::Insert));
372 + assert_eq!(ChangeOp::from_str_opt("UPDATE"), Some(ChangeOp::Update));
373 + assert_eq!(ChangeOp::from_str_opt("DELETE"), Some(ChangeOp::Delete));
374 + }
375 +
376 + #[test]
377 + fn change_op_from_str_invalid() {
378 + assert_eq!(ChangeOp::from_str_opt("insert"), None);
379 + assert_eq!(ChangeOp::from_str_opt("UPSERT"), None);
380 + assert_eq!(ChangeOp::from_str_opt(""), None);
381 + }
382 +
383 + // ── Malformed response types ──
384 +
385 + #[test]
386 + fn pull_response_missing_changes_fails() {
387 + let json = r#"{"cursor": 0, "has_more": false}"#;
388 + assert!(serde_json::from_str::<PullResponse>(json).is_err());
389 + }
390 +
391 + #[test]
392 + fn pull_response_missing_cursor_fails() {
393 + let json = r#"{"changes": [], "has_more": false}"#;
394 + assert!(serde_json::from_str::<PullResponse>(json).is_err());
395 + }
396 +
397 + #[test]
398 + fn push_response_missing_cursor_fails() {
399 + let json = r#"{}"#;
400 + assert!(serde_json::from_str::<PushResponse>(json).is_err());
401 + }
402 + }
M src/crypto.rs +30 -23
@@ -83,7 +83,7 @@ fn normalize_password(password: &str) -> Result<String> {
83 83 fn derive_wrapping_key(
84 84 password: &str,
85 85 salt: &[u8; 32],
86 - ) -> Result<[u8; KEY_SIZE]> {
86 + ) -> Result<ZeroizeOnDrop> {
87 87 let normalized = normalize_password(password)?;
88 88
89 89 let params = Params::new(
@@ -96,9 +96,9 @@ fn derive_wrapping_key(
96 96
97 97 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
98 98
99 - let mut wrapping_key = [0u8; KEY_SIZE];
99 + let mut wrapping_key = ZeroizeOnDrop([0u8; KEY_SIZE]);
100 100 argon2
101 - .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key)
101 + .hash_password_into(normalized.as_bytes(), salt, &mut wrapping_key.0)
102 102 .map_err(|e| SyncKitError::Crypto(format!("Argon2 hash: {e}")))?;
103 103
104 104 Ok(wrapping_key)
@@ -135,7 +135,7 @@ pub fn wrap_master_key(
135 135 let mut nonce_bytes = [0u8; NONCE_SIZE];
136 136 rand::thread_rng().fill_bytes(&mut nonce_bytes);
137 137
138 - let cipher = XChaCha20Poly1305::new((&wrapping_key).into());
138 + let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
139 139 let nonce = XNonce::from_slice(&nonce_bytes);
140 140
141 141 let ciphertext = cipher
@@ -192,7 +192,7 @@ pub fn unwrap_master_key(
192 192 salt.copy_from_slice(&salt_bytes);
193 193 let wrapping_key = derive_wrapping_key(password, &salt)?;
194 194
195 - let cipher = XChaCha20Poly1305::new((&wrapping_key).into());
195 + let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
196 196 let nonce = XNonce::from_slice(&nonce_bytes);
197 197
198 198 let plaintext = cipher
@@ -311,9 +311,11 @@ pub fn encrypt_json(
311 311 value: &serde_json::Value,
312 312 master_key: &[u8; KEY_SIZE],
313 313 ) -> Result<serde_json::Value> {
314 - let plaintext = serde_json::to_vec(value)?;
315 - let encrypted = encrypt_data(&plaintext, master_key)?;
316 - Ok(serde_json::Value::String(encrypted))
314 + use zeroize::Zeroize;
315 + let mut plaintext = serde_json::to_vec(value)?;
316 + let encrypted = encrypt_data(&plaintext, master_key);
317 + plaintext.zeroize();
318 + Ok(serde_json::Value::String(encrypted?))
317 319 }
318 320
319 321 /// Decrypt a JSON string from the `data` field back into the original value.
@@ -321,25 +323,30 @@ pub fn decrypt_json(
321 323 encrypted_value: &serde_json::Value,
322 324 master_key: &[u8; KEY_SIZE],
323 325 ) -> Result<serde_json::Value> {
326 + use zeroize::Zeroize;
324 327 let encoded = encrypted_value
325 328 .as_str()
326 329 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
327 330
328 - let plaintext = decrypt_data(encoded, master_key)?;
329 - serde_json::from_slice(&plaintext).map_err(Into::into)
331 + let mut plaintext = decrypt_data(encoded, master_key)?;
332 + let result = serde_json::from_slice(&plaintext);
333 + plaintext.zeroize();
334 + result.map_err(Into::into)
330 335 }
331 336
332 - /// Zero out a key on drop (best-effort memory defense).
337 + /// Zero out a key on drop using the `zeroize` crate.
333 338 pub(crate) struct ZeroizeOnDrop(pub(crate) [u8; KEY_SIZE]);
334 339
340 + impl std::fmt::Debug for ZeroizeOnDrop {
341 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 + f.write_str("[REDACTED 32-byte key]")
343 + }
344 + }
345 +
335 346 impl Drop for ZeroizeOnDrop {
336 347 fn drop(&mut self) {
337 - // Volatile write to prevent optimization
338 - for byte in self.0.iter_mut() {
339 - unsafe {
340 - std::ptr::write_volatile(byte, 0);
341 - }
342 - }
348 + use zeroize::Zeroize;
349 + self.0.zeroize();
343 350 }
344 351 }
345 352
@@ -367,7 +374,7 @@ mod tests {
367 374 let salt = [42u8; 32];
368 375 let k1 = derive_wrapping_key("password123", &salt).unwrap();
369 376 let k2 = derive_wrapping_key("password123", &salt).unwrap();
370 - assert_eq!(k1, k2, "Same inputs must produce same wrapping key");
377 + assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
371 378 }
372 379
373 380 #[test]
@@ -375,7 +382,7 @@ mod tests {
375 382 let salt = [42u8; 32];
376 383 let k1 = derive_wrapping_key("password1", &salt).unwrap();
377 384 let k2 = derive_wrapping_key("password2", &salt).unwrap();
378 - assert_ne!(k1, k2);
385 + assert_ne!(*k1, *k2);
379 386 }
380 387
381 388 #[test]
@@ -384,7 +391,7 @@ mod tests {
384 391 let salt2 = [2u8; 32];
385 392 let k1 = derive_wrapping_key("password", &salt1).unwrap();
386 393 let k2 = derive_wrapping_key("password", &salt2).unwrap();
387 - assert_ne!(k1, k2);
394 + assert_ne!(*k1, *k2);
388 395 }
389 396
390 397 // ── Password normalization (NFC/NFD) ──
@@ -407,7 +414,7 @@ mod tests {
407 414 let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
408 415 let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
409 416 assert_eq!(
410 - k1, k2,
417 + *k1, *k2,
411 418 "Same password in NFC and NFD forms must derive the same key"
412 419 );
413 420 }
@@ -532,8 +539,8 @@ mod tests {
532 539 let k2 = derive_wrapping_key(password, &salt).unwrap();
533 540 let k3 = derive_wrapping_key(password, &salt).unwrap();
534 541
535 - assert_eq!(k1, k2);
536 - assert_eq!(k2, k3);
542 + assert_eq!(*k1, *k2);
543 + assert_eq!(*k2, *k3);
537 544 }
538 545
539 546 // ── Key rotation: re-wrap with new password, old data still readable ──