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#[non_exhaustive]
40#[derive(Debug, thiserror::Error)]
41pub enum AgeVaultError {
42    /// The key file could not be read from disk.
43    #[error("failed to read key file: {0}")]
44    KeyRead(std::io::Error),
45    /// The key file content could not be parsed as an age identity.
46    #[error("failed to parse age identity: {0}")]
47    KeyParse(String),
48    /// The vault file could not be read from disk.
49    #[error("failed to read vault file: {0}")]
50    VaultRead(std::io::Error),
51    /// The age decryption step failed (wrong key, corrupted file, etc.).
52    #[error("age decryption failed: {0}")]
53    Decrypt(age::DecryptError),
54    /// An I/O error occurred while reading plaintext from the age stream.
55    #[error("I/O error during decryption: {0}")]
56    Io(std::io::Error),
57    /// The decrypted bytes could not be parsed as JSON.
58    #[error("invalid JSON in vault: {0}")]
59    Json(serde_json::Error),
60    /// The age encryption step failed.
61    #[error("age encryption failed: {0}")]
62    Encrypt(String),
63    /// The vault file (or its temporary predecessor) could not be written to disk.
64    #[error("failed to write vault file: {0}")]
65    VaultWrite(std::io::Error),
66    /// The key file could not be written to disk.
67    #[error("failed to write key file: {0}")]
68    KeyWrite(std::io::Error),
69}
70
71// ---------------------------------------------------------------------------
72// Provider
73// ---------------------------------------------------------------------------
74
75/// Age-encrypted vault backend.
76///
77/// Secrets are stored as a JSON object (`{"KEY": "value", ...}`) encrypted with an x25519
78/// keypair using the [age](https://age-encryption.org) format. The in-memory secret values
79/// are held in [`zeroize::Zeroizing`] buffers.
80///
81/// # File layout
82///
83/// ```text
84/// <dir>/vault-key.txt   # age identity (private key), Unix mode 0600
85/// <dir>/secrets.age     # age-encrypted JSON object
86/// ```
87///
88/// # Initialising a new vault
89///
90/// Use [`AgeVaultProvider::init_vault`] to generate a fresh keypair and create an empty vault:
91///
92/// ```no_run
93/// use std::path::Path;
94/// use zeph_vault::AgeVaultProvider;
95///
96/// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
97/// // Produces:
98/// //   /etc/zeph/vault-key.txt  (mode 0600)
99/// //   /etc/zeph/secrets.age    (empty encrypted vault)
100/// # Ok::<_, zeph_vault::AgeVaultError>(())
101/// ```
102///
103/// # Atomic writes
104///
105/// [`save`][AgeVaultProvider::save] writes to a `.age.tmp` sibling file first, then renames it
106/// atomically, so a crash during write never leaves the vault in a corrupted state.
107pub struct AgeVaultProvider {
108    pub(crate) secrets: BTreeMap<String, Zeroizing<String>>,
109    pub(crate) key_path: PathBuf,
110    pub(crate) vault_path: PathBuf,
111}
112
113impl fmt::Debug for AgeVaultProvider {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("AgeVaultProvider")
116            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
117            .field("key_path", &self.key_path)
118            .field("vault_path", &self.vault_path)
119            .finish()
120    }
121}
122
123impl AgeVaultProvider {
124    /// Decrypt an age-encrypted JSON secrets file.
125    ///
126    /// This is an alias for [`load`][Self::load] provided for ergonomic construction.
127    ///
128    /// # Arguments
129    ///
130    /// - `key_path` — path to the age identity (private key) file. Lines starting with `#`
131    ///   and blank lines are ignored; the first non-comment line is parsed as the identity.
132    /// - `vault_path` — path to the age-encrypted JSON file.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
137    ///
138    /// # Examples
139    ///
140    /// ```no_run
141    /// use std::path::Path;
142    /// use zeph_vault::AgeVaultProvider;
143    ///
144    /// let vault = AgeVaultProvider::new(
145    ///     Path::new("/etc/zeph/vault-key.txt"),
146    ///     Path::new("/etc/zeph/secrets.age"),
147    /// )?;
148    /// println!("{} secrets loaded", vault.list_keys().len());
149    /// # Ok::<_, zeph_vault::AgeVaultError>(())
150    /// ```
151    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
152        Self::load(key_path, vault_path)
153    }
154
155    /// Load vault from disk, storing paths for subsequent write operations.
156    ///
157    /// Reads and decrypts the vault, then retains both paths so that
158    /// [`save`][Self::save] can re-encrypt and persist changes without requiring callers to
159    /// pass paths again.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// use std::path::Path;
169    /// use zeph_vault::AgeVaultProvider;
170    ///
171    /// let vault = AgeVaultProvider::load(
172    ///     Path::new("/etc/zeph/vault-key.txt"),
173    ///     Path::new("/etc/zeph/secrets.age"),
174    /// )?;
175    /// # Ok::<_, zeph_vault::AgeVaultError>(())
176    /// ```
177    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
178        let key_str =
179            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
180        let identity = parse_identity(&key_str)?;
181        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
182        let secrets = decrypt_secrets(&identity, &ciphertext)?;
183        Ok(Self {
184            secrets,
185            key_path: key_path.to_owned(),
186            vault_path: vault_path.to_owned(),
187        })
188    }
189
190    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
191    ///
192    /// Re-reads and re-parses the key file on each call. For CLI one-shot use this is
193    /// acceptable; if used in a long-lived context consider caching the parsed identity.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`AgeVaultError`] on encryption or write failure.
198    ///
199    /// # Examples
200    ///
201    /// ```no_run
202    /// use std::path::Path;
203    /// use zeph_vault::AgeVaultProvider;
204    ///
205    /// let mut vault = AgeVaultProvider::load(
206    ///     Path::new("/etc/zeph/vault-key.txt"),
207    ///     Path::new("/etc/zeph/secrets.age"),
208    /// )?;
209    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into());
210    /// vault.save()?;
211    /// # Ok::<_, zeph_vault::AgeVaultError>(())
212    /// ```
213    pub fn save(&self) -> Result<(), AgeVaultError> {
214        let key_str = Zeroizing::new(
215            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
216        );
217        let identity = parse_identity(&key_str)?;
218        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
219        atomic_write(&self.vault_path, &ciphertext)
220    }
221
222    /// Insert or update a secret in the in-memory map.
223    ///
224    /// Call [`save`][Self::save] afterwards to persist the change to disk.
225    ///
226    /// # Examples
227    ///
228    /// ```no_run
229    /// use std::path::Path;
230    /// use zeph_vault::AgeVaultProvider;
231    ///
232    /// let mut vault = AgeVaultProvider::load(
233    ///     Path::new("/etc/zeph/vault-key.txt"),
234    ///     Path::new("/etc/zeph/secrets.age"),
235    /// )?;
236    /// vault.set_secret_mut("API_KEY".into(), "sk-...".into());
237    /// vault.save()?;
238    /// # Ok::<_, zeph_vault::AgeVaultError>(())
239    /// ```
240    pub fn set_secret_mut(&mut self, key: String, value: String) {
241        self.secrets.insert(key, Zeroizing::new(value));
242    }
243
244    /// Remove a secret from the in-memory map.
245    ///
246    /// Returns `true` if the key existed and was removed, `false` if it was not present.
247    /// Call [`save`][Self::save] afterwards to persist the removal to disk.
248    ///
249    /// # Examples
250    ///
251    /// ```no_run
252    /// use std::path::Path;
253    /// use zeph_vault::AgeVaultProvider;
254    ///
255    /// let mut vault = AgeVaultProvider::load(
256    ///     Path::new("/etc/zeph/vault-key.txt"),
257    ///     Path::new("/etc/zeph/secrets.age"),
258    /// )?;
259    /// let removed = vault.remove_secret_mut("OLD_KEY");
260    /// if removed {
261    ///     vault.save()?;
262    /// }
263    /// # Ok::<_, zeph_vault::AgeVaultError>(())
264    /// ```
265    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
266        self.secrets.remove(key).is_some()
267    }
268
269    /// Return sorted list of secret keys (no values exposed).
270    ///
271    /// Keys are returned in ascending lexicographic order. Secret values are never included.
272    ///
273    /// # Examples
274    ///
275    /// ```no_run
276    /// use std::path::Path;
277    /// use zeph_vault::AgeVaultProvider;
278    ///
279    /// let vault = AgeVaultProvider::load(
280    ///     Path::new("/etc/zeph/vault-key.txt"),
281    ///     Path::new("/etc/zeph/secrets.age"),
282    /// )?;
283    /// for key in vault.list_keys() {
284    ///     println!("{key}");
285    /// }
286    /// # Ok::<_, zeph_vault::AgeVaultError>(())
287    /// ```
288    #[must_use]
289    pub fn list_keys(&self) -> Vec<&str> {
290        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
291        keys.sort_unstable();
292        keys
293    }
294
295    /// Look up a secret value by key, returning `None` if not present.
296    ///
297    /// Returns a borrowed `&str` tied to the lifetime of the vault. For async use across await
298    /// points, use [`VaultProvider::get_secret`] instead, which returns an owned `String`.
299    ///
300    /// # Examples
301    ///
302    /// ```no_run
303    /// use std::path::Path;
304    /// use zeph_vault::AgeVaultProvider;
305    ///
306    /// let vault = AgeVaultProvider::load(
307    ///     Path::new("/etc/zeph/vault-key.txt"),
308    ///     Path::new("/etc/zeph/secrets.age"),
309    /// )?;
310    /// match vault.get("ZEPH_OPENAI_API_KEY") {
311    ///     Some(key) => println!("key length: {}", key.len()),
312    ///     None => println!("key not configured"),
313    /// }
314    /// # Ok::<_, zeph_vault::AgeVaultError>(())
315    /// ```
316    #[must_use]
317    pub fn get(&self, key: &str) -> Option<&str> {
318        self.secrets.get(key).map(|v| v.as_str())
319    }
320
321    /// Generate a new x25519 keypair, write the key file (mode 0600), and create an empty
322    /// encrypted vault.
323    ///
324    /// Creates `dir` and all missing parent directories before writing files. Existing files
325    /// are not checked — calling this on an already-initialised directory will overwrite both
326    /// the key and the vault, making the old key irrecoverable.
327    ///
328    /// # Output files
329    ///
330    /// | File | Contents | Unix mode |
331    /// |------|----------|-----------|
332    /// | `<dir>/vault-key.txt` | age identity (private + public key comment) | `0600` |
333    /// | `<dir>/secrets.age`   | age-encrypted empty JSON object `{}` | default |
334    ///
335    /// # Errors
336    ///
337    /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure.
338    ///
339    /// # Examples
340    ///
341    /// ```no_run
342    /// use std::path::Path;
343    /// use zeph_vault::AgeVaultProvider;
344    ///
345    /// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
346    /// // /etc/zeph/vault-key.txt and /etc/zeph/secrets.age are now ready.
347    /// # Ok::<_, zeph_vault::AgeVaultError>(())
348    /// ```
349    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
350        use age::secrecy::ExposeSecret as _;
351
352        std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
353
354        let identity = age::x25519::Identity::generate();
355        let public_key = identity.to_public();
356
357        let key_content = Zeroizing::new(format!(
358            "# public key: {}\n{}\n",
359            public_key,
360            identity.to_string().expose_secret()
361        ));
362
363        let key_path = dir.join("vault-key.txt");
364        write_private_file(&key_path, key_content.as_bytes())?;
365
366        let vault_path = dir.join("secrets.age");
367        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
368        let ciphertext = encrypt_secrets(&identity, &empty)?;
369        atomic_write(&vault_path, &ciphertext)?;
370
371        println!("Vault initialized:");
372        println!("  Key:   {}", key_path.display());
373        println!("  Vault: {}", vault_path.display());
374
375        Ok(())
376    }
377}
378
379impl VaultProvider for AgeVaultProvider {
380    fn get_secret(
381        &self,
382        key: &str,
383    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
384        let result = self.secrets.get(key).map(|v| (**v).clone());
385        Box::pin(async move { Ok(result) })
386    }
387
388    fn list_keys(&self) -> Vec<String> {
389        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
390        keys.sort_unstable();
391        keys
392    }
393}
394
395// ---------------------------------------------------------------------------
396// Internal helpers
397// ---------------------------------------------------------------------------
398
399pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
400    let key_line = key_str
401        .lines()
402        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
403        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
404    key_line
405        .trim()
406        .parse()
407        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
408}
409
410pub(crate) fn decrypt_secrets(
411    identity: &age::x25519::Identity,
412    ciphertext: &[u8],
413) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
414    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
415    let mut reader = decryptor
416        .decrypt(std::iter::once(identity as &dyn age::Identity))
417        .map_err(AgeVaultError::Decrypt)?;
418    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
419    reader
420        .read_to_end(&mut plaintext)
421        .map_err(AgeVaultError::Io)?;
422    let raw: BTreeMap<String, String> =
423        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
424    Ok(raw
425        .into_iter()
426        .map(|(k, v)| (k, Zeroizing::new(v)))
427        .collect())
428}
429
430pub(crate) fn encrypt_secrets(
431    identity: &age::x25519::Identity,
432    secrets: &BTreeMap<String, Zeroizing<String>>,
433) -> Result<Vec<u8>, AgeVaultError> {
434    let recipient = identity.to_public();
435    let encryptor =
436        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
437            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
438    let plain: BTreeMap<&str, &str> = secrets
439        .iter()
440        .map(|(k, v)| (k.as_str(), v.as_str()))
441        .collect();
442    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
443    let mut ciphertext = Vec::with_capacity(json.len() + 64);
444    let mut writer = encryptor
445        .wrap_output(&mut ciphertext)
446        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
447    writer.write_all(&json).map_err(AgeVaultError::Io)?;
448    writer
449        .finish()
450        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
451    Ok(ciphertext)
452}
453
454pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
455    zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
456}
457
458pub(crate) fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
459    zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use tempfile::tempdir;
466
467    fn init_temp_vault(dir: &Path) -> (PathBuf, PathBuf) {
468        AgeVaultProvider::init_vault(dir).expect("init_vault failed");
469        (dir.join("vault-key.txt"), dir.join("secrets.age"))
470    }
471
472    #[test]
473    fn round_trip() {
474        let dir = tempdir().unwrap();
475        let (key_path, vault_path) = init_temp_vault(dir.path());
476
477        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
478        vault.set_secret_mut("KEY".into(), "val".into());
479        vault.save().unwrap();
480
481        let loaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
482        assert_eq!(loaded.get("KEY"), Some("val"));
483    }
484
485    #[test]
486    fn remove_secret() {
487        let dir = tempdir().unwrap();
488        let (key_path, vault_path) = init_temp_vault(dir.path());
489
490        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
491        vault.set_secret_mut("KEY".into(), "val".into());
492
493        assert!(vault.remove_secret_mut("KEY"));
494        assert!(!vault.remove_secret_mut("KEY"));
495        assert_eq!(vault.get("KEY"), None);
496    }
497
498    #[test]
499    fn init_vault_creates_files() {
500        let dir = tempdir().unwrap();
501        AgeVaultProvider::init_vault(dir.path()).unwrap();
502
503        assert!(dir.path().join("vault-key.txt").exists());
504        assert!(dir.path().join("secrets.age").exists());
505    }
506
507    #[test]
508    fn load_missing_vault_errors() {
509        let dir = tempdir().unwrap();
510        let key_path = dir.path().join("vault-key.txt");
511        let vault_path = dir.path().join("secrets.age");
512
513        let result = AgeVaultProvider::load(&key_path, &vault_path);
514        assert!(result.is_err());
515    }
516
517    #[test]
518    #[cfg(unix)]
519    fn key_file_has_restricted_permissions() {
520        use std::os::unix::fs::PermissionsExt as _;
521
522        let dir = tempdir().unwrap();
523        let (key_path, _) = init_temp_vault(dir.path());
524
525        let mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
526        assert_eq!(
527            mode, 0o600,
528            "vault-key.txt must have mode 0600, got {mode:o}"
529        );
530    }
531
532    #[test]
533    fn load_blank_key_returns_key_parse_error() {
534        let dir = tempdir().unwrap();
535        let key_path = dir.path().join("vault-key.txt");
536        let vault_path = dir.path().join("secrets.age");
537
538        // Key file with only comments and blank lines — no valid identity line.
539        std::fs::write(&key_path, "# comment\n\n# another comment\n").unwrap();
540        // Vault file must exist so the error comes from key parsing, not vault read.
541        std::fs::write(&vault_path, b"").unwrap();
542
543        let result = AgeVaultProvider::load(&key_path, &vault_path);
544        assert!(
545            matches!(result, Err(AgeVaultError::KeyParse(_))),
546            "expected KeyParse, got {result:?}",
547        );
548    }
549
550    #[test]
551    fn decrypt_corrupted_ciphertext_returns_decrypt_error() {
552        let dir = tempdir().unwrap();
553        let (key_path, vault_path) = init_temp_vault(dir.path());
554
555        // Overwrite the encrypted vault with random garbage.
556        std::fs::write(&vault_path, b"not valid age ciphertext at all").unwrap();
557
558        let result = AgeVaultProvider::load(&key_path, &vault_path);
559        assert!(
560            matches!(result, Err(AgeVaultError::Decrypt(_))),
561            "expected Decrypt, got {result:?}",
562        );
563    }
564
565    #[test]
566    fn save_leaves_no_tmp_file() {
567        let dir = tempdir().unwrap();
568        let (key_path, vault_path) = init_temp_vault(dir.path());
569
570        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
571        vault.set_secret_mut("TMP_TEST".into(), "value".into());
572        vault.save().unwrap();
573
574        let tmp_path = vault_path.with_added_extension("tmp");
575        assert!(!tmp_path.exists(), ".age.tmp must not exist after save()");
576        assert!(vault_path.exists(), "secrets.age must exist after save()");
577    }
578}