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}