Skip to main content

max / synckit

30.3 KB · 793 lines History Blame Raw
1 //! End-to-end master-key rotation against the full server protocol.
2
3 // ── End-to-end key-rotation orchestration ──
4 //
5 // These drive `rotate_key()` through the full server protocol against wiremock:
6 // fetch key -> begin -> re-encrypt loop -> complete, plus the straggler retry on
7 // a 409, and finally what it does with the OS keychain.
8 //
9 // They run in both feature configurations. `rotate_key` finishes by caching the
10 // new key with `keystore::store_key`, which under `keychain` would hit the OS
11 // secret service; `common::ensure_mock_keystore` installs `keyring_core`'s
12 // in-memory mock as the process default store instead, so the shipping path runs
13 // with no daemon. Without the feature `store_key` is the no-op stub and the
14 // orchestration still runs, minus the keychain interaction; the two tests that
15 // assert on keychain contents are gated to the config that has one.
16 use crate::common::*;
17
18 const KEYS_PATH: &str = "/api/v1/sync/keys";
19 const ROTATE_PATH: &str = "/api/v1/sync/keys/rotate";
20 const ENTRIES_PATH: &str = "/api/v1/sync/keys/rotate/entries";
21 const BATCH_PATH: &str = "/api/v1/sync/keys/rotate/batch";
22 const COMPLETE_PATH: &str = "/api/v1/sync/keys/rotate/complete";
23 const PULL_PATH: &str = "/api/v1/sync/pull";
24
25 const ROTATE_PW: &str = "rotate-password";
26
27 /// A `GET /keys` body wrapping `old_key` under [`ROTATE_PW`], with no rotation
28 /// in progress, so `rotate_key` verifies the password and mints a fresh key.
29 fn get_keys_body(old_key: &[u8; 32]) -> serde_json::Value {
30 let envelope = synckit_client::crypto::wrap_master_key(old_key, ROTATE_PW).unwrap();
31 json!({ "encrypted_key": envelope, "key_version": 1, "key_id": 1 })
32 }
33
34 /// One rotation entry: `plaintext` sealed under `old_key` with the same
35 /// `(table, row_id)` AAD the client rebinds during re-encryption.
36 fn rotation_entry(old_key: &[u8; 32], table: &str, row_id: &str) -> serde_json::Value {
37 let ctx = synckit_client::crypto::AeadContext::entry(table, row_id);
38 let sealed =
39 synckit_client::crypto::encrypt_json_aad(&json!({"title": "secret"}), old_key, &ctx)
40 .unwrap();
41 json!({ "seq": 1, "table": table, "row_id": row_id, "data": sealed })
42 }
43
44 /// The `POST /keys/rotate` answer: a rotation covering `target_seq` entries.
45 fn begin_body(target_seq: usize) -> serde_json::Value {
46 json!({ "rotation_id": Uuid::new_v4(), "target_seq": target_seq, "new_key_id": 2 })
47 }
48
49 #[tokio::test]
50 async fn rotate_key_drives_full_orchestration() {
51 let kit = MockKit::start().await;
52 let old_key = synckit_client::crypto::generate_master_key();
53
54 kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await;
55 kit.post(ROTATE_PATH).json(begin_body(1)).await;
56 // One batch of work, then drained (has_more = false ends the re-encrypt loop).
57 kit.post(ENTRIES_PATH)
58 .json(json!({
59 "entries": [rotation_entry(&old_key, "tasks", "r1")],
60 "has_more": false
61 }))
62 .await;
63 kit.post(BATCH_PATH)
64 .json(json!({ "updated_count": 1 }))
65 .await;
66 kit.post(COMPLETE_PATH).empty().await;
67
68 kit.authed()
69 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
70 .await
71 .expect("full rotation should complete");
72
73 // Every stage of the protocol was driven, in the right shape.
74 assert_eq!(kit.hits(KEYS_PATH).await, 1, "fetched key state once");
75 assert_eq!(kit.hits(ROTATE_PATH).await, 1, "began rotation once");
76 assert!(
77 kit.hits(ENTRIES_PATH).await >= 1,
78 "pulled entries to re-encrypt"
79 );
80 assert_eq!(
81 kit.hits(BATCH_PATH).await,
82 1,
83 "pushed one re-encrypted batch"
84 );
85 assert_eq!(
86 kit.hits(COMPLETE_PATH).await,
87 1,
88 "completed once (no stragglers)"
89 );
90 }
91
92 #[tokio::test]
93 async fn rotate_key_retries_reencrypt_on_straggler_conflict() {
94 let kit = MockKit::start().await;
95 let old_key = synckit_client::crypto::generate_master_key();
96
97 kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await;
98 kit.post(ROTATE_PATH).json(begin_body(1)).await;
99 // First entries pull returns work; every later pull is drained. Mounted in
100 // this order so the `once()` mock wins the first call, then the empty-set
101 // fallback serves the straggler round's re-pull.
102 kit.post(ENTRIES_PATH)
103 .once()
104 .json(json!({
105 "entries": [rotation_entry(&old_key, "tasks", "r1")],
106 "has_more": false
107 }))
108 .await;
109 kit.post(ENTRIES_PATH)
110 .json(json!({ "entries": [], "has_more": false }))
111 .await;
112 kit.post(BATCH_PATH)
113 .json(json!({ "updated_count": 1 }))
114 .await;
115 // First completion reports a straggler (409); the retry then succeeds.
116 kit.post(COMPLETE_PATH)
117 .code(409)
118 .once()
119 .json(json!({ "message": "stragglers" }))
120 .await;
121 kit.post(COMPLETE_PATH).empty().await;
122
123 kit.authed()
124 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
125 .await
126 .expect("rotation should converge after the straggler retry");
127
128 // The 409 forced a second completion attempt, and the straggler round
129 // re-ran the re-encrypt loop (a second entries pull).
130 assert_eq!(
131 kit.hits(COMPLETE_PATH).await,
132 2,
133 "completed twice: 409 then 200"
134 );
135 assert!(
136 kit.hits(ENTRIES_PATH).await >= 2,
137 "straggler round re-pulled entries"
138 );
139 }
140
141 // ── Rotation changes the key and nothing else ──
142
143 /// The rows the relation below carries across a rotation. Two tables, so a
144 /// re-encryption that crossed the `(table, row_id)` AAD binding would fail to
145 /// open rather than quietly return the wrong row.
146 fn relation_rows() -> Vec<(&'static str, &'static str, serde_json::Value)> {
147 vec![
148 (
149 "tasks",
150 "row-1",
151 json!({"title": "write the relation", "n": 1}),
152 ),
153 (
154 "tasks",
155 "row-2",
156 json!({"title": "keep the plaintext", "n": 2}),
157 ),
158 (
159 "notes",
160 "row-3",
161 json!({"body": "unicode: \u{1f6ab} \u{4f60}\u{597d}", "n": 3}),
162 ),
163 ]
164 }
165
166 /// Seal one row the way `push` seals it: a v2 HLC envelope, bound to
167 /// `(table, row_id)` as associated data.
168 fn sealed_envelope(
169 key: &[u8; 32],
170 device_id: DeviceId,
171 table: &str,
172 row_id: &str,
173 payload: &serde_json::Value,
174 ) -> serde_json::Value {
175 let ctx = synckit_client::crypto::AeadContext::entry(table, row_id);
176 let envelope = json!({
177 "__skver": 2,
178 "__skhlc": Hlc::zero(device_id),
179 "data": payload,
180 });
181 synckit_client::crypto::encrypt_json_aad(&envelope, key, &ctx).unwrap()
182 }
183
184 /// Wrap a sealed payload in the pull wire shape the server returns.
185 fn pull_wire(
186 device_id: DeviceId,
187 seq: i64,
188 table: &str,
189 row_id: &str,
190 data: &serde_json::Value,
191 key_id: i32,
192 ) -> serde_json::Value {
193 json!({
194 "seq": seq,
195 "device_id": device_id,
196 "table": table,
197 "op": "INSERT",
198 "row_id": row_id,
199 "timestamp": "2025-06-01T12:00:00Z",
200 "key_id": key_id,
201 "data": data,
202 })
203 }
204
205 /// **Metamorphic relation:** a pull spanning a master-key rotation returns the
206 /// same plaintext as a pull before it. Rotation re-keys the ciphertext and must
207 /// change nothing a caller can observe, so any difference between the two pulls
208 /// is a bug, and relating the runs states that without an expected-value table
209 /// (Chen et al. 1998).
210 ///
211 /// The post-rotation run is fed the bytes the client itself produced: the
212 /// re-encrypted batch it pushed to `/keys/rotate/batch` is replayed back as the
213 /// body of the second pull. A re-encryption that dropped a row, crossed the
214 /// `(table, row_id)` AAD binding or mangled a payload therefore fails here,
215 /// where the orchestration tests above only count requests.
216 ///
217 /// The new key is not left to chance: the server offers a committed
218 /// `pending_key`, which is the resume path, so `rotate_key` adopts a key this
219 /// test knows and the second client can be built around it.
220 #[tokio::test]
221 async fn a_pull_spanning_a_rotation_yields_what_a_pull_before_it_yielded() {
222 let old_key = synckit_client::crypto::generate_master_key();
223 let new_key = synckit_client::crypto::generate_master_key();
224 let device_id = DeviceId::new(Uuid::new_v4());
225 let rows = relation_rows();
226
227 let sealed_under_old: Vec<serde_json::Value> = rows
228 .iter()
229 .map(|(table, row_id, payload)| {
230 sealed_envelope(&old_key, device_id, table, row_id, payload)
231 })
232 .collect();
233
234 // Run A: pull before the rotation, everything under the old key.
235 let before = {
236 let kit = MockKit::start().await;
237 let client = kit.authed();
238 client.set_master_key_raw(old_key);
239 let wire: Vec<serde_json::Value> = rows
240 .iter()
241 .zip(&sealed_under_old)
242 .enumerate()
243 .map(|(i, ((table, row_id, _), data))| {
244 pull_wire(device_id, i as i64 + 1, table, row_id, data, 1)
245 })
246 .collect();
247 kit.post(PULL_PATH)
248 .json(json!({
249 "changes": wire,
250 "cursor": rows.len(),
251 "has_more": false,
252 }))
253 .await;
254
255 let (changes, _, _) = client.pull(device_id, 0).await.unwrap();
256 changes
257 };
258
259 // The rotation itself, driven through the full protocol. `pending_key` makes
260 // it the resume path, so the client adopts `new_key` instead of minting one.
261 let reencrypted = {
262 let kit = MockKit::start().await;
263 kit.get(KEYS_PATH)
264 .json(json!({
265 "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(),
266 "key_version": 1,
267 "key_id": 1,
268 "pending_key": {
269 "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(),
270 "key_id": 2,
271 },
272 }))
273 .await;
274 kit.post(ROTATE_PATH).json(begin_body(rows.len())).await;
275 let entries: Vec<serde_json::Value> = rows
276 .iter()
277 .zip(&sealed_under_old)
278 .enumerate()
279 .map(|(i, ((table, row_id, _), data))| {
280 json!({ "seq": i as i64 + 1, "table": table, "row_id": row_id, "data": data })
281 })
282 .collect();
283 kit.post(ENTRIES_PATH)
284 .once()
285 .json(json!({ "entries": entries, "has_more": false }))
286 .await;
287 kit.post(ENTRIES_PATH)
288 .json(json!({ "entries": [], "has_more": false }))
289 .await;
290 kit.post(BATCH_PATH)
291 .json(json!({ "updated_count": rows.len() }))
292 .await;
293 kit.post(COMPLETE_PATH).empty().await;
294
295 let client = kit.authed();
296 client.set_master_key_raw(old_key);
297 client
298 .rotate_key(device_id, ROTATE_PW)
299 .await
300 .expect("the rotation should complete");
301
302 // Take back what the client re-encrypted, keyed by seq.
303 let body = kit.body(BATCH_PATH).await;
304 let entries = body["entries"]
305 .as_array()
306 .expect("batch body should carry entries")
307 .clone();
308 assert_eq!(
309 entries.len(),
310 rows.len(),
311 "the re-encrypted batch dropped a row before the pull below could see it"
312 );
313 entries
314 };
315
316 // Run B: pull after the rotation, replaying the re-encrypted bytes.
317 let after = {
318 let kit = MockKit::start().await;
319 let client = kit.authed();
320 client.set_master_key_raw(new_key);
321 let wire: Vec<serde_json::Value> = reencrypted
322 .iter()
323 .map(|entry| {
324 let seq = entry["seq"].as_i64().expect("batch entry keeps its seq");
325 let (table, row_id, _) = &rows[seq as usize - 1];
326 pull_wire(device_id, seq, table, row_id, &entry["data"], 2)
327 })
328 .collect();
329 kit.post(PULL_PATH)
330 .json(json!({
331 "changes": wire,
332 "cursor": rows.len(),
333 "has_more": false,
334 }))
335 .await;
336
337 let (changes, _, _) = client.pull(device_id, 0).await.unwrap();
338 changes
339 };
340
341 // Guard against a vacuous pass: the pre-rotation run has to have delivered
342 // every row, with a payload, before comparing the two proves anything.
343 assert_eq!(
344 before.len(),
345 rows.len(),
346 "the pre-rotation pull delivered nothing, so the comparison below is vacuous"
347 );
348 assert!(
349 before.iter().all(|c| c.data.is_some()),
350 "the pre-rotation pull returned a row with no payload"
351 );
352 assert_eq!(
353 before.len(),
354 after.len(),
355 "the rotation changed how many changes a pull returns: {} vs {}",
356 before.len(),
357 after.len()
358 );
359 for (a, b) in before.iter().zip(after.iter()) {
360 assert_eq!(
361 a.row_id, b.row_id,
362 "the rotation reordered or dropped a row"
363 );
364 assert_eq!(a.table, b.table);
365 assert_eq!(a.data, b.data, "the rotation changed a decrypted payload");
366 assert_eq!(a.hlc, b.hlc, "the rotation changed a row's clock");
367 }
368 }
369
370 // ── The re-encrypt loop pages, and the old key stops working ──
371
372 /// The rows the paging test carries, split into the two server pages below.
373 /// Distinct payloads per row so a re-encryption that swapped two rows, or
374 /// re-sealed one row's plaintext under another's AAD, shows up as a mismatch
375 /// rather than as two interchangeable blobs.
376 fn paged_rows() -> [Vec<(&'static str, &'static str, serde_json::Value)>; 2] {
377 [
378 vec![
379 ("tasks", "row-1", json!({"title": "first page, first row"})),
380 ("tasks", "row-2", json!({"title": "first page, second row"})),
381 ],
382 vec![
383 ("notes", "row-3", json!({"body": "second page, first row"})),
384 ("notes", "row-4", json!({"body": "second page, second row"})),
385 ],
386 ]
387 }
388
389 /// A `/keys/rotate/entries` page: the rows sealed under `old_key`, numbered from
390 /// `first_seq`, with the server's `has_more` verdict.
391 fn entries_page(
392 old_key: &[u8; 32],
393 rows: &[(&'static str, &'static str, serde_json::Value)],
394 first_seq: i64,
395 has_more: bool,
396 ) -> serde_json::Value {
397 let entries: Vec<serde_json::Value> = rows
398 .iter()
399 .enumerate()
400 .map(|(i, (table, row_id, payload))| {
401 let ctx = synckit_client::crypto::AeadContext::entry(table, row_id);
402 let sealed = synckit_client::crypto::encrypt_json_aad(payload, old_key, &ctx).unwrap();
403 json!({ "seq": first_seq + i as i64, "table": table, "row_id": row_id, "data": sealed })
404 })
405 .collect();
406 json!({ "entries": entries, "has_more": has_more })
407 }
408
409 /// A server that hands back two pages of work drives two re-encrypt rounds and
410 /// two batch pushes, and every payload it gets back is sealed under the NEW key
411 /// only.
412 ///
413 /// The `has_more = true` page is the point: it is what forces
414 /// `reencrypt_batch`'s `Ok(!has_more)` to say "not done", so a client that
415 /// stopped after the first page would leave page two readable under the old key
416 /// forever. Both halves are asserted, the second page really was pulled and
417 /// pushed, and the old key really is dead against all four re-encrypted rows.
418 ///
419 /// `pending_key` puts the rotation on the resume path so the new key is one this
420 /// test knows and can decrypt with, rather than a fresh key only the client saw.
421 #[tokio::test]
422 async fn a_second_entries_page_is_pulled_re_encrypted_and_pushed() {
423 let kit = MockKit::start().await;
424 let old_key = synckit_client::crypto::generate_master_key();
425 let new_key = synckit_client::crypto::generate_master_key();
426 assert_ne!(
427 old_key, new_key,
428 "the two keys must differ or the old-key assertions below prove nothing"
429 );
430 let [page_one, page_two] = paged_rows();
431
432 kit.get(KEYS_PATH)
433 .json(json!({
434 "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(),
435 "key_version": 1,
436 "key_id": 1,
437 "pending_key": {
438 "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(),
439 "key_id": 2,
440 },
441 }))
442 .await;
443 kit.post(ROTATE_PATH).json(begin_body(4)).await;
444 // Page one says has_more, page two drains. Mounted in order so the first
445 // `once()` mock answers the first pull and the second answers the next.
446 kit.post(ENTRIES_PATH)
447 .once()
448 .json(entries_page(&old_key, &page_one, 1, true))
449 .await;
450 kit.post(ENTRIES_PATH)
451 .once()
452 .json(entries_page(&old_key, &page_two, 3, false))
453 .await;
454 // Anything past those two pages would be a third round the loop must not run.
455 kit.post(ENTRIES_PATH)
456 .json(json!({ "entries": [], "has_more": false }))
457 .await;
458 kit.post(BATCH_PATH)
459 .json(json!({ "updated_count": 2 }))
460 .await;
461 kit.post(COMPLETE_PATH).empty().await;
462
463 kit.authed()
464 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
465 .await
466 .expect("a two-page rotation should complete");
467
468 assert_eq!(
469 kit.hits(ENTRIES_PATH).await,
470 2,
471 "one pull per page: has_more on page one must run a second round, and page two must end it"
472 );
473 assert_eq!(
474 kit.hits(BATCH_PATH).await,
475 2,
476 "each page is pushed back as its own batch"
477 );
478
479 // Flatten what the client pushed and check it row by row against what it was
480 // given, in seq order.
481 let pushed: Vec<serde_json::Value> = kit
482 .bodies("POST", BATCH_PATH)
483 .await
484 .iter()
485 .flat_map(|body| {
486 body["entries"]
487 .as_array()
488 .expect("a batch body carries entries")
489 .clone()
490 })
491 .collect();
492 let all_rows: Vec<_> = page_one.iter().chain(page_two.iter()).collect();
493 assert_eq!(
494 pushed.len(),
495 all_rows.len(),
496 "every row from both pages must come back re-encrypted"
497 );
498
499 for (i, (entry, (table, row_id, payload))) in pushed.iter().zip(&all_rows).enumerate() {
500 let seq = i as i64 + 1;
501 assert_eq!(
502 entry["seq"].as_i64(),
503 Some(seq),
504 "row {row_id} came back under the wrong seq"
505 );
506 let ctx = synckit_client::crypto::AeadContext::entry(table, row_id);
507 let opened = synckit_client::crypto::decrypt_json_aad(&entry["data"], &new_key, &ctx)
508 .unwrap_or_else(|e| panic!("row {row_id} does not open under the new key: {e}"));
509 assert_eq!(
510 opened, *payload,
511 "row {row_id} came back holding a different payload"
512 );
513 // The whole point of a rotation: the old key is retired. A no-op
514 // re-encrypt would leave this opening cleanly.
515 assert!(
516 synckit_client::crypto::decrypt_json_aad(&entry["data"], &old_key, &ctx).is_err(),
517 "row {row_id} still opens under the OLD key, so it was never re-encrypted"
518 );
519 }
520 }
521
522 // ── The straggler round cap ──
523
524 /// `MAX_ROTATION_ROUNDS` from `src/client/rotation.rs`. Private there, so it is
525 /// restated here; the assertions below pin the cap exactly, which is the only
526 /// way the counter's arithmetic is observable at all (a counter that never
527 /// advances and a counter that advances differ nowhere except at the cap).
528 const MAX_ROTATION_ROUNDS: usize = 100_000;
529
530 /// A wiremock responder that counts what it served.
531 ///
532 /// The house `MockKit` would answer this test's routes, but its `hits()` reads
533 /// wiremock's recorded-request log, and this test provokes 200,000 requests: the
534 /// log would hold every one of them in memory for the duration. Counting in the
535 /// responder and turning recording off keeps the test's footprint flat.
536 struct Counted {
537 hits: std::sync::Arc<std::sync::atomic::AtomicUsize>,
538 code: u16,
539 body: serde_json::Value,
540 }
541
542 impl wiremock::Respond for Counted {
543 fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
544 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
545 ResponseTemplate::new(self.code).set_body_json(self.body.clone())
546 }
547 }
548
549 /// A server that answers `POST /keys/rotate/complete` with 409 forever must not
550 /// spin the straggler loop forever: `rotate_key` gives up at
551 /// `MAX_ROTATION_ROUNDS` and returns the round-cap `Internal` error.
552 ///
553 /// This is the only test that can see the straggler counter at all. Every other
554 /// rotation test either never gets a 409 or gets exactly one, and on those the
555 /// counter's value is never read; only crossing the cap turns it into an
556 /// observable. The exact-count assertion is deliberate: it pins both the
557 /// increment (a counter that stalled at zero never returns) and the boundary
558 /// (the cap fires on the round that reaches it, not one round later).
559 #[tokio::test]
560 async fn a_server_that_reports_stragglers_forever_stops_at_the_round_cap() {
561 use std::sync::Arc;
562 use std::sync::atomic::{AtomicUsize, Ordering};
563
564 ensure_crypto_provider();
565 ensure_mock_keystore();
566 let old_key = synckit_client::crypto::generate_master_key();
567
568 // Recording off: see `Counted`.
569 let server = wiremock::MockServer::builder()
570 .disable_request_recording()
571 .start()
572 .await;
573
574 let completes = Arc::new(AtomicUsize::new(0));
575 let entries_pulls = Arc::new(AtomicUsize::new(0));
576
577 wiremock::Mock::given(wiremock::matchers::method("GET"))
578 .and(wiremock::matchers::path(KEYS_PATH))
579 .respond_with(ResponseTemplate::new(200).set_body_json(get_keys_body(&old_key)))
580 .mount(&server)
581 .await;
582 wiremock::Mock::given(wiremock::matchers::method("POST"))
583 .and(wiremock::matchers::path(ROTATE_PATH))
584 .respond_with(ResponseTemplate::new(200).set_body_json(begin_body(0)))
585 .mount(&server)
586 .await;
587 // Nothing left to re-encrypt, so each straggler round costs one pull and
588 // returns immediately: the loop that is being bounded is the straggler loop,
589 // not the re-encrypt loop inside it.
590 wiremock::Mock::given(wiremock::matchers::method("POST"))
591 .and(wiremock::matchers::path(ENTRIES_PATH))
592 .respond_with(Counted {
593 hits: Arc::clone(&entries_pulls),
594 code: 200,
595 body: json!({ "entries": [], "has_more": false }),
596 })
597 .mount(&server)
598 .await;
599 // The stall: stragglers, always, no matter how many rounds the client runs.
600 wiremock::Mock::given(wiremock::matchers::method("POST"))
601 .and(wiremock::matchers::path(COMPLETE_PATH))
602 .respond_with(Counted {
603 hits: Arc::clone(&completes),
604 code: 409,
605 body: json!({ "message": "stragglers" }),
606 })
607 .mount(&server)
608 .await;
609
610 let client = SyncKitClient::new(SyncKitConfig {
611 server_url: server.uri(),
612 api_key: "test-api-key".to_string(),
613 });
614 let (user_id, app_id) = test_ids();
615 client.restore_session(&fresh_token(), user_id, app_id);
616
617 let err = client
618 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
619 .await
620 .expect_err("a server that never converges must not be waited on forever");
621
622 // The straggler cap, not the re-encrypt cap: the two share a constant and a
623 // variant, so the message is what tells them apart.
624 match &err {
625 SyncKitError::Internal(msg) => assert!(
626 msg.contains("server kept reporting stragglers past the round cap"),
627 "wrong give-up path: {msg}"
628 ),
629 other => panic!("expected the round-cap Internal error, got {other:?}"),
630 }
631
632 assert_eq!(
633 completes.load(Ordering::Relaxed),
634 MAX_ROTATION_ROUNDS,
635 "the cap must fire on the round that reaches it: one completion attempt per round, no more and no fewer"
636 );
637 // Every round but the last re-ran the re-encrypt loop; the capped round
638 // returns before it does.
639 assert_eq!(
640 entries_pulls.load(Ordering::Relaxed),
641 MAX_ROTATION_ROUNDS,
642 "one initial re-encrypt pass plus one per straggler round short of the cap"
643 );
644 }
645
646 // ── What rotation leaves in the OS keychain ──
647 //
648 // `rotate_key` step 6 caches the new master key and, if that write fails, drops
649 // the entry rather than leaving the pre-rotation key in it. Both halves of that
650 // guard were unreachable before these tests existed: the suite ran only with
651 // `keychain` off, where `cache_key` cannot report false and `delete_key` is a
652 // no-op stub, so the failure branch had never been executed by anything.
653 //
654 // The two tests below pin the guard from both sides, which is what it takes:
655 // dropping the negation would satisfy either one alone.
656
657 /// Address the entry `keystore` writes for this session.
658 ///
659 /// `keystore::entry` is private, so its naming (`synckit:<app_id>` as the
660 /// service, the user id as the user) is restated here. Anything else addresses a
661 /// different credential and the assertions would pass vacuously.
662 #[cfg(feature = "keychain")]
663 fn keychain_entry(app_id: AppId, user_id: UserId) -> keyring_core::Entry {
664 keyring_core::Entry::new(&format!("synckit:{app_id}"), &user_id.to_string())
665 .expect("the mock store builds an entry")
666 }
667
668 /// A client with a keychain identity of its own.
669 ///
670 /// The mock store is process-global and its credentials are keyed on
671 /// (service, user), so a test asserting on keychain contents cannot share
672 /// `common::test_ids()` with every other rotation test in the binary.
673 #[cfg(feature = "keychain")]
674 fn client_with_own_keychain(kit: &MockKit, n: u128) -> (SyncKitClient, AppId, UserId) {
675 let app_id = AppId::new(Uuid::from_u128(n));
676 let user_id = UserId::new(Uuid::from_u128(n + 1000));
677 let client = kit.client();
678 client.restore_session(&fresh_token(), user_id, app_id);
679 (client, app_id, user_id)
680 }
681
682 /// Mount the whole happy-path rotation protocol, resuming onto `new_key` so the
683 /// caller knows the key the client will end up holding.
684 #[cfg(feature = "keychain")]
685 async fn mount_resumed_rotation(kit: &MockKit, old_key: &[u8; 32], new_key: &[u8; 32]) {
686 kit.get(KEYS_PATH)
687 .json(json!({
688 "encrypted_key": synckit_client::crypto::wrap_master_key(old_key, ROTATE_PW).unwrap(),
689 "key_version": 1,
690 "key_id": 1,
691 "pending_key": {
692 "encrypted_key": synckit_client::crypto::wrap_master_key(new_key, ROTATE_PW).unwrap(),
693 "key_id": 2,
694 },
695 }))
696 .await;
697 kit.post(ROTATE_PATH).json(begin_body(0)).await;
698 kit.post(ENTRIES_PATH)
699 .json(json!({ "entries": [], "has_more": false }))
700 .await;
701 kit.post(COMPLETE_PATH).empty().await;
702 }
703
704 /// A rotation whose cache write fails must clear the keychain entry, because
705 /// what is in it is the *pre-rotation* key.
706 ///
707 /// This is the case the guard at `rotation.rs` step 6 exists for, and it is
708 /// worse than an empty cache: a cold launch that loaded the stale entry would
709 /// decrypt nothing at all, with no password prompt to recover through. Deleting
710 /// it makes the next launch fall through to the password path.
711 ///
712 /// The mock clears its armed error after one call, so the sequence the client
713 /// actually walks is the real one: `store_key` fails, `cache_key` reports false,
714 /// and the `delete_key` that follows succeeds.
715 #[cfg(feature = "keychain")]
716 #[tokio::test]
717 async fn a_rotation_that_cannot_cache_the_new_key_drops_the_stale_one() {
718 let kit = MockKit::start().await;
719 let old_key = synckit_client::crypto::generate_master_key();
720 let new_key = synckit_client::crypto::generate_master_key();
721 mount_resumed_rotation(&kit, &old_key, &new_key).await;
722
723 let (client, app_id, user_id) = client_with_own_keychain(&kit, 9_001);
724 synckit_client::keystore::store_key(app_id, user_id, &old_key)
725 .expect("seed the keychain with the pre-rotation key");
726
727 // Arm the next write to fail, which is `cache_key` returning false.
728 let entry = keychain_entry(app_id, user_id);
729 let cred: &keyring_core::mock::Cred = entry
730 .as_any()
731 .downcast_ref()
732 .expect("the mock store yields mock credentials");
733 cred.set_error(keyring_core::Error::NoStorageAccess(Box::new(
734 std::io::Error::other("keychain is locked"),
735 )));
736
737 client
738 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
739 .await
740 .expect("a failed cache write must not fail the rotation: the key itself is fine");
741
742 match keychain_entry(app_id, user_id).get_password() {
743 Err(keyring_core::Error::NoEntry) => {}
744 Ok(held) => {
745 let stale = base64::engine::general_purpose::STANDARD.encode(old_key);
746 assert_ne!(
747 held, stale,
748 "the keychain still holds the PRE-ROTATION key: a cold launch would load it and decrypt nothing"
749 );
750 panic!("the stale entry was not dropped; it holds an unexpected value instead");
751 }
752 Err(e) => panic!("unexpected keychain error: {e}"),
753 }
754 }
755
756 /// A rotation whose cache write succeeds must leave the NEW key in the
757 /// keychain, and must not delete what it just wrote.
758 ///
759 /// The other side of the same guard. Without this, dropping the negation on the
760 /// `cache_key` check would wipe the entry on every successful rotation, which is
761 /// the very failure the sibling test's comment describes, arrived at from the
762 /// opposite direction.
763 #[cfg(feature = "keychain")]
764 #[tokio::test]
765 async fn a_rotation_that_caches_the_new_key_keeps_it() {
766 let kit = MockKit::start().await;
767 let old_key = synckit_client::crypto::generate_master_key();
768 let new_key = synckit_client::crypto::generate_master_key();
769 assert_ne!(
770 old_key, new_key,
771 "the two keys must differ or the assertion below proves nothing"
772 );
773 mount_resumed_rotation(&kit, &old_key, &new_key).await;
774
775 let (client, app_id, user_id) = client_with_own_keychain(&kit, 9_002);
776 synckit_client::keystore::store_key(app_id, user_id, &old_key)
777 .expect("seed the keychain with the pre-rotation key");
778
779 client
780 .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW)
781 .await
782 .expect("the rotation should complete");
783
784 let held = keychain_entry(app_id, user_id)
785 .get_password()
786 .expect("the new key must still be cached after a successful rotation");
787 assert_eq!(
788 held,
789 base64::engine::general_purpose::STANDARD.encode(new_key),
790 "the keychain does not hold the post-rotation key"
791 );
792 }
793