Skip to main content

zeph_vault/
age.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Age-encrypted vault backend.
5//!
6//! This module provides [`AgeVaultProvider`], the primary secret storage backend, and the
7//! associated [`AgeVaultError`] type. Secrets are stored as a JSON object encrypted with an
8//! x25519 keypair using the [age](https://age-encryption.org) format.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::future::Future;
13use std::io::{Read as _, Write as _};
14use std::path::{Path, PathBuf};
15use std::pin::Pin;
16
17use zeroize::Zeroizing;
18
19use crate::VaultProvider;
20use zeph_common::secret::VaultError;
21
22// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Errors that can occur during age vault operations.
27///
28/// Each variant wraps the underlying cause so callers can match on failure type without
29/// parsing error strings.
30///
31/// # Examples
32///
33/// ```
34/// use zeph_vault::AgeVaultError;
35///
36/// let err = AgeVaultError::KeyParse("no identity line found".into());
37/// assert!(err.to_string().contains("failed to parse age identity"));
38/// ```
39#[derive(Debug, thiserror::Error)]
40pub enum AgeVaultError {
41    /// The key file could not be read from disk.
42    #[error("failed to read key file: {0}")]
43    KeyRead(std::io::Error),
44    /// The key file content could not be parsed as an age identity.
45    #[error("failed to parse age identity: {0}")]
46    KeyParse(String),
47    /// The vault file could not be read from disk.
48    #[error("failed to read vault file: {0}")]
49    VaultRead(std::io::Error),
50    /// The age decryption step failed (wrong key, corrupted file, etc.).
51    #[error("age decryption failed: {0}")]
52    Decrypt(age::DecryptError),
53    /// An I/O error occurred while reading plaintext from the age stream.
54    #[error("I/O error during decryption: {0}")]
55    Io(std::io::Error),
56    /// The decrypted bytes could not be parsed as JSON.
57    #[error("invalid JSON in vault: {0}")]
58    Json(serde_json::Error),
59    /// The age encryption step failed.
60    #[error("age encryption failed: {0}")]
61    Encrypt(String),
62    /// The vault file (or its temporary predecessor) could not be written to disk.
63    #[error("failed to write vault file: {0}")]
64    VaultWrite(std::io::Error),
65    /// The key file could not be written to disk.
66    #[error("failed to write key file: {0}")]
67    KeyWrite(std::io::Error),
68}
69
70// ---------------------------------------------------------------------------
71// Provider
72// ---------------------------------------------------------------------------
73
74/// Age-encrypted vault backend.
75///
76/// Secrets are stored as a JSON object (`{"KEY": "value", ...}`) encrypted with an x25519
77/// keypair using the [age](https://age-encryption.org) format. The in-memory secret values
78/// are held in [`zeroize::Zeroizing`] buffers.
79///
80/// # File layout
81///
82/// ```text
83/// <dir>/vault-key.txt   # age identity (private key), Unix mode 0600
84/// <dir>/secrets.age     # age-encrypted JSON object
85/// ```
86///
87/// # Initialising a new vault
88///
89/// Use [`AgeVaultProvider::init_vault`] to generate a fresh keypair and create an empty vault:
90///
91/// ```no_run
92/// use std::path::Path;
93/// use zeph_vault::AgeVaultProvider;
94///
95/// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
96/// // Produces:
97/// //   /etc/zeph/vault-key.txt  (mode 0600)
98/// //   /etc/zeph/secrets.age    (empty encrypted vault)
99/// # Ok::<_, zeph_vault::AgeVaultError>(())
100/// ```
101///
102/// # Atomic writes
103///
104/// [`save`][AgeVaultProvider::save] writes to a `.age.tmp` sibling file first, then renames it
105/// atomically, so a crash during write never leaves the vault in a corrupted state.
106pub struct AgeVaultProvider {
107    pub(crate) secrets: BTreeMap<String, Zeroizing<String>>,
108    pub(crate) key_path: PathBuf,
109    pub(crate) vault_path: PathBuf,
110}
111
112impl fmt::Debug for AgeVaultProvider {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.debug_struct("AgeVaultProvider")
115            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
116            .field("key_path", &self.key_path)
117            .field("vault_path", &self.vault_path)
118            .finish()
119    }
120}
121
122impl AgeVaultProvider {
123    /// Decrypt an age-encrypted JSON secrets file.
124    ///
125    /// This is an alias for [`load`][Self::load] provided for ergonomic construction.
126    ///
127    /// # Arguments
128    ///
129    /// - `key_path` — path to the age identity (private key) file. Lines starting with `#`
130    ///   and blank lines are ignored; the first non-comment line is parsed as the identity.
131    /// - `vault_path` — path to the age-encrypted JSON file.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
136    ///
137    /// # Examples
138    ///
139    /// ```no_run
140    /// use std::path::Path;
141    /// use zeph_vault::AgeVaultProvider;
142    ///
143    /// let vault = AgeVaultProvider::new(
144    ///     Path::new("/etc/zeph/vault-key.txt"),
145    ///     Path::new("/etc/zeph/secrets.age"),
146    /// )?;
147    /// println!("{} secrets loaded", vault.list_keys().len());
148    /// # Ok::<_, zeph_vault::AgeVaultError>(())
149    /// ```
150    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
151        Self::load(key_path, vault_path)
152    }
153
154    /// Load vault from disk, storing paths for subsequent write operations.
155    ///
156    /// Reads and decrypts the vault, then retains both paths so that
157    /// [`save`][Self::save] can re-encrypt and persist changes without requiring callers to
158    /// pass paths again.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
163    ///
164    /// # Examples
165    ///
166    /// ```no_run
167    /// use std::path::Path;
168    /// use zeph_vault::AgeVaultProvider;
169    ///
170    /// let vault = AgeVaultProvider::load(
171    ///     Path::new("/etc/zeph/vault-key.txt"),
172    ///     Path::new("/etc/zeph/secrets.age"),
173    /// )?;
174    /// # Ok::<_, zeph_vault::AgeVaultError>(())
175    /// ```
176    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
177        let key_str =
178            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
179        let identity = parse_identity(&key_str)?;
180        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
181        let secrets = decrypt_secrets(&identity, &ciphertext)?;
182        Ok(Self {
183            secrets,
184            key_path: key_path.to_owned(),
185            vault_path: vault_path.to_owned(),
186        })
187    }
188
189    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
190    ///
191    /// Re-reads and re-parses the key file on each call. For CLI one-shot use this is
192    /// acceptable; if used in a long-lived context consider caching the parsed identity.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`AgeVaultError`] on encryption or write failure.
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use std::path::Path;
202    /// use zeph_vault::AgeVaultProvider;
203    ///
204    /// let mut vault = AgeVaultProvider::load(
205    ///     Path::new("/etc/zeph/vault-key.txt"),
206    ///     Path::new("/etc/zeph/secrets.age"),
207    /// )?;
208    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into());
209    /// vault.save()?;
210    /// # Ok::<_, zeph_vault::AgeVaultError>(())
211    /// ```
212    pub fn save(&self) -> Result<(), AgeVaultError> {
213        let key_str = Zeroizing::new(
214            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
215        );
216        let identity = parse_identity(&key_str)?;
217        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
218        atomic_write(&self.vault_path, &ciphertext)
219    }
220
221    /// Insert or update a secret in the in-memory map.
222    ///
223    /// Call [`save`][Self::save] afterwards to persist the change to disk.
224    ///
225    /// # Examples
226    ///
227    /// ```no_run
228    /// use std::path::Path;
229    /// use zeph_vault::AgeVaultProvider;
230    ///
231    /// let mut vault = AgeVaultProvider::load(
232    ///     Path::new("/etc/zeph/vault-key.txt"),
233    ///     Path::new("/etc/zeph/secrets.age"),
234    /// )?;
235    /// vault.set_secret_mut("API_KEY".into(), "sk-...".into());
236    /// vault.save()?;
237    /// # Ok::<_, zeph_vault::AgeVaultError>(())
238    /// ```
239    pub fn set_secret_mut(&mut self, key: String, value: String) {
240        self.secrets.insert(key, Zeroizing::new(value));
241    }
242
243    /// Remove a secret from the in-memory map.
244    ///
245    /// Returns `true` if the key existed and was removed, `false` if it was not present.
246    /// Call [`save`][Self::save] afterwards to persist the removal to disk.
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// use std::path::Path;
252    /// use zeph_vault::AgeVaultProvider;
253    ///
254    /// let mut vault = AgeVaultProvider::load(
255    ///     Path::new("/etc/zeph/vault-key.txt"),
256    ///     Path::new("/etc/zeph/secrets.age"),
257    /// )?;
258    /// let removed = vault.remove_secret_mut("OLD_KEY");
259    /// if removed {
260    ///     vault.save()?;
261    /// }
262    /// # Ok::<_, zeph_vault::AgeVaultError>(())
263    /// ```
264    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
265        self.secrets.remove(key).is_some()
266    }
267
268    /// Return sorted list of secret keys (no values exposed).
269    ///
270    /// Keys are returned in ascending lexicographic order. Secret values are never included.
271    ///
272    /// # Examples
273    ///
274    /// ```no_run
275    /// use std::path::Path;
276    /// use zeph_vault::AgeVaultProvider;
277    ///
278    /// let vault = AgeVaultProvider::load(
279    ///     Path::new("/etc/zeph/vault-key.txt"),
280    ///     Path::new("/etc/zeph/secrets.age"),
281    /// )?;
282    /// for key in vault.list_keys() {
283    ///     println!("{key}");
284    /// }
285    /// # Ok::<_, zeph_vault::AgeVaultError>(())
286    /// ```
287    #[must_use]
288    pub fn list_keys(&self) -> Vec<&str> {
289        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
290        keys.sort_unstable();
291        keys
292    }
293
294    /// Look up a secret value by key, returning `None` if not present.
295    ///
296    /// Returns a borrowed `&str` tied to the lifetime of the vault. For async use across await
297    /// points, use [`VaultProvider::get_secret`] instead, which returns an owned `String`.
298    ///
299    /// # Examples
300    ///
301    /// ```no_run
302    /// use std::path::Path;
303    /// use zeph_vault::AgeVaultProvider;
304    ///
305    /// let vault = AgeVaultProvider::load(
306    ///     Path::new("/etc/zeph/vault-key.txt"),
307    ///     Path::new("/etc/zeph/secrets.age"),
308    /// )?;
309    /// match vault.get("ZEPH_OPENAI_API_KEY") {
310    ///     Some(key) => println!("key length: {}", key.len()),
311    ///     None => println!("key not configured"),
312    /// }
313    /// # Ok::<_, zeph_vault::AgeVaultError>(())
314    /// ```
315    #[must_use]
316    pub fn get(&self, key: &str) -> Option<&str> {
317        self.secrets.get(key).map(|v| v.as_str())
318    }
319
320    /// Generate a new x25519 keypair, write the key file (mode 0600), and create an empty
321    /// encrypted vault.
322    ///
323    /// Creates `dir` and all missing parent directories before writing files. Existing files
324    /// are not checked — calling this on an already-initialised directory will overwrite both
325    /// the key and the vault, making the old key irrecoverable.
326    ///
327    /// # Output files
328    ///
329    /// | File | Contents | Unix mode |
330    /// |------|----------|-----------|
331    /// | `<dir>/vault-key.txt` | age identity (private + public key comment) | `0600` |
332    /// | `<dir>/secrets.age`   | age-encrypted empty JSON object `{}` | default |
333    ///
334    /// # Errors
335    ///
336    /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure.
337    ///
338    /// # Examples
339    ///
340    /// ```no_run
341    /// use std::path::Path;
342    /// use zeph_vault::AgeVaultProvider;
343    ///
344    /// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
345    /// // /etc/zeph/vault-key.txt and /etc/zeph/secrets.age are now ready.
346    /// # Ok::<_, zeph_vault::AgeVaultError>(())
347    /// ```
348    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
349        use age::secrecy::ExposeSecret as _;
350
351        std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
352
353        let identity = age::x25519::Identity::generate();
354        let public_key = identity.to_public();
355
356        let key_content = Zeroizing::new(format!(
357            "# public key: {}\n{}\n",
358            public_key,
359            identity.to_string().expose_secret()
360        ));
361
362        let key_path = dir.join("vault-key.txt");
363        write_private_file(&key_path, key_content.as_bytes())?;
364
365        let vault_path = dir.join("secrets.age");
366        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
367        let ciphertext = encrypt_secrets(&identity, &empty)?;
368        atomic_write(&vault_path, &ciphertext)?;
369
370        println!("Vault initialized:");
371        println!("  Key:   {}", key_path.display());
372        println!("  Vault: {}", vault_path.display());
373
374        Ok(())
375    }
376}
377
378impl VaultProvider for AgeVaultProvider {
379    fn get_secret(
380        &self,
381        key: &str,
382    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
383        let result = self.secrets.get(key).map(|v| (**v).clone());
384        Box::pin(async move { Ok(result) })
385    }
386
387    fn list_keys(&self) -> Vec<String> {
388        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
389        keys.sort_unstable();
390        keys
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Internal helpers
396// ---------------------------------------------------------------------------
397
398pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
399    let key_line = key_str
400        .lines()
401        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
402        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
403    key_line
404        .trim()
405        .parse()
406        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
407}
408
409pub(crate) fn decrypt_secrets(
410    identity: &age::x25519::Identity,
411    ciphertext: &[u8],
412) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
413    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
414    let mut reader = decryptor
415        .decrypt(std::iter::once(identity as &dyn age::Identity))
416        .map_err(AgeVaultError::Decrypt)?;
417    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
418    reader
419        .read_to_end(&mut plaintext)
420        .map_err(AgeVaultError::Io)?;
421    let raw: BTreeMap<String, String> =
422        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
423    Ok(raw
424        .into_iter()
425        .map(|(k, v)| (k, Zeroizing::new(v)))
426        .collect())
427}
428
429pub(crate) fn encrypt_secrets(
430    identity: &age::x25519::Identity,
431    secrets: &BTreeMap<String, Zeroizing<String>>,
432) -> Result<Vec<u8>, AgeVaultError> {
433    let recipient = identity.to_public();
434    let encryptor =
435        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
436            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
437    let plain: BTreeMap<&str, &str> = secrets
438        .iter()
439        .map(|(k, v)| (k.as_str(), v.as_str()))
440        .collect();
441    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
442    let mut ciphertext = Vec::with_capacity(json.len() + 64);
443    let mut writer = encryptor
444        .wrap_output(&mut ciphertext)
445        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
446    writer.write_all(&json).map_err(AgeVaultError::Io)?;
447    writer
448        .finish()
449        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
450    Ok(ciphertext)
451}
452
453pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
454    zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
455}
456
457pub(crate) fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
458    zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
459}