use std::collections::HashMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use aes_gcm::aead::Aead;
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit, Nonce};
use argon2::{Argon2, Params};
use base64::Engine as _;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecretBackendTag {
Keyring,
EncryptedFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretRef {
pub backend: SecretBackendTag,
pub id: String,
}
impl SecretRef {
#[must_use]
pub fn new(backend: SecretBackendTag, id: impl Into<String>) -> Self {
Self {
backend,
id: id.into(),
}
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct Secret(pub Vec<u8>);
impl Secret {
#[must_use]
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Secret(<{} bytes redacted>)", self.0.len())
}
}
impl AsRef<[u8]> for Secret {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SecretStoreError {
#[error("secret {0:?} not found")]
NotFound(SecretRef),
#[error("no passphrase available — set $VORTIX_PASSPHRASE or run interactively")]
PassphraseRequired,
#[error("keyring error: {0}")]
Keyring(String),
#[error("encrypted-file I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("decrypt failed (wrong passphrase, corrupted file, or tampered ciphertext)")]
DecryptFailed,
#[error("invalid base64 in secret payload: {0}")]
BadBase64(#[from] base64::DecodeError),
#[error("serialisation error: {0}")]
Serde(String),
}
pub trait SecretStore: Send + Sync {
fn get(&self, secret_ref: &SecretRef) -> Result<Secret, SecretStoreError>;
fn set(&self, id: &str, secret: Secret) -> Result<SecretRef, SecretStoreError>;
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretStoreError>;
}
#[derive(Debug, Clone)]
pub struct SecretStoreConfig {
pub fallback_path: PathBuf,
pub passphrase: Option<String>,
pub force_fallback: bool,
}
pub struct LayeredSecretStore {
inner: Box<dyn SecretStore>,
}
impl std::fmt::Debug for LayeredSecretStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LayeredSecretStore").finish_non_exhaustive()
}
}
impl LayeredSecretStore {
pub fn new(config: SecretStoreConfig) -> Result<Self, SecretStoreError> {
if !config.force_fallback && keyring_available() {
return Ok(Self {
inner: Box::new(KeyringSecretStore::new("vortix")),
});
}
let passphrase = config
.passphrase
.clone()
.or_else(|| std::env::var("VORTIX_PASSPHRASE").ok())
.ok_or(SecretStoreError::PassphraseRequired)?;
Ok(Self {
inner: Box::new(EncryptedFileSecretStore::open(
config.fallback_path,
&passphrase,
)?),
})
}
pub fn encrypted_file(path: PathBuf, passphrase: &str) -> Result<Self, SecretStoreError> {
Ok(Self {
inner: Box::new(EncryptedFileSecretStore::open(path, passphrase)?),
})
}
}
impl SecretStore for LayeredSecretStore {
fn get(&self, secret_ref: &SecretRef) -> Result<Secret, SecretStoreError> {
self.inner.get(secret_ref)
}
fn set(&self, id: &str, secret: Secret) -> Result<SecretRef, SecretStoreError> {
self.inner.set(id, secret)
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretStoreError> {
self.inner.delete(secret_ref)
}
}
fn keyring_available() -> bool {
let Ok(entry) = keyring::Entry::new("vortix", "_keyring_probe") else {
return false;
};
match entry.set_password("probe") {
Ok(()) => {
let _ = entry.delete_credential();
true
}
Err(_) => false,
}
}
pub struct KeyringSecretStore {
service: String,
}
impl KeyringSecretStore {
#[must_use]
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
}
impl SecretStore for KeyringSecretStore {
fn get(&self, secret_ref: &SecretRef) -> Result<Secret, SecretStoreError> {
let entry = keyring::Entry::new(&self.service, &secret_ref.id)
.map_err(|e| SecretStoreError::Keyring(e.to_string()))?;
let pwd = entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => SecretStoreError::NotFound(secret_ref.clone()),
other => SecretStoreError::Keyring(other.to_string()),
})?;
let bytes = base64::engine::general_purpose::STANDARD.decode(&pwd)?;
Ok(Secret::new(bytes))
}
fn set(&self, id: &str, secret: Secret) -> Result<SecretRef, SecretStoreError> {
let encoded = base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
let entry = keyring::Entry::new(&self.service, id)
.map_err(|e| SecretStoreError::Keyring(e.to_string()))?;
entry
.set_password(&encoded)
.map_err(|e| SecretStoreError::Keyring(e.to_string()))?;
Ok(SecretRef::new(SecretBackendTag::Keyring, id))
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretStoreError> {
let entry = keyring::Entry::new(&self.service, &secret_ref.id)
.map_err(|e| SecretStoreError::Keyring(e.to_string()))?;
entry
.delete_credential()
.map_err(|e| SecretStoreError::Keyring(e.to_string()))?;
Ok(())
}
}
#[derive(Debug)]
pub struct EncryptedFileSecretStore {
path: PathBuf,
key: [u8; 32],
salt: [u8; 16],
cache: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
impl EncryptedFileSecretStore {
pub fn open(path: PathBuf, passphrase: &str) -> Result<Self, SecretStoreError> {
let (salt, key, cache) = if path.exists() {
let mut file = std::fs::File::open(&path)?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
if buf.len() < 16 + 12 {
return Err(SecretStoreError::DecryptFailed);
}
let mut salt = [0u8; 16];
salt.copy_from_slice(&buf[..16]);
let nonce_bytes = &buf[16..28];
let ciphertext = &buf[28..];
let key = derive_key(passphrase, &salt)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| SecretStoreError::DecryptFailed)?;
let cache: HashMap<String, Vec<u8>> = serde_json::from_slice(&plaintext)
.map_err(|e| SecretStoreError::Serde(e.to_string()))?;
(salt, key, cache)
} else {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
let key = derive_key(passphrase, &salt)?;
(salt, key, HashMap::new())
};
Ok(Self {
path,
key,
salt,
cache: Arc::new(Mutex::new(cache)),
})
}
fn flush(&self, cache: &HashMap<String, Vec<u8>>) -> Result<(), SecretStoreError> {
let plaintext =
serde_json::to_vec(cache).map_err(|e| SecretStoreError::Serde(e.to_string()))?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
let nonce = Aes256Gcm::generate_nonce(&mut rand::thread_rng());
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_ref())
.map_err(|_| SecretStoreError::DecryptFailed)?;
let mut buf = Vec::with_capacity(16 + 12 + ciphertext.len());
buf.extend_from_slice(&self.salt);
buf.extend_from_slice(&nonce);
buf.extend_from_slice(&ciphertext);
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("enc.tmp");
std::fs::write(&tmp, &buf)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
impl SecretStore for EncryptedFileSecretStore {
fn get(&self, secret_ref: &SecretRef) -> Result<Secret, SecretStoreError> {
let cache = self.cache.lock().unwrap();
cache
.get(&secret_ref.id)
.cloned()
.map(Secret::new)
.ok_or_else(|| SecretStoreError::NotFound(secret_ref.clone()))
}
fn set(&self, id: &str, secret: Secret) -> Result<SecretRef, SecretStoreError> {
let snapshot = {
let mut cache = self.cache.lock().unwrap();
cache.insert(id.to_string(), secret.as_bytes().to_vec());
cache.clone()
};
self.flush(&snapshot)?;
Ok(SecretRef::new(SecretBackendTag::EncryptedFile, id))
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretStoreError> {
let snapshot = {
let mut cache = self.cache.lock().unwrap();
if cache.remove(&secret_ref.id).is_none() {
return Err(SecretStoreError::NotFound(secret_ref.clone()));
}
cache.clone()
};
self.flush(&snapshot)?;
Ok(())
}
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], SecretStoreError> {
let params =
Params::new(64 * 1024, 3, 4, None).map_err(|e| SecretStoreError::Serde(e.to_string()))?;
let argon = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = [0u8; 32];
argon
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| SecretStoreError::Serde(e.to_string()))?;
Ok(key)
}
#[allow(dead_code)]
fn _is_path(_p: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_debug_redacts_bytes() {
let s = Secret::new(b"super-secret".to_vec());
let dbg = format!("{s:?}");
assert!(!dbg.contains("super-secret"));
assert!(dbg.contains("12 bytes redacted"));
}
#[test]
fn encrypted_file_set_then_get_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.enc");
let store = EncryptedFileSecretStore::open(path.clone(), "passphrase").unwrap();
let r = store
.set("creds/corp", Secret::new(b"my-password".to_vec()))
.unwrap();
assert_eq!(r.backend, SecretBackendTag::EncryptedFile);
let back = store.get(&r).unwrap();
assert_eq!(back.as_bytes(), b"my-password");
}
#[test]
fn encrypted_file_survives_reopen() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.enc");
{
let store = EncryptedFileSecretStore::open(path.clone(), "pw").unwrap();
store
.set("creds/corp", Secret::new(b"my-password".to_vec()))
.unwrap();
}
let store = EncryptedFileSecretStore::open(path.clone(), "pw").unwrap();
let r = SecretRef::new(SecretBackendTag::EncryptedFile, "creds/corp");
let back = store.get(&r).unwrap();
assert_eq!(back.as_bytes(), b"my-password");
}
#[test]
fn wrong_passphrase_decrypts_to_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.enc");
{
let store = EncryptedFileSecretStore::open(path.clone(), "right").unwrap();
store.set("k", Secret::new(b"v".to_vec())).unwrap();
}
let err = EncryptedFileSecretStore::open(path.clone(), "wrong").unwrap_err();
assert!(matches!(err, SecretStoreError::DecryptFailed));
}
#[test]
fn delete_removes_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.enc");
let store = EncryptedFileSecretStore::open(path, "pw").unwrap();
let r = store.set("k", Secret::new(b"v".to_vec())).unwrap();
store.delete(&r).unwrap();
let err = store.get(&r).unwrap_err();
assert!(matches!(err, SecretStoreError::NotFound(_)));
}
}