Skip to main content

hashiverse_lib/client/key_locker/
key_locker.rs

1//! # Account identity and signing — pluggable storage
2//!
3//! A single device can have several accounts on it (work, personal, alt, …) and the
4//! application must be able to switch between them, create new ones, and sign outgoing
5//! messages under the currently-selected one. Two traits split that responsibility:
6//!
7//! - [`KeyLocker`] — the guarded holder of **one** account's private signing material.
8//!   Exposes only `client_id()` and `sign()`; the private key never leaves the locker.
9//!   This is deliberately the minimum surface so production implementations can delegate
10//!   signing to a platform keychain without exposing raw key bytes.
11//! - [`KeyLockerManager`] — the directory of lockers installed on this device:
12//!   `list` / `create` / `switch` / `delete` / `reset`.
13//!
14//! The constant [`GUEST_CLIENT_ID`] is the deterministic `ClientId` produced from an
15//! empty keyphrase. It is filtered out of `list()` so it never appears as a "stored
16//! account" in the login UI, even though it's always present as a fallback identity.
17//!
18//! Implementations: [`crate::client::key_locker::mem_key_locker`] for tests,
19//! [`crate::client::key_locker::disk_key_locker`] for native / desktop, and (in the WASM
20//! crate) an IndexedDB-backed variant for browsers.
21
22use crate::tools::client_id::ClientId;
23use crate::tools::types::Signature;
24use std::sync::Arc;
25
26/// The client ID produced by `Keys::from_phrase("")` — deterministic and shared by all guests.
27/// Filtered out of `list()` so it never appears as a stored account in the login UI.
28pub const GUEST_CLIENT_ID: &str = "fe050cc21479a93d00fdb825c98c8489ea47dd6ce180b6f8b72665f284842e41";
29
30/// The guarded holder of a single account's private signing material.
31///
32/// A `KeyLocker` binds together the public [`ClientId`] (what the rest of the protocol sees)
33/// and the private [`crate::tools::types::SignatureKey`] (what produces signatures). It
34/// exposes the minimum interface the client needs: "who am I" (`client_id`) and "sign this"
35/// (`sign`). The private key itself never leaves the locker — that is the whole point of the
36/// abstraction, which lets production implementations delegate signing to platform-native
37/// key stores (WebCrypto in the browser, OS keyring elsewhere) while tests can use a
38/// straightforward in-memory stub.
39///
40/// An account is identified externally by the hex string of its [`ClientId::id`]; the
41/// special [`GUEST_CLIENT_ID`] constant marks the deterministic "empty keyphrase" guest
42/// identity and is filtered out of [`KeyLockerManager::list`] so it never looks like a
43/// stored account in the login UI.
44#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
45#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
46pub trait KeyLocker: Send + Sync {
47    fn client_id(&self) -> &ClientId;
48    async fn sign(&self, data: &[u8]) -> anyhow::Result<Signature>;
49}
50
51/// The device-local account store that manages the set of [`KeyLocker`]s the user has set up
52/// on this machine.
53///
54/// Where a single [`KeyLocker`] holds one account, a `KeyLockerManager` is the directory of
55/// all of them: it can enumerate stored accounts (`list`), create a new one from a BIP-39-
56/// style keyphrase (`create`), hand back the [`KeyLocker`] for an existing account
57/// (`switch`), or remove one (`delete`). Implementations are backed by platform-specific
58/// persistent storage — IndexedDB in the browser, sled on native, in-memory in tests — but
59/// expose the same trait so the login flow in the UI never has to branch on target.
60///
61/// Generic over the concrete `KeyLocker` type so the WASM-friendly `?Send` bound can be
62/// preserved where needed.
63pub trait KeyLockerManager<TKeyLocker: KeyLocker> {
64    async fn new() -> anyhow::Result<Arc<Self>>;
65    async fn list(&self) -> anyhow::Result<Vec<String>>;
66    async fn create(&self, key_phrase: String) -> anyhow::Result<Arc<TKeyLocker>>;
67    async fn switch(&self, key_public: String) -> anyhow::Result<Arc<TKeyLocker>>;
68    async fn delete(&self, key_public: String) -> anyhow::Result<()>;
69    async fn reset(&self) -> anyhow::Result<()>;
70}
71
72#[cfg(any(test, feature = "generic-tests"))]
73pub mod tests {
74    use crate::client::key_locker::key_locker::{KeyLocker, KeyLockerManager, GUEST_CLIENT_ID};
75    use crate::tools::types::VerificationKey;
76    use crate::tools::{signing, tools};
77
78    pub async fn add_test<TKeyLocker: KeyLocker, TKeyLockerManager: KeyLockerManager<TKeyLocker>>() {
79        let result = try {
80            let key_locker_manager = TKeyLockerManager::new().await?;
81
82            // Start clean
83            key_locker_manager.reset().await?;
84            assert_eq!(0, key_locker_manager.list().await?.len());
85
86            // Add a key
87            let key_locker_1 = key_locker_manager.create("key_phrase_1".to_string()).await?;
88            let key_locker_2 = key_locker_manager.create("key_phrase_2".to_string()).await?;
89            assert_eq!(2, key_locker_manager.list().await?.len());
90
91            // Locate key
92            let public_key_1 = key_locker_1.client_id().id.to_hex_str();
93            let public_key_2 = key_locker_2.client_id().id.to_hex_str();
94            assert_eq!(key_locker_1.client_id(), key_locker_manager.switch(public_key_1).await?.client_id());
95            assert_eq!(key_locker_2.client_id(), key_locker_manager.switch(public_key_2).await?.client_id());
96
97            // End clean
98            key_locker_manager.reset().await?;
99            assert_eq!(0, key_locker_manager.list().await?.len());
100        };
101
102        if let Err(e) = result {
103            panic!("Test failed: {}", e);
104        }
105    }
106    /// Confirms that `list()` never returns the guest client ID.
107    pub async fn guest_client_id_excluded_from_list_test<TKeyLocker: KeyLocker, TKeyLockerManager: KeyLockerManager<TKeyLocker>>() {
108        let result: anyhow::Result<()> = try {
109            let key_locker_manager = TKeyLockerManager::new().await?;
110            key_locker_manager.reset().await?;
111
112            // Create a guest key (empty keyphrase) and a real key
113            let _guest_key_locker = key_locker_manager.create("".to_string()).await?;
114            let real_key_locker = key_locker_manager.create("real_keyphrase".to_string()).await?;
115            let real_public_key = real_key_locker.client_id().id.to_hex_str();
116
117            // list() should only return the real key, not the guest
118            let listed_keys = key_locker_manager.list().await?;
119            assert_eq!(listed_keys.len(), 1, "list() should return exactly 1 key, got {}", listed_keys.len());
120            assert_eq!(listed_keys[0], real_public_key);
121            assert!(!listed_keys.contains(&GUEST_CLIENT_ID.to_string()), "list() must not return the guest client ID");
122
123            key_locker_manager.reset().await?;
124        };
125
126        if let Err(e) = result {
127            panic!("Test failed: {}", e);
128        }
129    }
130
131    pub async fn sign_test<TKeyLocker: KeyLocker, TKeyLockerManager: KeyLockerManager<TKeyLocker>>() {
132        let result = try {
133            let key_locker_manager = TKeyLockerManager::new().await?;
134
135            // Start clean
136            key_locker_manager.reset().await?;
137            assert_eq!(0, key_locker_manager.list().await?.len());
138
139            // Add a key
140            let key_locker_1 = key_locker_manager.create("key_phrase_1".to_string()).await?;
141
142            // Test a signature
143            {
144                let mut message_to_sign = [0u8; 1024];
145                tools::random_fill_bytes(&mut message_to_sign);
146                let signature = key_locker_1.sign(&message_to_sign).await?;
147                let verification_key = VerificationKey::from_bytes(&key_locker_1.client_id().verification_key_bytes)?;
148                let result = signing::verify(&verification_key, &signature, &message_to_sign);
149                assert!(result.is_ok())
150            }
151        };
152
153        if let Err(e) = result {
154            panic!("Test failed: {}", e);
155        }
156    }
157
158    /// Confirms that `GUEST_CLIENT_ID` matches the client ID derived from an empty keyphrase.
159    /// If the key derivation changes, this test will fail and the constant must be updated.
160    #[test]
161    fn guest_client_id_constant_test() {
162        use crate::tools::client_id::ClientId;
163        use crate::tools::keys::Keys;
164        let keys = Keys::from_phrase("").expect("empty keyphrase should always work");
165        let client_id = ClientId::new(keys.verification_key_bytes, keys.pq_commitment_bytes).expect("client id creation should always work");
166        assert_eq!(client_id.id.to_hex_str(), GUEST_CLIENT_ID, "GUEST_CLIENT_ID constant is stale — update it to match the current empty-keyphrase derivation");
167    }
168}