Skip to main content

max / makenotwork

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