Skip to main content

max / synckit

32.6 KB · 890 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_streaming`](SyncKitClient::blob_upload_streaming)
22 //! (file-backed, bounded memory, the path for large blobs),
23 //! [`blob_upload_url`](SyncKitClient::blob_upload_url) +
24 //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory),
25 //! [`blob_confirm`](SyncKitClient::blob_confirm),
26 //! [`blob_download_url`](SyncKitClient::blob_download_url),
27 //! [`blob_download`](SyncKitClient::blob_download).
28 //!
29 //! ## Internal state
30 //!
31 //! The client holds two `RwLock`-wrapped fields: the authenticated session
32 //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both
33 //! start as `None` and are populated by the authentication and encryption
34 //! setup methods respectively.
35 //!
36 //! ## Thread safety
37 //!
38 //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public
39 //! methods take `&self`, acquiring the internal locks only briefly to read
40 //! or update state. The locks are never held across `.await` points.
41 //!
42 //! ## Retry strategy
43 //!
44 //! All HTTP operations retry transient failures (network errors, 5xx,
45 //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors
46 //! (4xx except 429) are permanent and returned immediately.
47 //!
48 //! ## Token handling
49 //!
50 //! The client decodes the JWT `exp` claim (without signature verification)
51 //! and applies a 30-second expiry buffer. If the token is about to expire,
52 //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller
53 //! can re-authenticate before the request fails on the server.
54
55 mod auth;
56 mod blob;
57 mod groups;
58 pub use blob::BlobUploadOutcome;
59 mod encryption;
60 pub(crate) mod helpers;
61 mod ota;
62 mod rotation;
63 mod subscribe;
64 pub mod subscription;
65 mod sync;
66
67 pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease};
68 pub use subscribe::SyncNotifyStream;
69
70 use parking_lot::RwLock;
71 use reqwest::Client;
72 use std::sync::Arc;
73 use std::time::Duration;
74 #[cfg(test)]
75 use uuid::Uuid;
76
77 use crate::{
78 crypto,
79 error::{Result, SyncKitError},
80 ids::{AppId, GroupId, UserId},
81 };
82
83 /// Maximum number of retry attempts for transient failures.
84 const MAX_RETRIES: u32 = 3;
85
86 /// Base delay for exponential backoff (1s, 2s, 4s).
87 const BASE_DELAY: Duration = Duration::from_secs(1);
88
89 /// Seconds before actual expiry to consider the token expired.
90 /// Avoids sending a request with a token that expires mid-flight.
91 const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
92
93 /// Inactivity (read) timeout for the streaming HTTP client. Bounds the gap
94 /// between successive body reads without capping the total transfer, so a stalled
95 /// (slow-loris) blob/OTA/SSE connection is torn down while a legitimately long,
96 /// steady transfer is not. Kept above the server's 30s SSE keepalive interval so
97 /// an idle notification stream is never mistaken for a stall.
98 const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90);
99
100 /// Configuration for the SyncKit client.
101 #[derive(Debug, Clone)]
102 pub struct SyncKitConfig {
103 /// Base URL of the MNW server (e.g. "https://makenot.work").
104 pub server_url: String,
105 /// App API key (obtained from MNW dashboard).
106 pub api_key: String,
107 }
108
109 /// Pre-built endpoint URLs, computed once at client construction.
110 struct Endpoints {
111 auth: String,
112 oauth_token: String,
113 devices: String,
114 push: String,
115 pull: String,
116 subscribe: String,
117 status: String,
118 keys: String,
119 groups_base: String,
120 blobs_upload: String,
121 blobs_confirm: String,
122 blobs_download: String,
123 blobs_multipart_start: String,
124 blobs_multipart_parts: String,
125 blobs_multipart_complete: String,
126 blobs_multipart_abort: String,
127 subscription: String,
128 subscription_checkout: String,
129 subscription_quote: String,
130 subscription_storage_cap: String,
131 app_pricing: String,
132 account: String,
133 /// Base for OTA paths (`{server}/api/v1/sync/ota`). The app-scoped and public
134 /// OTA URLs carry runtime ids (app, release, slug/target/arch/version) so they
135 /// cannot be pre-built like the static endpoints above; the `ota_*` builder
136 /// methods construct them from this base, keeping *all* path construction in
137 /// this one type instead of re-deriving it with `format!` in `ota.rs`.
138 ota_base: String,
139 }
140
141 impl Endpoints {
142 fn new(base: &str) -> Self {
143 let base = base.trim_end_matches('/');
144 Self {
145 auth: format!("{base}/api/v1/sync/auth"),
146 oauth_token: format!("{base}/oauth/token"),
147 devices: format!("{base}/api/v1/sync/devices"),
148 push: format!("{base}/api/v1/sync/push"),
149 pull: format!("{base}/api/v1/sync/pull"),
150 subscribe: format!("{base}/api/v1/sync/subscribe"),
151 status: format!("{base}/api/v1/sync/status"),
152 keys: format!("{base}/api/v1/sync/keys"),
153 blobs_upload: format!("{base}/api/v1/sync/blobs/upload"),
154 blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"),
155 blobs_download: format!("{base}/api/v1/sync/blobs/download"),
156 blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"),
157 blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"),
158 blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"),
159 blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"),
160 subscription: format!("{base}/api/v1/sync/subscription"),
161 subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"),
162 subscription_quote: format!("{base}/api/v1/sync/subscription/quote"),
163 subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"),
164 app_pricing: format!("{base}/api/v1/sync/app/pricing"),
165 account: format!("{base}/api/v1/sync/account"),
166 ota_base: format!("{base}/api/v1/sync/ota"),
167 groups_base: format!("{base}/api/v1/sync/groups"),
168 }
169 }
170
171 /// `GET`/`POST` the group collection: list the caller's groups, or create one.
172 fn groups(&self) -> &str {
173 &self.groups_base
174 }
175
176 /// `GET` here for the caller's own sealed GCK grant for a group.
177 fn group_grant(&self, group_id: GroupId) -> String {
178 format!("{}/{group_id}/grant", self.groups_base)
179 }
180
181 /// `POST` here to add a member to a group (admin only).
182 fn group_members(&self, group_id: GroupId) -> String {
183 format!("{}/{group_id}/members", self.groups_base)
184 }
185
186 /// `DELETE` here to remove a member from a group (admin only).
187 fn group_member(&self, group_id: GroupId, member: UserId) -> String {
188 format!("{}/{group_id}/members/{member}", self.groups_base)
189 }
190
191 /// `POST` encrypted changes to a group's shared changelog.
192 fn group_push(&self, group_id: GroupId) -> String {
193 format!("{}/{group_id}/push", self.groups_base)
194 }
195
196 /// `POST` to pull a group's changes since a cursor.
197 fn group_pull(&self, group_id: GroupId) -> String {
198 format!("{}/{group_id}/pull", self.groups_base)
199 }
200
201 /// `POST` here to create a release, or list releases, for an app.
202 fn ota_releases(&self, app_id: AppId) -> String {
203 format!("{}/apps/{app_id}/releases", self.ota_base)
204 }
205
206 /// `POST` here to register an artifact under a release.
207 fn ota_artifacts(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
208 format!(
209 "{}/apps/{app_id}/releases/{release_id}/artifacts",
210 self.ota_base
211 )
212 }
213
214 /// `POST` here to confirm an uploaded artifact under a release.
215 fn ota_confirm(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
216 format!(
217 "{}/apps/{app_id}/releases/{release_id}/artifacts/confirm",
218 self.ota_base
219 )
220 }
221
222 /// The public, unauthenticated Tauri updater check URL.
223 fn ota_updater(&self, slug: &str, target: &str, arch: &str, current_version: &str) -> String {
224 format!("{}/{slug}/{target}/{arch}/{current_version}", self.ota_base)
225 }
226 }
227
228 /// A bearer token whose bytes are scrubbed from memory when the last reference
229 /// is dropped.
230 ///
231 /// The SDK holds the session's JWT for the lifetime of the session; wrapping it
232 /// so its final drop zeroizes the heap keeps the credential from lingering after
233 /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and
234 /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer
235 /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is
236 /// redacted so the token cannot leak into a log line.
237 pub struct SecretToken(String);
238
239 impl SecretToken {
240 pub(crate) fn new(token: String) -> Self {
241 SecretToken(token)
242 }
243
244 /// The raw token string, for building the `Authorization` header.
245 pub fn as_str(&self) -> &str {
246 &self.0
247 }
248 }
249
250 impl std::ops::Deref for SecretToken {
251 type Target = str;
252 fn deref(&self) -> &str {
253 &self.0
254 }
255 }
256
257 impl std::fmt::Display for SecretToken {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 f.write_str(&self.0)
260 }
261 }
262
263 impl std::fmt::Debug for SecretToken {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 f.write_str("SecretToken(<redacted>)")
266 }
267 }
268
269 impl Drop for SecretToken {
270 fn drop(&mut self) {
271 use zeroize::Zeroize;
272 self.0.zeroize();
273 }
274 }
275
276 /// Session state obtained after authentication.
277 struct Session {
278 token: Arc<SecretToken>,
279 /// Cached `exp` claim from the JWT, extracted once at session creation.
280 token_exp: Option<i64>,
281 user_id: UserId,
282 app_id: AppId,
283 }
284
285 /// Public session info returned by `session_info()`.
286 pub struct SessionInfo {
287 /// The JWT bearer token for API requests (shared ref-counted to avoid
288 /// cloning; zeroized when the last reference drops).
289 pub token: Arc<SecretToken>,
290 /// The authenticated user's UUID.
291 pub user_id: UserId,
292 /// The SyncKit app UUID this session belongs to.
293 pub app_id: AppId,
294 }
295
296 /// Info about a pending key rotation, cached from `GET /keys`.
297 pub(crate) struct PendingKeyState {
298 pub key: crypto::ZeroizeOnDrop,
299 pub key_id: i32,
300 }
301
302 /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
303 pub struct SyncKitClient {
304 config: SyncKitConfig,
305 /// HTTP client for small JSON control-plane calls. Carries a whole-request
306 /// timeout, those calls should never run long.
307 http: Client,
308 /// HTTP client for large-body and long-lived transfers (blob up/download,
309 /// OTA artifacts, the SSE notification stream). Deliberately has NO
310 /// whole-request timeout, a multi-gigabyte blob or an open push stream must
311 /// not be torn down by a fixed clock, only a connect timeout.
312 http_stream: Client,
313 endpoints: Endpoints,
314 session: RwLock<Option<Session>>,
315 master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
316 /// The key_id associated with the current master_key. Default 1 (pre-rotation).
317 master_key_id: RwLock<i32>,
318 /// Pending rotation key, if a rotation is in progress.
319 pending_key: RwLock<Option<PendingKeyState>>,
320 /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version,
321 /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key)
322 /// from the sealed grant; a version mismatch (rotation) or a decrypt failure
323 /// forces a re-fetch. Holds one entry per group, the current generation.
324 gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>,
325 }
326
327 impl SyncKitClient {
328 /// Create a new client with the given configuration.
329 pub fn new(config: SyncKitConfig) -> Self {
330 // reqwest is built with `rustls-no-provider`; a real consumer app installs the
331 // process-wide crypto provider (audiofiles installs ring) before it ever builds
332 // a client. Unit tests have no such app, so install ring here once, or the
333 // client build below would panic. Never compiled into a consumer build.
334 #[cfg(test)]
335 {
336 static PROVIDER: std::sync::Once = std::sync::Once::new();
337 PROVIDER.call_once(|| {
338 let _ = rustls::crypto::ring::default_provider().install_default();
339 });
340 }
341
342 let https_only = requires_https(&config.server_url);
343 let http = Client::builder()
344 .timeout(Duration::from_secs(30))
345 .connect_timeout(Duration::from_secs(10))
346 .pool_max_idle_per_host(5)
347 .pool_idle_timeout(Duration::from_secs(90))
348 .https_only(https_only)
349 .build()
350 .expect("failed to build HTTP client");
351
352 // No whole-request timeout: this client carries multi-gigabyte blobs and
353 // the long-lived SSE stream, which a fixed 30s cap would break. It DOES
354 // carry a read (inactivity) timeout: without one, a server that dribbles
355 // one byte an hour holds a blob/OTA/SSE connection open forever (slow-
356 // loris). The cap bounds the gap *between* reads, not the total transfer,
357 // so a legitimate slow-but-steady transfer is unaffected. 90s sits well
358 // above the server's 30s SSE keepalive, so an idle notification stream
359 // (which receives a keepalive comment every 30s) is never torn down.
360 let http_stream = Client::builder()
361 .connect_timeout(Duration::from_secs(10))
362 .read_timeout(STREAM_READ_TIMEOUT)
363 .pool_max_idle_per_host(5)
364 .pool_idle_timeout(Duration::from_secs(90))
365 .https_only(https_only)
366 .build()
367 .expect("failed to build streaming HTTP client");
368
369 let endpoints = Endpoints::new(&config.server_url);
370 Self {
371 config,
372 http,
373 http_stream,
374 endpoints,
375 session: RwLock::new(None),
376 master_key: RwLock::new(None),
377 master_key_id: RwLock::new(1),
378 pending_key: RwLock::new(None),
379 gck_cache: RwLock::new(std::collections::HashMap::new()),
380 }
381 }
382
383 /// Create a new client with a custom HTTP client (for testing with custom timeouts).
384 ///
385 /// Test-only: gated behind the `testing` feature so consumer builds cannot
386 /// substitute an unvalidated HTTP client.
387 #[doc(hidden)]
388 #[cfg(any(test, feature = "testing"))]
389 pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self {
390 let endpoints = Endpoints::new(&config.server_url);
391 Self {
392 config,
393 http: http.clone(),
394 http_stream: http,
395 endpoints,
396 session: RwLock::new(None),
397 master_key: RwLock::new(None),
398 master_key_id: RwLock::new(1),
399 pending_key: RwLock::new(None),
400 gck_cache: RwLock::new(std::collections::HashMap::new()),
401 }
402 }
403
404 /// Returns the client configuration.
405 pub fn config(&self) -> &SyncKitConfig {
406 &self.config
407 }
408
409 /// Returns whether the master encryption key is loaded and ready.
410 pub fn has_master_key(&self) -> bool {
411 self.master_key.read().is_some()
412 }
413
414 /// Returns the current session info, if authenticated.
415 pub fn session_info(&self) -> Option<SessionInfo> {
416 let guard = self.session.read();
417 guard.as_ref().map(|s| SessionInfo {
418 token: Arc::clone(&s.token),
419 user_id: s.user_id,
420 app_id: s.app_id,
421 })
422 }
423
424 /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
425 ///
426 /// Test-only: gated behind the `testing` feature so a consumer build has no
427 /// chosen-key injection point that bypasses key derivation.
428 #[doc(hidden)]
429 #[cfg(any(test, feature = "testing"))]
430 pub fn set_master_key_raw(&self, key: [u8; 32]) {
431 *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
432 }
433
434 // ── Internal helpers ──
435
436 /// Extract the bearer token from the current session.
437 ///
438 /// Returns `NotAuthenticated` if no session exists. Also checks token
439 /// expiry and returns `TokenExpired` if the JWT `exp` claim is within
440 /// 30 seconds of the current time.
441 pub(crate) fn require_token(&self) -> Result<Arc<SecretToken>> {
442 let guard = self.session.read();
443 let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
444
445 if let Some(exp) = session.token_exp {
446 let now = chrono::Utc::now().timestamp();
447 if now >= exp - TOKEN_EXPIRY_BUFFER_SECS {
448 return Err(SyncKitError::TokenExpired);
449 }
450 }
451
452 Ok(Arc::clone(&session.token))
453 }
454
455 /// Extract `(app_id, user_id)` from the current session.
456 ///
457 /// Returns `NotAuthenticated` if no session exists.
458 pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> {
459 let guard = self.session.read();
460 guard
461 .as_ref()
462 .map(|s| (s.app_id, s.user_id))
463 .ok_or(SyncKitError::NotAuthenticated)
464 }
465
466 /// Return a copy of the 256-bit master encryption key, wrapped in
467 /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`.
468 ///
469 /// Returns `NoMasterKey` if encryption has not been set up yet.
470 pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> {
471 let guard = self.master_key.read();
472 guard
473 .as_ref()
474 .map(|k| crypto::ZeroizeOnDrop(**k))
475 .ok_or(SyncKitError::NoMasterKey)
476 }
477 }
478
479 /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`).
480 ///
481 /// Production traffic carries the bearer sync token, so plaintext `http` to any
482 /// non-loopback host is refused (defense-in-depth on top of normal cert
483 /// validation). Loopback hosts stay exempt so local development and the
484 /// mock-server test suite can use `http://127.0.0.1`.
485 fn requires_https(server_url: &str) -> bool {
486 let lower = server_url.trim().to_ascii_lowercase();
487 let Some(rest) = lower.strip_prefix("http://") else {
488 // https (or any non-plaintext scheme): enforcing https_only is a no-op.
489 return true;
490 };
491 // Extract the host *exactly*. A prefix match (`starts_with("127.")`,
492 // `starts_with("localhost")`) trusts `http://127.0.0.1.attacker.com` and
493 // `http://localhost.evil.com`, hostnames that resolve to an attacker's IP,
494 // and would send the bearer token to them in cleartext. Parse the authority,
495 // strip the port, and require an exact `localhost` or an IP literal that is
496 // genuinely loopback. `0.0.0.0` is the unspecified/bind-all address, not a
497 // loopback you connect *to*, so `is_loopback()` correctly rejects it.
498 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
499 // Strip userinfo: everything up to the last `@` is credentials, not the host.
500 // `http://127.0.0.1:80@evil.com` connects to evil.com, so a naive port-split
501 // on the raw authority would read `127.0.0.1` and wrongly exempt it (the same
502 // userinfo bypass class as the server-side SSRF finding).
503 let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
504 let host = if let Some(after) = authority.strip_prefix('[') {
505 // IPv6 literal `[::1]:port`: the host is inside the brackets, and the only
506 // thing allowed after `]` is a port. Anything else (`[::1].evil.com`) is
507 // malformed, fail closed.
508 match after.split_once(']') {
509 Some((h, rest)) if rest.is_empty() || rest.starts_with(':') => h,
510 _ => return true,
511 }
512 } else {
513 // `host:port` or bare `host`, cut at the port separator.
514 authority.split(':').next().unwrap_or(authority)
515 };
516 let is_loopback = host == "localhost"
517 || host
518 .parse::<std::net::IpAddr>()
519 .is_ok_and(|ip| ip.is_loopback());
520 !is_loopback
521 }
522
523 /// Returns the app name on success, or an error if the key is invalid or the server
524 /// is unreachable. This is intended for setup UIs that need to verify a key before
525 /// saving it.
526 #[tracing::instrument(skip(api_key))]
527 pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result<String> {
528 let url = format!("{server_url}/api/v1/sync/validate-app");
529 let http = reqwest::Client::builder()
530 .timeout(std::time::Duration::from_secs(10))
531 .https_only(requires_https(server_url))
532 .build()?;
533 let resp = http
534 .post(&url)
535 .header("content-type", "application/json")
536 .body(serde_json::to_vec(
537 &serde_json::json!({"api_key": api_key}),
538 )?)
539 .send()
540 .await?;
541 let status = resp.status().as_u16();
542 if status == 401 {
543 return Err(SyncKitError::Server {
544 status: 401,
545 message: "Invalid API key".to_string(),
546 retry_after_secs: None,
547 });
548 }
549 let resp = helpers::check_response(resp).await?;
550 #[derive(serde::Deserialize)]
551 struct ValidateResponse {
552 app_name: String,
553 }
554 let body: ValidateResponse = crate::client::helpers::read_json_capped(
555 resp,
556 crate::client::helpers::MAX_CONTROL_BODY_BYTES,
557 )
558 .await?;
559 Ok(body.app_name)
560 }
561
562 #[cfg(test)]
563 mod tests {
564 use super::*;
565 use base64::Engine;
566
567 #[test]
568 fn requires_https_enforces_tls_off_loopback() {
569 // https or non-loopback http must enforce TLS.
570 assert!(requires_https("https://makenot.work"));
571 assert!(requires_https("http://makenot.work"));
572 assert!(requires_https("http://192.168.1.5:7766"));
573 // 0.0.0.0 is the unspecified address, not loopback, must enforce TLS.
574 assert!(requires_https("http://0.0.0.0:8080"));
575 // loopback is exempt (local dev + mock-server tests use http://127.0.0.1).
576 assert!(!requires_https("http://127.0.0.1:8080"));
577 assert!(!requires_https("http://localhost:3000"));
578 assert!(!requires_https("http://[::1]:9000"));
579 assert!(!requires_https("http://127.0.0.1"));
580 assert!(!requires_https("http://127.5.6.7:80/path")); // 127/8 is all loopback
581 }
582
583 #[test]
584 fn requires_https_rejects_loopback_prefix_spoofs() {
585 // The exact-host fix: a hostname that merely starts with a loopback
586 // string but resolves elsewhere must NOT be exempted from TLS, the old
587 // prefix match sent the bearer token to these in cleartext.
588 assert!(requires_https("http://127.0.0.1.attacker.com"));
589 assert!(requires_https("http://127.0.0.1.attacker.com:8080/pull"));
590 assert!(requires_https("http://localhost.evil.com"));
591 assert!(requires_https("http://localhostx"));
592 assert!(requires_https("http://[::1].evil.com")); // junk after the bracket
593 // userinfo bypass: the real host is evil.com, not the loopback userinfo.
594 assert!(requires_https("http://127.0.0.1:80@evil.com"));
595 assert!(requires_https("http://localhost@evil.com/pull"));
596 }
597
598 fn test_config() -> SyncKitConfig {
599 SyncKitConfig {
600 server_url: "https://example.com".to_string(),
601 api_key: "test-api-key-123".to_string(),
602 }
603 }
604
605 fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
606 (
607 crate::ids::AppId::new(
608 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
609 ),
610 crate::ids::UserId::new(
611 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
612 ),
613 )
614 }
615
616 // ── SyncKitClient::new() construction ──
617
618 #[test]
619 fn new_client_starts_unauthenticated() {
620 let client = SyncKitClient::new(test_config());
621 assert!(client.session_info().is_none());
622 }
623
624 #[test]
625 fn new_client_has_no_master_key() {
626 let client = SyncKitClient::new(test_config());
627 assert!(!client.has_master_key());
628 }
629
630 #[test]
631 fn config_returns_provided_values() {
632 let client = SyncKitClient::new(test_config());
633 assert_eq!(client.config().server_url, "https://example.com");
634 assert_eq!(client.config().api_key, "test-api-key-123");
635 }
636
637 // ── SyncKitConfig ──
638
639 #[test]
640 fn config_clone() {
641 let config = test_config();
642 let cloned = config.clone();
643 assert_eq!(cloned.server_url, config.server_url);
644 assert_eq!(cloned.api_key, config.api_key);
645 }
646
647 #[test]
648 fn config_debug() {
649 let config = test_config();
650 let debug = format!("{config:?}");
651 assert!(debug.contains("SyncKitConfig"));
652 assert!(debug.contains("example.com"));
653 }
654
655 // ── require_token ──
656
657 #[test]
658 fn require_token_fails_without_session() {
659 let client = SyncKitClient::new(test_config());
660 let err = client.require_token().unwrap_err();
661 assert!(matches!(err, SyncKitError::NotAuthenticated));
662 }
663
664 #[test]
665 fn require_token_succeeds_with_session() {
666 let client = SyncKitClient::new(test_config());
667 let (app_id, user_id) = test_ids();
668 client.restore_session("my-token", user_id, app_id);
669
670 let token = client.require_token().unwrap();
671 assert_eq!(token.as_str(), "my-token");
672 }
673
674 #[test]
675 fn secret_token_exposes_value_but_redacts_debug() {
676 let t = SecretToken::new("super-secret-jwt".to_string());
677 // The raw value is reachable exactly where the HTTP layer needs it.
678 assert_eq!(t.as_str(), "super-secret-jwt");
679 assert_eq!(&*t, "super-secret-jwt"); // Deref to str
680 assert_eq!(t.to_string(), "super-secret-jwt"); // Display, for bearer_auth
681 // ...but Debug must never spill it into a log line.
682 let debug = format!("{t:?}");
683 assert!(
684 !debug.contains("super-secret-jwt"),
685 "Debug leaked the token: {debug}"
686 );
687 assert!(debug.contains("redacted"));
688 }
689
690 // ── require_session_ids ──
691
692 #[test]
693 fn require_session_ids_fails_without_session() {
694 let client = SyncKitClient::new(test_config());
695 let err = client.require_session_ids().unwrap_err();
696 assert!(matches!(err, SyncKitError::NotAuthenticated));
697 }
698
699 #[test]
700 fn require_session_ids_returns_correct_ids() {
701 let client = SyncKitClient::new(test_config());
702 let (app_id, user_id) = test_ids();
703 client.restore_session("token", user_id, app_id);
704
705 let (returned_app, returned_user) = client.require_session_ids().unwrap();
706 assert_eq!(returned_app, app_id);
707 assert_eq!(returned_user, user_id);
708 }
709
710 // ── require_master_key ──
711
712 #[test]
713 fn require_master_key_fails_without_key() {
714 let client = SyncKitClient::new(test_config());
715 let err = client.require_master_key().unwrap_err();
716 assert!(matches!(err, SyncKitError::NoMasterKey));
717 }
718
719 #[test]
720 fn require_master_key_succeeds_after_set() {
721 let client = SyncKitClient::new(test_config());
722 let test_key = [42u8; 32];
723 *client.master_key.write() = Some(crypto::ZeroizeOnDrop(test_key));
724
725 let key = client.require_master_key().unwrap();
726 assert_eq!(*key, test_key);
727 }
728
729 // ── has_master_key ──
730
731 #[test]
732 fn has_master_key_false_initially() {
733 let client = SyncKitClient::new(test_config());
734 assert!(!client.has_master_key());
735 }
736
737 #[test]
738 fn has_master_key_true_after_set() {
739 let client = SyncKitClient::new(test_config());
740 *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32]));
741 assert!(client.has_master_key());
742 }
743
744 // ── set_master_key_raw ──
745
746 #[test]
747 fn set_master_key_raw_makes_key_available() {
748 let client = SyncKitClient::new(test_config());
749 assert!(!client.has_master_key());
750
751 let key = [99u8; 32];
752 client.set_master_key_raw(key);
753
754 assert!(client.has_master_key());
755 assert_eq!(*client.require_master_key().unwrap(), key);
756 }
757
758 #[test]
759 fn set_master_key_raw_overwrites_previous() {
760 let client = SyncKitClient::new(test_config());
761 let key1 = [1u8; 32];
762 let key2 = [2u8; 32];
763
764 client.set_master_key_raw(key1);
765 assert_eq!(*client.require_master_key().unwrap(), key1);
766
767 client.set_master_key_raw(key2);
768 assert_eq!(*client.require_master_key().unwrap(), key2);
769 }
770
771 // ── with_http_client constructor ──
772
773 #[test]
774 fn with_http_client_starts_unauthenticated() {
775 let http = Client::builder()
776 .timeout(Duration::from_millis(100))
777 .build()
778 .unwrap();
779 let client = SyncKitClient::with_http_client(test_config(), http);
780 assert!(client.session_info().is_none());
781 assert!(!client.has_master_key());
782 }
783
784 // ── Send + Sync assertions ──
785
786 #[test]
787 fn client_is_send_and_sync() {
788 fn assert_send_sync<T: Send + Sync>() {}
789 assert_send_sync::<SyncKitClient>();
790 }
791
792 // ── Config edge case ──
793
794 #[test]
795 fn config_with_trailing_slash_url() {
796 let config = SyncKitConfig {
797 server_url: "https://example.com/".to_string(),
798 api_key: "key".to_string(),
799 };
800 let client = SyncKitClient::new(config);
801 assert_eq!(client.config().server_url, "https://example.com/");
802 // Endpoints should not have double slashes
803 assert_eq!(
804 client.endpoints.auth,
805 "https://example.com/api/v1/sync/auth"
806 );
807 }
808
809 // ── SyncKitError Display ──
810
811 #[test]
812 fn error_display_not_authenticated() {
813 let err = SyncKitError::NotAuthenticated;
814 assert!(err.to_string().contains("Not authenticated"));
815 }
816
817 #[test]
818 fn error_display_no_master_key() {
819 let err = SyncKitError::NoMasterKey;
820 assert!(err.to_string().contains("Encryption not initialized"));
821 }
822
823 #[test]
824 fn error_display_server() {
825 let err = SyncKitError::Server {
826 status: 500,
827 message: "boom".to_string(),
828 retry_after_secs: None,
829 };
830 let msg = err.to_string();
831 assert!(msg.contains("500"));
832 assert!(msg.contains("boom"));
833 }
834
835 #[test]
836 fn error_display_decryption_failed() {
837 let err = SyncKitError::DecryptionFailed;
838 assert!(err.to_string().contains("Wrong password"));
839 }
840
841 #[test]
842 fn error_display_invalid_envelope() {
843 let err = SyncKitError::InvalidEnvelope("bad version".to_string());
844 let msg = err.to_string();
845 assert!(msg.contains("Invalid key envelope"));
846 assert!(msg.contains("bad version"));
847 }
848
849 #[test]
850 fn error_display_crypto() {
851 let err = SyncKitError::Crypto("aead failed".to_string());
852 let msg = err.to_string();
853 assert!(msg.contains("Encryption error"));
854 assert!(msg.contains("aead failed"));
855 }
856
857 #[test]
858 fn error_display_token_expired() {
859 let err = SyncKitError::TokenExpired;
860 assert!(err.to_string().contains("Token expired"));
861 }
862
863 // ── SyncKitError conversions ──
864
865 #[test]
866 fn error_from_serde_json() {
867 let err: SyncKitError = serde_json::from_str::<serde_json::Value>("{{bad}}")
868 .unwrap_err()
869 .into();
870 assert!(matches!(err, SyncKitError::Json(_)));
871 assert!(err.to_string().contains("JSON"));
872 }
873
874 #[test]
875 fn error_from_base64() {
876 let err: SyncKitError = base64::engine::general_purpose::STANDARD
877 .decode("!!!bad!!!")
878 .unwrap_err()
879 .into();
880 assert!(matches!(err, SyncKitError::Base64(_)));
881 assert!(err.to_string().contains("Base64"));
882 }
883
884 #[test]
885 fn error_internal_contains_message() {
886 let err = SyncKitError::Internal("test internal error".to_string());
887 assert!(err.to_string().contains("test internal error"));
888 }
889 }
890