1use std::path::PathBuf;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use audit::{ActionTier, AuditEntry, AuditTrail};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::backend::{BackendKind, VaultBackend};
12use crate::file::{FileBackend, PassphraseSource};
13use crate::inject::{CredentialMetadata, CredentialValue, InjectedCredential, InjectionShape};
14
15#[derive(Debug, Error)]
16pub enum VaultError {
17 #[error("credential not found for tool={tool} key={key}")]
18 NotFound { tool: String, key: String },
19
20 #[error("vault backend unavailable: {0}")]
21 BackendUnavailable(String),
22
23 #[error("bad passphrase — verifier check failed")]
24 BadPassphrase,
25
26 #[error("passphrase required but no source available (passphrase_file or TTY)")]
27 PassphraseMissing,
28
29 #[error("encryption/decryption failed: {0}")]
30 Crypto(String),
31
32 #[error("io error: {0}")]
33 Io(#[from] std::io::Error),
34
35 #[error("invalid data: {0}")]
36 InvalidData(String),
37
38 #[error("backend error: {0}")]
39 Backend(String),
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VaultConfig {
45 #[serde(default)]
47 pub backend: BackendSelection,
48
49 #[serde(default)]
52 pub dir: Option<PathBuf>,
53
54 #[serde(default)]
62 pub passphrase_file: Option<PathBuf>,
63}
64
65impl Default for VaultConfig {
66 fn default() -> Self {
67 Self {
68 backend: BackendSelection::Auto,
69 dir: None,
70 passphrase_file: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
76#[serde(rename_all = "snake_case")]
77pub enum BackendSelection {
78 #[default]
79 Auto,
80 Keychain,
81 File,
82}
83
84#[async_trait]
90pub trait CredentialVault: Send + Sync {
91 async fn store(
93 &self,
94 tool: &str,
95 key: &str,
96 value: CredentialValue,
97 shape: InjectionShape,
98 ) -> Result<(), VaultError>;
99
100 async fn get(&self, tool: &str, key: &str) -> Result<InjectedCredential, VaultError>;
102
103 async fn delete(&self, tool: &str, key: &str) -> Result<(), VaultError>;
105
106 async fn list(&self, tool: Option<&str>) -> Result<Vec<CredentialMetadata>, VaultError>;
108
109 fn backend_kind(&self) -> BackendKind;
111}
112
113pub struct DefaultVault {
116 backend: VaultBackend,
117 audit: Option<Arc<dyn AuditTrail>>,
118}
119
120impl DefaultVault {
121 pub fn new(backend: VaultBackend) -> Self {
122 Self {
123 backend,
124 audit: None,
125 }
126 }
127
128 pub fn with_audit(mut self, audit: Arc<dyn AuditTrail>) -> Self {
129 self.audit = Some(audit);
130 self
131 }
132
133 async fn record(&self, tier: ActionTier, action: &str, tool: &str, key: &str) {
134 let Some(audit) = &self.audit else {
135 return;
136 };
137 let metadata = serde_json::json!({
138 "tool": tool,
139 "key": key,
140 "backend": self.backend.kind().to_string(),
141 });
142 let entry = AuditEntry::new(
143 format!("vault.{action} {tool}:{key}"),
144 format!("vault.{action}"),
145 format!("vault.{action}"),
146 tier,
147 )
148 .with_source("vault")
149 .with_metadata(metadata);
150 if let Err(err) = audit.record(entry).await {
151 tracing::warn!(error = %err, "vault: audit record failed");
152 }
153 }
154}
155
156#[async_trait]
157impl CredentialVault for DefaultVault {
158 async fn store(
159 &self,
160 tool: &str,
161 key: &str,
162 value: CredentialValue,
163 shape: InjectionShape,
164 ) -> Result<(), VaultError> {
165 self.backend.store(tool, key, value, shape).await?;
166 self.record(ActionTier::Write, "store", tool, key).await;
167 Ok(())
168 }
169
170 async fn get(&self, tool: &str, key: &str) -> Result<InjectedCredential, VaultError> {
171 let injected = self.backend.get(tool, key).await?;
172 self.record(ActionTier::External, "get", tool, key).await;
173 Ok(injected)
174 }
175
176 async fn delete(&self, tool: &str, key: &str) -> Result<(), VaultError> {
177 self.backend.delete(tool, key).await?;
178 self.record(ActionTier::Write, "delete", tool, key).await;
179 Ok(())
180 }
181
182 async fn list(&self, tool: Option<&str>) -> Result<Vec<CredentialMetadata>, VaultError> {
183 self.backend.list(tool).await
184 }
185
186 fn backend_kind(&self) -> BackendKind {
187 self.backend.kind()
188 }
189}
190
191pub fn resolve_backend(config: &VaultConfig) -> Result<VaultBackend, VaultError> {
196 let vault_dir = resolve_vault_dir(config)?;
197 let file_backend = || FileBackend::new(vault_dir.clone(), resolve_passphrase(config));
198
199 match config.backend {
200 BackendSelection::File => Ok(VaultBackend::File(file_backend())),
201 BackendSelection::Keychain => keychain_backend_or_err(),
202 BackendSelection::Auto => {
203 if vault_dir.join(".verifier").exists() {
208 return Ok(VaultBackend::File(file_backend()));
209 }
210 match keychain_backend_or_err() {
211 Ok(b) => Ok(b),
212 Err(_) => Ok(VaultBackend::File(file_backend())),
213 }
214 }
215 }
216}
217
218fn keychain_backend_or_err() -> Result<VaultBackend, VaultError> {
219 #[cfg(target_os = "macos")]
220 {
221 Ok(VaultBackend::Keychain(
222 crate::keychain::KeychainBackend::new(),
223 ))
224 }
225 #[cfg(target_os = "linux")]
226 {
227 Ok(VaultBackend::SecretService(
228 crate::keyring::SecretServiceBackend::new(),
229 ))
230 }
231 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
232 {
233 Err(VaultError::BackendUnavailable(
234 "no OS keychain compiled in for this platform".into(),
235 ))
236 }
237}
238
239fn resolve_vault_dir(config: &VaultConfig) -> Result<PathBuf, VaultError> {
240 if let Some(dir) = &config.dir {
241 return Ok(dir.clone());
242 }
243 let home = std::env::var_os("HOME")
244 .map(PathBuf::from)
245 .ok_or_else(|| VaultError::BackendUnavailable("HOME not set".into()))?;
246 Ok(home.join(".brain").join("vault"))
247}
248
249fn resolve_passphrase(config: &VaultConfig) -> PassphraseSource {
250 if std::env::var_os("BRAIN_VAULT_PASSPHRASE").is_some() {
254 tracing::warn!(
255 "BRAIN_VAULT_PASSPHRASE is set but ignored — env vars leak via \
256 /proc/<pid>/environ, shell history, and `ps -e`. Move the \
257 passphrase to a file (config: vault.passphrase_file) or unset \
258 the variable to take the TTY prompt path."
259 );
260 }
261 if let Some(path) = &config.passphrase_file {
262 return PassphraseSource::File(path.clone());
263 }
264 PassphraseSource::Prompt
265}