Skip to main content

dig_keystore/backend/
mod.rs

1//! Storage backend abstraction.
2//!
3//! # Coupled records
4//!
5//! `write` is **replace-semantics**: it settles the new bytes over whatever was
6//! there. That is the right shape for updating one record, and the wrong shape
7//! for *establishing* one — so [`write_new`](KeychainBackend::write_new) exists
8//! alongside it, and the difference matters most for **coupled** records.
9//!
10//! Two records are coupled when neither is useful without the other: a wrapped
11//! blob and the device key that opens it, a sealed secret and its salt, a
12//! payload and its integrity sidecar. Written with `write`, two concurrent
13//! starts can settle **device key `D_B` beside blob `B_A`** — a key that does
14//! not open the blob next to it. A well-behaved consumer also refuses to
15//! re-mint an identity it already has, and those two individually-correct
16//! decisions compose into a state that **can never self-heal**: the consumer is
17//! permanently unable to open its own data, and restarting does nothing.
18//!
19//! The known-good remedy is structural rather than a lock. Establish the shared
20//! record with `write_new` and **adopt on
21//! [`AlreadyExists`](crate::error::KeystoreError::AlreadyExists)**, so exactly
22//! one racer creates it and every other seals under the record that won. The
23//! mismatch becomes unreachable instead of unlikely. Check
24//! [`write_new_exclusivity`](KeychainBackend::write_new_exclusivity) first: on a
25//! [`BestEffort`](Exclusivity::BestEffort) backend the reasoning does not hold.
26//!
27//! Preventing the state matters more than reporting it, because the resulting
28//! error **structurally cannot name its own cause**. Once hardware binding is in
29//! play a mismatch surfaces as `HardwareUnwrapFailed`, which `SPEC.md` §17.5b
30//! establishes cannot distinguish a blob copied to another machine from a device
31//! whose key was wiped. No inspection of the bytes recovers the difference.
32//!
33//! A `KeychainBackend` is any byte-blob KV store. Three ship today:
34//! `MemoryBackend` (always available), `FileBackend` (feature `file-backend`,
35//! atomic tmp + rename writes to the local filesystem), and
36//! `OsKeychainBackend` (feature `os-keychain`, the host OS credential store on
37//! Windows/macOS — see its module docs for the access boundary it does and
38//! does not provide, and for why a machine service must not use it). Planned
39//! future backends: hardware-signer backends (`LedgerBackend`, `YubiHsmBackend`)
40//! that proxy `sign` to an external device.
41
42use crate::error::Result;
43
44#[cfg(feature = "file-backend")]
45mod file;
46mod memory;
47// `pub(crate)` (not private) so `crate::hardware::tests` can reach the
48// in-memory `test_support` doubles and compose a real `OsKeychainBackend`
49// under a `HardwareBoundBackend` without a live OS credential store.
50#[cfg(feature = "os-keychain")]
51pub(crate) mod os_keychain;
52
53#[cfg(feature = "file-backend")]
54pub use file::FileBackend;
55/// In-memory backend — always available. Originally feature-gated, now
56/// unconditional because production adapters (e.g., `dig-l1-wallet`'s
57/// encrypt/decrypt-bytes helpers) wrap it in scratch backends to reuse the
58/// full keystore format without touching the filesystem.
59pub use memory::MemoryBackend;
60#[cfg(feature = "os-keychain")]
61pub use os_keychain::OsKeychainBackend;
62
63/// An opaque key identifying a single encrypted blob within a backend.
64///
65/// For `FileBackend`, the key maps to `<root>/<key>.dks`; for an OS-keyring
66/// backend it maps to a service / account pair; for a hardware signer it maps
67/// to a slot identifier.
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct BackendKey(pub String);
70
71impl BackendKey {
72    /// Construct from any string-like value.
73    pub fn new(name: impl Into<String>) -> Self {
74        Self(name.into())
75    }
76
77    /// Borrow as `&str`.
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl<T: Into<String>> From<T> for BackendKey {
84    fn from(v: T) -> Self {
85        Self(v.into())
86    }
87}
88
89impl std::fmt::Display for BackendKey {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(&self.0)
92    }
93}
94
95/// Whether a backend's [`write_new`](KeychainBackend::write_new) is exclusive
96/// by construction, or only by a check the backend performs itself.
97///
98/// A consumer reaches for `write_new` to make a coupled-record mismatch
99/// **unreachable** rather than merely unlikely, and that reasoning is only
100/// valid against a backend whose store offers a create-if-absent primitive.
101/// So the claim is exposed rather than assumed.
102///
103/// The default is [`BestEffort`](Self::BestEffort): a backend that says nothing
104/// **understates** its guarantee. Overstating it hands a consumer back exactly
105/// the race the method exists to remove, which is the more expensive direction
106/// to be wrong in.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum Exclusivity {
109    /// The underlying store creates-if-absent in one indivisible step, so two
110    /// concurrent `write_new` calls cannot both succeed. Exactly one racer
111    /// establishes the record and every other receives
112    /// [`KeystoreError::AlreadyExists`](crate::error::KeystoreError::AlreadyExists).
113    Atomic,
114
115    /// The backend checks for the key and then writes. Correct when uncontended,
116    /// but two concurrent calls can both observe absence and both write, so a
117    /// consumer MUST NOT rely on this to make a coupled mismatch unreachable.
118    ///
119    /// The store offers no create-if-absent primitive — not a defect in the
120    /// backend, and the reason this is reported rather than hidden.
121    BestEffort,
122}
123
124impl std::fmt::Display for Exclusivity {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(match self {
127            Self::Atomic => "atomic create-if-absent",
128            Self::BestEffort => "best-effort (check then write)",
129        })
130    }
131}
132
133/// Storage backend trait. Implementations must be `Send + Sync + 'static` so
134/// they can be held behind `Arc<dyn KeychainBackend>`.
135pub trait KeychainBackend: Send + Sync + 'static {
136    /// Read the full contents of the blob at `key`.
137    ///
138    /// Returns a backend I/O error if the blob does not exist.
139    fn read(&self, key: &BackendKey) -> Result<Vec<u8>>;
140
141    /// Write `data` to `key`. Implementations should be atomic — a reader
142    /// seeing the key after this call must see either the old bytes or the
143    /// new bytes in full, never a torn mix.
144    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
145
146    /// Write `data` to `key` **only if nothing is stored there**, returning
147    /// [`KeystoreError::AlreadyExists`](crate::error::KeystoreError::AlreadyExists)
148    /// otherwise.
149    ///
150    /// This is the *establish*, not *update*, entry point. Use it whenever the
151    /// write is meant to happen exactly once — see the crate-level
152    /// [coupled-records](self#coupled-records) note for why that distinction
153    /// is load-bearing and not a convenience.
154    ///
155    /// Consult [`write_new_exclusivity`](Self::write_new_exclusivity) before
156    /// relying on it to make a coupled mismatch *unreachable*: not every store
157    /// can offer create-if-absent as one indivisible step.
158    ///
159    /// There is deliberately **no default implementation**. A default composed
160    /// of `exists` then `write` would look like this contract while quietly
161    /// providing none of it, and every backend that forgot to override it
162    /// would inherit the race silently.
163    fn write_new(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
164
165    /// How strong [`write_new`](Self::write_new)'s exclusivity is on this
166    /// backend.
167    ///
168    /// Defaults to [`Exclusivity::BestEffort`] so that a backend which has not
169    /// considered the question **understates** rather than overstates what a
170    /// consumer may rely on.
171    fn write_new_exclusivity(&self) -> Exclusivity {
172        Exclusivity::BestEffort
173    }
174
175    /// Remove the blob at `key`. Implementations should best-effort overwrite
176    /// the storage before removing so residual disk sectors do not retain the
177    /// ciphertext.
178    fn delete(&self, key: &BackendKey) -> Result<()>;
179
180    /// List keys that start with `prefix`. Order is unspecified.
181    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>>;
182
183    /// Whether a blob exists at `key`.
184    ///
185    /// **Three-valued, and it must stay that way.** `Ok(true)` is present,
186    /// `Ok(false)` is *confidently* absent, and `Err` is **could not
187    /// determine** — an unreadable parent, a failing mount, an I/O fault. An
188    /// implementation MUST NOT collapse the third answer into the second.
189    ///
190    /// The reason is what callers do with it: this answer decides whether to
191    /// **mint**. Since `write` replaces, a spurious `false` does not produce a
192    /// harmless duplicate beside the original — it overwrites the original, and
193    /// once that blob is hardware-wrapped (`SPEC.md` §17) the overwrite is
194    /// unrecoverable. Refusing on an unanswerable read is therefore the
195    /// fail-closed choice, and the only safe one.
196    ///
197    /// The default impl delegates to `read`. Backends with a cheaper existence
198    /// check may override, but the override inherits this contract — note that
199    /// `Path::exists()` does **not** satisfy it, because it maps every error to
200    /// `false`.
201    fn exists(&self, key: &BackendKey) -> Result<bool> {
202        match self.read(key) {
203            Ok(_) => Ok(true),
204            Err(crate::error::KeystoreError::Backend(e))
205                if e.kind() == std::io::ErrorKind::NotFound =>
206            {
207                Ok(false)
208            }
209            Err(e) => Err(e),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::error::KeystoreError;
218    use std::io;
219
220    /// A minimal backend that does **not** override `exists`, so it exercises
221    /// the [`KeychainBackend::exists`] *default* implementation. The shipped
222    /// backends (`FileBackend`, `MemoryBackend`) both override `exists` with a
223    /// cheaper check, leaving the default's three branches (present →
224    /// `Ok(true)`, `NotFound` → `Ok(false)`, other error → propagate) otherwise
225    /// unexercised. This stub is the only way to test that contract.
226    #[derive(Default)]
227    struct ProbeBackend {
228        /// When set, every `read` returns this io error kind instead of data.
229        fail_kind: Option<io::ErrorKind>,
230        present: bool,
231    }
232
233    impl KeychainBackend for ProbeBackend {
234        fn read(&self, _key: &BackendKey) -> Result<Vec<u8>> {
235            if let Some(kind) = self.fail_kind {
236                return Err(KeystoreError::from(io::Error::new(kind, "probe")));
237            }
238            if self.present {
239                Ok(vec![1, 2, 3])
240            } else {
241                Err(KeystoreError::from(io::Error::new(
242                    io::ErrorKind::NotFound,
243                    "absent",
244                )))
245            }
246        }
247        fn write(&self, _key: &BackendKey, _data: &[u8]) -> Result<()> {
248            Ok(())
249        }
250        fn write_new(&self, _key: &BackendKey, _data: &[u8]) -> Result<()> {
251            Ok(())
252        }
253        fn delete(&self, _key: &BackendKey) -> Result<()> {
254            Ok(())
255        }
256        fn list(&self, _prefix: &str) -> Result<Vec<BackendKey>> {
257            Ok(vec![])
258        }
259        // Deliberately NO `exists` override.
260    }
261
262    /// **Proves:** `BackendKey` round-trips through every constructor + accessor
263    /// — `new`, the blanket `From<T: Into<String>>`, `as_str`, and the
264    /// `Display` impl all agree on the same underlying string.
265    ///
266    /// **Why it matters:** `BackendKey` is the address every backend keys off
267    /// (`FileBackend` maps it to `<root>/<key>.dks`). A `Display`/`as_str`
268    /// disagreement, or a `From` that mangled the input, would route reads and
269    /// writes to different paths — a silent data-loss bug.
270    ///
271    /// **Catches:** an `as_str` that returns a transformed copy; a `Display`
272    /// impl that adds quotes/prefixes; a `From` that drops or alters the value.
273    #[test]
274    fn backend_key_constructors_and_accessors_agree() {
275        let from_new = BackendKey::new("validator");
276        let from_into: BackendKey = "validator".into();
277        let from_string: BackendKey = BackendKey::from(String::from("validator"));
278
279        assert_eq!(from_new.as_str(), "validator");
280        assert_eq!(from_new, from_into);
281        assert_eq!(from_new, from_string);
282        assert_eq!(format!("{from_new}"), "validator");
283        // Eq / Hash derive sanity: distinct values are not equal.
284        assert_ne!(from_new, BackendKey::new("other"));
285    }
286
287    /// **Proves:** the default [`KeychainBackend::exists`] returns `Ok(true)`
288    /// when the blob reads back successfully.
289    ///
290    /// **Why it matters:** This is the happy-path branch of the default impl
291    /// that backends inherit unless they override it. `Keystore::create` calls
292    /// `exists` to refuse overwrites; if the default returned `false` for a
293    /// present key, `create` would clobber existing keys.
294    ///
295    /// **Catches:** an inverted truth value in the `Ok(_) => Ok(true)` arm.
296    #[test]
297    fn default_exists_true_when_present() {
298        let be = ProbeBackend {
299            present: true,
300            ..Default::default()
301        };
302        assert!(be.exists(&BackendKey::new("k")).unwrap());
303    }
304
305    /// **Proves:** the default `exists` maps a `NotFound` read error to
306    /// `Ok(false)` rather than propagating it.
307    ///
308    /// **Why it matters:** A missing key is the normal "not yet created" state,
309    /// not an error. `Keystore::create` relies on `exists(..) == Ok(false)` to
310    /// proceed with a first-time write. If `NotFound` propagated as `Err`,
311    /// creating any new keystore would fail outright.
312    ///
313    /// **Catches:** removing the `NotFound` guard so absence surfaces as an
314    /// error.
315    #[test]
316    fn default_exists_false_when_not_found() {
317        let be = ProbeBackend::default(); // present=false → NotFound
318        assert!(!be.exists(&BackendKey::new("k")).unwrap());
319    }
320
321    /// **Proves:** the default `exists` propagates non-`NotFound` I/O errors
322    /// (e.g. a permission error) instead of swallowing them as `false`.
323    ///
324    /// **Why it matters:** Treating a `PermissionDenied` as "does not exist"
325    /// would let `create` attempt to overwrite a file it merely cannot read —
326    /// masking a real environment problem behind a confusing later failure.
327    /// The default must distinguish "absent" from "inaccessible".
328    ///
329    /// **Catches:** a too-broad error arm that maps every error to `Ok(false)`.
330    #[test]
331    fn default_exists_propagates_other_errors() {
332        let be = ProbeBackend {
333            fail_kind: Some(io::ErrorKind::PermissionDenied),
334            ..Default::default()
335        };
336        let err = be.exists(&BackendKey::new("k")).unwrap_err();
337        assert!(matches!(err, KeystoreError::Backend(_)));
338    }
339
340    /// **Proves:** a backend that implements only the required trait items
341    /// reports [`Exclusivity::BestEffort`].
342    ///
343    /// **Why it matters:** the direction of this default is a safety property,
344    /// not a style choice. A consumer reads it to decide whether `write_new`
345    /// makes a coupled-record mismatch *unreachable* (§10.2a). An implementor
346    /// who has not considered the question must therefore **understate** the
347    /// guarantee; a default of `Atomic` would let silence hand back exactly the
348    /// race the method exists to remove, and nothing would flag it.
349    ///
350    /// **Catches:** flipping the default to `Atomic` — which nothing else in
351    /// the suite would notice, because all three shipped backends override it.
352    #[test]
353    fn a_backend_that_says_nothing_claims_only_best_effort() {
354        let be = ProbeBackend::default();
355        assert_eq!(be.write_new_exclusivity(), Exclusivity::BestEffort);
356    }
357
358    /// **Proves:** the two [`Exclusivity`] variants render as distinct,
359    /// non-empty strings that say which is which.
360    ///
361    /// **Why it matters:** this value is reported into operator-facing logs and
362    /// diagnostics to explain *why* a consumer must place a coupled record on
363    /// one backend rather than another. Two variants that printed the same
364    /// text, or an empty one, would make the distinction the type exists to
365    /// carry invisible at exactly the point someone is reading for it.
366    ///
367    /// **Catches:** a copy-paste in the `Display` arms.
368    #[test]
369    fn exclusivity_renders_distinguishably() {
370        let atomic = Exclusivity::Atomic.to_string();
371        let best = Exclusivity::BestEffort.to_string();
372        assert!(atomic.contains("atomic"), "{atomic}");
373        assert!(best.contains("best-effort"), "{best}");
374        assert_ne!(atomic, best);
375    }
376}