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, Exclusivity, 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    /// Establish `key` only if the credential store has nothing under it.
266    ///
267    /// **Best-effort, and reported as such.** The OS credential-store APIs on
268    /// both Windows and macOS expose get and set, with no create-if-absent
269    /// primitive to build on, so the vacancy check and the write are two
270    /// separate calls and two concurrent racers can both pass the check.
271    ///
272    /// That is a limitation of the store, not a shortcut taken here, which is
273    /// exactly why [`write_new_exclusivity`](KeychainBackend::write_new_exclusivity)
274    /// exists: a consumer relying on `write_new` to make a coupled-record
275    /// mismatch *unreachable* must read that answer and use a different
276    /// backend for the shared record. Claiming atomicity we cannot deliver
277    /// would hand back the very race the method exists to remove.
278    fn write_new(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
279        validate_key_name(key.as_str())?;
280        if self.store.get(key.as_str())?.is_some() {
281            return Err(KeystoreError::AlreadyExists(key.as_str().to_string()));
282        }
283        self.write(key, data)
284    }
285
286    /// [`Exclusivity::BestEffort`] — see [`write_new`](Self::write_new). This
287    /// is the trait default, restated explicitly so the answer is a decision on
288    /// the record rather than an omission.
289    fn write_new_exclusivity(&self) -> Exclusivity {
290        Exclusivity::BestEffort
291    }
292
293    fn delete(&self, key: &BackendKey) -> Result<()> {
294        self.store.remove(key.as_str())?;
295        self.index_remove(key.as_str());
296        Ok(())
297    }
298
299    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
300        Ok(self
301            .load_index()
302            .into_iter()
303            .filter(|k| k.starts_with(prefix))
304            .map(BackendKey::new)
305            .collect())
306    }
307
308    fn exists(&self, key: &BackendKey) -> Result<bool> {
309        // Consult the store directly — the index is not authoritative.
310        Ok(self.store.get(key.as_str())?.is_some())
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Real keyring-backed store — Windows Credential Manager / macOS Keychain only.
316// ---------------------------------------------------------------------------
317
318/// The real [`RawStore`], backed by the OS credential store via [`keyring`].
319///
320/// One instance owns a single `service` namespace; each account is a
321/// [`BackendKey`] string. Secrets are stored through `keyring`'s binary API so
322/// no textual re-encoding is applied to the ciphertext.
323#[cfg(any(target_os = "windows", target_os = "macos"))]
324struct KeyringStore {
325    /// The credential-store service (namespace) all entries are filed under.
326    service: String,
327}
328
329#[cfg(any(target_os = "windows", target_os = "macos"))]
330impl KeyringStore {
331    fn entry(&self, account: &str) -> keyring::Result<keyring::Entry> {
332        keyring::Entry::new(&self.service, account)
333    }
334}
335
336#[cfg(any(target_os = "windows", target_os = "macos"))]
337fn keyring_err(e: keyring::Error) -> KeystoreError {
338    // Never embed SECRET material in the message. `{e}` is only the error
339    // class in the common case, but the pathological `keyring::Error::Ambiguous`
340    // variant may include a non-secret account/service identifier pulled from
341    // the platform backend (e.g. which of several matching credential-store
342    // entries it found) — that identifier is not sensitive on its own, unlike
343    // the secret bytes this function never has access to.
344    KeystoreError::from(std::io::Error::other(format!("OS credential store: {e}")))
345}
346
347#[cfg(any(target_os = "windows", target_os = "macos"))]
348impl RawStore for KeyringStore {
349    fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
350        match self.entry(account).and_then(|e| e.get_secret()) {
351            Ok(secret) => Ok(Some(secret)),
352            Err(keyring::Error::NoEntry) => Ok(None),
353            Err(e) => Err(keyring_err(e)),
354        }
355    }
356
357    fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
358        self.entry(account)
359            .and_then(|e| e.set_secret(secret))
360            .map_err(keyring_err)
361    }
362
363    fn remove(&self, account: &str) -> Result<()> {
364        match self.entry(account).and_then(|e| e.delete_credential()) {
365            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
366            Err(e) => Err(keyring_err(e)),
367        }
368    }
369}
370
371// ---------------------------------------------------------------------------
372// `open` — platform-specific construction. `None` means "no usable store
373// here"; the crate performs no fallback, the caller chooses what to do.
374// ---------------------------------------------------------------------------
375
376#[cfg(any(target_os = "windows", target_os = "macos"))]
377impl OsKeychainBackend {
378    /// Open the OS credential store for `service`, probing the backend once.
379    ///
380    /// Returns `None` when no usable OS store exists on this host (a locked
381    /// keychain, an unreachable Credential Manager). The crate performs no
382    /// fallback — a `None` is the caller's cue to select another backend. The
383    /// probe looks up a throwaway account: a `NoEntry` result proves the store is reachable; only
384    /// a hard backend error returns `None`. This makes "is the OS store
385    /// usable?" a single decision taken once, not a failure surfacing
386    /// mid-`unlock`.
387    pub fn open(service: impl Into<String>) -> Option<Self> {
388        let store = KeyringStore {
389            service: service.into(),
390        };
391        let probe = format!("__dig_keystore_probe__{}", std::process::id());
392        match store.get(&probe) {
393            Ok(_) => Some(Self::with_store(Box::new(store))),
394            Err(_) => None,
395        }
396    }
397}
398
399#[cfg(not(any(target_os = "windows", target_os = "macos")))]
400impl OsKeychainBackend {
401    /// No OS credential store is used on this target (Linux / wasm) — always
402    /// returns `None`. The crate performs no fallback; choosing another
403    /// backend is the caller's responsibility. See the module docs for why
404    /// Linux is excluded as a custody primary.
405    pub fn open(_service: impl Into<String>) -> Option<Self> {
406        None
407    }
408}
409
410/// In-memory doubles shared by this module's unit tests and the hardware
411/// composition tests (`crate::hardware::tests`), which need to stand a real
412/// [`OsKeychainBackend`] up as the inner store of a
413/// [`HardwareBoundBackend`](crate::hardware::HardwareBoundBackend) without a
414/// live OS credential store.
415#[cfg(test)]
416pub(crate) mod test_support {
417    use super::*;
418    use std::collections::HashMap;
419
420    /// An in-memory [`RawStore`] double — stands in for the OS credential store
421    /// so the backend's round-trip, index, and error logic run identically on
422    /// every platform (Linux CI included).
423    #[derive(Default)]
424    pub(crate) struct FakeStore {
425        pub(crate) map: Mutex<HashMap<String, Vec<u8>>>,
426        /// When set, every `get` fails — models an unreachable backend.
427        pub(crate) fail: bool,
428    }
429
430    impl RawStore for FakeStore {
431        fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
432            if self.fail {
433                return Err(KeystoreError::from(std::io::Error::other("unreachable")));
434            }
435            Ok(self.map.lock().get(account).cloned())
436        }
437        fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
438            self.map.lock().insert(account.to_owned(), secret.to_vec());
439            Ok(())
440        }
441        fn remove(&self, account: &str) -> Result<()> {
442            self.map.lock().remove(account);
443            Ok(())
444        }
445    }
446
447    /// An [`OsKeychainBackend`] over a fresh empty [`FakeStore`].
448    pub(crate) fn fake_backend() -> OsKeychainBackend {
449        OsKeychainBackend::with_store(Box::<FakeStore>::default())
450    }
451
452    /// An [`OsKeychainBackend`] over a [`FakeStore`] pre-seeded with `entries`,
453    /// bypassing `write` entirely — the way to model bytes an EARLIER crate
454    /// version already persisted into a user's credential store.
455    pub(crate) fn fake_backend_seeded(entries: &[(&str, &[u8])]) -> OsKeychainBackend {
456        let store = FakeStore::default();
457        for (account, secret) in entries {
458            store
459                .map
460                .lock()
461                .insert((*account).to_owned(), secret.to_vec());
462        }
463        OsKeychainBackend::with_store(Box::new(store))
464    }
465
466    /// A payload carrying the `DIGOP1` container magic — the shape a caller is
467    /// obliged to store here (`SPEC.md` §10.5: sealed containers only). The
468    /// tail stands in for sealed ciphertext.
469    pub(crate) fn sealed(seed: u8) -> Vec<u8> {
470        let mut blob = b"DIGOP1".to_vec();
471        blob.extend_from_slice(&[seed; 16]);
472        blob
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::test_support::{fake_backend as backend, fake_backend_seeded, sealed, FakeStore};
479    use super::*;
480    use std::collections::HashMap;
481
482    /// **Proves:** `OsKeychainBackend::write_new` establishes a vacant key and
483    /// refuses an occupied one with the adoptable `AlreadyExists`, without
484    /// disturbing the occupant — and that it reports its exclusivity as
485    /// `BestEffort` rather than overstating it.
486    ///
487    /// **Why it matters:** the credential-store APIs on both platforms offer
488    /// get and set with no create-if-absent primitive, so the vacancy check and
489    /// the write are two calls and two racers can both pass the check. The
490    /// method is still useful — it expresses *establish, not update* — but a
491    /// consumer relying on it to make a coupled mismatch **unreachable**
492    /// (`SPEC.md` §10.2a) must read the exclusivity answer and use a different
493    /// backend. Claiming `Atomic` here would hand that consumer back the exact
494    /// race it was avoiding.
495    ///
496    /// **Catches:** a `write_new` that replaces the occupant; one that reports
497    /// the collision as a generic store error; and — the load-bearing half — an
498    /// exclusivity claim upgraded to `Atomic` without the primitive to back it.
499    #[test]
500    fn write_new_establishes_then_refuses_and_does_not_claim_atomicity() {
501        let be = backend();
502        let key = BackendKey::new("coupled");
503
504        be.write_new(&key, b"established").unwrap();
505        let err = be.write_new(&key, b"usurper").unwrap_err();
506
507        assert!(
508            matches!(err, KeystoreError::AlreadyExists(ref k) if k == "coupled"),
509            "the collision must be adoptable: {err:?}"
510        );
511        assert_eq!(be.read(&key).unwrap(), b"established");
512        assert_eq!(
513            be.write_new_exclusivity(),
514            Exclusivity::BestEffort,
515            "the OS credential store offers no create-if-absent primitive, so \
516             atomicity must not be claimed"
517        );
518    }
519
520    /// **Proves:** `write_new` applies the same key-name validation `write`
521    /// does.
522    ///
523    /// **Why it matters:** `write_new` is a second entry point into the same
524    /// store. A guard enforced on only one of two doors is not enforced — the
525    /// reserved index name and the newline injection `write` rejects would be
526    /// reachable through `write_new` instead.
527    ///
528    /// **Catches:** a `write_new` that omits `validate_key_name`.
529    #[test]
530    fn write_new_rejects_the_names_write_rejects() {
531        let be = backend();
532        for bad in [INDEX_ACCOUNT, "has\nnewline"] {
533            assert!(
534                be.write_new(&BackendKey::new(bad), b"x").is_err(),
535                "write_new must reject {bad:?} exactly as write does"
536            );
537        }
538    }
539
540    /// **Proves:** a blob written through `OsKeychainBackend` reads back
541    /// byte-identical.
542    ///
543    /// **Why it matters:** This is the custody round-trip — a stored sealed
544    /// identity blob must return exactly the bytes stored, or every later
545    /// `unlock` decrypts garbage. Exercises the store `set`/`get` path plus the
546    /// binary (non-re-encoded) secret contract.
547    ///
548    /// **Catches:** any encoding/truncation regression in the store round-trip.
549    #[test]
550    fn write_then_read_roundtrip() {
551        let be = backend();
552        let key = BackendKey::new("identity");
553        let blob = sealed(0xDE);
554        be.write(&key, &blob).unwrap();
555        assert_eq!(be.read(&key).unwrap(), blob);
556    }
557
558    /// **Proves:** reading an absent key returns `Backend(NotFound)`, the exact
559    /// error shape the default `exists` and `Keystore::create` overwrite guard
560    /// branch on.
561    ///
562    /// **Why it matters:** If a missing key surfaced as a different error kind,
563    /// `Keystore::create` would refuse to create a first-time keystore.
564    ///
565    /// **Catches:** a `read` that maps "absent" to a generic error or `Ok`.
566    #[test]
567    fn read_absent_is_not_found() {
568        let be = backend();
569        let err = be.read(&BackendKey::new("missing")).unwrap_err();
570        match err {
571            KeystoreError::Backend(io) => {
572                assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
573            }
574            other => panic!("expected Backend(NotFound), got {other:?}"),
575        }
576    }
577
578    /// **Proves:** `write` to an existing key overwrites in place.
579    ///
580    /// **Why it matters:** Password/KDF rotation re-`write`s the same key with
581    /// fresh ciphertext; a stale or appended value would fail the next unlock.
582    ///
583    /// **Catches:** a `set` that refuses to replace an existing entry.
584    #[test]
585    fn write_overwrites_in_place() {
586        let be = backend();
587        let key = BackendKey::new("k");
588        be.write(&key, &sealed(1)).unwrap();
589        be.write(&key, &sealed(2)).unwrap();
590        assert_eq!(be.read(&key).unwrap(), sealed(2));
591    }
592
593    /// **Proves:** `delete` removes the entry and is idempotent (a second
594    /// delete on the now-absent key still succeeds).
595    ///
596    /// **Why it matters:** Profile removal and rotation call `delete` without
597    /// pre-checking existence; a non-idempotent delete would error on a
598    /// concurrent double-remove.
599    ///
600    /// **Catches:** a `delete` that errors on a missing entry, or an `exists`
601    /// that reports a deleted key as present.
602    #[test]
603    fn delete_removes_and_is_idempotent() {
604        let be = backend();
605        let key = BackendKey::new("gone");
606        be.write(&key, &sealed(3)).unwrap();
607        assert!(be.exists(&key).unwrap());
608        be.delete(&key).unwrap();
609        assert!(!be.exists(&key).unwrap());
610        be.delete(&key).unwrap(); // idempotent
611    }
612
613    /// **Proves:** `list(prefix)` returns exactly the live keys whose name
614    /// starts with `prefix`, and the reserved index account never leaks into a
615    /// listing.
616    ///
617    /// **Why it matters:** `list` is what enumerates keystores for a caller;
618    /// substring matching, or exposing the internal index account, would
619    /// surface wrong or bogus keys.
620    ///
621    /// **Catches:** `contains` instead of `starts_with`; the `INDEX_ACCOUNT`
622    /// bookkeeping entry appearing as a real key.
623    #[test]
624    fn list_filters_by_prefix_and_hides_index() {
625        let be = backend();
626        be.write(&BackendKey::new("validator/a"), &sealed(1))
627            .unwrap();
628        be.write(&BackendKey::new("validator/b"), &sealed(2))
629            .unwrap();
630        be.write(&BackendKey::new("wallet/c"), &sealed(3)).unwrap();
631
632        let mut matched: Vec<String> = be
633            .list("validator/")
634            .unwrap()
635            .into_iter()
636            .map(|k| k.as_str().to_owned())
637            .collect();
638        matched.sort();
639        assert_eq!(matched, vec!["validator/a", "validator/b"]);
640
641        // Empty prefix lists every real key — and only real keys.
642        let all: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
643        assert_eq!(all.len(), 3);
644        assert!(!all.iter().any(|k| k == INDEX_ACCOUNT));
645    }
646
647    /// **Proves:** after deleting a key, it disappears from `list` — the index
648    /// tracks removals, not just insertions.
649    ///
650    /// **Why it matters:** A `list` that kept showing deleted keys would report
651    /// keystores that no longer exist.
652    ///
653    /// **Catches:** an `index_remove` that fails to persist the shrunk index.
654    #[test]
655    fn delete_drops_key_from_list() {
656        let be = backend();
657        be.write(&BackendKey::new("a"), &sealed(1)).unwrap();
658        be.write(&BackendKey::new("b"), &sealed(2)).unwrap();
659        be.delete(&BackendKey::new("a")).unwrap();
660        let remaining: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
661        assert_eq!(remaining, vec!["b".to_owned()]);
662    }
663
664    /// **Proves:** `read`/`exists` surface a hard backend error rather than
665    /// masking it as "absent" when the store itself is unreachable.
666    ///
667    /// **Why it matters:** Treating an unreachable store as "no such key" would
668    /// let `Keystore::create` clobber a keystore it merely could not read. The
669    /// `open` probe is what avoids ever constructing a backend on an
670    /// unreachable store, but the read path must still fail closed.
671    ///
672    /// **Catches:** an over-broad error arm mapping every store error to
673    /// `NotFound`/`false`.
674    #[test]
675    fn store_error_is_propagated_not_swallowed() {
676        let be = OsKeychainBackend::with_store(Box::new(FakeStore {
677            fail: true,
678            ..Default::default()
679        }));
680        assert!(be.read(&BackendKey::new("x")).is_err());
681        assert!(be.exists(&BackendKey::new("x")).is_err());
682    }
683
684    /// **Proves:** `write` rejects a key name equal to the reserved
685    /// [`INDEX_ACCOUNT`] sentinel or containing a newline, while a normal
686    /// name still succeeds.
687    ///
688    /// **Why it matters:** A newline in a key name would poison the
689    /// newline-joined index format (`store_index`/`load_index` split on
690    /// `\n`), corrupting `list()` for every other key. A name equal to
691    /// `INDEX_ACCOUNT` would let a caller's `write` silently overwrite the
692    /// enumeration index itself.
693    ///
694    /// **Catches:** a `write` that stores the raw name without validating it
695    /// first.
696    #[test]
697    fn write_rejects_reserved_name_and_newline() {
698        let be = backend();
699
700        let err = be
701            .write(&BackendKey::new(INDEX_ACCOUNT), &sealed(9))
702            .unwrap_err();
703        assert!(matches!(err, KeystoreError::Backend(_)));
704
705        let err = be
706            .write(&BackendKey::new("evil\nname"), &sealed(9))
707            .unwrap_err();
708        assert!(matches!(err, KeystoreError::Backend(_)));
709
710        // A normal name is unaffected.
711        be.write(&BackendKey::new("validator_bls"), &sealed(4))
712            .unwrap();
713        assert_eq!(
714            be.read(&BackendKey::new("validator_bls")).unwrap(),
715            sealed(4)
716        );
717    }
718
719    /// **Proves:** a transient/hard error reading the index during a `write`
720    /// does NOT clobber the index — previously-indexed key names survive and
721    /// still appear in a later `list()` once the store's index read recovers.
722    ///
723    /// **Why it matters:** `load_index` used to collapse "index read failed"
724    /// and "index is empty" into the same `Vec::new()`, so `index_insert`
725    /// would persist a fresh index containing only the just-written key,
726    /// silently dropping every other already-indexed name from future
727    /// `list()` calls.
728    ///
729    /// **Catches:** a `load_index_for_update`/`index_insert` that treats a
730    /// hard read error as "start empty" instead of "skip the update".
731    #[test]
732    fn transient_index_read_error_does_not_drop_existing_names() {
733        // A `RawStore` double whose index read can be toggled to fail
734        // independently of every other account — models a transient
735        // keyring hiccup on just the enumeration entry, not "no index yet".
736        // The fail flag is shared via `Arc` so the test can flip it after
737        // constructing the backend (which takes ownership of the store).
738        struct FlakyIndexStore {
739            map: Mutex<HashMap<String, Vec<u8>>>,
740            fail_index_read: std::sync::Arc<Mutex<bool>>,
741        }
742
743        impl RawStore for FlakyIndexStore {
744            fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
745                if account == INDEX_ACCOUNT && *self.fail_index_read.lock() {
746                    return Err(KeystoreError::from(std::io::Error::other(
747                        "transient keyring read failure",
748                    )));
749                }
750                Ok(self.map.lock().get(account).cloned())
751            }
752            fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
753                self.map.lock().insert(account.to_owned(), secret.to_vec());
754                Ok(())
755            }
756            fn remove(&self, account: &str) -> Result<()> {
757                self.map.lock().remove(account);
758                Ok(())
759            }
760        }
761
762        let fail_index_read = std::sync::Arc::new(Mutex::new(true));
763        let store = FlakyIndexStore {
764            map: Mutex::new(HashMap::from([
765                ("a".to_owned(), b"1".to_vec()),
766                ("b".to_owned(), b"2".to_vec()),
767                (INDEX_ACCOUNT.to_owned(), b"a\nb".to_vec()),
768            ])),
769            fail_index_read: fail_index_read.clone(),
770        };
771        let be = OsKeychainBackend::with_store(Box::new(store));
772
773        // `write` still succeeds — the authoritative store entry is written
774        // even though the index read underneath it is currently failing.
775        be.write(&BackendKey::new("c"), &sealed(3)).unwrap();
776        assert!(be.exists(&BackendKey::new("c")).unwrap());
777
778        // `list` is best-effort and reports empty while the index is
779        // unreadable.
780        assert!(be.list("").unwrap().is_empty());
781
782        // Recover the index read and confirm "a" and "b" are STILL indexed
783        // — the earlier write must not have persisted an empty/partial
784        // index while the read was failing. ("c", written during the
785        // outage, is legitimately absent — its insert was skipped, not
786        // silently lost data; a fresh `write` after recovery would index it.)
787        *fail_index_read.lock() = false;
788        let mut names: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
789        names.sort();
790        assert_eq!(names, vec!["a".to_owned(), "b".to_owned()]);
791    }
792
793    /// **Proves:** the `Debug` impl redacts — no secret/service material.
794    ///
795    /// **Why it matters:** A backend accidentally logged (via `{:?}`) must not
796    /// spill key material or the credential-store namespace.
797    ///
798    /// **Catches:** a derived `Debug` that prints the inner store/service.
799    #[test]
800    fn debug_is_redacted() {
801        let rendered = format!("{:?}", backend());
802        assert!(rendered.contains("<redacted>"));
803    }
804
805    // -----------------------------------------------------------------------
806    // Back-compat and layering.
807    // -----------------------------------------------------------------------
808
809    /// **Proves:** `read` returns a blob written by an earlier crate version
810    /// byte-identically, whatever its prefix.
811    ///
812    /// **Why it matters:** §5.1 is a HARD rule: every blob any prior version
813    /// wrote must still read. Any prefix-based precondition creeping onto the
814    /// read path would strand bytes an earlier dig-keystore already persisted
815    /// into a user's credential store — bytes only that store holds. The
816    /// fixture is seeded through the raw store, not `write`, so it models a
817    /// pre-existing entry rather than one this version produced.
818    ///
819    /// **Catches:** a container/shape check applied to `read`.
820    #[test]
821    fn read_returns_preexisting_unsealed_blob_byte_identically() {
822        let legacy = b"hunter2-written-by-v0.6.1".to_vec();
823        let be = fake_backend_seeded(&[("identity", &legacy)]);
824        assert_eq!(be.read(&BackendKey::new("identity")).unwrap(), legacy);
825        assert!(be.exists(&BackendKey::new("identity")).unwrap());
826    }
827
828    /// **Proves:** the enumeration index is written on a path that does not go
829    /// through the public `write` API — two writes are both listed, and the
830    /// index entry itself never passes `validate_key_name`.
831    ///
832    /// **Why it matters:** `store_index` persists newline-joined key NAMES
833    /// through the private `RawStore`, under the reserved `INDEX_ACCOUNT` that
834    /// `write` explicitly rejects. Routing the index through `write` would make
835    /// `list` self-defeating: the very name the public API refuses is the one
836    /// the index must store.
837    ///
838    /// **Catches:** an index write re-routed through `KeychainBackend::write`,
839    /// or an `INDEX_ACCOUNT` exemption added inside it.
840    #[test]
841    fn enumeration_index_is_written_below_the_public_write_api() {
842        let be = backend();
843        be.write(&BackendKey::new("validator_bls"), &sealed(1))
844            .unwrap();
845        be.write(&BackendKey::new("wallet"), &sealed(2)).unwrap();
846
847        let mut names: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
848        names.sort();
849        assert_eq!(names, vec!["validator_bls".to_owned(), "wallet".to_owned()]);
850
851        // The index really is in the store under the reserved account — and
852        // that account is one the public `write` refuses outright, which is
853        // what makes the private path necessary rather than incidental.
854        assert!(be.store.get(INDEX_ACCOUNT).unwrap().is_some());
855        assert!(be
856            .write(&BackendKey::new(INDEX_ACCOUNT), &sealed(9))
857            .is_err());
858    }
859
860    /// **Proves:** on a target with no supported credential store — Linux and
861    /// `wasm32` — `open` returns `None` (conformance C-29).
862    ///
863    /// **Why it matters:** C-29 was asserted in `SPEC.md` §10.5 with nothing
864    /// exercising it. The only other `open` call in the suite is gated to
865    /// Windows/macOS, so on Linux CI — the one place the claim has any content
866    /// — the stub was never run. The body is a literal `None` today, making
867    /// this drift protection: it fails the moment someone gives the excluded
868    /// targets a real implementation without revisiting the spec, which is
869    /// exactly how a conformance row rots into a rubber stamp.
870    ///
871    /// **Catches:** a fallback quietly introduced on an excluded target, and a
872    /// `Some` returned from the stub.
873    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
874    #[test]
875    fn open_returns_none_on_an_unsupported_target() {
876        assert!(
877            OsKeychainBackend::open("dig-keystore-test").is_none(),
878            "no credential store is supported on this target, and the crate \
879             performs no fallback of its own"
880        );
881    }
882}
883
884// ---------------------------------------------------------------------------
885// Real OS-store integration test — self-skips where no backend is available.
886// ---------------------------------------------------------------------------
887
888#[cfg(all(test, any(target_os = "windows", target_os = "macos")))]
889mod os_integration {
890    use super::*;
891
892    /// Exercise the REAL OS credential store end-to-end where a backend exists
893    /// (Windows Credential Manager · macOS Keychain). Self-skips on a host with
894    /// no usable backend so it is never flaky; the [`FakeStore`](super::tests)
895    /// unit tests cover the logic on every platform. The service is namespaced
896    /// per-process and every entry is cleaned up so it cannot pollute a
897    /// developer's real store.
898    #[test]
899    fn real_os_store_round_trips_where_available() {
900        let service = format!("dig-keystore-test:{}", std::process::id());
901        let Some(be) = OsKeychainBackend::open(&service) else {
902            eprintln!(
903                "no OS credential store on this host — skipping (FakeStore covers the logic)"
904            );
905            return;
906        };
907
908        let key = BackendKey::new("identity");
909        assert!(!be.exists(&key).unwrap());
910
911        let blob = super::test_support::sealed(0x01);
912        be.write(&key, &blob).unwrap();
913        assert!(be.exists(&key).unwrap());
914        assert_eq!(be.read(&key).unwrap(), blob);
915
916        // Overwrite replaces the value.
917        let v2 = super::test_support::sealed(0x02);
918        be.write(&key, &v2).unwrap();
919        assert_eq!(be.read(&key).unwrap(), v2);
920
921        // list reflects the live key.
922        assert!(be
923            .list("")
924            .unwrap()
925            .iter()
926            .any(|k| k.as_str() == "identity"));
927
928        be.delete(&key).unwrap();
929        assert!(!be.exists(&key).unwrap());
930        be.delete(&key).unwrap(); // idempotent
931
932        // Clean up the index bookkeeping entry too.
933        let _ = be.store.remove(INDEX_ACCOUNT);
934    }
935}