//! End-to-end master-key rotation against the full server protocol. // ── End-to-end key-rotation orchestration ── // // These drive `rotate_key()` through the full server protocol against wiremock: // fetch key -> begin -> re-encrypt loop -> complete, plus the straggler retry on // a 409, and finally what it does with the OS keychain. // // They run in both feature configurations. `rotate_key` finishes by caching the // new key with `keystore::store_key`, which under `keychain` would hit the OS // secret service; `common::ensure_mock_keystore` installs `keyring_core`'s // in-memory mock as the process default store instead, so the shipping path runs // with no daemon. Without the feature `store_key` is the no-op stub and the // orchestration still runs, minus the keychain interaction; the two tests that // assert on keychain contents are gated to the config that has one. use crate::common::*; const KEYS_PATH: &str = "/api/v1/sync/keys"; const ROTATE_PATH: &str = "/api/v1/sync/keys/rotate"; const ENTRIES_PATH: &str = "/api/v1/sync/keys/rotate/entries"; const BATCH_PATH: &str = "/api/v1/sync/keys/rotate/batch"; const COMPLETE_PATH: &str = "/api/v1/sync/keys/rotate/complete"; const PULL_PATH: &str = "/api/v1/sync/pull"; const ROTATE_PW: &str = "rotate-password"; /// A `GET /keys` body wrapping `old_key` under [`ROTATE_PW`], with no rotation /// in progress, so `rotate_key` verifies the password and mints a fresh key. fn get_keys_body(old_key: &[u8; 32]) -> serde_json::Value { let envelope = synckit_client::crypto::wrap_master_key(old_key, ROTATE_PW).unwrap(); json!({ "encrypted_key": envelope, "key_version": 1, "key_id": 1 }) } /// One rotation entry: `plaintext` sealed under `old_key` with the same /// `(table, row_id)` AAD the client rebinds during re-encryption. fn rotation_entry(old_key: &[u8; 32], table: &str, row_id: &str) -> serde_json::Value { let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); let sealed = synckit_client::crypto::encrypt_json_aad(&json!({"title": "secret"}), old_key, &ctx) .unwrap(); json!({ "seq": 1, "table": table, "row_id": row_id, "data": sealed }) } /// The `POST /keys/rotate` answer: a rotation covering `target_seq` entries. fn begin_body(target_seq: usize) -> serde_json::Value { json!({ "rotation_id": Uuid::new_v4(), "target_seq": target_seq, "new_key_id": 2 }) } #[tokio::test] async fn rotate_key_drives_full_orchestration() { let kit = MockKit::start().await; let old_key = synckit_client::crypto::generate_master_key(); kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await; kit.post(ROTATE_PATH).json(begin_body(1)).await; // One batch of work, then drained (has_more = false ends the re-encrypt loop). kit.post(ENTRIES_PATH) .json(json!({ "entries": [rotation_entry(&old_key, "tasks", "r1")], "has_more": false })) .await; kit.post(BATCH_PATH) .json(json!({ "updated_count": 1 })) .await; kit.post(COMPLETE_PATH).empty().await; kit.authed() .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect("full rotation should complete"); // Every stage of the protocol was driven, in the right shape. assert_eq!(kit.hits(KEYS_PATH).await, 1, "fetched key state once"); assert_eq!(kit.hits(ROTATE_PATH).await, 1, "began rotation once"); assert!( kit.hits(ENTRIES_PATH).await >= 1, "pulled entries to re-encrypt" ); assert_eq!( kit.hits(BATCH_PATH).await, 1, "pushed one re-encrypted batch" ); assert_eq!( kit.hits(COMPLETE_PATH).await, 1, "completed once (no stragglers)" ); } #[tokio::test] async fn rotate_key_retries_reencrypt_on_straggler_conflict() { let kit = MockKit::start().await; let old_key = synckit_client::crypto::generate_master_key(); kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await; kit.post(ROTATE_PATH).json(begin_body(1)).await; // First entries pull returns work; every later pull is drained. Mounted in // this order so the `once()` mock wins the first call, then the empty-set // fallback serves the straggler round's re-pull. kit.post(ENTRIES_PATH) .once() .json(json!({ "entries": [rotation_entry(&old_key, "tasks", "r1")], "has_more": false })) .await; kit.post(ENTRIES_PATH) .json(json!({ "entries": [], "has_more": false })) .await; kit.post(BATCH_PATH) .json(json!({ "updated_count": 1 })) .await; // First completion reports a straggler (409); the retry then succeeds. kit.post(COMPLETE_PATH) .code(409) .once() .json(json!({ "message": "stragglers" })) .await; kit.post(COMPLETE_PATH).empty().await; kit.authed() .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect("rotation should converge after the straggler retry"); // The 409 forced a second completion attempt, and the straggler round // re-ran the re-encrypt loop (a second entries pull). assert_eq!( kit.hits(COMPLETE_PATH).await, 2, "completed twice: 409 then 200" ); assert!( kit.hits(ENTRIES_PATH).await >= 2, "straggler round re-pulled entries" ); } // ── Rotation changes the key and nothing else ── /// The rows the relation below carries across a rotation. Two tables, so a /// re-encryption that crossed the `(table, row_id)` AAD binding would fail to /// open rather than quietly return the wrong row. fn relation_rows() -> Vec<(&'static str, &'static str, serde_json::Value)> { vec![ ( "tasks", "row-1", json!({"title": "write the relation", "n": 1}), ), ( "tasks", "row-2", json!({"title": "keep the plaintext", "n": 2}), ), ( "notes", "row-3", json!({"body": "unicode: \u{1f6ab} \u{4f60}\u{597d}", "n": 3}), ), ] } /// Seal one row the way `push` seals it: a v2 HLC envelope, bound to /// `(table, row_id)` as associated data. fn sealed_envelope( key: &[u8; 32], device_id: DeviceId, table: &str, row_id: &str, payload: &serde_json::Value, ) -> serde_json::Value { let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); let envelope = json!({ "__skver": 2, "__skhlc": Hlc::zero(device_id), "data": payload, }); synckit_client::crypto::encrypt_json_aad(&envelope, key, &ctx).unwrap() } /// Wrap a sealed payload in the pull wire shape the server returns. fn pull_wire( device_id: DeviceId, seq: i64, table: &str, row_id: &str, data: &serde_json::Value, key_id: i32, ) -> serde_json::Value { json!({ "seq": seq, "device_id": device_id, "table": table, "op": "INSERT", "row_id": row_id, "timestamp": "2025-06-01T12:00:00Z", "key_id": key_id, "data": data, }) } /// **Metamorphic relation:** a pull spanning a master-key rotation returns the /// same plaintext as a pull before it. Rotation re-keys the ciphertext and must /// change nothing a caller can observe, so any difference between the two pulls /// is a bug, and relating the runs states that without an expected-value table /// (Chen et al. 1998). /// /// The post-rotation run is fed the bytes the client itself produced: the /// re-encrypted batch it pushed to `/keys/rotate/batch` is replayed back as the /// body of the second pull. A re-encryption that dropped a row, crossed the /// `(table, row_id)` AAD binding or mangled a payload therefore fails here, /// where the orchestration tests above only count requests. /// /// The new key is not left to chance: the server offers a committed /// `pending_key`, which is the resume path, so `rotate_key` adopts a key this /// test knows and the second client can be built around it. #[tokio::test] async fn a_pull_spanning_a_rotation_yields_what_a_pull_before_it_yielded() { let old_key = synckit_client::crypto::generate_master_key(); let new_key = synckit_client::crypto::generate_master_key(); let device_id = DeviceId::new(Uuid::new_v4()); let rows = relation_rows(); let sealed_under_old: Vec = rows .iter() .map(|(table, row_id, payload)| { sealed_envelope(&old_key, device_id, table, row_id, payload) }) .collect(); // Run A: pull before the rotation, everything under the old key. let before = { let kit = MockKit::start().await; let client = kit.authed(); client.set_master_key_raw(old_key); let wire: Vec = rows .iter() .zip(&sealed_under_old) .enumerate() .map(|(i, ((table, row_id, _), data))| { pull_wire(device_id, i as i64 + 1, table, row_id, data, 1) }) .collect(); kit.post(PULL_PATH) .json(json!({ "changes": wire, "cursor": rows.len(), "has_more": false, })) .await; let (changes, _, _) = client.pull(device_id, 0).await.unwrap(); changes }; // The rotation itself, driven through the full protocol. `pending_key` makes // it the resume path, so the client adopts `new_key` instead of minting one. let reencrypted = { let kit = MockKit::start().await; kit.get(KEYS_PATH) .json(json!({ "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(), "key_version": 1, "key_id": 1, "pending_key": { "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(), "key_id": 2, }, })) .await; kit.post(ROTATE_PATH).json(begin_body(rows.len())).await; let entries: Vec = rows .iter() .zip(&sealed_under_old) .enumerate() .map(|(i, ((table, row_id, _), data))| { json!({ "seq": i as i64 + 1, "table": table, "row_id": row_id, "data": data }) }) .collect(); kit.post(ENTRIES_PATH) .once() .json(json!({ "entries": entries, "has_more": false })) .await; kit.post(ENTRIES_PATH) .json(json!({ "entries": [], "has_more": false })) .await; kit.post(BATCH_PATH) .json(json!({ "updated_count": rows.len() })) .await; kit.post(COMPLETE_PATH).empty().await; let client = kit.authed(); client.set_master_key_raw(old_key); client .rotate_key(device_id, ROTATE_PW) .await .expect("the rotation should complete"); // Take back what the client re-encrypted, keyed by seq. let body = kit.body(BATCH_PATH).await; let entries = body["entries"] .as_array() .expect("batch body should carry entries") .clone(); assert_eq!( entries.len(), rows.len(), "the re-encrypted batch dropped a row before the pull below could see it" ); entries }; // Run B: pull after the rotation, replaying the re-encrypted bytes. let after = { let kit = MockKit::start().await; let client = kit.authed(); client.set_master_key_raw(new_key); let wire: Vec = reencrypted .iter() .map(|entry| { let seq = entry["seq"].as_i64().expect("batch entry keeps its seq"); let (table, row_id, _) = &rows[seq as usize - 1]; pull_wire(device_id, seq, table, row_id, &entry["data"], 2) }) .collect(); kit.post(PULL_PATH) .json(json!({ "changes": wire, "cursor": rows.len(), "has_more": false, })) .await; let (changes, _, _) = client.pull(device_id, 0).await.unwrap(); changes }; // Guard against a vacuous pass: the pre-rotation run has to have delivered // every row, with a payload, before comparing the two proves anything. assert_eq!( before.len(), rows.len(), "the pre-rotation pull delivered nothing, so the comparison below is vacuous" ); assert!( before.iter().all(|c| c.data.is_some()), "the pre-rotation pull returned a row with no payload" ); assert_eq!( before.len(), after.len(), "the rotation changed how many changes a pull returns: {} vs {}", before.len(), after.len() ); for (a, b) in before.iter().zip(after.iter()) { assert_eq!( a.row_id, b.row_id, "the rotation reordered or dropped a row" ); assert_eq!(a.table, b.table); assert_eq!(a.data, b.data, "the rotation changed a decrypted payload"); assert_eq!(a.hlc, b.hlc, "the rotation changed a row's clock"); } } // ── The re-encrypt loop pages, and the old key stops working ── /// The rows the paging test carries, split into the two server pages below. /// Distinct payloads per row so a re-encryption that swapped two rows, or /// re-sealed one row's plaintext under another's AAD, shows up as a mismatch /// rather than as two interchangeable blobs. fn paged_rows() -> [Vec<(&'static str, &'static str, serde_json::Value)>; 2] { [ vec![ ("tasks", "row-1", json!({"title": "first page, first row"})), ("tasks", "row-2", json!({"title": "first page, second row"})), ], vec![ ("notes", "row-3", json!({"body": "second page, first row"})), ("notes", "row-4", json!({"body": "second page, second row"})), ], ] } /// A `/keys/rotate/entries` page: the rows sealed under `old_key`, numbered from /// `first_seq`, with the server's `has_more` verdict. fn entries_page( old_key: &[u8; 32], rows: &[(&'static str, &'static str, serde_json::Value)], first_seq: i64, has_more: bool, ) -> serde_json::Value { let entries: Vec = rows .iter() .enumerate() .map(|(i, (table, row_id, payload))| { let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); let sealed = synckit_client::crypto::encrypt_json_aad(payload, old_key, &ctx).unwrap(); json!({ "seq": first_seq + i as i64, "table": table, "row_id": row_id, "data": sealed }) }) .collect(); json!({ "entries": entries, "has_more": has_more }) } /// A server that hands back two pages of work drives two re-encrypt rounds and /// two batch pushes, and every payload it gets back is sealed under the NEW key /// only. /// /// The `has_more = true` page is the point: it is what forces /// `reencrypt_batch`'s `Ok(!has_more)` to say "not done", so a client that /// stopped after the first page would leave page two readable under the old key /// forever. Both halves are asserted, the second page really was pulled and /// pushed, and the old key really is dead against all four re-encrypted rows. /// /// `pending_key` puts the rotation on the resume path so the new key is one this /// test knows and can decrypt with, rather than a fresh key only the client saw. #[tokio::test] async fn a_second_entries_page_is_pulled_re_encrypted_and_pushed() { let kit = MockKit::start().await; let old_key = synckit_client::crypto::generate_master_key(); let new_key = synckit_client::crypto::generate_master_key(); assert_ne!( old_key, new_key, "the two keys must differ or the old-key assertions below prove nothing" ); let [page_one, page_two] = paged_rows(); kit.get(KEYS_PATH) .json(json!({ "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(), "key_version": 1, "key_id": 1, "pending_key": { "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(), "key_id": 2, }, })) .await; kit.post(ROTATE_PATH).json(begin_body(4)).await; // Page one says has_more, page two drains. Mounted in order so the first // `once()` mock answers the first pull and the second answers the next. kit.post(ENTRIES_PATH) .once() .json(entries_page(&old_key, &page_one, 1, true)) .await; kit.post(ENTRIES_PATH) .once() .json(entries_page(&old_key, &page_two, 3, false)) .await; // Anything past those two pages would be a third round the loop must not run. kit.post(ENTRIES_PATH) .json(json!({ "entries": [], "has_more": false })) .await; kit.post(BATCH_PATH) .json(json!({ "updated_count": 2 })) .await; kit.post(COMPLETE_PATH).empty().await; kit.authed() .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect("a two-page rotation should complete"); assert_eq!( kit.hits(ENTRIES_PATH).await, 2, "one pull per page: has_more on page one must run a second round, and page two must end it" ); assert_eq!( kit.hits(BATCH_PATH).await, 2, "each page is pushed back as its own batch" ); // Flatten what the client pushed and check it row by row against what it was // given, in seq order. let pushed: Vec = kit .bodies("POST", BATCH_PATH) .await .iter() .flat_map(|body| { body["entries"] .as_array() .expect("a batch body carries entries") .clone() }) .collect(); let all_rows: Vec<_> = page_one.iter().chain(page_two.iter()).collect(); assert_eq!( pushed.len(), all_rows.len(), "every row from both pages must come back re-encrypted" ); for (i, (entry, (table, row_id, payload))) in pushed.iter().zip(&all_rows).enumerate() { let seq = i as i64 + 1; assert_eq!( entry["seq"].as_i64(), Some(seq), "row {row_id} came back under the wrong seq" ); let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); let opened = synckit_client::crypto::decrypt_json_aad(&entry["data"], &new_key, &ctx) .unwrap_or_else(|e| panic!("row {row_id} does not open under the new key: {e}")); assert_eq!( opened, *payload, "row {row_id} came back holding a different payload" ); // The whole point of a rotation: the old key is retired. A no-op // re-encrypt would leave this opening cleanly. assert!( synckit_client::crypto::decrypt_json_aad(&entry["data"], &old_key, &ctx).is_err(), "row {row_id} still opens under the OLD key, so it was never re-encrypted" ); } } // ── The straggler round cap ── /// `MAX_ROTATION_ROUNDS` from `src/client/rotation.rs`. Private there, so it is /// restated here; the assertions below pin the cap exactly, which is the only /// way the counter's arithmetic is observable at all (a counter that never /// advances and a counter that advances differ nowhere except at the cap). const MAX_ROTATION_ROUNDS: usize = 100_000; /// A wiremock responder that counts what it served. /// /// The house `MockKit` would answer this test's routes, but its `hits()` reads /// wiremock's recorded-request log, and this test provokes 200,000 requests: the /// log would hold every one of them in memory for the duration. Counting in the /// responder and turning recording off keeps the test's footprint flat. struct Counted { hits: std::sync::Arc, code: u16, body: serde_json::Value, } impl wiremock::Respond for Counted { fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate { self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); ResponseTemplate::new(self.code).set_body_json(self.body.clone()) } } /// A server that answers `POST /keys/rotate/complete` with 409 forever must not /// spin the straggler loop forever: `rotate_key` gives up at /// `MAX_ROTATION_ROUNDS` and returns the round-cap `Internal` error. /// /// This is the only test that can see the straggler counter at all. Every other /// rotation test either never gets a 409 or gets exactly one, and on those the /// counter's value is never read; only crossing the cap turns it into an /// observable. The exact-count assertion is deliberate: it pins both the /// increment (a counter that stalled at zero never returns) and the boundary /// (the cap fires on the round that reaches it, not one round later). #[tokio::test] async fn a_server_that_reports_stragglers_forever_stops_at_the_round_cap() { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; ensure_crypto_provider(); ensure_mock_keystore(); let old_key = synckit_client::crypto::generate_master_key(); // Recording off: see `Counted`. let server = wiremock::MockServer::builder() .disable_request_recording() .start() .await; let completes = Arc::new(AtomicUsize::new(0)); let entries_pulls = Arc::new(AtomicUsize::new(0)); wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path(KEYS_PATH)) .respond_with(ResponseTemplate::new(200).set_body_json(get_keys_body(&old_key))) .mount(&server) .await; wiremock::Mock::given(wiremock::matchers::method("POST")) .and(wiremock::matchers::path(ROTATE_PATH)) .respond_with(ResponseTemplate::new(200).set_body_json(begin_body(0))) .mount(&server) .await; // Nothing left to re-encrypt, so each straggler round costs one pull and // returns immediately: the loop that is being bounded is the straggler loop, // not the re-encrypt loop inside it. wiremock::Mock::given(wiremock::matchers::method("POST")) .and(wiremock::matchers::path(ENTRIES_PATH)) .respond_with(Counted { hits: Arc::clone(&entries_pulls), code: 200, body: json!({ "entries": [], "has_more": false }), }) .mount(&server) .await; // The stall: stragglers, always, no matter how many rounds the client runs. wiremock::Mock::given(wiremock::matchers::method("POST")) .and(wiremock::matchers::path(COMPLETE_PATH)) .respond_with(Counted { hits: Arc::clone(&completes), code: 409, body: json!({ "message": "stragglers" }), }) .mount(&server) .await; let client = SyncKitClient::new(SyncKitConfig { server_url: server.uri(), api_key: "test-api-key".to_string(), }); let (user_id, app_id) = test_ids(); client.restore_session(&fresh_token(), user_id, app_id); let err = client .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect_err("a server that never converges must not be waited on forever"); // The straggler cap, not the re-encrypt cap: the two share a constant and a // variant, so the message is what tells them apart. match &err { SyncKitError::Internal(msg) => assert!( msg.contains("server kept reporting stragglers past the round cap"), "wrong give-up path: {msg}" ), other => panic!("expected the round-cap Internal error, got {other:?}"), } assert_eq!( completes.load(Ordering::Relaxed), MAX_ROTATION_ROUNDS, "the cap must fire on the round that reaches it: one completion attempt per round, no more and no fewer" ); // Every round but the last re-ran the re-encrypt loop; the capped round // returns before it does. assert_eq!( entries_pulls.load(Ordering::Relaxed), MAX_ROTATION_ROUNDS, "one initial re-encrypt pass plus one per straggler round short of the cap" ); } // ── What rotation leaves in the OS keychain ── // // `rotate_key` step 6 caches the new master key and, if that write fails, drops // the entry rather than leaving the pre-rotation key in it. Both halves of that // guard were unreachable before these tests existed: the suite ran only with // `keychain` off, where `cache_key` cannot report false and `delete_key` is a // no-op stub, so the failure branch had never been executed by anything. // // The two tests below pin the guard from both sides, which is what it takes: // dropping the negation would satisfy either one alone. /// Address the entry `keystore` writes for this session. /// /// `keystore::entry` is private, so its naming (`synckit:` as the /// service, the user id as the user) is restated here. Anything else addresses a /// different credential and the assertions would pass vacuously. #[cfg(feature = "keychain")] fn keychain_entry(app_id: AppId, user_id: UserId) -> keyring_core::Entry { keyring_core::Entry::new(&format!("synckit:{app_id}"), &user_id.to_string()) .expect("the mock store builds an entry") } /// A client with a keychain identity of its own. /// /// The mock store is process-global and its credentials are keyed on /// (service, user), so a test asserting on keychain contents cannot share /// `common::test_ids()` with every other rotation test in the binary. #[cfg(feature = "keychain")] fn client_with_own_keychain(kit: &MockKit, n: u128) -> (SyncKitClient, AppId, UserId) { let app_id = AppId::new(Uuid::from_u128(n)); let user_id = UserId::new(Uuid::from_u128(n + 1000)); let client = kit.client(); client.restore_session(&fresh_token(), user_id, app_id); (client, app_id, user_id) } /// Mount the whole happy-path rotation protocol, resuming onto `new_key` so the /// caller knows the key the client will end up holding. #[cfg(feature = "keychain")] async fn mount_resumed_rotation(kit: &MockKit, old_key: &[u8; 32], new_key: &[u8; 32]) { kit.get(KEYS_PATH) .json(json!({ "encrypted_key": synckit_client::crypto::wrap_master_key(old_key, ROTATE_PW).unwrap(), "key_version": 1, "key_id": 1, "pending_key": { "encrypted_key": synckit_client::crypto::wrap_master_key(new_key, ROTATE_PW).unwrap(), "key_id": 2, }, })) .await; kit.post(ROTATE_PATH).json(begin_body(0)).await; kit.post(ENTRIES_PATH) .json(json!({ "entries": [], "has_more": false })) .await; kit.post(COMPLETE_PATH).empty().await; } /// A rotation whose cache write fails must clear the keychain entry, because /// what is in it is the *pre-rotation* key. /// /// This is the case the guard at `rotation.rs` step 6 exists for, and it is /// worse than an empty cache: a cold launch that loaded the stale entry would /// decrypt nothing at all, with no password prompt to recover through. Deleting /// it makes the next launch fall through to the password path. /// /// The mock clears its armed error after one call, so the sequence the client /// actually walks is the real one: `store_key` fails, `cache_key` reports false, /// and the `delete_key` that follows succeeds. #[cfg(feature = "keychain")] #[tokio::test] async fn a_rotation_that_cannot_cache_the_new_key_drops_the_stale_one() { let kit = MockKit::start().await; let old_key = synckit_client::crypto::generate_master_key(); let new_key = synckit_client::crypto::generate_master_key(); mount_resumed_rotation(&kit, &old_key, &new_key).await; let (client, app_id, user_id) = client_with_own_keychain(&kit, 9_001); synckit_client::keystore::store_key(app_id, user_id, &old_key) .expect("seed the keychain with the pre-rotation key"); // Arm the next write to fail, which is `cache_key` returning false. let entry = keychain_entry(app_id, user_id); let cred: &keyring_core::mock::Cred = entry .as_any() .downcast_ref() .expect("the mock store yields mock credentials"); cred.set_error(keyring_core::Error::NoStorageAccess(Box::new( std::io::Error::other("keychain is locked"), ))); client .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect("a failed cache write must not fail the rotation: the key itself is fine"); match keychain_entry(app_id, user_id).get_password() { Err(keyring_core::Error::NoEntry) => {} Ok(held) => { let stale = base64::engine::general_purpose::STANDARD.encode(old_key); assert_ne!( held, stale, "the keychain still holds the PRE-ROTATION key: a cold launch would load it and decrypt nothing" ); panic!("the stale entry was not dropped; it holds an unexpected value instead"); } Err(e) => panic!("unexpected keychain error: {e}"), } } /// A rotation whose cache write succeeds must leave the NEW key in the /// keychain, and must not delete what it just wrote. /// /// The other side of the same guard. Without this, dropping the negation on the /// `cache_key` check would wipe the entry on every successful rotation, which is /// the very failure the sibling test's comment describes, arrived at from the /// opposite direction. #[cfg(feature = "keychain")] #[tokio::test] async fn a_rotation_that_caches_the_new_key_keeps_it() { let kit = MockKit::start().await; let old_key = synckit_client::crypto::generate_master_key(); let new_key = synckit_client::crypto::generate_master_key(); assert_ne!( old_key, new_key, "the two keys must differ or the assertion below proves nothing" ); mount_resumed_rotation(&kit, &old_key, &new_key).await; let (client, app_id, user_id) = client_with_own_keychain(&kit, 9_002); synckit_client::keystore::store_key(app_id, user_id, &old_key) .expect("seed the keychain with the pre-rotation key"); client .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) .await .expect("the rotation should complete"); let held = keychain_entry(app_id, user_id) .get_password() .expect("the new key must still be cached after a successful rotation"); assert_eq!( held, base64::engine::general_purpose::STANDARD.encode(new_key), "the keychain does not hold the post-rotation key" ); }