use crate::core::cipher;
use crate::core::config::{self, Config};
use crate::core::constants;
use crate::core::domain::{Diff, Env, Identity, Recipient, Secret, SyncResult, VaultInfo};
use crate::core::store;
use crate::core::types::{MemberName, PublicKey, SecretKey};
use crate::error::{ConfigError, Result, SecretError, ValidationError};
use sha2::{Digest, Sha256};
use tracing::{debug, info, instrument};
use zeroize::Zeroizing;
pub struct Vault {
config: Config,
project_id: String,
identity: Identity,
backend: cipher::CipherBackend,
vault_name: Option<String>,
}
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)
.field("vault_name", &self.vault_name)
.finish()
}
}
impl Vault {
pub fn open_vault(vault: Option<&str>) -> Result<Self> {
let config = Config::load_from(vault)?;
let project_id = config.project_id();
let identity = Identity::from_env()
.filter(|id| identity_has_access(&config, id))
.or_else(|| {
store::load_identity(&project_id)
.ok()
.filter(|id| identity_has_access(&config, id))
})
.or_else(|| {
store::has_global()
.ok()
.filter(|has| *has)
.and_then(|_| store::load_global_identity().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,
vault_name: vault.map(|s| s.to_string()),
})
}
pub fn open() -> Result<Self> {
Self::open_vault(None)
}
pub fn init_vault(vault: Option<&str>, name: &str, kms_key: Option<String>) -> Result<Self> {
validate_member_name(name)?;
if Config::exists_for(vault) {
return Err(ConfigError::AlreadyInitialized.into());
}
let mut config = Config::new();
if let Some(ref key) = kms_key {
config.kms = Some(crate::core::config::KmsConfig { key: key.clone() });
}
let project_id = config.project_id();
let (public_key, identity) = if store::has_key(&project_id) {
let id = store::load_identity(&project_id)?;
let pk = id.public_key();
(pk, id)
} else if store::has_global()? {
let global_pubkey = Identity::load_global_pubkey()?;
let global_identity = store::load_global_identity()?;
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() {
let global_path = Identity::global_path()?;
if global_path.exists() {
std::fs::copy(&global_path, &project_key_path)?;
} else {
use age::secrecy::ExposeSecret;
let secret = global_identity.as_age().to_string();
std::fs::write(&project_key_path, format!("{}\n", secret.expose_secret()))?;
}
#[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_to(vault)?;
config::ensure_gitignore()?;
let backend = cipher::CipherBackend::from_config(&config)?;
Ok(Self {
config,
project_id,
identity,
backend,
vault_name: vault.map(|s| s.to_string()),
})
}
pub fn init(name: &str, kms_key: Option<String>) -> Result<Self> {
Self::init_vault(None, name, kms_key)
}
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> {
debug!(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.update_recipients_hash();
self.config.save_to(self.vault_name.as_deref())?;
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<()> {
debug!(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_to(self.vault_name.as_deref())?;
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.update_recipients_hash();
self.config.save_to(self.vault_name.as_deref())?;
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_to(self.vault_name.as_deref())?;
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_to(self.vault_name.as_deref())?;
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()
}
fn migrate_legacy_requests() -> Result<()> {
let legacy_dir = std::path::Path::new(".dugout/requests");
let new_dir = constants::request_dir(None);
if !legacy_dir.exists() {
return Ok(());
}
let mut has_legacy_files = false;
for entry in std::fs::read_dir(legacy_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("pub") {
has_legacy_files = true;
break;
}
}
if !has_legacy_files {
return Ok(());
}
std::fs::create_dir_all(&new_dir)?;
for entry in std::fs::read_dir(legacy_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("pub") {
if let Some(filename) = path.file_name() {
let new_path = new_dir.join(filename);
if !new_path.exists() {
std::fs::rename(&path, &new_path)?;
debug!(from = %path.display(), to = %new_path.display(), "migrated legacy request file");
}
}
}
}
Ok(())
}
pub fn pending_requests(&self) -> Result<Vec<(String, String)>> {
Self::migrate_legacy_requests()?;
let requests_dir = constants::request_dir(self.vault_name.as_deref());
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 =
constants::request_dir(self.vault_name.as_deref()).join(format!("{}.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.update_recipients_hash();
self.config.save_to(self.vault_name.as_deref())?;
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 fn recipients_fingerprint(&self) -> String {
recipients_fingerprint(&self.config)
}
pub fn needs_sync(&self) -> bool {
if self.config.secrets.is_empty() {
return false;
}
match &self.config.dugout.recipients_hash {
Some(stored) => stored != &self.recipients_fingerprint(),
None => true, }
}
#[instrument(skip(self))]
pub fn sync(&mut self, force: bool) -> Result<SyncResult> {
let secrets = self.config.secrets.len();
let recipients = self.config.recipients.len();
let needed = force || self.needs_sync();
if !needed {
debug!("already in sync, skipping re-encryption");
return Ok(SyncResult {
secrets,
recipients,
was_needed: false,
});
}
if secrets > 0 {
info!(
secrets,
recipients, "syncing secrets for current recipients"
);
self.reencrypt_all()?;
}
self.config.dugout.recipients_hash = Some(self.recipients_fingerprint());
self.config.save_to(self.vault_name.as_deref())?;
Ok(SyncResult {
secrets,
recipients,
was_needed: true,
})
}
pub fn vault_name(&self) -> Option<&str> {
self.vault_name.as_deref()
}
fn update_recipients_hash(&mut self) {
self.config.dugout.recipients_hash = Some(self.recipients_fingerprint());
}
pub fn find_vault_files() -> Result<Vec<std::path::PathBuf>> {
let mut vaults = Vec::new();
for entry in std::fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == ".dugout.toml"
|| (name.starts_with(".dugout.") && name.ends_with(".toml"))
{
vaults.push(path);
}
}
}
vaults.sort();
Ok(vaults)
}
pub fn list_vaults() -> Result<Vec<VaultInfo>> {
use crate::core::constants::vault_name_from_path;
let vault_files = Self::find_vault_files()?;
let mut infos = Vec::new();
let identity_pubkey = Identity::load_global_pubkey().ok();
for path in vault_files {
let vault_name = vault_name_from_path(&path);
let config_path = &path;
if !config_path.exists() {
continue;
}
let contents = std::fs::read_to_string(config_path)?;
let config: Config = toml::from_str(&contents).map_err(ConfigError::Parse)?;
let has_access = identity_pubkey
.as_ref()
.map(|pk| config.recipients.values().any(|k| k == pk))
.unwrap_or(false);
infos.push(VaultInfo {
name: vault_name.unwrap_or_else(|| "default".to_string()),
path: path.clone(),
secret_count: config.secrets.len(),
recipient_count: config.recipients.len(),
has_access,
});
}
Ok(infos)
}
pub fn has_multiple_vaults() -> Result<bool> {
Ok(Self::find_vault_files()?.len() > 1)
}
}
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 recipients_fingerprint(config: &Config) -> String {
let mut keys: Vec<&str> = config.recipients.values().map(|k| k.as_str()).collect();
keys.sort();
let joined = keys.join("\n");
let hash = Sha256::digest(joined.as_bytes());
format!("{:x}", hash)
}
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 std::sync::{Mutex, MutexGuard, Once};
use tempfile::TempDir;
struct TestContext {
_tmp: TempDir,
_original_dir: std::path::PathBuf,
_cwd_guard: MutexGuard<'static, ()>,
}
static FORCE_FILESYSTEM_BACKEND: Once = Once::new();
static CWD_LOCK: Mutex<()> = Mutex::new(());
impl Drop for TestContext {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self._original_dir);
}
}
fn setup_test_vault() -> (TestContext, Vault) {
FORCE_FILESYSTEM_BACKEND.call_once(|| {
std::env::set_var("DUGOUT_NO_KEYCHAIN", "1");
});
let cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let original_dir =
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
std::env::set_current_dir(tmp.path()).unwrap();
let vault = Vault::init("alice", None).unwrap();
let ctx = TestContext {
_tmp: tmp,
_original_dir: original_dir,
_cwd_guard: cwd_guard,
};
(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"));
}
}