1use 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#[derive(Clone)]
31pub enum StoreConfig {
32 #[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
35 Unavailable,
36 #[cfg(feature = "db-keystore")]
38 DbKeystore {
39 path: PathBuf,
41 cipher: String,
43 dek: SecretArray<32>,
45 },
46 #[cfg(feature = "onepassword")]
48 OnePassword {
49 provider_uri: String,
52 project: String,
54 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#[derive(Debug, thiserror::Error)]
89pub enum StoreError {
90 #[error("key not found: {0}")]
92 NotFound(String),
93 #[error("backend error: {0}")]
95 Backend(String),
96 #[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
114pub struct SecretStore {
116 inner: StoreInner,
117}
118
119impl SecretStore {
120 #[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 #[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 #[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 #[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 #[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 #[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 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 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 envelope.ciphertext[0] ^= 0x01;
414 assert!(matches!(
415 decrypt_aead(key.expose_secret(), &envelope, None),
416 Err(crate::CryptoError::DecryptFailed)
417 ));
418
419 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 #[cfg(feature = "onepassword")]
433 #[test]
434 fn onepassword_provider_config_fail_closed() {
435 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}