max / makenotwork
4 files changed,
+50 insertions,
-15 deletions
| @@ -26,7 +26,7 @@ impl SyncKitClient { | |||
| 26 | 26 | /// # Errors | |
| 27 | 27 | /// | |
| 28 | 28 | /// Returns `Server { status: 401, .. }` for wrong credentials. | |
| 29 | - | #[instrument(skip(self, email, password))] | |
| 29 | + | #[instrument(skip(self, email, password, key))] | |
| 30 | 30 | pub async fn authenticate( | |
| 31 | 31 | &self, | |
| 32 | 32 | email: &str, | |
| @@ -146,7 +146,7 @@ impl SyncKitClient { | |||
| 146 | 146 | /// | |
| 147 | 147 | /// Call this after receiving the code from the localhost callback server. | |
| 148 | 148 | /// On success, stores the session internally (same as `authenticate()`). | |
| 149 | - | #[instrument(skip(self, code, code_verifier))] | |
| 149 | + | #[instrument(skip(self, code, code_verifier, key))] | |
| 150 | 150 | pub async fn authenticate_with_code( | |
| 151 | 151 | &self, | |
| 152 | 152 | code: &str, |
| @@ -42,17 +42,20 @@ impl SyncKitClient { | |||
| 42 | 42 | pub async fn setup_encryption_new(&self, password: &str) -> Result<()> { | |
| 43 | 43 | let (app_id, user_id) = self.require_session_ids()?; | |
| 44 | 44 | ||
| 45 | - | let master_key = crypto::generate_master_key(); | |
| 46 | - | let envelope = crypto::wrap_master_key(&master_key, password)?; | |
| 45 | + | // Wrap the fresh key immediately so it is scrubbed on every early-return | |
| 46 | + | // path below (wrap/put/store can each fail), not left as a bare array on | |
| 47 | + | // the stack until the final move into the lock. | |
| 48 | + | let master_key = crypto::ZeroizeOnDrop(crypto::generate_master_key()); | |
| 49 | + | let envelope = crypto::wrap_master_key(&master_key.0, password)?; | |
| 47 | 50 | ||
| 48 | 51 | // Push to server (expected_version 0 = first key) | |
| 49 | 52 | self.put_server_key(&envelope, 0).await?; | |
| 50 | 53 | ||
| 51 | 54 | // Cache in OS keychain | |
| 52 | - | keystore::store_key(app_id, user_id, &master_key)?; | |
| 55 | + | keystore::store_key(app_id, user_id, &master_key.0)?; | |
| 53 | 56 | ||
| 54 | 57 | // Store in memory | |
| 55 | - | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 58 | + | *self.master_key.write() = Some(master_key); | |
| 56 | 59 | ||
| 57 | 60 | tracing::info!("New master key generated and stored"); | |
| 58 | 61 | Ok(()) | |
| @@ -64,13 +67,15 @@ impl SyncKitClient { | |||
| 64 | 67 | let (app_id, user_id) = self.require_session_ids()?; | |
| 65 | 68 | ||
| 66 | 69 | let (envelope_json, _key_version) = self.get_server_key().await?; | |
| 67 | - | let master_key = crypto::unwrap_master_key(&envelope_json, password)?; | |
| 70 | + | // Wrap immediately so a failed keychain store scrubs the key rather than | |
| 71 | + | // dropping a bare array. | |
| 72 | + | let master_key = crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?); | |
| 68 | 73 | ||
| 69 | 74 | // Cache in OS keychain | |
| 70 | - | keystore::store_key(app_id, user_id, &master_key)?; | |
| 75 | + | keystore::store_key(app_id, user_id, &master_key.0)?; | |
| 71 | 76 | ||
| 72 | 77 | // Store in memory | |
| 73 | - | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(master_key)); | |
| 78 | + | *self.master_key.write() = Some(master_key); | |
| 74 | 79 | ||
| 75 | 80 | tracing::info!("Master key recovered from server"); | |
| 76 | 81 | Ok(()) | |
| @@ -113,8 +118,21 @@ impl SyncKitClient { | |||
| 113 | 118 | ||
| 114 | 119 | let master_key = crypto::ZeroizeOnDrop(verified_key); | |
| 115 | 120 | ||
| 121 | + | // If we hold the master key in memory, the envelope the server just handed | |
| 122 | + | // us MUST wrap that same key. A hostile server could otherwise substitute a | |
| 123 | + | // *different* envelope that also unwraps under `old_password` (e.g. one it | |
| 124 | + | // captured), making us re-wrap the wrong key under the new password and | |
| 125 | + | // lock the user out of their own (original-key-encrypted) data. Refuse. | |
| 126 | + | if let Some(cached) = self.master_key.read().as_ref() | |
| 127 | + | && cached.0 != master_key.0 | |
| 128 | + | { | |
| 129 | + | return Err(crate::error::SyncKitError::Crypto( | |
| 130 | + | "server key envelope does not match the in-memory master key; refusing to change password".into(), | |
| 131 | + | )); | |
| 132 | + | } | |
| 133 | + | ||
| 116 | 134 | // Re-wrap with new password (generates fresh random salt) | |
| 117 | - | let new_envelope = crypto::wrap_master_key(&master_key, new_password)?; | |
| 135 | + | let new_envelope = crypto::wrap_master_key(&master_key.0, new_password)?; | |
| 118 | 136 | ||
| 119 | 137 | // Optimistic lock: reject if another device changed the password | |
| 120 | 138 | // between our GET and this PUT. |
| @@ -127,9 +127,11 @@ impl SyncKitClient { | |||
| 127 | 127 | Ok((key, pending.encrypted_key)) | |
| 128 | 128 | } | |
| 129 | 129 | None => { | |
| 130 | - | let key = crypto::generate_master_key(); | |
| 131 | - | let envelope = crypto::wrap_master_key(&key, password)?; | |
| 132 | - | Ok((key, envelope)) | |
| 130 | + | // Wrap before the fallible `wrap_master_key` so a wrap error | |
| 131 | + | // scrubs the fresh key instead of dropping a bare array. | |
| 132 | + | let key = crypto::ZeroizeOnDrop(crypto::generate_master_key()); | |
| 133 | + | let envelope = crypto::wrap_master_key(&key.0, password)?; | |
| 134 | + | Ok((key.0, envelope)) | |
| 133 | 135 | } | |
| 134 | 136 | } | |
| 135 | 137 | } |
| @@ -46,7 +46,12 @@ const ARGON2_PARALLELISM: u32 = 1; | |||
| 46 | 46 | // ceiling rejects an inflated one whose allocation would OOM every syncing device. | |
| 47 | 47 | // The pinned wrap constants above sit inside this range, so real envelopes unwrap. | |
| 48 | 48 | const ARGON2_MEM_MIN_KB: u32 = 8 * 1024; // 8 MiB | |
| 49 | - | const ARGON2_MEM_MAX_KB: u32 = 1024 * 1024; // 1 GiB | |
| 49 | + | // 256 MiB. The wrap cost is 64 MiB, so real envelopes sit comfortably inside the | |
| 50 | + | // range. A 1 GiB ceiling let a hostile server demand a 1 GiB Argon2 allocation on | |
| 51 | + | // every setup/change-password/rotate — an OOM (jetsam) kill lever on a 2-4 GiB | |
| 52 | + | // mobile device. 256 MiB still permits a stronger-than-default envelope while | |
| 53 | + | // staying well under the mobile memory budget. | |
| 54 | + | const ARGON2_MEM_MAX_KB: u32 = 256 * 1024; // 256 MiB | |
| 50 | 55 | const ARGON2_TIME_MAX: u32 = 16; | |
| 51 | 56 | const ARGON2_PARALLELISM_MAX: u32 = 16; | |
| 52 | 57 | ||
| @@ -496,7 +501,14 @@ fn blob_chunk_count(total_len: usize, chunk_size: usize) -> u32 { | |||
| 496 | 501 | if total_len == 0 { | |
| 497 | 502 | 1 | |
| 498 | 503 | } else { | |
| 499 | - | total_len.div_ceil(chunk_size) as u32 | |
| 504 | + | // Saturate rather than truncate. `total_len` is trusted (= `data.len()`, | |
| 505 | + | // capped upstream) on the encrypt path but ATTACKER-CONTROLLED on the | |
| 506 | + | // decrypt path (it comes from the v3 header), where a bare `as u32` would | |
| 507 | + | // silently wrap an astronomical claim down to a small count. Saturating to | |
| 508 | + | // u32::MAX keeps the value obviously-too-large so the caller's | |
| 509 | + | // `total_len`-vs-input-length bound rejects the header instead — and a | |
| 510 | + | // panic (debug_assert) here would itself be a crash DoS on that path. | |
| 511 | + | total_len.div_ceil(chunk_size).min(u32::MAX as usize) as u32 | |
| 500 | 512 | } | |
| 501 | 513 | } | |
| 502 | 514 | ||
| @@ -1432,6 +1444,9 @@ mod tests { | |||
| 1432 | 1444 | let salt = [7u8; 32]; | |
| 1433 | 1445 | // Inflated memory (OOM DoS from a hostile envelope) rejected before allocation. | |
| 1434 | 1446 | assert!(derive_wrapping_key_with_params("pw", &salt, 4_000_000, 3, 1).is_err()); | |
| 1447 | + | // Mobile-OOM fix: 512 MiB — valid under the old 1 GiB ceiling — is now | |
| 1448 | + | // rejected (before any allocation) by the 256 MiB ceiling. | |
| 1449 | + | assert!(derive_wrapping_key_with_params("pw", &salt, 512 * 1024, 3, 1).is_err()); | |
| 1435 | 1450 | // Weakened memory (KDF downgrade) rejected. | |
| 1436 | 1451 | assert!(derive_wrapping_key_with_params("pw", &salt, 8, 3, 1).is_err()); | |
| 1437 | 1452 | // Zero time / parallelism rejected. |