Skip to main content

max / synckit

10.5 KB · 279 lines History Blame Raw
1 //! Master-key setup and password lifecycle.
2 //!
3 //! Establishes the master key on a first or subsequent device by wrapping and
4 //! unwrapping it against the server-held key envelope, caches it in the OS
5 //! keychain when available, and re-wraps it on a password change.
6
7 use bytes::Bytes;
8 use std::sync::Arc;
9 use tracing::instrument;
10
11 use crate::{
12 crypto,
13 error::Result,
14 keystore,
15 types::{GetKeyResponse, PutKeyRequest},
16 };
17
18 use super::helpers::{Idempotency, check_response};
19 use super::{SecretToken, SyncKitClient};
20
21 impl SyncKitClient {
22 /// Check if the server has an encrypted master key for this user.
23 #[instrument(skip(self))]
24 pub async fn has_server_key(&self) -> Result<bool> {
25 let (url, token) = self.key_url_and_token()?;
26
27 let result = self
28 .retry_request(Idempotency::ReadOnly, || {
29 let req = self.http.get(url).bearer_auth(&token);
30 async move {
31 let resp = req.send().await?;
32 match resp.status().as_u16() {
33 200 | 404 => Ok(resp),
34 status => {
35 let message = crate::client::helpers::read_text_capped(
36 resp,
37 crate::client::helpers::MAX_CONTROL_BODY_BYTES,
38 )
39 .await;
40 Err(crate::error::SyncKitError::Server {
41 status,
42 message,
43 retry_after_secs: None,
44 })
45 }
46 }
47 }
48 })
49 .await?;
50
51 Ok(result.status().as_u16() == 200)
52 }
53
54 /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
55 #[instrument(skip(self, password))]
56 pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
57 let (app_id, user_id) = self.require_session_ids()?;
58
59 // Wrap the fresh key immediately so it is scrubbed on every early-return
60 // path below (wrap/put/store can each fail), not left as a bare array on
61 // the stack until the final move into the lock.
62 let master_key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
63 let envelope = crypto::wrap_master_key(&master_key.0, password)?;
64
65 // Push to server (expected_version 0 = first key)
66 self.put_server_key(&envelope, 0).await?;
67
68 // Cache in OS keychain
69 keystore::store_key(app_id, user_id, &master_key.0)?;
70
71 // Store in memory
72 *self.master_key.write() = Some(master_key);
73
74 tracing::info!("New master key generated and stored");
75 Ok(())
76 }
77
78 /// Second device: pull encrypted master key from server, decrypt with password, cache.
79 #[instrument(skip(self, password))]
80 pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
81 let (app_id, user_id) = self.require_session_ids()?;
82
83 let (envelope_json, _key_version) = self.get_server_key().await?;
84 // Wrap immediately so a failed keychain store scrubs the key rather than
85 // dropping a bare array.
86 let master_key =
87 crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?);
88
89 // Cache in OS keychain
90 keystore::store_key(app_id, user_id, &master_key.0)?;
91
92 // Store in memory
93 *self.master_key.write() = Some(master_key);
94
95 tracing::info!("Master key recovered from server");
96 Ok(())
97 }
98
99 /// Subsequent launches: try to load the master key from the OS keychain.
100 /// Returns true if a key was found.
101 pub fn try_load_key_from_keychain(&self) -> Result<bool> {
102 let (app_id, user_id) = self.require_session_ids()?;
103
104 if let Some(key) = keystore::load_key(app_id, user_id)? {
105 *self.master_key.write() = Some(key);
106 tracing::info!("Master key loaded from keychain");
107 Ok(true)
108 } else {
109 Ok(false)
110 }
111 }
112
113 /// Change the encryption password. Always validates the old password
114 /// against the server envelope before re-encrypting with the new password.
115 ///
116 /// Even when the master key is cached in memory (normal case, user is
117 /// logged in), the old password is verified by attempting to unwrap the
118 /// server envelope. This prevents an attacker with session access from
119 /// changing the password without knowing the current one.
120 #[instrument(skip(self, old_password, new_password))]
121 pub async fn change_password(&self, old_password: &str, new_password: &str) -> Result<()> {
122 // Always fetch the envelope from the server and verify old_password
123 // can decrypt it, regardless of whether we have a cached key.
124 let (envelope_json, key_version) = self.get_server_key().await?;
125 let verified_key = crypto::verify_password_against_envelope(&envelope_json, old_password)?;
126
127 let master_key = crypto::ZeroizeOnDrop(verified_key);
128
129 // If we hold the master key in memory, the envelope the server just handed
130 // us MUST wrap that same key. A hostile server could otherwise substitute a
131 // *different* envelope that also unwraps under `old_password` (e.g. one it
132 // captured), making us re-wrap the wrong key under the new password and
133 // lock the user out of their own (original-key-encrypted) data. Refuse.
134 if let Some(cached) = self.master_key.read().as_ref()
135 && cached.0 != master_key.0
136 {
137 return Err(crate::error::SyncKitError::Crypto(
138 "server key envelope does not match the in-memory master key; refusing to change password".into(),
139 ));
140 }
141
142 // Re-wrap with new password (generates fresh random salt)
143 let new_envelope = crypto::wrap_master_key(&master_key.0, new_password)?;
144
145 // Optimistic lock: reject if another device changed the password
146 // between our GET and this PUT.
147 self.put_server_key(&new_envelope, key_version).await?;
148
149 tracing::info!("Encryption password changed");
150 Ok(())
151 }
152
153 /// Build the key endpoint URL and extract the bearer token.
154 pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<SecretToken>)> {
155 let token = self.require_token()?;
156 Ok((&self.endpoints.keys, token))
157 }
158
159 /// Upload the encrypted master key envelope to the server (PUT /api/sync/keys).
160 ///
161 /// `expected_version` is the key version the client expects. The server
162 /// rejects with 409 if the current version doesn't match (another device
163 /// changed the password). Use 0 for the initial key upload.
164 pub(super) async fn put_server_key(
165 &self,
166 envelope_json: &str,
167 expected_version: i32,
168 ) -> Result<()> {
169 let (url, token) = self.key_url_and_token()?;
170
171 let body = Bytes::from(serde_json::to_vec(&PutKeyRequest {
172 encrypted_key: envelope_json.to_string(),
173 expected_version,
174 })?);
175
176 self.retry_request(
177 Idempotency::IdempotentWrite {
178 on: "expected_version optimistic lock",
179 },
180 || {
181 let req = self
182 .http
183 .put(url)
184 .bearer_auth(&token)
185 .header("content-type", "application/json")
186 .body(body.clone());
187 async move { check_response(req.send().await?).await }
188 },
189 )
190 .await?;
191 Ok(())
192 }
193
194 /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
195 /// Returns `(envelope_json, key_version)`.
196 pub(super) async fn get_server_key(&self) -> Result<(String, i32)> {
197 let resp = self.get_server_key_full().await?;
198 Ok((resp.encrypted_key, resp.key_version.unwrap_or(0)))
199 }
200
201 /// Download the full key state, including any in-progress rotation's
202 /// `pending_key`. Used by `rotate_key` to resume an interrupted rotation
203 /// with the already-committed key rather than minting a fresh one.
204 pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> {
205 let (url, token) = self.key_url_and_token()?;
206
207 self.retry_request_json(Idempotency::ReadOnly, || {
208 let req = self.http.get(url).bearer_auth(&token);
209 async move { check_response(req.send().await?).await }
210 })
211 .await
212 }
213 }
214
215 #[cfg(test)]
216 mod tests {
217 use crate::error::SyncKitError;
218
219 use super::*;
220
221 fn test_config() -> super::super::SyncKitConfig {
222 super::super::SyncKitConfig {
223 server_url: "https://example.com".to_string(),
224 api_key: "test-api-key-123".to_string(),
225 }
226 }
227
228 fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
229 (
230 crate::ids::AppId::new(
231 uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
232 ),
233 crate::ids::UserId::new(
234 uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
235 ),
236 )
237 }
238
239 #[test]
240 fn key_url_and_token_builds_correct_url() {
241 let client = SyncKitClient::new(test_config());
242 let (app_id, user_id) = test_ids();
243 client.restore_session("bearer-token", user_id, app_id);
244
245 let (url, token) = client.key_url_and_token().unwrap();
246 assert_eq!(url, "https://example.com/api/v1/sync/keys");
247 assert_eq!(token.as_str(), "bearer-token");
248 }
249
250 #[test]
251 fn key_url_and_token_fails_without_session() {
252 let client = SyncKitClient::new(test_config());
253 let err = client.key_url_and_token().unwrap_err();
254 assert!(matches!(err, SyncKitError::NotAuthenticated));
255 }
256
257 // ── Key types ──
258
259 #[test]
260 fn put_key_request_serialization() {
261 let req = PutKeyRequest {
262 encrypted_key: "envelope-json-here".to_string(),
263 expected_version: 1,
264 };
265
266 let json = serde_json::to_string(&req).unwrap();
267 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
268 assert_eq!(parsed["encrypted_key"], "envelope-json-here");
269 assert_eq!(parsed["expected_version"], 1);
270 }
271
272 #[test]
273 fn get_key_response_deserialization() {
274 let json = r#"{"encrypted_key": "{\"v\":1,\"salt\":\"...\",\"nonce\":\"...\",\"ciphertext\":\"...\"}"}"#;
275 let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
276 assert!(resp.encrypted_key.contains("\"v\":1"));
277 }
278 }
279