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}