Skip to main content

brainos_vault/
vault.rs

1//! `CredentialVault` trait, error type, default vault impl, and config.
2
3use 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/// Vault configuration.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VaultConfig {
45    /// Backend selection. `Auto` picks the best available backend at runtime.
46    #[serde(default)]
47    pub backend: BackendSelection,
48
49    /// Base directory for the encrypted-file backend and verifier.
50    /// Defaults to `$HOME/.brain/vault`.
51    #[serde(default)]
52    pub dir: Option<PathBuf>,
53
54    /// Path to a file containing the fallback passphrase. When unset and
55    /// the file backend is active, the caller prompts on the TTY.
56    ///
57    /// Issue 132: the env-var path (`BRAIN_VAULT_PASSPHRASE`) was removed
58    /// as a source — env vars leak via `/proc/<pid>/environ`, shell
59    /// histories, and `ps -e` on some systems. If the env var is set,
60    /// vault startup logs a warning and ignores it.
61    #[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/// Credential vault.
85///
86/// Per-tool scoping is caller-side: the `(tool, key)` pair forms the
87/// namespace. There is no cross-tool ACL — single-user machine per
88/// VISION §1.
89#[async_trait]
90pub trait CredentialVault: Send + Sync {
91    /// Store a credential with a given injection shape.
92    async fn store(
93        &self,
94        tool: &str,
95        key: &str,
96        value: CredentialValue,
97        shape: InjectionShape,
98    ) -> Result<(), VaultError>;
99
100    /// Retrieve a credential prepared for injection.
101    async fn get(&self, tool: &str, key: &str) -> Result<InjectedCredential, VaultError>;
102
103    /// Delete a credential.
104    async fn delete(&self, tool: &str, key: &str) -> Result<(), VaultError>;
105
106    /// List credential metadata (no values).
107    async fn list(&self, tool: Option<&str>) -> Result<Vec<CredentialMetadata>, VaultError>;
108
109    /// Which backend is active.
110    fn backend_kind(&self) -> BackendKind;
111}
112
113/// Default vault that delegates to a `VaultBackend` and optionally records
114/// audit entries for `get` / `store` / `delete`.
115pub 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
191/// Resolve a `VaultBackend` from a `VaultConfig`. On `Auto`, prefers the OS
192/// keychain when compiled in, otherwise falls back to the encrypted file
193/// backend. Returns `BackendUnavailable` if explicitly requesting a keychain
194/// backend on a platform that wasn't compiled in.
195pub 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 the file vault has been initialised (verifier present),
204            // prefer it over the OS keychain — the user's explicit
205            // `vault init --file` choice stays sticky without a separate
206            // config file.
207            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    // Issue 132: env var is no longer a recognised source. Warn loudly
251    // if it's set so the operator notices and moves the secret to a
252    // passphrase_file or the interactive TTY prompt.
253    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}