use std::fmt;
#[cfg(feature = "db-keystore")]
use std::path::PathBuf;
#[cfg(feature = "db-keystore")]
use std::sync::Arc;
#[cfg(feature = "db-keystore")]
use db_keystore::{DbKeyStore, DbKeyStoreConfig, EncryptionOpts};
#[cfg(feature = "db-keystore")]
use keyring_core::CredentialStore;
#[cfg(feature = "db-keystore")]
use zero_secrets::SecretArray;
use zero_secrets::SecretBytes;
#[cfg(feature = "db-keystore")]
use zeroize::Zeroizing;
#[cfg(feature = "onepassword")]
use crate::onepassword::{OnePasswordConfig, OnePasswordProvider};
#[cfg(feature = "db-keystore")]
const SERVICE: &str = "basil";
#[derive(Clone)]
pub enum StoreConfig {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
Unavailable,
#[cfg(feature = "db-keystore")]
DbKeystore {
path: PathBuf,
cipher: String,
dek: SecretArray<32>,
},
#[cfg(feature = "onepassword")]
OnePassword {
provider_uri: String,
project: String,
profile: String,
},
}
impl fmt::Debug for StoreConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
Self::Unavailable => f.write_str("Unavailable"),
#[cfg(feature = "db-keystore")]
Self::DbKeystore { path, cipher, dek } => f
.debug_struct("DbKeystore")
.field("path", path)
.field("cipher", cipher)
.field("dek", dek)
.finish(),
#[cfg(feature = "onepassword")]
Self::OnePassword {
provider_uri: _,
project,
profile,
} => f
.debug_struct("OnePassword")
.field("provider_uri", &"REDACTED")
.field("project", project)
.field("profile", profile)
.finish(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("key not found: {0}")]
NotFound(String),
#[error("backend error: {0}")]
Backend(String),
#[error("backend requires UTF-8 values")]
NonUtf8Value,
}
enum StoreInner {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
Unavailable,
#[cfg(feature = "db-keystore")]
DbKeystore(Arc<CredentialStore>),
#[cfg(feature = "onepassword")]
OnePassword {
provider: OnePasswordProvider,
project: String,
profile: String,
},
}
pub struct SecretStore {
inner: StoreInner,
}
impl SecretStore {
#[cfg_attr(
not(any(feature = "db-keystore", feature = "onepassword")),
allow(clippy::missing_const_for_fn, clippy::needless_pass_by_value)
)]
pub fn open(config: StoreConfig) -> Result<Self, StoreError> {
match config {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
StoreConfig::Unavailable => Ok(Self {
inner: StoreInner::Unavailable,
}),
#[cfg(feature = "db-keystore")]
StoreConfig::DbKeystore { path, cipher, dek } => {
let hexkey = hex_key(dek.expose_secret());
let store = DbKeyStore::new(DbKeyStoreConfig {
path,
encryption_opts: Some(EncryptionOpts::new(cipher, hexkey.as_str())),
..Default::default()
})
.map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
Ok(Self {
inner: StoreInner::DbKeystore(store as Arc<CredentialStore>),
})
}
#[cfg(feature = "onepassword")]
StoreConfig::OnePassword {
provider_uri,
project,
profile,
} => {
let config = OnePasswordConfig::from_uri(&provider_uri)?;
Ok(Self {
inner: StoreInner::OnePassword {
provider: OnePasswordProvider::new(config),
project,
profile,
},
})
}
}
}
#[cfg_attr(
not(any(feature = "db-keystore", feature = "onepassword")),
allow(unused_variables)
)]
pub fn get(&self, key: &str) -> Result<Vec<u8>, StoreError> {
match &self.inner {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
StoreInner::Unavailable => {
Err(StoreError::Backend("no-keystore-backend-enabled".into()))
}
#[cfg(feature = "db-keystore")]
StoreInner::DbKeystore(store) => {
let entry = store
.build(SERVICE, key, None)
.map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
match entry.get_secret() {
Ok(bytes) => Ok(bytes),
Err(keyring_core::Error::NoEntry) => Err(StoreError::NotFound(key.to_owned())),
Err(e) => Err(StoreError::Backend(keyring_error_summary(&e).to_owned())),
}
}
#[cfg(feature = "onepassword")]
StoreInner::OnePassword {
provider,
project,
profile,
} => provider
.get(project, key, profile)?
.map(|bytes| bytes.to_vec())
.ok_or_else(|| StoreError::NotFound(key.to_owned())),
}
}
#[cfg_attr(
not(any(feature = "db-keystore", feature = "onepassword")),
allow(unused_variables)
)]
pub fn get_secret(&self, key: &str) -> Result<SecretBytes, StoreError> {
match &self.inner {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
StoreInner::Unavailable => {
Err(StoreError::Backend("no-keystore-backend-enabled".into()))
}
#[cfg(feature = "db-keystore")]
StoreInner::DbKeystore(store) => {
let entry = store
.build(SERVICE, key, None)
.map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
match entry.get_secret() {
Ok(bytes) => Ok(SecretBytes::new(bytes)),
Err(keyring_core::Error::NoEntry) => Err(StoreError::NotFound(key.to_owned())),
Err(e) => Err(StoreError::Backend(keyring_error_summary(&e).to_owned())),
}
}
#[cfg(feature = "onepassword")]
StoreInner::OnePassword {
provider,
project,
profile,
} => provider
.get(project, key, profile)?
.map(|bytes| SecretBytes::new(bytes.to_vec()))
.ok_or_else(|| StoreError::NotFound(key.to_owned())),
}
}
#[cfg_attr(
not(any(feature = "db-keystore", feature = "onepassword")),
allow(unused_variables)
)]
pub fn put(&self, key: &str, value: &[u8]) -> Result<(), StoreError> {
match &self.inner {
#[cfg(not(any(feature = "db-keystore", feature = "onepassword")))]
StoreInner::Unavailable => {
Err(StoreError::Backend("no-keystore-backend-enabled".into()))
}
#[cfg(feature = "db-keystore")]
StoreInner::DbKeystore(store) => {
let entry = store
.build(SERVICE, key, None)
.map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))?;
entry
.set_secret(value)
.map_err(|e| StoreError::Backend(keyring_error_summary(&e).to_owned()))
}
#[cfg(feature = "onepassword")]
StoreInner::OnePassword {
provider,
project,
profile,
} => provider.set(project, key, value, profile),
}
}
}
#[cfg(feature = "db-keystore")]
fn hex_key(dek: &[u8]) -> Zeroizing<String> {
let mut out = String::with_capacity(64);
for b in dek {
push_hex_nibble(&mut out, b >> 4);
push_hex_nibble(&mut out, b & 0x0f);
}
Zeroizing::new(out)
}
#[cfg(feature = "db-keystore")]
fn push_hex_nibble(out: &mut String, nibble: u8) {
out.push(char::from(match nibble {
0 => b'0',
1 => b'1',
2 => b'2',
3 => b'3',
4 => b'4',
5 => b'5',
6 => b'6',
7 => b'7',
8 => b'8',
9 => b'9',
10 => b'a',
11 => b'b',
12 => b'c',
13 => b'd',
14 => b'e',
_ => b'f',
}));
}
#[cfg(feature = "db-keystore")]
const fn keyring_error_summary(err: &keyring_core::Error) -> &'static str {
match err {
keyring_core::Error::NoEntry => "no-entry",
keyring_core::Error::Ambiguous(_) => "ambiguous",
keyring_core::Error::BadEncoding(_) => "bad-encoding",
keyring_core::Error::TooLong(_, _) => "too-long",
keyring_core::Error::Invalid(_, _) => "invalid",
keyring_core::Error::NotSupportedByStore(_) => "not-supported",
keyring_core::Error::NoDefaultStore => "no-default-store",
keyring_core::Error::BadStoreFormat(_) => "bad-store-format",
keyring_core::Error::BadDataFormat(_, _) => "bad-data-format",
keyring_core::Error::PlatformFailure(_) => "platform-failure",
keyring_core::Error::NoStorageAccess(_) => "no-storage-access",
_ => "other",
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
#[cfg(feature = "db-keystore")]
#[test]
fn db_keystore_config_debug_redacts_dek() {
use zero_secrets::SecretArray;
let cfg = super::StoreConfig::DbKeystore {
path: "test.db".into(),
cipher: "aegis256".to_string(),
dek: SecretArray::new([0xabu8; 32]),
};
let rendered = format!("{cfg:?}");
assert!(rendered.contains("REDACTED"));
assert!(!rendered.contains("171"));
assert!(!rendered.contains("ab"));
}
#[cfg(feature = "db-keystore")]
fn unique_temp_path(stem: &str, ext: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"basil-keystore-{stem}-{}-{n}.{ext}",
std::process::id()
))
}
#[cfg(feature = "db-keystore")]
#[test]
fn db_keystore_materialize_to_use_round_trip() {
use crate::{decrypt_aead, encrypt_aead, public_ed25519, sign_ed25519, verify_ed25519};
use basil::proto::AeadAlgorithm;
use zero_secrets::SecretArray;
let path = unique_temp_path("db", "db");
let store = super::SecretStore::open(super::StoreConfig::DbKeystore {
path: path.clone(),
cipher: "aegis256".to_string(),
dek: SecretArray::new([0x11u8; 32]),
})
.expect("open encrypted db-keystore store");
let seed = [0x42u8; 32];
store.put("kv2/signing-seed", &seed).expect("store seed");
let materialized = store
.get_secret("kv2/signing-seed")
.expect("materialize seed");
assert_eq!(materialized.expose_secret(), &seed);
let message = b"db-keystore materialize-to-sign";
let signature = sign_ed25519(materialized.expose_secret(), message).unwrap();
let public = public_ed25519(materialized.expose_secret()).unwrap();
assert!(verify_ed25519(&public, message, &signature).unwrap());
let aead_key = [0x7cu8; 32];
store
.put("kv2/aead-key", &aead_key)
.expect("store aead key");
let key = store.get_secret("kv2/aead-key").expect("materialize key");
let plaintext = b"db-keystore materialize-to-use aead";
let mut envelope = encrypt_aead(
key.expose_secret(),
AeadAlgorithm::Aes256Gcm,
plaintext,
None,
)
.unwrap();
let recovered = decrypt_aead(key.expose_secret(), &envelope, None).unwrap();
assert_eq!(recovered.as_slice(), plaintext.as_slice());
envelope.ciphertext[0] ^= 0x01;
assert!(matches!(
decrypt_aead(key.expose_secret(), &envelope, None),
Err(crate::CryptoError::DecryptFailed)
));
assert!(matches!(
store.get_secret("kv2/absent"),
Err(super::StoreError::NotFound(_))
));
drop(store);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "onepassword")]
#[test]
fn onepassword_provider_config_fail_closed() {
match super::SecretStore::open(super::StoreConfig::OnePassword {
provider_uri: "not-a-real-scheme://host/path".to_string(),
project: "p".to_string(),
profile: "default".to_string(),
}) {
Err(super::StoreError::Backend(_)) => {}
Err(other) => panic!("expected a Backend error, got {other:?}"),
Ok(_) => panic!("a non-onepassword scheme must fail closed"),
}
}
#[cfg(feature = "onepassword")]
#[test]
fn onepassword_store_config_debug_redacts_provider_uri() {
let cfg = super::StoreConfig::OnePassword {
provider_uri: "onepassword+token://acct:ops_tok@Private".to_string(),
project: "p".to_string(),
profile: "default".to_string(),
};
let rendered = format!("{cfg:?}");
assert!(rendered.contains("REDACTED"));
assert!(!rendered.contains("ops_tok"));
}
}