Skip to main content

max / synckit-client

17.7 KB · 556 lines History Blame Raw
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 subscribe;
57 mod sync;
58
59 pub use subscribe::SyncNotifyStream;
60
61 use parking_lot::RwLock;
62 use reqwest::Client;
63 use std::sync::Arc;
64 use std::time::Duration;
65 use uuid::Uuid;
66
67 use crate::{
68 crypto,
69 error::{Result, SyncKitError},
70 };
71
72 /// Maximum number of retry attempts for transient failures.
73 const MAX_RETRIES: u32 = 3;
74
75 /// Base delay for exponential backoff (1s, 2s, 4s).
76 const BASE_DELAY: Duration = Duration::from_secs(1);
77
78 /// Seconds before actual expiry to consider the token expired.
79 /// Avoids sending a request with a token that expires mid-flight.
80 const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
81
82 /// Configuration for the SyncKit client.
83 #[derive(Debug, Clone)]
84 pub struct SyncKitConfig {
85 /// Base URL of the MNW server (e.g. "https://makenot.work").
86 pub server_url: String,
87 /// App API key (obtained from MNW dashboard).
88 pub api_key: String,
89 }
90
91 /// Pre-built endpoint URLs, computed once at client construction.
92 struct Endpoints {
93 auth: String,
94 oauth_token: String,
95 devices: String,
96 push: String,
97 pull: String,
98 subscribe: String,
99 status: String,
100 keys: String,
101 blobs_upload: String,
102 blobs_confirm: String,
103 blobs_download: String,
104 }
105
106 impl Endpoints {
107 fn new(base: &str) -> Self {
108 Self {
109 auth: format!("{base}/api/sync/auth"),
110 oauth_token: format!("{base}/oauth/token"),
111 devices: format!("{base}/api/sync/devices"),
112 push: format!("{base}/api/sync/push"),
113 pull: format!("{base}/api/sync/pull"),
114 subscribe: format!("{base}/api/sync/subscribe"),
115 status: format!("{base}/api/sync/status"),
116 keys: format!("{base}/api/sync/keys"),
117 blobs_upload: format!("{base}/api/sync/blobs/upload"),
118 blobs_confirm: format!("{base}/api/sync/blobs/confirm"),
119 blobs_download: format!("{base}/api/sync/blobs/download"),
120 }
121 }
122 }
123
124 /// Session state obtained after authentication.
125 struct Session {
126 token: Arc<String>,
127 /// Cached `exp` claim from the JWT, extracted once at session creation.
128 token_exp: Option<i64>,
129 user_id: Uuid,
130 app_id: Uuid,
131 }
132
133 /// Public session info returned by `session_info()`.
134 pub struct SessionInfo {
135 /// The JWT bearer token for API requests (shared ref-counted to avoid cloning).
136 pub token: Arc<String>,
137 /// The authenticated user's UUID.
138 pub user_id: Uuid,
139 /// The SyncKit app UUID this session belongs to.
140 pub app_id: Uuid,
141 }
142
143 /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
144 pub struct SyncKitClient {
145 config: SyncKitConfig,
146 http: Client,
147 endpoints: Endpoints,
148 session: RwLock<Option<Session>>,
149 master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
150 }
151
152 impl SyncKitClient {
153 /// Create a new client with the given configuration.
154 pub fn new(config: SyncKitConfig) -> Self {
155 let http = Client::builder()
156 .timeout(Duration::from_secs(30))
157 .connect_timeout(Duration::from_secs(10))
158 .pool_max_idle_per_host(5)
159 .pool_idle_timeout(Duration::from_secs(90))
160 .build()
161 .expect("failed to build HTTP client");
162
163 let endpoints = Endpoints::new(&config.server_url);
164 Self {
165 config,
166 http,
167 endpoints,
168 session: RwLock::new(None),
169 master_key: RwLock::new(None),
170 }
171 }
172
173 /// Create a new client with a custom HTTP client (for testing with custom timeouts).
174 #[doc(hidden)]
175 pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self {
176 let endpoints = Endpoints::new(&config.server_url);
177 Self {
178 config,
179 http,
180 endpoints,
181 session: RwLock::new(None),
182 master_key: RwLock::new(None),
183 }
184 }
185
186 /// Returns the client configuration.
187 pub fn config(&self) -> &SyncKitConfig {
188 &self.config
189 }
190
191 /// Returns whether the master encryption key is loaded and ready.
192 pub fn has_master_key(&self) -> bool {
193 self.master_key.read().is_some()
194 }
195
196 /// Returns the current session info, if authenticated.
197 pub fn session_info(&self) -> Option<SessionInfo> {
198 let guard = self.session.read();
199 guard.as_ref().map(|s| SessionInfo {
200 token: Arc::clone(&s.token),
201 user_id: s.user_id,
202 app_id: s.app_id,
203 })
204 }
205
206 /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
207 #[doc(hidden)]
208 pub fn set_master_key_raw(&self, key: [u8; 32]) {
209 *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
210 }
211
212 // ── Internal helpers ──
213
214 /// Extract the bearer token from the current session.
215 ///
216 /// Returns `NotAuthenticated` if no session exists. Also checks token
217 /// expiry and returns `TokenExpired` if the JWT `exp` claim is within
218 /// 30 seconds of the current time.
219 pub(crate) fn require_token(&self) -> Result<Arc<String>> {
220 let guard = self.session.read();
221 let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
222
223 if let Some(exp) = session.token_exp {
224 let now = chrono::Utc::now().timestamp();
225 if now >= exp - TOKEN_EXPIRY_BUFFER_SECS {
226 return Err(SyncKitError::TokenExpired);
227 }
228 }
229
230 Ok(Arc::clone(&session.token))
231 }
232
233 /// Extract `(app_id, user_id)` from the current session.
234 ///
235 /// Returns `NotAuthenticated` if no session exists.
236 pub(crate) fn require_session_ids(&self) -> Result<(Uuid, Uuid)> {
237 let guard = self.session.read();
238 guard
239 .as_ref()
240 .map(|s| (s.app_id, s.user_id))
241 .ok_or(SyncKitError::NotAuthenticated)
242 }
243
244 /// Return a copy of the 256-bit master encryption key, wrapped in
245 /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`.
246 ///
247 /// Returns `NoMasterKey` if encryption has not been set up yet.
248 pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> {
249 let guard = self.master_key.read();
250 guard
251 .as_ref()
252 .map(|k| crypto::ZeroizeOnDrop(**k))
253 .ok_or(SyncKitError::NoMasterKey)
254 }
255 }
256
257 /// Validate an API key against a SyncKit server without constructing a full client.
258 ///
259 /// Returns the app name on success, or an error if the key is invalid or the server
260 /// is unreachable. This is intended for setup UIs that need to verify a key before
261 /// saving it.
262 pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result<String> {
263 let url = format!("{server_url}/api/sync/validate-app");
264 let http = reqwest::Client::builder()
265 .timeout(std::time::Duration::from_secs(10))
266 .build()?;
267 let resp = http
268 .get(&url)
269 .query(&[("api_key", api_key)])
270 .send()
271 .await?;
272 let status = resp.status().as_u16();
273 if status == 401 {
274 return Err(SyncKitError::Server {
275 status: 401,
276 message: "Invalid API key".to_string(),
277 });
278 }
279 let resp = helpers::check_response(resp).await?;
280 #[derive(serde::Deserialize)]
281 struct ValidateResponse {
282 app_name: String,
283 }
284 let body: ValidateResponse = resp.json().await?;
285 Ok(body.app_name)
286 }
287
288 #[cfg(test)]
289 mod tests {
290 use super::*;
291 use base64::Engine;
292
293 fn test_config() -> SyncKitConfig {
294 SyncKitConfig {
295 server_url: "https://example.com".to_string(),
296 api_key: "test-api-key-123".to_string(),
297 }
298 }
299
300 fn test_ids() -> (Uuid, Uuid) {
301 (
302 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
303 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
304 )
305 }
306
307 // ── SyncKitClient::new() construction ──
308
309 #[test]
310 fn new_client_starts_unauthenticated() {
311 let client = SyncKitClient::new(test_config());
312 assert!(client.session_info().is_none());
313 }
314
315 #[test]
316 fn new_client_has_no_master_key() {
317 let client = SyncKitClient::new(test_config());
318 assert!(!client.has_master_key());
319 }
320
321 #[test]
322 fn config_returns_provided_values() {
323 let client = SyncKitClient::new(test_config());
324 assert_eq!(client.config().server_url, "https://example.com");
325 assert_eq!(client.config().api_key, "test-api-key-123");
326 }
327
328 // ── SyncKitConfig ──
329
330 #[test]
331 fn config_clone() {
332 let config = test_config();
333 let cloned = config.clone();
334 assert_eq!(cloned.server_url, config.server_url);
335 assert_eq!(cloned.api_key, config.api_key);
336 }
337
338 #[test]
339 fn config_debug() {
340 let config = test_config();
341 let debug = format!("{:?}", config);
342 assert!(debug.contains("SyncKitConfig"));
343 assert!(debug.contains("example.com"));
344 }
345
346 // ── require_token ──
347
348 #[test]
349 fn require_token_fails_without_session() {
350 let client = SyncKitClient::new(test_config());
351 let err = client.require_token().unwrap_err();
352 assert!(matches!(err, SyncKitError::NotAuthenticated));
353 }
354
355 #[test]
356 fn require_token_succeeds_with_session() {
357 let client = SyncKitClient::new(test_config());
358 let (app_id, user_id) = test_ids();
359 client.restore_session("my-token", user_id, app_id);
360
361 let token = client.require_token().unwrap();
362 assert_eq!(*token, "my-token");
363 }
364
365 // ── require_session_ids ──
366
367 #[test]
368 fn require_session_ids_fails_without_session() {
369 let client = SyncKitClient::new(test_config());
370 let err = client.require_session_ids().unwrap_err();
371 assert!(matches!(err, SyncKitError::NotAuthenticated));
372 }
373
374 #[test]
375 fn require_session_ids_returns_correct_ids() {
376 let client = SyncKitClient::new(test_config());
377 let (app_id, user_id) = test_ids();
378 client.restore_session("token", user_id, app_id);
379
380 let (returned_app, returned_user) = client.require_session_ids().unwrap();
381 assert_eq!(returned_app, app_id);
382 assert_eq!(returned_user, user_id);
383 }
384
385 // ── require_master_key ──
386
387 #[test]
388 fn require_master_key_fails_without_key() {
389 let client = SyncKitClient::new(test_config());
390 let err = client.require_master_key().unwrap_err();
391 assert!(matches!(err, SyncKitError::NoMasterKey));
392 }
393
394 #[test]
395 fn require_master_key_succeeds_after_set() {
396 let client = SyncKitClient::new(test_config());
397 let test_key = [42u8; 32];
398 *client.master_key.write() = Some(crypto::ZeroizeOnDrop(test_key));
399
400 let key = client.require_master_key().unwrap();
401 assert_eq!(*key, test_key);
402 }
403
404 // ── has_master_key ──
405
406 #[test]
407 fn has_master_key_false_initially() {
408 let client = SyncKitClient::new(test_config());
409 assert!(!client.has_master_key());
410 }
411
412 #[test]
413 fn has_master_key_true_after_set() {
414 let client = SyncKitClient::new(test_config());
415 *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32]));
416 assert!(client.has_master_key());
417 }
418
419 // ── set_master_key_raw ──
420
421 #[test]
422 fn set_master_key_raw_makes_key_available() {
423 let client = SyncKitClient::new(test_config());
424 assert!(!client.has_master_key());
425
426 let key = [99u8; 32];
427 client.set_master_key_raw(key);
428
429 assert!(client.has_master_key());
430 assert_eq!(*client.require_master_key().unwrap(), key);
431 }
432
433 #[test]
434 fn set_master_key_raw_overwrites_previous() {
435 let client = SyncKitClient::new(test_config());
436 let key1 = [1u8; 32];
437 let key2 = [2u8; 32];
438
439 client.set_master_key_raw(key1);
440 assert_eq!(*client.require_master_key().unwrap(), key1);
441
442 client.set_master_key_raw(key2);
443 assert_eq!(*client.require_master_key().unwrap(), key2);
444 }
445
446 // ── with_http_client constructor ──
447
448 #[test]
449 fn with_http_client_starts_unauthenticated() {
450 let http = Client::builder()
451 .timeout(Duration::from_millis(100))
452 .build()
453 .unwrap();
454 let client = SyncKitClient::with_http_client(test_config(), http);
455 assert!(client.session_info().is_none());
456 assert!(!client.has_master_key());
457 }
458
459 // ── Send + Sync assertions ──
460
461 #[test]
462 fn client_is_send_and_sync() {
463 fn assert_send_sync<T: Send + Sync>() {}
464 assert_send_sync::<SyncKitClient>();
465 }
466
467 // ── Config edge case ──
468
469 #[test]
470 fn config_with_trailing_slash_url() {
471 let config = SyncKitConfig {
472 server_url: "https://example.com/".to_string(),
473 api_key: "key".to_string(),
474 };
475 let client = SyncKitClient::new(config);
476 assert_eq!(client.config().server_url, "https://example.com/");
477 }
478
479 // ── SyncKitError Display ──
480
481 #[test]
482 fn error_display_not_authenticated() {
483 let err = SyncKitError::NotAuthenticated;
484 assert!(err.to_string().contains("Not authenticated"));
485 }
486
487 #[test]
488 fn error_display_no_master_key() {
489 let err = SyncKitError::NoMasterKey;
490 assert!(err.to_string().contains("Encryption not initialized"));
491 }
492
493 #[test]
494 fn error_display_server() {
495 let err = SyncKitError::Server { status: 500, message: "boom".to_string() };
496 let msg = err.to_string();
497 assert!(msg.contains("500"));
498 assert!(msg.contains("boom"));
499 }
500
501 #[test]
502 fn error_display_decryption_failed() {
503 let err = SyncKitError::DecryptionFailed;
504 assert!(err.to_string().contains("Wrong password"));
505 }
506
507 #[test]
508 fn error_display_invalid_envelope() {
509 let err = SyncKitError::InvalidEnvelope("bad version".to_string());
510 let msg = err.to_string();
511 assert!(msg.contains("Invalid key envelope"));
512 assert!(msg.contains("bad version"));
513 }
514
515 #[test]
516 fn error_display_crypto() {
517 let err = SyncKitError::Crypto("aead failed".to_string());
518 let msg = err.to_string();
519 assert!(msg.contains("Encryption error"));
520 assert!(msg.contains("aead failed"));
521 }
522
523 #[test]
524 fn error_display_token_expired() {
525 let err = SyncKitError::TokenExpired;
526 assert!(err.to_string().contains("Token expired"));
527 }
528
529 // ── SyncKitError conversions ──
530
531 #[test]
532 fn error_from_serde_json() {
533 let err: SyncKitError = serde_json::from_str::<serde_json::Value>("{{bad}}")
534 .unwrap_err()
535 .into();
536 assert!(matches!(err, SyncKitError::Json(_)));
537 assert!(err.to_string().contains("JSON"));
538 }
539
540 #[test]
541 fn error_from_base64() {
542 let err: SyncKitError = base64::engine::general_purpose::STANDARD
543 .decode("!!!bad!!!")
544 .unwrap_err()
545 .into();
546 assert!(matches!(err, SyncKitError::Base64(_)));
547 assert!(err.to_string().contains("Base64"));
548 }
549
550 #[test]
551 fn error_internal_contains_message() {
552 let err = SyncKitError::Internal("test internal error".to_string());
553 assert!(err.to_string().contains("test internal error"));
554 }
555 }
556