dig_keystore/backend/mod.rs
1//! Storage backend abstraction.
2//!
3//! A `KeychainBackend` is any byte-blob KV store. Three ship today:
4//! `MemoryBackend` (always available), `FileBackend` (feature `file-backend`,
5//! atomic tmp + rename writes to the local filesystem), and
6//! `OsKeychainBackend` (feature `os-keychain`, the host OS credential store on
7//! Windows/macOS — see its module docs for the access boundary it does and
8//! does not provide, and for why a machine service must not use it). Planned
9//! future backends: hardware-signer backends (`LedgerBackend`, `YubiHsmBackend`)
10//! that proxy `sign` to an external device.
11
12use crate::error::Result;
13
14#[cfg(feature = "file-backend")]
15mod file;
16mod memory;
17// `pub(crate)` (not private) so `crate::hardware::tests` can reach the
18// in-memory `test_support` doubles and compose a real `OsKeychainBackend`
19// under a `HardwareBoundBackend` without a live OS credential store.
20#[cfg(feature = "os-keychain")]
21pub(crate) mod os_keychain;
22
23#[cfg(feature = "file-backend")]
24pub use file::FileBackend;
25/// In-memory backend — always available. Originally feature-gated, now
26/// unconditional because production adapters (e.g., `dig-l1-wallet`'s
27/// encrypt/decrypt-bytes helpers) wrap it in scratch backends to reuse the
28/// full keystore format without touching the filesystem.
29pub use memory::MemoryBackend;
30#[cfg(feature = "os-keychain")]
31pub use os_keychain::OsKeychainBackend;
32
33/// An opaque key identifying a single encrypted blob within a backend.
34///
35/// For `FileBackend`, the key maps to `<root>/<key>.dks`; for an OS-keyring
36/// backend it maps to a service / account pair; for a hardware signer it maps
37/// to a slot identifier.
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct BackendKey(pub String);
40
41impl BackendKey {
42 /// Construct from any string-like value.
43 pub fn new(name: impl Into<String>) -> Self {
44 Self(name.into())
45 }
46
47 /// Borrow as `&str`.
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl<T: Into<String>> From<T> for BackendKey {
54 fn from(v: T) -> Self {
55 Self(v.into())
56 }
57}
58
59impl std::fmt::Display for BackendKey {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65/// Storage backend trait. Implementations must be `Send + Sync + 'static` so
66/// they can be held behind `Arc<dyn KeychainBackend>`.
67pub trait KeychainBackend: Send + Sync + 'static {
68 /// Read the full contents of the blob at `key`.
69 ///
70 /// Returns a backend I/O error if the blob does not exist.
71 fn read(&self, key: &BackendKey) -> Result<Vec<u8>>;
72
73 /// Write `data` to `key`. Implementations should be atomic — a reader
74 /// seeing the key after this call must see either the old bytes or the
75 /// new bytes in full, never a torn mix.
76 fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
77
78 /// Remove the blob at `key`. Implementations should best-effort overwrite
79 /// the storage before removing so residual disk sectors do not retain the
80 /// ciphertext.
81 fn delete(&self, key: &BackendKey) -> Result<()>;
82
83 /// List keys that start with `prefix`. Order is unspecified.
84 fn list(&self, prefix: &str) -> Result<Vec<BackendKey>>;
85
86 /// Whether a blob exists at `key`. Default impl delegates to `read`;
87 /// backends with cheaper existence checks should override.
88 fn exists(&self, key: &BackendKey) -> Result<bool> {
89 match self.read(key) {
90 Ok(_) => Ok(true),
91 Err(crate::error::KeystoreError::Backend(e))
92 if e.kind() == std::io::ErrorKind::NotFound =>
93 {
94 Ok(false)
95 }
96 Err(e) => Err(e),
97 }
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::error::KeystoreError;
105 use std::io;
106
107 /// A minimal backend that does **not** override `exists`, so it exercises
108 /// the [`KeychainBackend::exists`] *default* implementation. The shipped
109 /// backends (`FileBackend`, `MemoryBackend`) both override `exists` with a
110 /// cheaper check, leaving the default's three branches (present →
111 /// `Ok(true)`, `NotFound` → `Ok(false)`, other error → propagate) otherwise
112 /// unexercised. This stub is the only way to test that contract.
113 #[derive(Default)]
114 struct ProbeBackend {
115 /// When set, every `read` returns this io error kind instead of data.
116 fail_kind: Option<io::ErrorKind>,
117 present: bool,
118 }
119
120 impl KeychainBackend for ProbeBackend {
121 fn read(&self, _key: &BackendKey) -> Result<Vec<u8>> {
122 if let Some(kind) = self.fail_kind {
123 return Err(KeystoreError::from(io::Error::new(kind, "probe")));
124 }
125 if self.present {
126 Ok(vec![1, 2, 3])
127 } else {
128 Err(KeystoreError::from(io::Error::new(
129 io::ErrorKind::NotFound,
130 "absent",
131 )))
132 }
133 }
134 fn write(&self, _key: &BackendKey, _data: &[u8]) -> Result<()> {
135 Ok(())
136 }
137 fn delete(&self, _key: &BackendKey) -> Result<()> {
138 Ok(())
139 }
140 fn list(&self, _prefix: &str) -> Result<Vec<BackendKey>> {
141 Ok(vec![])
142 }
143 // Deliberately NO `exists` override.
144 }
145
146 /// **Proves:** `BackendKey` round-trips through every constructor + accessor
147 /// — `new`, the blanket `From<T: Into<String>>`, `as_str`, and the
148 /// `Display` impl all agree on the same underlying string.
149 ///
150 /// **Why it matters:** `BackendKey` is the address every backend keys off
151 /// (`FileBackend` maps it to `<root>/<key>.dks`). A `Display`/`as_str`
152 /// disagreement, or a `From` that mangled the input, would route reads and
153 /// writes to different paths — a silent data-loss bug.
154 ///
155 /// **Catches:** an `as_str` that returns a transformed copy; a `Display`
156 /// impl that adds quotes/prefixes; a `From` that drops or alters the value.
157 #[test]
158 fn backend_key_constructors_and_accessors_agree() {
159 let from_new = BackendKey::new("validator");
160 let from_into: BackendKey = "validator".into();
161 let from_string: BackendKey = BackendKey::from(String::from("validator"));
162
163 assert_eq!(from_new.as_str(), "validator");
164 assert_eq!(from_new, from_into);
165 assert_eq!(from_new, from_string);
166 assert_eq!(format!("{from_new}"), "validator");
167 // Eq / Hash derive sanity: distinct values are not equal.
168 assert_ne!(from_new, BackendKey::new("other"));
169 }
170
171 /// **Proves:** the default [`KeychainBackend::exists`] returns `Ok(true)`
172 /// when the blob reads back successfully.
173 ///
174 /// **Why it matters:** This is the happy-path branch of the default impl
175 /// that backends inherit unless they override it. `Keystore::create` calls
176 /// `exists` to refuse overwrites; if the default returned `false` for a
177 /// present key, `create` would clobber existing keys.
178 ///
179 /// **Catches:** an inverted truth value in the `Ok(_) => Ok(true)` arm.
180 #[test]
181 fn default_exists_true_when_present() {
182 let be = ProbeBackend {
183 present: true,
184 ..Default::default()
185 };
186 assert!(be.exists(&BackendKey::new("k")).unwrap());
187 }
188
189 /// **Proves:** the default `exists` maps a `NotFound` read error to
190 /// `Ok(false)` rather than propagating it.
191 ///
192 /// **Why it matters:** A missing key is the normal "not yet created" state,
193 /// not an error. `Keystore::create` relies on `exists(..) == Ok(false)` to
194 /// proceed with a first-time write. If `NotFound` propagated as `Err`,
195 /// creating any new keystore would fail outright.
196 ///
197 /// **Catches:** removing the `NotFound` guard so absence surfaces as an
198 /// error.
199 #[test]
200 fn default_exists_false_when_not_found() {
201 let be = ProbeBackend::default(); // present=false → NotFound
202 assert!(!be.exists(&BackendKey::new("k")).unwrap());
203 }
204
205 /// **Proves:** the default `exists` propagates non-`NotFound` I/O errors
206 /// (e.g. a permission error) instead of swallowing them as `false`.
207 ///
208 /// **Why it matters:** Treating a `PermissionDenied` as "does not exist"
209 /// would let `create` attempt to overwrite a file it merely cannot read —
210 /// masking a real environment problem behind a confusing later failure.
211 /// The default must distinguish "absent" from "inaccessible".
212 ///
213 /// **Catches:** a too-broad error arm that maps every error to `Ok(false)`.
214 #[test]
215 fn default_exists_propagates_other_errors() {
216 let be = ProbeBackend {
217 fail_kind: Some(io::ErrorKind::PermissionDenied),
218 ..Default::default()
219 };
220 let err = be.exists(&BackendKey::new("k")).unwrap_err();
221 assert!(matches!(err, KeystoreError::Backend(_)));
222 }
223}