Skip to main content

basil_keystore_backend/
store.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Unified key/value secret store for Basil's key-store backend.
6
7use std::fmt;
8#[cfg(feature = "db-keystore")]
9use std::path::PathBuf;
10#[cfg(feature = "db-keystore")]
11use std::sync::Arc;
12
13#[cfg(feature = "db-keystore")]
14use db_keystore::{DbKeyStore, DbKeyStoreConfig, EncryptionOpts};
15#[cfg(feature = "db-keystore")]
16use keyring_core::CredentialStore;
17#[cfg(feature = "db-keystore")]
18use zero_secrets::SecretArray;
19use zero_secrets::SecretBytes;
20#[cfg(feature = "db-keystore")]
21use zeroize::Zeroizing;
22
23#[cfg(feature = "onepassword")]
24use crate::onepassword::{OnePasswordConfig, OnePasswordProvider};
25
26#[cfg(feature = "db-keystore")]
27const SERVICE: &str = "basil";
28
29/// Store open configuration.
30#[derive(Clone)]
31pub enum StoreConfig {
32    /// Placeholder used only when the crate is built without concrete backend
33    /// features. `basil-agent` rejects that feature combination before use.
34    #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
35    Unavailable,
36    /// Encrypted db-keystore database.
37    #[cfg(feature = "db-keystore")]
38    DbKeystore {
39        /// SQLite-compatible database path.
40        path: PathBuf,
41        /// turso encryption cipher, for example `aegis256`.
42        cipher: String,
43        /// 32-byte DEK supplied by Basil's sealed bundle.
44        dek: SecretArray<32>,
45    },
46    /// `1Password` provider URI and addressing context.
47    #[cfg(feature = "onepassword")]
48    OnePassword {
49        /// Provider URI, for example `onepassword://vault` or
50        /// `onepassword+token://token@vault`.
51        provider_uri: String,
52        /// Item-title project namespace.
53        project: String,
54        /// Item-title profile.
55        profile: String,
56    },
57}
58
59impl fmt::Debug for StoreConfig {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
63            Self::Unavailable => f.write_str("Unavailable"),
64            #[cfg(feature = "db-keystore")]
65            Self::DbKeystore { path, cipher, dek } => f
66                .debug_struct("DbKeystore")
67                .field("path", path)
68                .field("cipher", cipher)
69                .field("dek", dek)
70                .finish(),
71            #[cfg(feature = "onepassword")]
72            Self::OnePassword {
73                provider_uri: _,
74                project,
75                profile,
76            } => f
77                .debug_struct("OnePassword")
78                .field("provider_uri", &"REDACTED")
79                .field("project", project)
80                .field("profile", profile)
81                .finish(),
82        }
83    }
84}
85
86/// Secret-store failure. Variants carry only stable discriminators, paths, or
87/// redacted backend summaries, never secret values.
88#[derive(Debug, thiserror::Error)]
89pub enum StoreError {
90    /// The requested key does not exist.
91    #[error("key not found: {0}")]
92    NotFound(String),
93    /// The concrete backend rejected the operation.
94    #[error("backend error: {0}")]
95    Backend(String),
96    /// The backend cannot store non-UTF-8 bytes.
97    #[error("backend requires UTF-8 values")]
98    NonUtf8Value,
99}
100
101enum StoreInner {
102    #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
103    Unavailable,
104    #[cfg(feature = "db-keystore")]
105    DbKeystore(Arc<CredentialStore>),
106    #[cfg(feature = "onepassword")]
107    OnePassword {
108        provider: OnePasswordProvider,
109        project: String,
110        profile: String,
111    },
112}
113
114/// A unified secret store over enabled key-store backends.
115pub struct SecretStore {
116    inner: StoreInner,
117}
118
119impl SecretStore {
120    /// Open a store from configuration.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`StoreError::Backend`] if the configured backend cannot be opened.
125    #[cfg_attr(
126        not(any(feature = "db-keystore", feature = "onepassword")),
127        allow(clippy::missing_const_for_fn, clippy::needless_pass_by_value)
128    )]
129    pub fn open(config: StoreConfig) -> Result<Self, StoreError> {
130        match config {
131            #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
132            StoreConfig::Unavailable => Ok(Self {
133                inner: StoreInner::Unavailable,
134            }),
135            #[cfg(feature = "db-keystore")]
136            StoreConfig::DbKeystore { path, cipher, dek } => {
137                let hexkey = hex_key(dek.expose_secret());
138                let store = DbKeyStore::new(DbKeyStoreConfig {
139                    path,
140                    encryption_opts: Some(EncryptionOpts::new(cipher, hexkey.as_str())),
141                    ..Default::default()
142                })
143                .map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
144                Ok(Self {
145                    inner: StoreInner::DbKeystore(store as Arc<CredentialStore>),
146                })
147            }
148            #[cfg(feature = "onepassword")]
149            StoreConfig::OnePassword {
150                provider_uri,
151                project,
152                profile,
153            } => {
154                let config = OnePasswordConfig::from_uri(&provider_uri)?;
155                Ok(Self {
156                    inner: StoreInner::OnePassword {
157                        provider: OnePasswordProvider::new(config),
158                        project,
159                        profile,
160                    },
161                })
162            }
163        }
164    }
165
166    /// Fetch a non-secret value. The returned buffer is plain because Basil uses
167    /// this path only for public/value reads.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`StoreError::NotFound`] when `key` is absent.
172    #[cfg_attr(
173        not(any(feature = "db-keystore", feature = "onepassword")),
174        allow(unused_variables)
175    )]
176    pub fn get(&self, key: &str) -> Result<Vec<u8>, StoreError> {
177        match &self.inner {
178            #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
179            StoreInner::Unavailable => {
180                Err(StoreError::Backend("no-keystore-backend-enabled".into()))
181            }
182            #[cfg(feature = "db-keystore")]
183            StoreInner::DbKeystore(store) => {
184                let entry = store
185                    .build(SERVICE, key, None)
186                    .map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
187                match entry.get_secret() {
188                    Ok(bytes) => Ok(bytes),
189                    Err(keyring_core::Error::NoEntry) => Err(StoreError::NotFound(key.to_owned())),
190                    Err(e) => Err(StoreError::Backend(keyring_error_summary(&e).to_owned())),
191                }
192            }
193            #[cfg(feature = "onepassword")]
194            StoreInner::OnePassword {
195                provider,
196                project,
197                profile,
198            } => provider
199                .get(project, key, profile)?
200                .map(|bytes| bytes.to_vec())
201                .ok_or_else(|| StoreError::NotFound(key.to_owned())),
202        }
203    }
204
205    /// Fetch a secret value in a zeroizing owner.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`StoreError::NotFound`] when `key` is absent.
210    #[cfg_attr(
211        not(any(feature = "db-keystore", feature = "onepassword")),
212        allow(unused_variables)
213    )]
214    pub fn get_secret(&self, key: &str) -> Result<SecretBytes, StoreError> {
215        match &self.inner {
216            #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
217            StoreInner::Unavailable => {
218                Err(StoreError::Backend("no-keystore-backend-enabled".into()))
219            }
220            #[cfg(feature = "db-keystore")]
221            StoreInner::DbKeystore(store) => {
222                let entry = store
223                    .build(SERVICE, key, None)
224                    .map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
225                match entry.get_secret() {
226                    Ok(bytes) => Ok(SecretBytes::new(bytes)),
227                    Err(keyring_core::Error::NoEntry) => Err(StoreError::NotFound(key.to_owned())),
228                    Err(e) => Err(StoreError::Backend(keyring_error_summary(&e).to_owned())),
229                }
230            }
231            #[cfg(feature = "onepassword")]
232            StoreInner::OnePassword {
233                provider,
234                project,
235                profile,
236            } => provider
237                .get(project, key, profile)?
238                .map(|bytes| SecretBytes::new(bytes.to_vec()))
239                .ok_or_else(|| StoreError::NotFound(key.to_owned())),
240        }
241    }
242
243    /// Store `value` at `key`, overwriting any previous value.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`StoreError::NonUtf8Value`] when a string-oriented provider such
248    /// as `1Password` cannot represent the bytes.
249    #[cfg_attr(
250        not(any(feature = "db-keystore", feature = "onepassword")),
251        allow(unused_variables)
252    )]
253    pub fn put(&self, key: &str, value: &[u8]) -> Result<(), StoreError> {
254        match &self.inner {
255            #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
256            StoreInner::Unavailable => {
257                Err(StoreError::Backend("no-keystore-backend-enabled".into()))
258            }
259            #[cfg(feature = "db-keystore")]
260            StoreInner::DbKeystore(store) => {
261                let entry = store
262                    .build(SERVICE, key, None)
263                    .map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
264                entry
265                    .set_secret(value)
266                    .map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))
267            }
268            #[cfg(feature = "onepassword")]
269            StoreInner::OnePassword {
270                provider,
271                project,
272                profile,
273            } => provider.set(project, key, value, profile),
274        }
275    }
276}
277
278#[cfg(feature = "db-keystore")]
279fn hex_key(dek: &[u8]) -> Zeroizing<String> {
280    let mut out = String::with_capacity(64);
281    for b in dek {
282        push_hex_nibble(&mut out, b >> 4);
283        push_hex_nibble(&mut out, b & 0x0f);
284    }
285    Zeroizing::new(out)
286}
287
288#[cfg(feature = "db-keystore")]
289fn push_hex_nibble(out: &mut String, nibble: u8) {
290    out.push(char::from(match nibble {
291        0 => b'0',
292        1 => b'1',
293        2 => b'2',
294        3 => b'3',
295        4 => b'4',
296        5 => b'5',
297        6 => b'6',
298        7 => b'7',
299        8 => b'8',
300        9 => b'9',
301        10 => b'a',
302        11 => b'b',
303        12 => b'c',
304        13 => b'd',
305        14 => b'e',
306        _ => b'f',
307    }));
308}
309
310#[cfg(feature = "db-keystore")]
311const fn keyring_error_summary(err: &keyring_core::Error) -> &'static str {
312    match err {
313        keyring_core::Error::NoEntry => "no-entry",
314        keyring_core::Error::Ambiguous(_) => "ambiguous",
315        keyring_core::Error::BadEncoding(_) => "bad-encoding",
316        keyring_core::Error::TooLong(_, _) => "too-long",
317        keyring_core::Error::Invalid(_, _) => "invalid",
318        keyring_core::Error::NotSupportedByStore(_) => "not-supported",
319        keyring_core::Error::NoDefaultStore => "no-default-store",
320        keyring_core::Error::BadStoreFormat(_) => "bad-store-format",
321        keyring_core::Error::BadDataFormat(_, _) => "bad-data-format",
322        keyring_core::Error::PlatformFailure(_) => "platform-failure",
323        keyring_core::Error::NoStorageAccess(_) => "no-storage-access",
324        _ => "other",
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    #![allow(
331        clippy::unwrap_used,
332        clippy::expect_used,
333        clippy::indexing_slicing,
334        clippy::panic
335    )]
336
337    #[cfg(feature = "db-keystore")]
338    #[test]
339    fn db_keystore_config_debug_redacts_dek() {
340        use zero_secrets::SecretArray;
341
342        let cfg = super::StoreConfig::DbKeystore {
343            path: "test.db".into(),
344            cipher: "aegis256".to_string(),
345            dek: SecretArray::new([0xabu8; 32]),
346        };
347        let rendered = format!("{cfg:?}");
348        assert!(rendered.contains("REDACTED"));
349        assert!(!rendered.contains("171"));
350        assert!(!rendered.contains("ab"));
351    }
352
353    /// A unique, absolute temp path so parallel tests never share a store file.
354    #[cfg(feature = "db-keystore")]
355    fn unique_temp_path(stem: &str, ext: &str) -> std::path::PathBuf {
356        use std::sync::atomic::{AtomicU64, Ordering};
357        static COUNTER: AtomicU64 = AtomicU64::new(0);
358        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
359        std::env::temp_dir().join(format!(
360            "basil-keystore-{stem}-{}-{n}.{ext}",
361            std::process::id()
362        ))
363    }
364
365    /// Functional coverage for the db-keystore materialize-to-use path over a
366    /// real encrypted turso store: provision key material through the store,
367    /// read it back in a zeroizing owner, and drive local crypto with it.
368    #[cfg(feature = "db-keystore")]
369    #[test]
370    fn db_keystore_materialize_to_use_round_trip() {
371        use crate::{decrypt_aead, encrypt_aead, public_ed25519, sign_ed25519, verify_ed25519};
372        use basil::proto::AeadAlgorithm;
373        use zero_secrets::SecretArray;
374
375        let path = unique_temp_path("db", "db");
376        let store = super::SecretStore::open(super::StoreConfig::DbKeystore {
377            path: path.clone(),
378            cipher: "aegis256".to_string(),
379            dek: SecretArray::new([0x11u8; 32]),
380        })
381        .expect("open encrypted db-keystore store");
382
383        // --- Ed25519 sign: provision a seed, materialize it, sign, verify.
384        let seed = [0x42u8; 32];
385        store.put("kv2/signing-seed", &seed).expect("store seed");
386        let materialized = store
387            .get_secret("kv2/signing-seed")
388            .expect("materialize seed");
389        assert_eq!(materialized.expose_secret(), &seed);
390        let message = b"db-keystore materialize-to-sign";
391        let signature = sign_ed25519(materialized.expose_secret(), message).unwrap();
392        let public = public_ed25519(materialized.expose_secret()).unwrap();
393        assert!(verify_ed25519(&public, message, &signature).unwrap());
394
395        // --- AEAD: provision a key, materialize it, encrypt then decrypt.
396        let aead_key = [0x7cu8; 32];
397        store
398            .put("kv2/aead-key", &aead_key)
399            .expect("store aead key");
400        let key = store.get_secret("kv2/aead-key").expect("materialize key");
401        let plaintext = b"db-keystore materialize-to-use aead";
402        let mut envelope = encrypt_aead(
403            key.expose_secret(),
404            AeadAlgorithm::Aes256Gcm,
405            plaintext,
406            None,
407        )
408        .unwrap();
409        let recovered = decrypt_aead(key.expose_secret(), &envelope, None).unwrap();
410        assert_eq!(recovered.as_slice(), plaintext.as_slice());
411
412        // A tampered envelope fails closed.
413        envelope.ciphertext[0] ^= 0x01;
414        assert!(matches!(
415            decrypt_aead(key.expose_secret(), &envelope, None),
416            Err(crate::CryptoError::DecryptFailed)
417        ));
418
419        // Absent keys surface as NotFound, never a panic.
420        assert!(matches!(
421            store.get_secret("kv2/absent"),
422            Err(super::StoreError::NotFound(_))
423        ));
424
425        drop(store);
426        let _ = std::fs::remove_file(&path);
427    }
428
429    /// The `1Password` provider-config path fails closed on a URI that is not a
430    /// `1Password` scheme (no live `op`/vault required: construction parses the
431    /// URI before any I/O).
432    #[cfg(feature = "onepassword")]
433    #[test]
434    fn onepassword_provider_config_fail_closed() {
435        // `SecretStore` is intentionally not `Debug`, so match rather than
436        // `expect_err`.
437        match super::SecretStore::open(super::StoreConfig::OnePassword {
438            provider_uri: "not-a-real-scheme://host/path".to_string(),
439            project: "p".to_string(),
440            profile: "default".to_string(),
441        }) {
442            Err(super::StoreError::Backend(_)) => {}
443            Err(other) => panic!("expected a Backend error, got {other:?}"),
444            Ok(_) => panic!("a non-onepassword scheme must fail closed"),
445        }
446    }
447
448    #[cfg(feature = "onepassword")]
449    #[test]
450    fn onepassword_store_config_debug_redacts_provider_uri() {
451        let cfg = super::StoreConfig::OnePassword {
452            provider_uri: "onepassword+token://acct:ops_tok@Private".to_string(),
453            project: "p".to_string(),
454            profile: "default".to_string(),
455        };
456        let rendered = format!("{cfg:?}");
457        assert!(rendered.contains("REDACTED"));
458        assert!(!rendered.contains("ops_tok"));
459    }
460}