Skip to main content

macula_rust/
keystore.rs

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