Skip to main content

dig_keystore/backend/
os_keychain.rs

1//! OS-native credential-store backend.
2//!
3//! [`OsKeychainBackend`] persists each encrypted blob in the host operating
4//! system's credential store — **Windows Credential Manager** or **macOS
5//! Keychain** — through the cross-platform [`keyring`] crate. It absorbs the
6//! proven `OsCredentialStore` shape that shipped in dig-app so the ecosystem
7//! keeps exactly one keystore implementation.
8//!
9//! # Status — no current consumers; NOT for a machine service
10//!
11//! No crate in the ecosystem uses this backend today. The OS credential store
12//! is released by the **login session**, so a machine/system service — a
13//! dig-node running as SYSTEM or under a non-interactive account — has no
14//! session to release it and **MUST NOT** use this backend; the
15//! passphrase-sealed [`FileBackend`](crate::backend::FileBackend) is the
16//! backend for that case. It is retained for the user-application case (a
17//! desktop app whose secrets live and die with a logged-in user) and to
18//! migrate an existing dig-app account off the retired machine-password model.
19//!
20//! # What this backend is, and is not
21//!
22//! It is a **storage location, not an access-control primitive**. The
23//! keystore's own Argon2id + AES-256-GCM sealing (`SPEC.md` §3–§5) remains the
24//! primary access control for everything filed here; the credential store is
25//! defence-in-depth only.
26//!
27//! The access boundary is **not the same on both platforms**:
28//!
29//! - **macOS** — the Keychain applies a per-application ACL, but one gated by
30//!   **user consent**: a different process running as the same user triggers an
31//!   authorization prompt the user may answer "Always Allow", and the
32//!   trusted-application designation rests on a code signature a same-user
33//!   process can generally overwrite. Real, but not a hard boundary against a
34//!   same-user attacker.
35//! - **Windows** — a generic Credential Manager entry is protected by DPAPI
36//!   under the logged-in user's key and is readable by **any process running
37//!   as that user**. That is a **per-user** boundary. Never assume a
38//!   per-application one here. It is not even machine-local: `keyring` writes
39//!   with `CRED_PERSIST_ENTERPRISE`, so on a domain-joined host the credential
40//!   **roams with the user profile** — the boundary is any process running as
41//!   that user on any machine they roam to.
42//!
43//! Because of that, callers **MUST NOT** write an unlock password, passphrase,
44//! mnemonic, raw seed, or any other plaintext secret to this backend — only
45//! blobs this crate has already sealed. That is a caller obligation
46//! (`SPEC.md` §10.5), not something the backend enforces: it is a byte-blob KV
47//! store and stores whatever it is given.
48//!
49//! # Platform gating (HARD)
50//!
51//! The [`keyring`] dependency is compiled **only** on `target_os = "windows"`
52//! or `target_os = "macos"`. On every other target — Linux and `wasm32`
53//! included — `keyring` is never pulled (no dbus/libsecret system-library tax,
54//! no wasm break) and [`OsKeychainBackend::open`] returns `None`. The crate
55//! performs **no** fallback of any kind: selecting another backend is entirely
56//! the caller's decision.
57//!
58//! **Linux is deliberately excluded as a custody primary.** The kernel
59//! keyutils session keyring is readable by any same-UID process in the session
60//! and is non-persistent across reboot/logout, so it is unsafe as a custody
61//! primary and would lose the identity on logout. On Linux the
62//! passphrase-sealed file is the correct primary instead.
63//!
64//! # Enumeration
65//!
66//! OS credential stores expose no native key enumeration. `OsKeychainBackend`
67//! keeps a best-effort **index entry** (a reserved account) holding the set of
68//! live keys; [`list`](KeychainBackend::list) filters it. `read`/`write`/
69//! `delete`/`exists` consult the credential store directly and are the source
70//! of truth — the index only powers `list`, so index/store drift can never
71//! corrupt a read or a write, only stale a listing.
72
73use crate::backend::{BackendKey, KeychainBackend};
74use crate::error::{KeystoreError, Result};
75
76use parking_lot::Mutex;
77use zeroize::Zeroizing;
78
79/// Reserved account under which the enumeration index is stored. Chosen to be
80/// distinct from any real [`BackendKey`] a caller would use (which are simple
81/// identifiers like `validator_bls`).
82const INDEX_ACCOUNT: &str = "__dig_keystore_index__";
83
84/// Low-level `(account) -> secret` store abstraction over one credential-store
85/// namespace (service). Extracted so the enumeration/round-trip logic is
86/// testable without touching a real OS store: the real implementation is
87/// [`KeyringStore`]; tests inject an in-memory double.
88trait RawStore: Send + Sync + 'static {
89    /// Fetch the secret stored under `account`, or `None` if no entry exists.
90    /// A backend that exists but cannot be read is an `Err`, distinct from
91    /// "absent".
92    fn get(&self, account: &str) -> Result<Option<Vec<u8>>>;
93
94    /// Store `secret` under `account`, overwriting any existing entry.
95    fn set(&self, account: &str, secret: &[u8]) -> Result<()>;
96
97    /// Delete the entry under `account`. Deleting an absent entry is a no-op.
98    fn remove(&self, account: &str) -> Result<()>;
99}
100
101/// A [`KeychainBackend`] backed by the host OS credential store.
102///
103/// Construct with [`OsKeychainBackend::open`], which returns `None` when no
104/// usable OS store exists on this host. The crate performs no fallback; the
105/// caller selects an alternative backend, as the example below does.
106///
107/// A storage location, not an access-control primitive: the crate's own seal
108/// is the primary access control, so callers MUST NOT file plaintext secrets
109/// here. See the module docs for the per-platform access boundary and for why
110/// a machine/system service must not use this backend.
111///
112/// # Example
113///
114/// ```no_run
115/// use std::sync::Arc;
116/// use dig_keystore::backend::{KeychainBackend, OsKeychainBackend, FileBackend, BackendKey};
117///
118/// // Prefer the OS credential store; fall back to a file backend elsewhere.
119/// let backend: Arc<dyn KeychainBackend> = match OsKeychainBackend::open("dig-app") {
120///     Some(os) => Arc::new(os),
121///     None => Arc::new(FileBackend::new("/var/lib/dig/keys")),
122/// };
123/// backend.write(&BackendKey::new("identity"), b"...").unwrap();
124/// ```
125pub struct OsKeychainBackend {
126    /// The underlying credential store (real keyring, or a test double).
127    store: Box<dyn RawStore>,
128    /// Serializes read-modify-write of the enumeration index within this
129    /// process. Cross-process index races are tolerated (best-effort `list`).
130    index_lock: Mutex<()>,
131}
132
133impl OsKeychainBackend {
134    /// Construct from an arbitrary [`RawStore`] — the seam every path shares.
135    ///
136    /// `open` uses it on Windows/macOS with a real keyring store; tests use it
137    /// with an in-memory double. Not compiled where it would be unused (a
138    /// non-test Linux/wasm build, where [`open`](Self::open) always returns
139    /// `None`).
140    #[cfg(any(test, target_os = "windows", target_os = "macos"))]
141    fn with_store(store: Box<dyn RawStore>) -> Self {
142        Self {
143            store,
144            index_lock: Mutex::new(()),
145        }
146    }
147
148    /// Load the enumeration index for `list` (the set of live keys). A
149    /// missing or unreadable index yields an empty set — `list` is
150    /// best-effort per the module docs. Insert/remove use
151    /// [`load_index_for_update`](Self::load_index_for_update) instead, which
152    /// keeps a hard read error distinct from "no index yet" so a
153    /// read-modify-write never clobbers a previously-persisted index.
154    fn load_index(&self) -> Vec<String> {
155        self.load_index_for_update().unwrap_or_default()
156    }
157
158    /// Load the enumeration index for a read-modify-write, distinguishing a
159    /// genuinely empty index (`Ok(None)` — fresh keystore, nothing indexed
160    /// yet) from a hard/transient store error (`Err`).
161    ///
162    /// This distinction matters: `index_insert`/`index_remove` must NOT
163    /// treat a transient read failure as "empty" and then persist that empty
164    /// index, which would silently drop every other already-indexed key
165    /// name from future `list()` calls.
166    fn load_index_for_update(&self) -> Result<Vec<String>> {
167        match self.store.get(INDEX_ACCOUNT) {
168            Ok(Some(bytes)) => {
169                let raw = Zeroizing::new(bytes);
170                Ok(String::from_utf8_lossy(&raw)
171                    .lines()
172                    .filter(|l| !l.is_empty())
173                    .map(str::to_owned)
174                    .collect())
175            }
176            Ok(None) => Ok(Vec::new()),
177            Err(e) => Err(e),
178        }
179    }
180
181    /// Persist the enumeration index. Best-effort — a failure to write the
182    /// index never fails the caller's `write`/`delete`, it only risks a stale
183    /// `list` (the credential store itself already holds the authoritative
184    /// entry).
185    fn store_index(&self, keys: &[String]) {
186        let joined = Zeroizing::new(keys.join("\n").into_bytes());
187        let _ = self.store.set(INDEX_ACCOUNT, &joined);
188    }
189
190    /// Add `key` to the index if absent.
191    ///
192    /// Skips the update entirely on a hard/transient index-read error rather
193    /// than persisting an empty index in its place — see
194    /// [`load_index_for_update`](Self::load_index_for_update).
195    fn index_insert(&self, key: &str) {
196        let _guard = self.index_lock.lock();
197        let Ok(mut keys) = self.load_index_for_update() else {
198            return;
199        };
200        if !keys.iter().any(|k| k == key) {
201            keys.push(key.to_owned());
202            self.store_index(&keys);
203        }
204    }
205
206    /// Remove `key` from the index if present.
207    ///
208    /// Skips the update entirely on a hard/transient index-read error, for
209    /// the same reason as [`index_insert`](Self::index_insert).
210    fn index_remove(&self, key: &str) {
211        let _guard = self.index_lock.lock();
212        let Ok(mut keys) = self.load_index_for_update() else {
213            return;
214        };
215        let before = keys.len();
216        keys.retain(|k| k != key);
217        if keys.len() != before {
218            self.store_index(&keys);
219        }
220    }
221}
222
223/// Reject a key name that cannot safely be stored: one equal to the
224/// reserved [`INDEX_ACCOUNT`] sentinel (which would shadow the enumeration
225/// index itself) or containing a newline (which would poison the
226/// newline-joined index format persisted by [`store_index`]).
227fn validate_key_name(name: &str) -> Result<()> {
228    if name == INDEX_ACCOUNT || name.contains('\n') {
229        return Err(KeystoreError::from(std::io::Error::new(
230            std::io::ErrorKind::InvalidInput,
231            format!("invalid key name (reserved or contains newline): {name:?}"),
232        )));
233    }
234    Ok(())
235}
236
237/// Redacted `Debug` — never prints service, account, or secret material.
238impl std::fmt::Debug for OsKeychainBackend {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_struct("OsKeychainBackend")
241            .field("store", &"<redacted>")
242            .finish()
243    }
244}
245
246impl KeychainBackend for OsKeychainBackend {
247    fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
248        match self.store.get(key.as_str())? {
249            Some(bytes) => Ok(bytes),
250            None => Err(KeystoreError::from(std::io::Error::new(
251                std::io::ErrorKind::NotFound,
252                format!("key not found: {key}"),
253            ))),
254        }
255    }
256
257    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
258        validate_key_name(key.as_str())?;
259        self.store.set(key.as_str(), data)?;
260        // Authoritative entry is written; index is a best-effort convenience.
261        self.index_insert(key.as_str());
262        Ok(())
263    }
264
265    fn delete(&self, key: &BackendKey) -> Result<()> {
266        self.store.remove(key.as_str())?;
267        self.index_remove(key.as_str());
268        Ok(())
269    }
270
271    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
272        Ok(self
273            .load_index()
274            .into_iter()
275            .filter(|k| k.starts_with(prefix))
276            .map(BackendKey::new)
277            .collect())
278    }
279
280    fn exists(&self, key: &BackendKey) -> Result<bool> {
281        // Consult the store directly — the index is not authoritative.
282        Ok(self.store.get(key.as_str())?.is_some())
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Real keyring-backed store — Windows Credential Manager / macOS Keychain only.
288// ---------------------------------------------------------------------------
289
290/// The real [`RawStore`], backed by the OS credential store via [`keyring`].
291///
292/// One instance owns a single `service` namespace; each account is a
293/// [`BackendKey`] string. Secrets are stored through `keyring`'s binary API so
294/// no textual re-encoding is applied to the ciphertext.
295#[cfg(any(target_os = "windows", target_os = "macos"))]
296struct KeyringStore {
297    /// The credential-store service (namespace) all entries are filed under.
298    service: String,
299}
300
301#[cfg(any(target_os = "windows", target_os = "macos"))]
302impl KeyringStore {
303    fn entry(&self, account: &str) -> keyring::Result<keyring::Entry> {
304        keyring::Entry::new(&self.service, account)
305    }
306}
307
308#[cfg(any(target_os = "windows", target_os = "macos"))]
309fn keyring_err(e: keyring::Error) -> KeystoreError {
310    // Never embed SECRET material in the message. `{e}` is only the error
311    // class in the common case, but the pathological `keyring::Error::Ambiguous`
312    // variant may include a non-secret account/service identifier pulled from
313    // the platform backend (e.g. which of several matching credential-store
314    // entries it found) — that identifier is not sensitive on its own, unlike
315    // the secret bytes this function never has access to.
316    KeystoreError::from(std::io::Error::other(format!("OS credential store: {e}")))
317}
318
319#[cfg(any(target_os = "windows", target_os = "macos"))]
320impl RawStore for KeyringStore {
321    fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
322        match self.entry(account).and_then(|e| e.get_secret()) {
323            Ok(secret) => Ok(Some(secret)),
324            Err(keyring::Error::NoEntry) => Ok(None),
325            Err(e) => Err(keyring_err(e)),
326        }
327    }
328
329    fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
330        self.entry(account)
331            .and_then(|e| e.set_secret(secret))
332            .map_err(keyring_err)
333    }
334
335    fn remove(&self, account: &str) -> Result<()> {
336        match self.entry(account).and_then(|e| e.delete_credential()) {
337            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
338            Err(e) => Err(keyring_err(e)),
339        }
340    }
341}
342
343// ---------------------------------------------------------------------------
344// `open` — platform-specific construction. `None` means "no usable store
345// here"; the crate performs no fallback, the caller chooses what to do.
346// ---------------------------------------------------------------------------
347
348#[cfg(any(target_os = "windows", target_os = "macos"))]
349impl OsKeychainBackend {
350    /// Open the OS credential store for `service`, probing the backend once.
351    ///
352    /// Returns `None` when no usable OS store exists on this host (a locked
353    /// keychain, an unreachable Credential Manager). The crate performs no
354    /// fallback — a `None` is the caller's cue to select another backend. The
355    /// probe looks up a throwaway account: a `NoEntry` result proves the store is reachable; only
356    /// a hard backend error returns `None`. This makes "is the OS store
357    /// usable?" a single decision taken once, not a failure surfacing
358    /// mid-`unlock`.
359    pub fn open(service: impl Into<String>) -> Option<Self> {
360        let store = KeyringStore {
361            service: service.into(),
362        };
363        let probe = format!("__dig_keystore_probe__{}", std::process::id());
364        match store.get(&probe) {
365            Ok(_) => Some(Self::with_store(Box::new(store))),
366            Err(_) => None,
367        }
368    }
369}
370
371#[cfg(not(any(target_os = "windows", target_os = "macos")))]
372impl OsKeychainBackend {
373    /// No OS credential store is used on this target (Linux / wasm) — always
374    /// returns `None`. The crate performs no fallback; choosing another
375    /// backend is the caller's responsibility. See the module docs for why
376    /// Linux is excluded as a custody primary.
377    pub fn open(_service: impl Into<String>) -> Option<Self> {
378        None
379    }
380}
381
382/// In-memory doubles shared by this module's unit tests and the hardware
383/// composition tests (`crate::hardware::tests`), which need to stand a real
384/// [`OsKeychainBackend`] up as the inner store of a
385/// [`HardwareBoundBackend`](crate::hardware::HardwareBoundBackend) without a
386/// live OS credential store.
387#[cfg(test)]
388pub(crate) mod test_support {
389    use super::*;
390    use std::collections::HashMap;
391
392    /// An in-memory [`RawStore`] double — stands in for the OS credential store
393    /// so the backend's round-trip, index, and error logic run identically on
394    /// every platform (Linux CI included).
395    #[derive(Default)]
396    pub(crate) struct FakeStore {
397        pub(crate) map: Mutex<HashMap<String, Vec<u8>>>,
398        /// When set, every `get` fails — models an unreachable backend.
399        pub(crate) fail: bool,
400    }
401
402    impl RawStore for FakeStore {
403        fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
404            if self.fail {
405                return Err(KeystoreError::from(std::io::Error::other("unreachable")));
406            }
407            Ok(self.map.lock().get(account).cloned())
408        }
409        fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
410            self.map.lock().insert(account.to_owned(), secret.to_vec());
411            Ok(())
412        }
413        fn remove(&self, account: &str) -> Result<()> {
414            self.map.lock().remove(account);
415            Ok(())
416        }
417    }
418
419    /// An [`OsKeychainBackend`] over a fresh empty [`FakeStore`].
420    pub(crate) fn fake_backend() -> OsKeychainBackend {
421        OsKeychainBackend::with_store(Box::<FakeStore>::default())
422    }
423
424    /// An [`OsKeychainBackend`] over a [`FakeStore`] pre-seeded with `entries`,
425    /// bypassing `write` entirely — the way to model bytes an EARLIER crate
426    /// version already persisted into a user's credential store.
427    pub(crate) fn fake_backend_seeded(entries: &[(&str, &[u8])]) -> OsKeychainBackend {
428        let store = FakeStore::default();
429        for (account, secret) in entries {
430            store
431                .map
432                .lock()
433                .insert((*account).to_owned(), secret.to_vec());
434        }
435        OsKeychainBackend::with_store(Box::new(store))
436    }
437
438    /// A payload carrying the `DIGOP1` container magic — the shape a caller is
439    /// obliged to store here (`SPEC.md` §10.5: sealed containers only). The
440    /// tail stands in for sealed ciphertext.
441    pub(crate) fn sealed(seed: u8) -> Vec<u8> {
442        let mut blob = b"DIGOP1".to_vec();
443        blob.extend_from_slice(&[seed; 16]);
444        blob
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::test_support::{fake_backend as backend, fake_backend_seeded, sealed, FakeStore};
451    use super::*;
452    use std::collections::HashMap;
453
454    /// **Proves:** a blob written through `OsKeychainBackend` reads back
455    /// byte-identical.
456    ///
457    /// **Why it matters:** This is the custody round-trip — a stored sealed
458    /// identity blob must return exactly the bytes stored, or every later
459    /// `unlock` decrypts garbage. Exercises the store `set`/`get` path plus the
460    /// binary (non-re-encoded) secret contract.
461    ///
462    /// **Catches:** any encoding/truncation regression in the store round-trip.
463    #[test]
464    fn write_then_read_roundtrip() {
465        let be = backend();
466        let key = BackendKey::new("identity");
467        let blob = sealed(0xDE);
468        be.write(&key, &blob).unwrap();
469        assert_eq!(be.read(&key).unwrap(), blob);
470    }
471
472    /// **Proves:** reading an absent key returns `Backend(NotFound)`, the exact
473    /// error shape the default `exists` and `Keystore::create` overwrite guard
474    /// branch on.
475    ///
476    /// **Why it matters:** If a missing key surfaced as a different error kind,
477    /// `Keystore::create` would refuse to create a first-time keystore.
478    ///
479    /// **Catches:** a `read` that maps "absent" to a generic error or `Ok`.
480    #[test]
481    fn read_absent_is_not_found() {
482        let be = backend();
483        let err = be.read(&BackendKey::new("missing")).unwrap_err();
484        match err {
485            KeystoreError::Backend(io) => {
486                assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
487            }
488            other => panic!("expected Backend(NotFound), got {other:?}"),
489        }
490    }
491
492    /// **Proves:** `write` to an existing key overwrites in place.
493    ///
494    /// **Why it matters:** Password/KDF rotation re-`write`s the same key with
495    /// fresh ciphertext; a stale or appended value would fail the next unlock.
496    ///
497    /// **Catches:** a `set` that refuses to replace an existing entry.
498    #[test]
499    fn write_overwrites_in_place() {
500        let be = backend();
501        let key = BackendKey::new("k");
502        be.write(&key, &sealed(1)).unwrap();
503        be.write(&key, &sealed(2)).unwrap();
504        assert_eq!(be.read(&key).unwrap(), sealed(2));
505    }
506
507    /// **Proves:** `delete` removes the entry and is idempotent (a second
508    /// delete on the now-absent key still succeeds).
509    ///
510    /// **Why it matters:** Profile removal and rotation call `delete` without
511    /// pre-checking existence; a non-idempotent delete would error on a
512    /// concurrent double-remove.
513    ///
514    /// **Catches:** a `delete` that errors on a missing entry, or an `exists`
515    /// that reports a deleted key as present.
516    #[test]
517    fn delete_removes_and_is_idempotent() {
518        let be = backend();
519        let key = BackendKey::new("gone");
520        be.write(&key, &sealed(3)).unwrap();
521        assert!(be.exists(&key).unwrap());
522        be.delete(&key).unwrap();
523        assert!(!be.exists(&key).unwrap());
524        be.delete(&key).unwrap(); // idempotent
525    }
526
527    /// **Proves:** `list(prefix)` returns exactly the live keys whose name
528    /// starts with `prefix`, and the reserved index account never leaks into a
529    /// listing.
530    ///
531    /// **Why it matters:** `list` is what enumerates keystores for a caller;
532    /// substring matching, or exposing the internal index account, would
533    /// surface wrong or bogus keys.
534    ///
535    /// **Catches:** `contains` instead of `starts_with`; the `INDEX_ACCOUNT`
536    /// bookkeeping entry appearing as a real key.
537    #[test]
538    fn list_filters_by_prefix_and_hides_index() {
539        let be = backend();
540        be.write(&BackendKey::new("validator/a"), &sealed(1))
541            .unwrap();
542        be.write(&BackendKey::new("validator/b"), &sealed(2))
543            .unwrap();
544        be.write(&BackendKey::new("wallet/c"), &sealed(3)).unwrap();
545
546        let mut matched: Vec<String> = be
547            .list("validator/")
548            .unwrap()
549            .into_iter()
550            .map(|k| k.as_str().to_owned())
551            .collect();
552        matched.sort();
553        assert_eq!(matched, vec!["validator/a", "validator/b"]);
554
555        // Empty prefix lists every real key — and only real keys.
556        let all: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
557        assert_eq!(all.len(), 3);
558        assert!(!all.iter().any(|k| k == INDEX_ACCOUNT));
559    }
560
561    /// **Proves:** after deleting a key, it disappears from `list` — the index
562    /// tracks removals, not just insertions.
563    ///
564    /// **Why it matters:** A `list` that kept showing deleted keys would report
565    /// keystores that no longer exist.
566    ///
567    /// **Catches:** an `index_remove` that fails to persist the shrunk index.
568    #[test]
569    fn delete_drops_key_from_list() {
570        let be = backend();
571        be.write(&BackendKey::new("a"), &sealed(1)).unwrap();
572        be.write(&BackendKey::new("b"), &sealed(2)).unwrap();
573        be.delete(&BackendKey::new("a")).unwrap();
574        let remaining: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
575        assert_eq!(remaining, vec!["b".to_owned()]);
576    }
577
578    /// **Proves:** `read`/`exists` surface a hard backend error rather than
579    /// masking it as "absent" when the store itself is unreachable.
580    ///
581    /// **Why it matters:** Treating an unreachable store as "no such key" would
582    /// let `Keystore::create` clobber a keystore it merely could not read. The
583    /// `open` probe is what avoids ever constructing a backend on an
584    /// unreachable store, but the read path must still fail closed.
585    ///
586    /// **Catches:** an over-broad error arm mapping every store error to
587    /// `NotFound`/`false`.
588    #[test]
589    fn store_error_is_propagated_not_swallowed() {
590        let be = OsKeychainBackend::with_store(Box::new(FakeStore {
591            fail: true,
592            ..Default::default()
593        }));
594        assert!(be.read(&BackendKey::new("x")).is_err());
595        assert!(be.exists(&BackendKey::new("x")).is_err());
596    }
597
598    /// **Proves:** `write` rejects a key name equal to the reserved
599    /// [`INDEX_ACCOUNT`] sentinel or containing a newline, while a normal
600    /// name still succeeds.
601    ///
602    /// **Why it matters:** A newline in a key name would poison the
603    /// newline-joined index format (`store_index`/`load_index` split on
604    /// `\n`), corrupting `list()` for every other key. A name equal to
605    /// `INDEX_ACCOUNT` would let a caller's `write` silently overwrite the
606    /// enumeration index itself.
607    ///
608    /// **Catches:** a `write` that stores the raw name without validating it
609    /// first.
610    #[test]
611    fn write_rejects_reserved_name_and_newline() {
612        let be = backend();
613
614        let err = be
615            .write(&BackendKey::new(INDEX_ACCOUNT), &sealed(9))
616            .unwrap_err();
617        assert!(matches!(err, KeystoreError::Backend(_)));
618
619        let err = be
620            .write(&BackendKey::new("evil\nname"), &sealed(9))
621            .unwrap_err();
622        assert!(matches!(err, KeystoreError::Backend(_)));
623
624        // A normal name is unaffected.
625        be.write(&BackendKey::new("validator_bls"), &sealed(4))
626            .unwrap();
627        assert_eq!(
628            be.read(&BackendKey::new("validator_bls")).unwrap(),
629            sealed(4)
630        );
631    }
632
633    /// **Proves:** a transient/hard error reading the index during a `write`
634    /// does NOT clobber the index — previously-indexed key names survive and
635    /// still appear in a later `list()` once the store's index read recovers.
636    ///
637    /// **Why it matters:** `load_index` used to collapse "index read failed"
638    /// and "index is empty" into the same `Vec::new()`, so `index_insert`
639    /// would persist a fresh index containing only the just-written key,
640    /// silently dropping every other already-indexed name from future
641    /// `list()` calls.
642    ///
643    /// **Catches:** a `load_index_for_update`/`index_insert` that treats a
644    /// hard read error as "start empty" instead of "skip the update".
645    #[test]
646    fn transient_index_read_error_does_not_drop_existing_names() {
647        // A `RawStore` double whose index read can be toggled to fail
648        // independently of every other account — models a transient
649        // keyring hiccup on just the enumeration entry, not "no index yet".
650        // The fail flag is shared via `Arc` so the test can flip it after
651        // constructing the backend (which takes ownership of the store).
652        struct FlakyIndexStore {
653            map: Mutex<HashMap<String, Vec<u8>>>,
654            fail_index_read: std::sync::Arc<Mutex<bool>>,
655        }
656
657        impl RawStore for FlakyIndexStore {
658            fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
659                if account == INDEX_ACCOUNT && *self.fail_index_read.lock() {
660                    return Err(KeystoreError::from(std::io::Error::other(
661                        "transient keyring read failure",
662                    )));
663                }
664                Ok(self.map.lock().get(account).cloned())
665            }
666            fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
667                self.map.lock().insert(account.to_owned(), secret.to_vec());
668                Ok(())
669            }
670            fn remove(&self, account: &str) -> Result<()> {
671                self.map.lock().remove(account);
672                Ok(())
673            }
674        }
675
676        let fail_index_read = std::sync::Arc::new(Mutex::new(true));
677        let store = FlakyIndexStore {
678            map: Mutex::new(HashMap::from([
679                ("a".to_owned(), b"1".to_vec()),
680                ("b".to_owned(), b"2".to_vec()),
681                (INDEX_ACCOUNT.to_owned(), b"a\nb".to_vec()),
682            ])),
683            fail_index_read: fail_index_read.clone(),
684        };
685        let be = OsKeychainBackend::with_store(Box::new(store));
686
687        // `write` still succeeds — the authoritative store entry is written
688        // even though the index read underneath it is currently failing.
689        be.write(&BackendKey::new("c"), &sealed(3)).unwrap();
690        assert!(be.exists(&BackendKey::new("c")).unwrap());
691
692        // `list` is best-effort and reports empty while the index is
693        // unreadable.
694        assert!(be.list("").unwrap().is_empty());
695
696        // Recover the index read and confirm "a" and "b" are STILL indexed
697        // — the earlier write must not have persisted an empty/partial
698        // index while the read was failing. ("c", written during the
699        // outage, is legitimately absent — its insert was skipped, not
700        // silently lost data; a fresh `write` after recovery would index it.)
701        *fail_index_read.lock() = false;
702        let mut names: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
703        names.sort();
704        assert_eq!(names, vec!["a".to_owned(), "b".to_owned()]);
705    }
706
707    /// **Proves:** the `Debug` impl redacts — no secret/service material.
708    ///
709    /// **Why it matters:** A backend accidentally logged (via `{:?}`) must not
710    /// spill key material or the credential-store namespace.
711    ///
712    /// **Catches:** a derived `Debug` that prints the inner store/service.
713    #[test]
714    fn debug_is_redacted() {
715        let rendered = format!("{:?}", backend());
716        assert!(rendered.contains("<redacted>"));
717    }
718
719    // -----------------------------------------------------------------------
720    // Back-compat and layering.
721    // -----------------------------------------------------------------------
722
723    /// **Proves:** `read` returns a blob written by an earlier crate version
724    /// byte-identically, whatever its prefix.
725    ///
726    /// **Why it matters:** §5.1 is a HARD rule: every blob any prior version
727    /// wrote must still read. Any prefix-based precondition creeping onto the
728    /// read path would strand bytes an earlier dig-keystore already persisted
729    /// into a user's credential store — bytes only that store holds. The
730    /// fixture is seeded through the raw store, not `write`, so it models a
731    /// pre-existing entry rather than one this version produced.
732    ///
733    /// **Catches:** a container/shape check applied to `read`.
734    #[test]
735    fn read_returns_preexisting_unsealed_blob_byte_identically() {
736        let legacy = b"hunter2-written-by-v0.6.1".to_vec();
737        let be = fake_backend_seeded(&[("identity", &legacy)]);
738        assert_eq!(be.read(&BackendKey::new("identity")).unwrap(), legacy);
739        assert!(be.exists(&BackendKey::new("identity")).unwrap());
740    }
741
742    /// **Proves:** the enumeration index is written on a path that does not go
743    /// through the public `write` API — two writes are both listed, and the
744    /// index entry itself never passes `validate_key_name`.
745    ///
746    /// **Why it matters:** `store_index` persists newline-joined key NAMES
747    /// through the private `RawStore`, under the reserved `INDEX_ACCOUNT` that
748    /// `write` explicitly rejects. Routing the index through `write` would make
749    /// `list` self-defeating: the very name the public API refuses is the one
750    /// the index must store.
751    ///
752    /// **Catches:** an index write re-routed through `KeychainBackend::write`,
753    /// or an `INDEX_ACCOUNT` exemption added inside it.
754    #[test]
755    fn enumeration_index_is_written_below_the_public_write_api() {
756        let be = backend();
757        be.write(&BackendKey::new("validator_bls"), &sealed(1))
758            .unwrap();
759        be.write(&BackendKey::new("wallet"), &sealed(2)).unwrap();
760
761        let mut names: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
762        names.sort();
763        assert_eq!(names, vec!["validator_bls".to_owned(), "wallet".to_owned()]);
764
765        // The index really is in the store under the reserved account — and
766        // that account is one the public `write` refuses outright, which is
767        // what makes the private path necessary rather than incidental.
768        assert!(be.store.get(INDEX_ACCOUNT).unwrap().is_some());
769        assert!(be
770            .write(&BackendKey::new(INDEX_ACCOUNT), &sealed(9))
771            .is_err());
772    }
773
774    /// **Proves:** on a target with no supported credential store — Linux and
775    /// `wasm32` — `open` returns `None` (conformance C-29).
776    ///
777    /// **Why it matters:** C-29 was asserted in `SPEC.md` §10.5 with nothing
778    /// exercising it. The only other `open` call in the suite is gated to
779    /// Windows/macOS, so on Linux CI — the one place the claim has any content
780    /// — the stub was never run. The body is a literal `None` today, making
781    /// this drift protection: it fails the moment someone gives the excluded
782    /// targets a real implementation without revisiting the spec, which is
783    /// exactly how a conformance row rots into a rubber stamp.
784    ///
785    /// **Catches:** a fallback quietly introduced on an excluded target, and a
786    /// `Some` returned from the stub.
787    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
788    #[test]
789    fn open_returns_none_on_an_unsupported_target() {
790        assert!(
791            OsKeychainBackend::open("dig-keystore-test").is_none(),
792            "no credential store is supported on this target, and the crate \
793             performs no fallback of its own"
794        );
795    }
796}
797
798// ---------------------------------------------------------------------------
799// Real OS-store integration test — self-skips where no backend is available.
800// ---------------------------------------------------------------------------
801
802#[cfg(all(test, any(target_os = "windows", target_os = "macos")))]
803mod os_integration {
804    use super::*;
805
806    /// Exercise the REAL OS credential store end-to-end where a backend exists
807    /// (Windows Credential Manager · macOS Keychain). Self-skips on a host with
808    /// no usable backend so it is never flaky; the [`FakeStore`](super::tests)
809    /// unit tests cover the logic on every platform. The service is namespaced
810    /// per-process and every entry is cleaned up so it cannot pollute a
811    /// developer's real store.
812    #[test]
813    fn real_os_store_round_trips_where_available() {
814        let service = format!("dig-keystore-test:{}", std::process::id());
815        let Some(be) = OsKeychainBackend::open(&service) else {
816            eprintln!(
817                "no OS credential store on this host — skipping (FakeStore covers the logic)"
818            );
819            return;
820        };
821
822        let key = BackendKey::new("identity");
823        assert!(!be.exists(&key).unwrap());
824
825        let blob = super::test_support::sealed(0x01);
826        be.write(&key, &blob).unwrap();
827        assert!(be.exists(&key).unwrap());
828        assert_eq!(be.read(&key).unwrap(), blob);
829
830        // Overwrite replaces the value.
831        let v2 = super::test_support::sealed(0x02);
832        be.write(&key, &v2).unwrap();
833        assert_eq!(be.read(&key).unwrap(), v2);
834
835        // list reflects the live key.
836        assert!(be
837            .list("")
838            .unwrap()
839            .iter()
840            .any(|k| k.as_str() == "identity"));
841
842        be.delete(&key).unwrap();
843        assert!(!be.exists(&key).unwrap());
844        be.delete(&key).unwrap(); // idempotent
845
846        // Clean up the index bookkeeping entry too.
847        let _ = be.store.remove(INDEX_ACCOUNT);
848    }
849}