| 4 |
4 |
|
//
|
| 5 |
5 |
|
// These drive `rotate_key()` through the full server protocol against wiremock:
|
| 6 |
6 |
|
// fetch key -> begin -> re-encrypt loop -> complete, plus the straggler retry on
|
| 7 |
|
- |
// a 409. They are gated `#[cfg(not(feature = "keychain"))]` because `rotate_key`
|
| 8 |
|
- |
// finishes by caching the new key with `keystore::store_key`, which hits the OS
|
| 9 |
|
- |
// secret-service under the `keychain` feature and is unavailable on a headless
|
| 10 |
|
- |
// test host. With keychain off, `store_key` is the no-op stub, so the test
|
| 11 |
|
- |
// exercises the orchestration hermetically. Run with:
|
| 12 |
|
- |
// cargo test --no-default-features --features store,testing
|
|
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.
|
| 13 |
16 |
|
use crate::common::*;
|
| 14 |
17 |
|
|
| 15 |
18 |
|
const KEYS_PATH: &str = "/api/v1/sync/keys";
|
| 559 |
562 |
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
| 560 |
563 |
|
|
| 561 |
564 |
|
ensure_crypto_provider();
|
|
565 |
+ |
ensure_mock_keystore();
|
| 562 |
566 |
|
let old_key = synckit_client::crypto::generate_master_key();
|
| 563 |
567 |
|
|
| 564 |
568 |
|
// Recording off: see `Counted`.
|
| 638 |
642 |
|
"one initial re-encrypt pass plus one per straggler round short of the cap"
|
| 639 |
643 |
|
);
|
| 640 |
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 |
+ |
}
|