Skip to main content

macula_rust/
keystore.rs

1//! Overridable, per-platform secure storage for a node key.
2//!
3//! Where a node key is kept at rest:
4//!
5//! - **unix**: an owner-only key file
6//!   ([`NodeKey::save`](crate::node_key::NodeKey::save)/[`load`](crate::node_key::NodeKey::load),
7//!   0o600 in a 0o700 directory, refused on load unless the effective user
8//!   owns it and nobody else can read it), or a key store from this module:
9//!   Linux keyutils ([`LinuxKeyutilsStore`]) or the platform's secure store
10//!   ([`KeyringStore`]).
11//! - **Windows**: Credential Manager only, through [`KeyringStore`]. A key
12//!   file is refused there with
13//!   [`KeyFileError::NoKeyFile`](crate::node_key::KeyFileError::NoKeyFile)
14//!   (macula-rust#19).
15//! - **mobile**: the platform's secure store, through [`KeyringStore`].
16//!
17//! Every store keeps the key's private part only, in one write: the
18//! ML-DSA-87 seed, and in pq_hybrid the RSA-PSS key as PKCS #1, at most 2,431
19//! bytes, within Credential Manager's 2,560-byte limit on one secret. The
20//! public keys are derived again on load. A key kept by macula-rust 0.7.0, in
21//! the key-file form, no longer loads; create the identity again.
22//!
23//! This module is
24//! a small [`KeyStore`] trait plus [`KeyringStore`], a
25//! default implementation backed by the `keyring` crate, which selects the
26//! actual native secure store per target automatically —
27//! Keychain (`Security.framework`) on macOS and iOS, Secret Service (D-Bus)
28//! on Linux, Credential Manager on Windows, and the Android Keystore (via
29//! JNI) on Android. Nothing in this crate branches on target platform
30//! itself; `keyring`'s own `Cargo.toml` does that selection via per-target
31//! optional dependencies (see `keyring = 4.2.0`'s manifest), and the
32//! backend that ends up linked is what actually runs.
33//!
34//! [`KeyStore`] itself is deliberately not tied to `keyring` at all — a
35//! caller with a different secure-storage requirement (a hardware security
36//! module, a different vault) can implement the trait directly and hand it
37//! to [`NodeKey::save_to_keystore`](crate::node_key::NodeKey::save_to_keystore)/
38//! [`load_from_keystore`](crate::node_key::NodeKey::load_from_keystore) —
39//! "overridable per target platform" is a property of the trait boundary,
40//! not something wired into this crate's own logic.
41//!
42//! ## Android setup, required once per app, not something this crate can do for you
43//!
44//! `android-native-keyring-store` (the backend `keyring` links in for
45//! Android, confirmed via its own `Cargo.toml`: `jni` + `ndk-context`)
46//! needs the embedding app to hand it a JNI `Context` once at startup,
47//! because Android's Keystore is a Java API with no NDK surface — there is
48//! no way for Rust code alone to reach it. That crate ships its own JNI
49//! export for exactly this (confirmed in its README, not assumed): once
50//! this crate is linked into an Android `.so`, that export is present
51//! automatically, and the Kotlin side calls it once, e.g. from
52//! `MainActivity.onCreate`:
53//!
54//! ```kotlin
55//! package io.crates.keyring
56//! class Keyring {
57//!     companion object {
58//!         init { System.loadLibrary("your_actual_library_name") }
59//!         external fun initializeNdkContext(context: Context)
60//!     }
61//! }
62//! // in onCreate:
63//! Keyring.initializeNdkContext(this.applicationContext)
64//! ```
65//!
66//! No custom UniFFI foreign-trait bridge is needed for Android or iOS —
67//! both have first-party `keyring` backends, confirmed by reading
68//! `keyring` 4.2.0's own `Cargo.toml` target-cfg dependency blocks
69//! directly, not assumed from the crate's name.
70//!
71//! ## A second Linux backend, [`LinuxKeyutilsStore`]
72//!
73//! `KeyringStore`'s Linux path (`keyring`'s `v1` API) unconditionally uses
74//! the D-Bus Secret Service, which requires a running provider
75//! (`gnome-keyring`, KWallet) — absent on headless boxes, containers, and
76//! this crate's own dev sandbox (confirmed directly: a D-Bus session
77//! socket exists here, but no `org.freedesktop.secrets` provider is
78//! listening on it, so `KeyringStore::new` returns
79//! [`KeyStoreError::Backend`] with `NoDefaultStore`). [`LinuxKeyutilsStore`]
80//! uses the kernel's own `keyutils` facility instead, always available on
81//! Linux — this is exactly what lets this module's own tests verify a
82//! real save/load/delete round trip in this environment.
83
84use keyring::Entry;
85use macula_mldsa::Zeroizing;
86
87/// Secure storage for one node key, as the bytes of its key file (the seed
88/// form: the ML-DSA-87 seed, and in pq_hybrid the RSA-PSS key too, a few KiB).
89/// Implement this directly for a backend other than [`KeyringStore`] (a
90/// hardware security module, a different vault) — this is the override point
91/// "per target platform" hangs off, not a platform enum this crate switches on
92/// internally.
93pub trait KeyStore {
94    /// Persist `key`, overwriting any value already stored under this
95    /// store's identity.
96    fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError>;
97
98    /// Retrieve a previously-[`save_key`](Self::save_key)d key.
99    /// [`KeyStoreError::NotFound`] if nothing has been stored yet.
100    fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError>;
101
102    /// Remove a previously-stored key, if any. Not required before a
103    /// [`save_key`](Self::save_key) (which overwrites), only for
104    /// deliberately forgetting an identity.
105    fn delete_key(&self) -> Result<(), KeyStoreError>;
106}
107
108/// The default [`KeyStore`]: the platform-native secure store `keyring`
109/// selects for the current target (see this module's own doc). On Windows a
110/// Credential Manager credential persisted on this machine only
111/// (CRED_PERSIST_LOCAL_MACHINE): the default, Enterprise persistence, roams
112/// with a domain user's profile, and a node's key does not leave the node
113/// (macula-rust#19). `service`
114/// and `account` are the same two strings every `keyring` consumer already
115/// uses to address one credential — pick values scoped to this
116/// application, e.g. `("com.example.myapp", "macula-identity")`, since the
117/// underlying stores are shared OS-wide facilities, not sandboxed to this
118/// crate.
119pub struct KeyringStore {
120    entry: keyring_core::Entry,
121}
122
123impl KeyringStore {
124    #[cfg(not(windows))]
125    pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
126        Ok(Self {
127            entry: Entry::new(service, account)?.inner,
128        })
129    }
130
131    /// Built against Windows Credential Manager directly, persisted on this
132    /// machine only, not through `keyring`'s default store.
133    #[cfg(windows)]
134    pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
135        use keyring_core::api::CredentialStoreApi;
136
137        let local = std::collections::HashMap::from([("persistence", "local")]);
138        let store = windows_native_keyring_store::Store::new()?;
139        let entry = store.build(service, account, Some(&local))?;
140        Ok(Self { entry })
141    }
142}
143
144impl KeyStore for KeyringStore {
145    fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> {
146        self.entry.set_secret(key)?;
147        Ok(())
148    }
149
150    fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError> {
151        match self.entry.get_secret() {
152            Ok(secret) => Ok(Zeroizing::new(secret)),
153            Err(keyring_core::Error::NoEntry) => Err(KeyStoreError::NotFound),
154            Err(e) => Err(e.into()),
155        }
156    }
157
158    fn delete_key(&self) -> Result<(), KeyStoreError> {
159        match self.entry.delete_credential() {
160            Ok(()) => Ok(()),
161            Err(keyring_core::Error::NoEntry) => Ok(()),
162            Err(e) => Err(e.into()),
163        }
164    }
165}
166
167/// A second, independently-selectable [`KeyStore`] backend — proof that
168/// the trait boundary is genuinely overridable, not merely declared to be:
169/// the kernel's own `keyutils` facility, no D-Bus/secret-service daemon
170/// required. This is what [`KeyringStore`]'s underlying `keyring`
171/// dependency itself recommends for headless Linux (containers, CI, a
172/// sandboxed dev box with no `gnome-keyring`/`kwalletd` running) — see
173/// `linux-keyutils-keyring-store`'s own module doc, which states this
174/// outright.
175///
176/// Deliberately does NOT go through `keyring`'s own `v1::Entry`/
177/// `keyring_core::set_default_store` (a process-global) — doing so would
178/// silently fight [`KeyringStore`]'s own default-store selection if both
179/// were ever constructed in the same process, with whichever initializes
180/// first winning. Instead this holds its own `Store` instance and asks it
181/// directly for a credential via `CredentialStoreApi::build`, which never
182/// touches the global default at all.
183#[cfg(target_os = "linux")]
184pub struct LinuxKeyutilsStore {
185    entry: keyring_core::Entry,
186}
187
188#[cfg(target_os = "linux")]
189impl LinuxKeyutilsStore {
190    pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
191        use keyring_core::api::CredentialStoreApi;
192
193        let store = linux_keyutils_keyring_store::Store::new()?;
194        let entry = store.build(service, account, None)?;
195        Ok(Self { entry })
196    }
197}
198
199#[cfg(target_os = "linux")]
200impl KeyStore for LinuxKeyutilsStore {
201    fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> {
202        self.entry.set_secret(key)?;
203        Ok(())
204    }
205
206    fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError> {
207        match self.entry.get_secret() {
208            Ok(secret) => Ok(Zeroizing::new(secret)),
209            Err(keyring_core::Error::NoEntry) => Err(KeyStoreError::NotFound),
210            Err(e) => Err(e.into()),
211        }
212    }
213
214    fn delete_key(&self) -> Result<(), KeyStoreError> {
215        match self.entry.delete_credential() {
216            Ok(()) => Ok(()),
217            Err(keyring_core::Error::NoEntry) => Ok(()),
218            Err(e) => Err(e.into()),
219        }
220    }
221}
222
223#[derive(Debug)]
224pub enum KeyStoreError {
225    /// No key has been stored yet under this store's identity.
226    NotFound,
227    /// The underlying platform secure store rejected the operation.
228    Backend(keyring::Error),
229}
230
231impl std::fmt::Display for KeyStoreError {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            KeyStoreError::NotFound => write!(f, "no key stored under this identity"),
235            KeyStoreError::Backend(e) => write!(f, "platform secure store error: {e}"),
236        }
237    }
238}
239
240impl std::error::Error for KeyStoreError {
241    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
242        match self {
243            KeyStoreError::Backend(e) => Some(e),
244            _ => None,
245        }
246    }
247}
248
249impl From<keyring::Error> for KeyStoreError {
250    fn from(e: keyring::Error) -> Self {
251        match e {
252            keyring::Error::NoEntry => KeyStoreError::NotFound,
253            other => KeyStoreError::Backend(other),
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    // Real round trip against a real backend -- not mocked. Uses
263    // LinuxKeyutilsStore rather than KeyringStore: this crate's own dev
264    // sandbox has a D-Bus session socket but no org.freedesktop.secrets
265    // provider registered on it (no gnome-keyring/kwalletd running), so
266    // KeyringStore::new genuinely fails here with NoDefaultStore -- a real
267    // environment fact, confirmed directly, not a bug in either backend.
268    // LinuxKeyutilsStore's kernel-keyutils backend has no such external
269    // dependency, which is exactly why it exists (see this module's own
270    // doc). Uses a service/account pair distinguishable from any real
271    // application's own entries, and always deletes what it wrote, pass or
272    // fail, so a test run never leaves a credential behind.
273    //
274    // KEYRING_TEST_MUTEX below is load-bearing, not defensive boilerplate:
275    // confirmed directly that these tests fail under Rust's default
276    // parallel test-thread scheduling (NotFound errors reading a secret
277    // just written by the same test) but pass 100% reliably under
278    // `--test-threads=1`. This sandbox's kernel resolves
279    // KeyRingIdentifier::Session per-thread rather than per-process under
280    // concurrent first access -- a real environment characteristic, not a
281    // bug in this module's own save/load/delete logic (which is exactly
282    // what running these serially, but still by default under `cargo
283    // test`, proves).
284    #[cfg(target_os = "linux")]
285    static KEYRING_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
286
287    #[cfg(target_os = "linux")]
288    fn test_store() -> LinuxKeyutilsStore {
289        LinuxKeyutilsStore::new("macula-rust-test", "keystore-round-trip-test-entry")
290            .expect("Store::new/build should succeed -- keyutils is always available on Linux")
291    }
292
293    #[cfg(target_os = "linux")]
294    #[test]
295    fn save_then_load_returns_the_same_key() {
296        let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
297        let store = test_store();
298        let key = vec![0x42u8; 2400];
299
300        let result = (|| -> Result<(), KeyStoreError> {
301            store.save_key(&key)?;
302            let loaded = store.load_key()?;
303            assert_eq!(*loaded, key);
304            Ok(())
305        })();
306
307        store.delete_key().expect("cleanup delete should succeed");
308        result.expect("save/load round trip should succeed");
309    }
310
311    #[cfg(target_os = "linux")]
312    #[test]
313    fn load_before_any_save_reports_not_found() {
314        let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
315        let store = test_store();
316        // Guard against a leftover entry from a prior failed run on this
317        // machine before asserting NotFound.
318        let _ = store.delete_key();
319
320        assert!(matches!(store.load_key(), Err(KeyStoreError::NotFound)));
321    }
322
323    // On Windows the node key lives in Credential Manager only: a real round
324    // trip of both profiles through KeyringStore, and a key file refused.
325    #[cfg(windows)]
326    #[test]
327    fn a_node_key_round_trips_through_credential_manager_and_a_key_file_is_refused() {
328        use crate::node_key::{KeyFileError, NodeKey, Purpose};
329        use crate::profile::Profile;
330
331        for profile in [Profile::PqPure, Profile::PqHybrid] {
332            let account = format!("keystore-round-trip-{}", profile.name());
333            let store =
334                KeyringStore::new("macula-rust-test", &account).expect("Credential Manager");
335            let key = NodeKey::generate_identity(profile, 0).expect("a key");
336            let result = key
337                .save_to_keystore(&store)
338                .and_then(|()| NodeKey::load_from_keystore(&store, Purpose::Identity, profile));
339            // Persisted on this machine only, never roaming with the profile.
340            let persistence = store
341                .entry
342                .get_attributes()
343                .map(|a| a.get("persistence").cloned());
344            store.delete_key().expect("cleanup delete should succeed");
345            assert_eq!(
346                persistence.ok().flatten().as_deref(),
347                Some("Local"),
348                "{profile:?}"
349            );
350            let loaded = result.expect("save/load round trip through Credential Manager");
351            assert_eq!(loaded.public_key(), key.public_key(), "{profile:?}");
352        }
353        let path = std::env::temp_dir().join("macula-rust-test-key");
354        let _ = std::fs::remove_file(&path);
355        let refused = NodeKey::load_or_create(&path, Profile::PqPure);
356        assert!(matches!(refused, Err(KeyFileError::NoKeyFile)));
357        assert!(!path.exists(), "no key file written");
358    }
359
360    #[cfg(target_os = "linux")]
361    #[test]
362    fn delete_is_idempotent() {
363        let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
364        let store = test_store();
365        store.save_key(&[0x7Fu8; 32]).expect("save");
366        store.delete_key().expect("first delete");
367        // A second delete of an already-absent entry must not error --
368        // KeyStore::delete_key's own doc promises this.
369        store
370            .delete_key()
371            .expect("second delete on an absent entry");
372    }
373}