macula_rust/keystore.rs
1//! Overridable, per-platform secure storage for a persisted identity seed.
2//!
3//! [`KeyPair::save`](crate::identity::KeyPair::save)/[`load`](crate::identity::KeyPair::load)
4//! write a raw file — explicitly documented there as "a testing/parity
5//! convenience," not what a real mobile binding should use. This module is
6//! the real answer: 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 [`KeyPair::save_to_keystore`](crate::identity::KeyPair::save_to_keystore)/
20//! [`load_from_keystore`](crate::identity::KeyPair::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;
67
68/// Secure storage for a 32-byte identity seed. Implement this directly for
69/// a backend other than [`KeyringStore`] (a hardware security module, a
70/// different vault) — this is the override point "per target platform"
71/// hangs off, not a platform enum this crate switches on internally.
72pub trait KeyStore {
73 /// Persist `seed`, overwriting any value already stored under this
74 /// store's identity.
75 fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError>;
76
77 /// Retrieve a previously-[`save_seed`](Self::save_seed)d seed.
78 /// [`KeyStoreError::NotFound`] if nothing has been stored yet.
79 fn load_seed(&self) -> Result<[u8; 32], KeyStoreError>;
80
81 /// Remove a previously-stored seed, if any. Not required before a
82 /// [`save_seed`](Self::save_seed) (which overwrites), only for
83 /// deliberately forgetting an identity.
84 fn delete_seed(&self) -> Result<(), KeyStoreError>;
85}
86
87/// The default [`KeyStore`]: the platform-native secure store `keyring`
88/// selects for the current target (see this module's own doc). `service`
89/// and `account` are the same two strings every `keyring` consumer already
90/// uses to address one credential — pick values scoped to this
91/// application, e.g. `("com.example.myapp", "macula-identity")`, since the
92/// underlying stores are shared OS-wide facilities, not sandboxed to this
93/// crate.
94pub struct KeyringStore {
95 entry: Entry,
96}
97
98impl KeyringStore {
99 pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
100 Ok(Self {
101 entry: Entry::new(service, account)?,
102 })
103 }
104}
105
106impl KeyStore for KeyringStore {
107 fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> {
108 self.entry.set_secret(seed)?;
109 Ok(())
110 }
111
112 fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> {
113 let secret = match self.entry.get_secret() {
114 Ok(secret) => secret,
115 Err(keyring::Error::NoEntry) => return Err(KeyStoreError::NotFound),
116 Err(e) => return Err(e.into()),
117 };
118 let actual = secret.len();
119 secret
120 .try_into()
121 .map_err(|_| KeyStoreError::InvalidSeedLength { actual })
122 }
123
124 fn delete_seed(&self) -> Result<(), KeyStoreError> {
125 match self.entry.delete_credential() {
126 Ok(()) => Ok(()),
127 Err(keyring::Error::NoEntry) => Ok(()),
128 Err(e) => Err(e.into()),
129 }
130 }
131}
132
133/// A second, independently-selectable [`KeyStore`] backend — proof that
134/// the trait boundary is genuinely overridable, not merely declared to be:
135/// the kernel's own `keyutils` facility, no D-Bus/secret-service daemon
136/// required. This is what [`KeyringStore`]'s underlying `keyring`
137/// dependency itself recommends for headless Linux (containers, CI, a
138/// sandboxed dev box with no `gnome-keyring`/`kwalletd` running) — see
139/// `linux-keyutils-keyring-store`'s own module doc, which states this
140/// outright.
141///
142/// Deliberately does NOT go through `keyring`'s own `v1::Entry`/
143/// `keyring_core::set_default_store` (a process-global) — doing so would
144/// silently fight [`KeyringStore`]'s own default-store selection if both
145/// were ever constructed in the same process, with whichever initializes
146/// first winning. Instead this holds its own `Store` instance and asks it
147/// directly for a credential via `CredentialStoreApi::build`, which never
148/// touches the global default at all.
149#[cfg(target_os = "linux")]
150pub struct LinuxKeyutilsStore {
151 entry: keyring_core::Entry,
152}
153
154#[cfg(target_os = "linux")]
155impl LinuxKeyutilsStore {
156 pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
157 use keyring_core::api::CredentialStoreApi;
158
159 let store = linux_keyutils_keyring_store::Store::new()?;
160 let entry = store.build(service, account, None)?;
161 Ok(Self { entry })
162 }
163}
164
165#[cfg(target_os = "linux")]
166impl KeyStore for LinuxKeyutilsStore {
167 fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> {
168 self.entry.set_secret(seed)?;
169 Ok(())
170 }
171
172 fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> {
173 let secret = match self.entry.get_secret() {
174 Ok(secret) => secret,
175 Err(keyring_core::Error::NoEntry) => return Err(KeyStoreError::NotFound),
176 Err(e) => return Err(e.into()),
177 };
178 let actual = secret.len();
179 secret
180 .try_into()
181 .map_err(|_| KeyStoreError::InvalidSeedLength { actual })
182 }
183
184 fn delete_seed(&self) -> Result<(), KeyStoreError> {
185 match self.entry.delete_credential() {
186 Ok(()) => Ok(()),
187 Err(keyring_core::Error::NoEntry) => Ok(()),
188 Err(e) => Err(e.into()),
189 }
190 }
191}
192
193#[derive(Debug)]
194pub enum KeyStoreError {
195 /// No seed has been stored yet under this store's identity.
196 NotFound,
197 /// A stored secret existed but wasn't 32 bytes — corrupted, or written
198 /// by something other than [`KeyStore::save_seed`].
199 InvalidSeedLength { actual: usize },
200 /// The underlying platform secure store rejected the operation.
201 Backend(keyring::Error),
202}
203
204impl std::fmt::Display for KeyStoreError {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 match self {
207 KeyStoreError::NotFound => write!(f, "no seed stored under this identity"),
208 KeyStoreError::InvalidSeedLength { actual } => {
209 write!(f, "stored secret is {actual} bytes, expected 32")
210 }
211 KeyStoreError::Backend(e) => write!(f, "platform secure store error: {e}"),
212 }
213 }
214}
215
216impl std::error::Error for KeyStoreError {
217 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
218 match self {
219 KeyStoreError::Backend(e) => Some(e),
220 _ => None,
221 }
222 }
223}
224
225impl From<keyring::Error> for KeyStoreError {
226 fn from(e: keyring::Error) -> Self {
227 match e {
228 keyring::Error::NoEntry => KeyStoreError::NotFound,
229 other => KeyStoreError::Backend(other),
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 // Real round trip against a real backend -- not mocked. Uses
239 // LinuxKeyutilsStore rather than KeyringStore: this crate's own dev
240 // sandbox has a D-Bus session socket but no org.freedesktop.secrets
241 // provider registered on it (no gnome-keyring/kwalletd running), so
242 // KeyringStore::new genuinely fails here with NoDefaultStore -- a real
243 // environment fact, confirmed directly, not a bug in either backend.
244 // LinuxKeyutilsStore's kernel-keyutils backend has no such external
245 // dependency, which is exactly why it exists (see this module's own
246 // doc). Uses a service/account pair distinguishable from any real
247 // application's own entries, and always deletes what it wrote, pass or
248 // fail, so a test run never leaves a credential behind.
249 //
250 // KEYRING_TEST_MUTEX below is load-bearing, not defensive boilerplate:
251 // confirmed directly that these tests fail under Rust's default
252 // parallel test-thread scheduling (NotFound errors reading a secret
253 // just written by the same test) but pass 100% reliably under
254 // `--test-threads=1`. This sandbox's kernel resolves
255 // KeyRingIdentifier::Session per-thread rather than per-process under
256 // concurrent first access -- a real environment characteristic, not a
257 // bug in this module's own save/load/delete logic (which is exactly
258 // what running these serially, but still by default under `cargo
259 // test`, proves).
260 #[cfg(target_os = "linux")]
261 static KEYRING_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
262
263 #[cfg(target_os = "linux")]
264 fn test_store() -> LinuxKeyutilsStore {
265 LinuxKeyutilsStore::new("macula-rust-test", "keystore-round-trip-test-entry")
266 .expect("Store::new/build should succeed -- keyutils is always available on Linux")
267 }
268
269 #[cfg(target_os = "linux")]
270 #[test]
271 fn save_then_load_returns_the_same_seed() {
272 let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
273 let store = test_store();
274 let seed = [0x42u8; 32];
275
276 let result = (|| -> Result<(), KeyStoreError> {
277 store.save_seed(&seed)?;
278 let loaded = store.load_seed()?;
279 assert_eq!(loaded, seed);
280 Ok(())
281 })();
282
283 store.delete_seed().expect("cleanup delete should succeed");
284 result.expect("save/load round trip should succeed");
285 }
286
287 #[cfg(target_os = "linux")]
288 #[test]
289 fn load_before_any_save_reports_not_found() {
290 let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
291 let store = test_store();
292 // Guard against a leftover entry from a prior failed run on this
293 // machine before asserting NotFound.
294 let _ = store.delete_seed();
295
296 assert!(matches!(store.load_seed(), Err(KeyStoreError::NotFound)));
297 }
298
299 #[cfg(target_os = "linux")]
300 #[test]
301 fn delete_is_idempotent() {
302 let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
303 let store = test_store();
304 store.save_seed(&[0x7Fu8; 32]).expect("save");
305 store.delete_seed().expect("first delete");
306 // A second delete of an already-absent entry must not error --
307 // KeyStore::delete_seed's own doc promises this.
308 store
309 .delete_seed()
310 .expect("second delete on an absent entry");
311 }
312}