Skip to main content

max / synckit

12.8 KB · 407 lines History Blame Raw
1 //! Envelope setup, password change, and server-key presence.
2 //!
3 //! `setup_encryption_new`/`_existing` are how a device gets the master key, and
4 //! `change_password` re-wraps it. These are the paths where a wrong answer costs
5 //! the user their data, so the negative cases outnumber the positive ones.
6
7 use crate::common::*;
8
9 const KEYS_PATH: &str = "/api/v1/sync/keys";
10
11 /// The `GET /keys` body: a master key wrapped under `password`.
12 fn envelope_body(envelope: &str) -> serde_json::Value {
13 json!({ "encrypted_key": envelope })
14 }
15
16 /// The envelope from the `n`th `PUT /keys` the client sent.
17 async fn uploaded_envelope(kit: &MockKit, n: usize) -> String {
18 let bodies = kit.bodies("PUT", KEYS_PATH).await;
19 let body = bodies
20 .get(n)
21 .unwrap_or_else(|| panic!("expected at least {} PUTs to {KEYS_PATH}", n + 1));
22 body["encrypted_key"]
23 .as_str()
24 .expect("the PUT body carries an encrypted_key")
25 .to_string()
26 }
27
28 // ── Key management ──
29
30 #[tokio::test]
31 async fn has_server_key_true_on_200() {
32 let kit = MockKit::start().await;
33 kit.get(KEYS_PATH)
34 .json(envelope_body("envelope-data"))
35 .await;
36
37 assert!(kit.authed().has_server_key().await.unwrap());
38 }
39
40 #[tokio::test]
41 async fn has_server_key_false_on_404() {
42 let kit = MockKit::start().await;
43 kit.get(KEYS_PATH).code(404).empty().await;
44
45 assert!(!kit.authed().has_server_key().await.unwrap());
46 }
47
48 #[tokio::test]
49 async fn has_server_key_retries_on_500() {
50 let kit = MockKit::start().await;
51 kit.get(KEYS_PATH)
52 .code(500)
53 .once()
54 .text("Internal Server Error")
55 .await;
56 kit.get(KEYS_PATH).json(envelope_body("envelope")).await;
57
58 assert!(kit.authed().has_server_key().await.unwrap());
59 }
60
61 // ── change_password: CRITICAL bug fix tests ──
62
63 /// A logged-in client holding its master key, plus that key and the envelope
64 /// wrapping it under `password`. The state a real device is in when the user
65 /// changes their password.
66 fn cached_key_client(kit: &MockKit, password: &str) -> (SyncKitClient, [u8; 32], String) {
67 let (client, master_key) = kit.keyed();
68 let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap();
69 (client, master_key, envelope)
70 }
71
72 #[tokio::test]
73 async fn change_password_wrong_old_password_with_cached_key_fails() {
74 let kit = MockKit::start().await;
75 let (client, _master_key, envelope) = cached_key_client(&kit, "correct-old-pass");
76 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
77
78 // Attempt to change password with wrong old password.
79 // The key IS cached, but the old password must still be validated.
80 let result = client.change_password("wrong-old-pass", "new-pass").await;
81
82 assert!(
83 result.is_err(),
84 "change_password must fail when old_password is wrong, even with cached key"
85 );
86 assert!(
87 matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
88 "Should get DecryptionFailed for wrong old password"
89 );
90 }
91
92 #[tokio::test]
93 async fn change_password_correct_old_password_with_cached_key_succeeds() {
94 let kit = MockKit::start().await;
95 let (client, master_key, envelope) = cached_key_client(&kit, "correct-old-pass");
96 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
97 kit.put(KEYS_PATH).empty().await;
98
99 let result = client.change_password("correct-old-pass", "new-pass").await;
100 assert!(
101 result.is_ok(),
102 "change_password should succeed with correct old password"
103 );
104
105 // Exactly one PUT: the new envelope was uploaded, once.
106 let puts = kit.bodies("PUT", KEYS_PATH).await;
107 assert_eq!(puts.len(), 1, "Should have sent exactly one PUT");
108
109 // Verify the new envelope can be unwrapped with the new password
110 let new_envelope = uploaded_envelope(&kit, 0).await;
111 let recovered = synckit_client::crypto::unwrap_master_key(&new_envelope, "new-pass").unwrap();
112 assert_eq!(
113 recovered, master_key,
114 "New envelope should unwrap to the same master key"
115 );
116 }
117
118 #[tokio::test]
119 async fn change_password_wrong_old_password_without_cached_key_fails() {
120 let kit = MockKit::start().await;
121 let master_key = synckit_client::crypto::generate_master_key();
122 let envelope =
123 synckit_client::crypto::wrap_master_key(&master_key, "correct-old-pass").unwrap();
124
125 // Deliberately NOT setting a master key: no cached key
126 let client = kit.authed();
127 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
128
129 let result = client.change_password("wrong-old-pass", "new-pass").await;
130
131 assert!(
132 result.is_err(),
133 "change_password must fail with wrong old password even without cached key"
134 );
135 assert!(matches!(
136 result.unwrap_err(),
137 SyncKitError::DecryptionFailed
138 ));
139 }
140
141 #[tokio::test]
142 async fn change_password_old_envelope_invalid_with_new_password() {
143 let kit = MockKit::start().await;
144 let (client, _master_key, envelope) = cached_key_client(&kit, "old-pass");
145 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
146 kit.put(KEYS_PATH).empty().await;
147
148 client
149 .change_password("old-pass", "new-pass")
150 .await
151 .unwrap();
152
153 // Old password should NOT work on the new envelope
154 let new_envelope = uploaded_envelope(&kit, 0).await;
155 let result = synckit_client::crypto::unwrap_master_key(&new_envelope, "old-pass");
156 assert!(
157 result.is_err(),
158 "Old password must not work on the new envelope"
159 );
160 }
161
162 // ── Encryption setup ──
163
164 #[tokio::test]
165 async fn setup_encryption_new_stores_key_and_uploads_envelope() {
166 let kit = MockKit::start().await;
167 kit.put(KEYS_PATH).exactly(1).empty().await;
168
169 let client = kit.authed();
170 assert!(!client.has_master_key());
171
172 client.setup_encryption_new("test-password").await.unwrap();
173
174 // Master key should now be in memory
175 assert!(client.has_master_key());
176
177 // Verify the PUT body contains a valid envelope unwrappable with the same password
178 let envelope = uploaded_envelope(&kit, 0).await;
179 let recovered = synckit_client::crypto::unwrap_master_key(&envelope, "test-password").unwrap();
180 assert_eq!(recovered.len(), 32);
181 }
182
183 #[tokio::test]
184 async fn setup_encryption_new_without_auth_fails() {
185 let kit = MockKit::start().await;
186 let err = kit
187 .client()
188 .setup_encryption_new("password")
189 .await
190 .unwrap_err();
191 assert!(matches!(err, SyncKitError::NotAuthenticated));
192 }
193
194 #[tokio::test]
195 async fn setup_encryption_new_retries_on_server_error() {
196 let kit = MockKit::start().await;
197 kit.put(KEYS_PATH)
198 .code(500)
199 .once()
200 .text("Internal Server Error")
201 .await;
202 kit.put(KEYS_PATH).empty().await;
203
204 let client = kit.authed();
205 let result = client.setup_encryption_new("password").await;
206 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
207 assert!(client.has_master_key());
208 }
209
210 #[tokio::test]
211 async fn setup_encryption_existing_recovers_key() {
212 let kit = MockKit::start().await;
213
214 let master_key = synckit_client::crypto::generate_master_key();
215 let envelope = synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap();
216 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
217
218 let client = kit.authed();
219 assert!(!client.has_master_key());
220
221 client
222 .setup_encryption_existing("my-password")
223 .await
224 .unwrap();
225
226 assert!(client.has_master_key());
227 }
228
229 #[tokio::test]
230 async fn setup_encryption_existing_wrong_password_fails() {
231 let kit = MockKit::start().await;
232
233 let master_key = synckit_client::crypto::generate_master_key();
234 let envelope =
235 synckit_client::crypto::wrap_master_key(&master_key, "correct-password").unwrap();
236 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
237
238 let client = kit.authed();
239 let err = client
240 .setup_encryption_existing("wrong-password")
241 .await
242 .unwrap_err();
243 assert!(
244 matches!(err, SyncKitError::DecryptionFailed),
245 "Wrong password should produce DecryptionFailed: {err:?}"
246 );
247 assert!(!client.has_master_key());
248 }
249
250 #[tokio::test]
251 async fn setup_encryption_existing_without_auth_fails() {
252 let kit = MockKit::start().await;
253 let err = kit
254 .client()
255 .setup_encryption_existing("password")
256 .await
257 .unwrap_err();
258 assert!(matches!(err, SyncKitError::NotAuthenticated));
259 }
260
261 #[tokio::test]
262 async fn setup_encryption_existing_retries_on_server_error() {
263 let kit = MockKit::start().await;
264
265 let master_key = synckit_client::crypto::generate_master_key();
266 let envelope = synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap();
267
268 kit.get(KEYS_PATH)
269 .code(502)
270 .once()
271 .text("Bad Gateway")
272 .await;
273 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
274
275 let client = kit.authed();
276 let result = client.setup_encryption_existing("password").await;
277 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
278 assert!(client.has_master_key());
279 }
280
281 #[tokio::test]
282 async fn setup_encryption_existing_no_server_key_returns_error() {
283 let kit = MockKit::start().await;
284 kit.get(KEYS_PATH).code(404).text("Not Found").await;
285
286 let err = kit
287 .authed()
288 .setup_encryption_existing("password")
289 .await
290 .unwrap_err();
291 assert!(
292 matches!(err, SyncKitError::Server { status: 404, .. }),
293 "Missing server key should produce 404 error: {err:?}"
294 );
295 }
296
297 /// Two-device roundtrip: device 1 generates key via setup_encryption_new,
298 /// device 2 recovers it via setup_encryption_existing. Data encrypted by
299 /// device 1 must be decryptable by device 2.
300 #[tokio::test]
301 async fn encryption_setup_cross_device_roundtrip() {
302 let kit = MockKit::start().await;
303
304 // Device 1: setup_encryption_new
305 kit.put(KEYS_PATH).empty().await;
306 kit.post("/api/v1/sync/push")
307 .json(json!({"cursor": 1}))
308 .await;
309
310 let client1 = kit.authed();
311 client1
312 .setup_encryption_new("shared-password")
313 .await
314 .unwrap();
315
316 // Push encrypted data from device 1
317 let device_id = DeviceId::new(Uuid::new_v4());
318 let original_data = json!({"title": "cross-device test", "secret": true});
319 client1
320 .push(
321 device_id,
322 vec![ChangeEntry {
323 table: "tasks".into(),
324 op: ChangeOp::Insert,
325 row_id: "cross-r1".into(),
326 timestamp: Utc::now(),
327 hlc: Hlc::zero(DeviceId::nil()),
328 data: Some(original_data.clone()),
329 extra: serde_json::Map::default(),
330 }],
331 )
332 .await
333 .unwrap();
334
335 // Capture the envelope and encrypted data
336 let envelope = uploaded_envelope(&kit, 0).await;
337 let push_body = kit.body("/api/v1/sync/push").await;
338 let encrypted_data = push_body["changes"][0]["data"].clone();
339
340 // Device 2: setup_encryption_existing with same password
341 kit.reset().await;
342
343 kit.get(KEYS_PATH).json(envelope_body(&envelope)).await;
344 kit.post("/api/v1/sync/pull")
345 .json(json!({
346 "changes": [{
347 "seq": 1,
348 "device_id": device_id,
349 "table": "tasks",
350 "op": "INSERT",
351 "row_id": "cross-r1",
352 "timestamp": "2025-06-01T12:00:00Z",
353 "data": encrypted_data,
354 }],
355 "cursor": 1,
356 "has_more": false,
357 }))
358 .await;
359
360 let client2 = kit.authed();
361 client2
362 .setup_encryption_existing("shared-password")
363 .await
364 .unwrap();
365
366 // Pull and decrypt with device 2's recovered key
367 let (changes, _, _) = client2.pull(device_id, 0).await.unwrap();
368 assert_eq!(changes.len(), 1);
369 assert_eq!(
370 changes[0].data.as_ref().unwrap(),
371 &original_data,
372 "Data encrypted by device 1 must be decryptable by device 2"
373 );
374 }
375
376 // ── has_server_key without auth ──
377
378 #[tokio::test]
379 async fn has_server_key_without_auth_returns_not_authenticated() {
380 let kit = MockKit::start().await;
381 let err = kit.client().has_server_key().await.unwrap_err();
382 assert!(matches!(err, SyncKitError::NotAuthenticated));
383 }
384
385 // ── Encryption state edge cases ──
386
387 #[tokio::test]
388 async fn setup_encryption_new_twice_overwrites() {
389 let kit = MockKit::start().await;
390 kit.put(KEYS_PATH).empty().await;
391
392 let client = kit.authed();
393
394 client.setup_encryption_new("pass1").await.unwrap();
395 assert!(client.has_master_key());
396
397 // Second call overwrites
398 client.setup_encryption_new("pass2").await.unwrap();
399 assert!(client.has_master_key());
400
401 // Verify the second PUT used a different envelope
402 let puts = kit.bodies("PUT", KEYS_PATH).await;
403 assert_eq!(puts.len(), 2);
404 // Different envelopes (different random keys)
405 assert_ne!(puts[0], puts[1]);
406 }
407