max / synckit-client
10 files changed,
+2262 insertions,
-542 deletions
| @@ -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", |
| @@ -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] |
| @@ -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 | + | } |
| @@ -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 ── |