use crate::core::cipher;
use crate::core::config::{self, Config};
use crate::core::domain::{Diff, Env, Identity, Recipient, Secret};
use crate::core::store;
use crate::core::types::{MemberName, PublicKey, SecretKey};
use crate::error::{ConfigError, Result, SecretError, ValidationError};
use tracing::{debug, info, instrument};
use zeroize::Zeroizing;
pub struct Vault {
config: Config,
project_id: String,
identity: Identity,
backend: cipher::CipherBackend,
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("config", &self.config)
.field("project_id", &self.project_id)
.field("identity", &self.identity)
.field("backend", &self.backend)
.finish()
}
}
impl Vault {
pub fn open() -> Result<Self> {
let config = Config::load()?;
let project_id = config.project_id();
let identity = store::load_identity(&project_id)
.ok()
.filter(|id| identity_has_access(&config, id))
.or_else(|| {
Identity::has_global()
.ok()
.filter(|has| *has)
.and_then(|_| Identity::load_global().ok())
.filter(|id| identity_has_access(&config, id))
})
.ok_or(ConfigError::AccessDenied)?;
let backend = cipher::CipherBackend::from_config(&config)?;
Ok(Self {
config,
project_id,
identity,
backend,
})
}
pub fn init(
name: &str,
cipher_type: Option<String>,
kms_key_id: Option<String>,
gcp_resource: Option<String>,
) -> Result<Self> {
validate_member_name(name)?;
if Config::exists() {
return Err(crate::error::ConfigError::AlreadyInitialized.into());
}
let mut config = Config::new();
config.dugout.cipher = cipher_type;
config.dugout.kms_key_id = kms_key_id;
config.dugout.gcp_resource = gcp_resource;
let project_id = config.project_id();
let (public_key, identity) = if Identity::has_global()? {
let global_pubkey = Identity::load_global_pubkey()?;
let global_identity = Identity::load_global()?;
let key_dir = Identity::project_dir(&project_id)?;
std::fs::create_dir_all(&key_dir)?;
let project_key_path = key_dir.join("identity.key");
if !project_key_path.exists() {
std::fs::copy(Identity::global_path()?, &project_key_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&project_key_path,
std::fs::Permissions::from_mode(0o600),
)?;
}
}
(global_pubkey, global_identity)
} else {
let pk = store::generate_keypair(&project_id)?;
let id = store::load_identity(&project_id)?;
(pk, id)
};
config
.recipients
.insert(name.to_string(), public_key.clone());
config.save()?;
config::ensure_gitignore()?;
let backend = cipher::CipherBackend::from_config(&config)?;
Ok(Self {
config,
project_id,
identity,
backend,
})
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn identity(&self) -> &Identity {
&self.identity
}
pub fn project_id(&self) -> &str {
&self.project_id
}
#[instrument(skip(self, value))]
pub fn set(&mut self, key: &str, value: &str, force: bool) -> Result<Secret> {
info!(key = %key, force = force, "setting secret");
validate_key(key)?;
validate_value(key, value)?;
if self.config.secrets.contains_key(key) && !force {
return Err(SecretError::AlreadyExists(key.to_string()).into());
}
let recipients = get_recipients_as_strings(&self.config);
if recipients.is_empty() {
return Err(ConfigError::NoRecipients.into());
}
let encrypted = self.backend.encrypt(value, &recipients)?;
self.config
.secrets
.insert(key.to_string(), encrypted.clone());
self.config.save()?;
debug!(key = %key, "secret set, saving config");
Ok(Secret::new(key.to_string(), encrypted))
}
#[instrument(skip(self))]
pub fn get(&self, key: &str) -> Result<Zeroizing<String>> {
let encrypted = self.config.secrets.get(key).ok_or_else(|| {
let available: Vec<String> = self.config.secrets.keys().cloned().collect();
SecretError::not_found_with_suggestions(key.to_string(), &available)
})?;
let plaintext = self.backend.decrypt(encrypted, self.identity.as_age())?;
Ok(Zeroizing::new(plaintext))
}
#[instrument(skip(self))]
pub fn remove(&mut self, key: &str) -> Result<()> {
info!(key = %key, "removing secret");
if self.config.secrets.remove(key).is_none() {
let available: Vec<String> = self.config.secrets.keys().cloned().collect();
return Err(
SecretError::not_found_with_suggestions(key.to_string(), &available).into(),
);
}
self.config.save()?;
Ok(())
}
pub fn list(&self) -> Vec<Secret> {
self.config
.secrets
.iter()
.map(|(key, value)| Secret::new(key.clone(), value.clone()))
.collect()
}
#[instrument(skip(self))]
pub fn decrypt_all(&self) -> Result<Vec<(SecretKey, Zeroizing<String>)>> {
debug!(count = self.config.secrets.len(), "decrypting all secrets");
let mut pairs = Vec::new();
for (key, encrypted) in &self.config.secrets {
let plaintext = self.backend.decrypt(encrypted, self.identity.as_age())?;
pairs.push((key.clone(), Zeroizing::new(plaintext)));
}
Ok(pairs)
}
pub fn reencrypt_all(&mut self) -> Result<()> {
let recipients = get_recipients_as_strings(&self.config);
let mut updated = std::collections::BTreeMap::new();
for (key, encrypted) in &self.config.secrets {
let plaintext =
Zeroizing::new(self.backend.decrypt(encrypted, self.identity.as_age())?);
let reencrypted = self.backend.encrypt(&plaintext, &recipients)?;
updated.insert(key.clone(), reencrypted);
}
self.config.secrets = updated;
self.config.save()?;
Ok(())
}
#[instrument(skip(self, key))]
pub fn add_recipient(&mut self, name: &str, key: &str) -> Result<()> {
info!(name = %name, "adding team member");
validate_member_name(name)?;
cipher::parse_recipient(key)?;
self.config
.recipients
.insert(name.to_string(), key.to_string());
self.config.save()?;
if !self.config.secrets.is_empty() {
self.reencrypt_all()?;
}
Ok(())
}
#[instrument(skip(self))]
pub fn remove_recipient(&mut self, name: &str) -> Result<()> {
info!(name = %name, "removing team member");
if self.config.recipients.remove(name).is_none() {
return Err(ConfigError::RecipientNotFound(name.to_string()).into());
}
self.config.save()?;
if !self.config.secrets.is_empty() {
self.reencrypt_all()?;
}
Ok(())
}
pub fn recipients(&self) -> Vec<Recipient> {
list_recipients(&self.config)
.into_iter()
.filter_map(|(name, key)| Recipient::new(name, key).ok())
.collect()
}
pub fn pending_requests(&self) -> Result<Vec<(String, String)>> {
let requests_dir = std::path::Path::new(".dugout/requests");
if !requests_dir.exists() {
return Ok(Vec::new());
}
let mut requests = Vec::new();
for entry in std::fs::read_dir(requests_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("pub") {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let pubkey = std::fs::read_to_string(&path)?.trim().to_string();
requests.push((name, pubkey));
}
}
Ok(requests)
}
#[instrument(skip(self))]
pub fn admit(&mut self, name: &str) -> Result<()> {
info!(name = %name, "admitting team member from request");
validate_member_name(name)?;
let request_path = std::path::PathBuf::from(format!(".dugout/requests/{}.pub", name));
if !request_path.exists() {
return Err(ConfigError::RecipientNotFound(format!(
"no pending request from '{}'",
name
))
.into());
}
let pubkey = std::fs::read_to_string(&request_path)?.trim().to_string();
self.add_recipient(name, &pubkey)?;
std::fs::remove_file(&request_path)?;
debug!(name = %name, "team member admitted, request file deleted");
Ok(())
}
#[instrument(skip(self, path))]
pub fn import(&mut self, path: impl AsRef<std::path::Path>) -> Result<Vec<SecretKey>> {
let path_str = path.as_ref().display().to_string();
info!(path = %path_str, "importing secrets");
let env = Env::load(path)?;
let mut imported = Vec::new();
for (key, value) in env.entries() {
validate_key(key)?;
validate_value(key, value)?;
let recipients = get_recipients_as_strings(&self.config);
if recipients.is_empty() {
return Err(ConfigError::NoRecipients.into());
}
let encrypted = self.backend.encrypt(value, &recipients)?;
self.config.secrets.insert(key.clone(), encrypted);
imported.push(key.clone());
}
self.config.save()?;
debug!(count = imported.len(), "import complete");
Ok(imported)
}
#[instrument(skip(self))]
pub fn export(&self) -> Result<Env> {
info!("exporting secrets as env");
let pairs = self
.decrypt_all()?
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect();
Ok(Env::from_pairs(pairs, std::path::PathBuf::from(".env")))
}
#[instrument(skip(self))]
pub fn unlock(&self) -> Result<Env> {
info!("unlocking vault to .env");
let env = self.export()?;
env.save()?;
debug!(count = env.len(), "unlock complete");
Ok(env)
}
pub fn diff(&self, env_path: impl AsRef<std::path::Path>) -> Result<Diff> {
let vault_pairs = self
.decrypt_all()?
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect::<Vec<_>>();
let env_pairs = if env_path.as_ref().exists() {
let env = Env::load(env_path)?;
env.entries().to_vec()
} else {
Vec::new()
};
Ok(Diff::compute(&vault_pairs, &env_pairs))
}
}
pub(crate) fn validate_key(key: &str) -> Result<()> {
if key.is_empty() {
return Err(ValidationError::EmptyKey.into());
}
if let Some(first_char) = key.chars().next() {
if first_char.is_ascii_digit() {
return Err(ValidationError::InvalidKey {
key: key.to_string(),
reason: "cannot start with a digit".to_string(),
}
.into());
}
}
for (i, ch) in key.chars().enumerate() {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return Err(ValidationError::InvalidKey {
key: key.to_string(),
reason: format!(
"invalid character '{}' at position {}. Only A-Z, 0-9, and underscore are allowed",
ch, i + 1
),
}
.into());
}
}
Ok(())
}
pub(crate) fn validate_member_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(ValidationError::InvalidMemberName {
name: name.to_string(),
reason: "cannot be empty".to_string(),
}
.into());
}
if name.len() > 64 {
return Err(ValidationError::InvalidMemberName {
name: name.to_string(),
reason: "must be at most 64 characters".to_string(),
}
.into());
}
if name.starts_with('.') {
return Err(ValidationError::InvalidMemberName {
name: name.to_string(),
reason: "cannot start with '.'".to_string(),
}
.into());
}
for (i, ch) in name.chars().enumerate() {
if !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != '@' {
return Err(ValidationError::InvalidMemberName {
name: name.to_string(),
reason: format!(
"invalid character '{}' at position {}. Allowed: A-Z, a-z, 0-9, _, -, ., @",
ch,
i + 1
),
}
.into());
}
}
Ok(())
}
fn validate_value(key: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(ValidationError::EmptyValue(key.to_string()).into());
}
Ok(())
}
fn get_recipients_as_strings(config: &Config) -> Vec<String> {
config.recipients.values().cloned().collect()
}
fn list_recipients(config: &Config) -> Vec<(MemberName, PublicKey)> {
config
.recipients
.iter()
.map(|(name, key)| (name.clone(), key.clone()))
.collect()
}
fn identity_has_access(config: &Config, identity: &Identity) -> bool {
let identity_pubkey = identity.public_key();
config
.recipients
.values()
.any(|key| key == &identity_pubkey)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
struct TestContext {
_tmp: TempDir,
_original_dir: std::path::PathBuf,
}
impl Drop for TestContext {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self._original_dir);
}
}
fn setup_test_vault() -> (TestContext, Vault) {
let tmp = TempDir::new().unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let vault = Vault::init("alice", None, None, None).unwrap();
let ctx = TestContext {
_tmp: tmp,
_original_dir: original_dir,
};
(ctx, vault)
}
#[test]
fn test_vault_set_and_get() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("API_KEY", "secret123", false).unwrap();
let value = vault.get("API_KEY").unwrap();
assert_eq!(value.as_str(), "secret123");
}
#[test]
fn test_vault_remove() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("TEMP_SECRET", "value", false).unwrap();
vault.remove("TEMP_SECRET").unwrap();
assert!(vault.get("TEMP_SECRET").is_err());
let secrets = vault.list();
let keys: Vec<String> = secrets.iter().map(|s| s.key().to_string()).collect();
assert!(!keys.contains(&"TEMP_SECRET".to_string()));
}
#[test]
fn test_vault_list() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("KEY_ONE", "value1", false).unwrap();
vault.set("KEY_TWO", "value2", false).unwrap();
vault.set("KEY_THREE", "value3", false).unwrap();
let secrets = vault.list();
assert_eq!(secrets.len(), 3);
let keys: Vec<String> = secrets.iter().map(|s| s.key().to_string()).collect();
assert!(keys.contains(&"KEY_ONE".to_string()));
assert!(keys.contains(&"KEY_TWO".to_string()));
assert!(keys.contains(&"KEY_THREE".to_string()));
}
#[test]
fn test_vault_add_recipient() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("SHARED_SECRET", "value", false).unwrap();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
vault.add_recipient("bob", &pubkey).unwrap();
let recipients = vault.recipients();
assert_eq!(recipients.len(), 2);
assert!(recipients.iter().any(|r| r.name() == "bob"));
let value = vault.get("SHARED_SECRET").unwrap();
assert_eq!(value.as_str(), "value");
}
#[test]
fn test_vault_remove_recipient() {
let (_ctx, mut vault) = setup_test_vault();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
vault.add_recipient("bob", &pubkey).unwrap();
assert_eq!(vault.recipients().len(), 2);
vault.remove_recipient("bob").unwrap();
let recipients = vault.recipients();
assert_eq!(recipients.len(), 1);
assert!(recipients.iter().all(|r| r.name() != "bob"));
}
#[test]
fn test_vault_reencrypt_after_team_change() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("TEAM_SECRET", "original", false).unwrap();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
vault.add_recipient("bob", &pubkey).unwrap();
let value = vault.get("TEAM_SECRET").unwrap();
assert_eq!(value.as_str(), "original");
let all_secrets = vault.decrypt_all().unwrap();
assert_eq!(all_secrets.len(), 1);
assert_eq!(all_secrets[0].0, "TEAM_SECRET");
assert_eq!(all_secrets[0].1.as_str(), "original");
}
#[test]
fn test_vault_open_denies_non_member_identity() {
let (_ctx, _vault) = setup_test_vault();
let outsider = age::x25519::Identity::generate();
let outsider_key = outsider.to_public().to_string();
let mut cfg = Config::load().unwrap();
cfg.recipients.clear();
cfg.recipients.insert("bob".to_string(), outsider_key);
cfg.save().unwrap();
let err = Vault::open().unwrap_err();
assert!(matches!(
err,
crate::error::Error::Config(ConfigError::AccessDenied)
));
}
#[test]
fn test_validate_member_name_rejects_path_separators() {
let result = validate_member_name("../bob");
assert!(result.is_err());
}
#[test]
fn test_vault_import() {
let (_ctx, mut vault) = setup_test_vault();
let env_content = "IMPORT_ONE=value1\nIMPORT_TWO=value2\n";
fs::write(".env.test", env_content).unwrap();
let imported = vault.import(".env.test").unwrap();
assert_eq!(imported.len(), 2);
assert_eq!(vault.get("IMPORT_ONE").unwrap().as_str(), "value1");
assert_eq!(vault.get("IMPORT_TWO").unwrap().as_str(), "value2");
}
#[test]
fn test_vault_export_roundtrip() {
let (_ctx, mut vault) = setup_test_vault();
vault.set("EXPORT_KEY", "export_value", false).unwrap();
vault.set("ANOTHER_KEY", "another_value", false).unwrap();
let env = vault.export().unwrap();
let exported = format!("{}", env);
assert!(exported.contains("EXPORT_KEY=export_value"));
assert!(exported.contains("ANOTHER_KEY=another_value"));
}
}