Skip to main content

max / makenotwork

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