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    /// [`AgeVaultProvider::set_secret_mut`] was called with `overwrite: false` for a key that
70    /// already exists in the vault.
71    #[error("secret key already exists: {0} (pass overwrite=true to replace it)")]
72    AlreadyExists(String),
73    /// [`AgeVaultProvider::init_vault`] (or [`AgeVaultProvider::init_vault_at`]) found an
74    /// existing `vault-key.txt` or `secrets.age` at the target location and `force` was not
75    /// set.
76    #[error("vault already exists at {0} (pass force=true / --force to overwrite it)")]
77    VaultAlreadyExists(PathBuf),
78}
79
80// ---------------------------------------------------------------------------
81// Provider
82// ---------------------------------------------------------------------------
83
84/// Age-encrypted vault backend.
85///
86/// Secrets are stored as a JSON object (`{"KEY": "value", ...}`) encrypted with an x25519
87/// keypair using the [age](https://age-encryption.org) format. The in-memory secret values
88/// are held in [`zeroize::Zeroizing`] buffers.
89///
90/// # File layout
91///
92/// ```text
93/// <dir>/vault-key.txt   # age identity (private key), Unix mode 0600
94/// <dir>/secrets.age     # age-encrypted JSON object
95/// ```
96///
97/// # Initialising a new vault
98///
99/// Use [`AgeVaultProvider::init_vault`] to generate a fresh keypair and create an empty vault:
100///
101/// ```no_run
102/// use std::path::Path;
103/// use zeph_vault::AgeVaultProvider;
104///
105/// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
106/// // Produces:
107/// //   /etc/zeph/vault-key.txt  (mode 0600)
108/// //   /etc/zeph/secrets.age    (empty encrypted vault)
109/// # Ok::<_, zeph_vault::AgeVaultError>(())
110/// ```
111///
112/// # Atomic writes
113///
114/// [`save`][AgeVaultProvider::save] writes to a `.age.tmp` sibling file first, then renames it
115/// atomically, so a crash during write never leaves the vault in a corrupted state.
116pub struct AgeVaultProvider {
117    pub(crate) secrets: BTreeMap<String, Zeroizing<String>>,
118    pub(crate) key_path: PathBuf,
119    pub(crate) vault_path: PathBuf,
120}
121
122impl fmt::Debug for AgeVaultProvider {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.debug_struct("AgeVaultProvider")
125            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
126            .field("key_path", &self.key_path)
127            .field("vault_path", &self.vault_path)
128            .finish()
129    }
130}
131
132impl AgeVaultProvider {
133    /// Decrypt an age-encrypted JSON secrets file.
134    ///
135    /// This is an alias for [`load`][Self::load] provided for ergonomic construction.
136    ///
137    /// # Arguments
138    ///
139    /// - `key_path` — path to the age identity (private key) file. Lines starting with `#`
140    ///   and blank lines are ignored; the first non-comment line is parsed as the identity.
141    /// - `vault_path` — path to the age-encrypted JSON file.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
146    ///
147    /// # Examples
148    ///
149    /// ```no_run
150    /// use std::path::Path;
151    /// use zeph_vault::AgeVaultProvider;
152    ///
153    /// let vault = AgeVaultProvider::new(
154    ///     Path::new("/etc/zeph/vault-key.txt"),
155    ///     Path::new("/etc/zeph/secrets.age"),
156    /// )?;
157    /// println!("{} secrets loaded", vault.list_keys().len());
158    /// # Ok::<_, zeph_vault::AgeVaultError>(())
159    /// ```
160    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
161        Self::load(key_path, vault_path)
162    }
163
164    /// Load vault from disk, storing paths for subsequent write operations.
165    ///
166    /// Reads and decrypts the vault, then retains both paths so that
167    /// [`save`][Self::save] can re-encrypt and persist changes without requiring callers to
168    /// pass paths again.
169    ///
170    /// This method performs blocking I/O on the calling thread. Use [`load_async`][Self::load_async]
171    /// when calling from an async context to avoid stalling the tokio executor.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
176    ///
177    /// # Examples
178    ///
179    /// ```no_run
180    /// use std::path::Path;
181    /// use zeph_vault::AgeVaultProvider;
182    ///
183    /// let vault = AgeVaultProvider::load(
184    ///     Path::new("/etc/zeph/vault-key.txt"),
185    ///     Path::new("/etc/zeph/secrets.age"),
186    /// )?;
187    /// # Ok::<_, zeph_vault::AgeVaultError>(())
188    /// ```
189    #[tracing::instrument(name = "vault.age.load", skip_all, err)]
190    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
191        let key_str =
192            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
193        let identity = parse_identity(&key_str)?;
194        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
195        let secrets = decrypt_secrets(&identity, &ciphertext)?;
196        Ok(Self {
197            secrets,
198            key_path: key_path.to_owned(),
199            vault_path: vault_path.to_owned(),
200        })
201    }
202
203    /// Async variant of [`load`][Self::load] — offloads blocking I/O to a `spawn_blocking` thread.
204    ///
205    /// Use this when calling from an async context to avoid stalling the tokio executor.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, decryption failure, or
210    /// if the blocking task panics.
211    ///
212    /// # Examples
213    ///
214    /// ```no_run
215    /// use std::path::Path;
216    /// use zeph_vault::AgeVaultProvider;
217    ///
218    /// # async fn example() -> Result<(), zeph_vault::AgeVaultError> {
219    /// let vault = AgeVaultProvider::load_async(
220    ///     Path::new("/etc/zeph/vault-key.txt"),
221    ///     Path::new("/etc/zeph/secrets.age"),
222    /// ).await?;
223    /// # Ok(())
224    /// # }
225    /// ```
226    #[tracing::instrument(name = "vault.age.load_async", skip_all, err)]
227    pub async fn load_async(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
228        let key_path = key_path.to_owned();
229        let vault_path = vault_path.to_owned();
230        tokio::task::spawn_blocking(move || Self::load(&key_path, &vault_path))
231            .await
232            .map_err(|e| {
233                AgeVaultError::Io(std::io::Error::other(format!(
234                    "spawn_blocking panicked: {e}"
235                )))
236            })?
237    }
238
239    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
240    ///
241    /// Re-reads and re-parses the key file on each call. For CLI one-shot use this is
242    /// acceptable; if used in a long-lived context consider caching the parsed identity.
243    ///
244    /// This method performs blocking I/O on the calling thread. Use [`save_async`][Self::save_async]
245    /// when calling from an async context to avoid stalling the tokio executor.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`AgeVaultError`] on encryption or write failure.
250    ///
251    /// # Examples
252    ///
253    /// ```no_run
254    /// use std::path::Path;
255    /// use zeph_vault::AgeVaultProvider;
256    ///
257    /// let mut vault = AgeVaultProvider::load(
258    ///     Path::new("/etc/zeph/vault-key.txt"),
259    ///     Path::new("/etc/zeph/secrets.age"),
260    /// )?;
261    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into(), false)?;
262    /// vault.save()?;
263    /// # Ok::<_, zeph_vault::AgeVaultError>(())
264    /// ```
265    #[tracing::instrument(name = "vault.age.save", skip_all, err)]
266    pub fn save(&self) -> Result<(), AgeVaultError> {
267        let key_str = Zeroizing::new(
268            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
269        );
270        let identity = parse_identity(&key_str)?;
271        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
272        atomic_write(&self.vault_path, &ciphertext)
273    }
274
275    /// Async variant of [`save`][Self::save] — offloads blocking I/O to a `spawn_blocking` thread.
276    ///
277    /// Use this when calling from an async context to avoid stalling the tokio executor.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`AgeVaultError`] on encryption or write failure, or if the blocking task panics.
282    ///
283    /// # Examples
284    ///
285    /// ```no_run
286    /// use std::path::Path;
287    /// use zeph_vault::AgeVaultProvider;
288    ///
289    /// # async fn example() -> Result<(), zeph_vault::AgeVaultError> {
290    /// let mut vault = AgeVaultProvider::load(
291    ///     Path::new("/etc/zeph/vault-key.txt"),
292    ///     Path::new("/etc/zeph/secrets.age"),
293    /// )?;
294    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into(), false)?;
295    /// vault.save_async().await?;
296    /// # Ok(())
297    /// # }
298    /// ```
299    #[tracing::instrument(name = "vault.age.save_async", skip_all, err)]
300    pub async fn save_async(&self) -> Result<(), AgeVaultError> {
301        let key_path = self.key_path.clone();
302        let vault_path = self.vault_path.clone();
303        let secrets = self.secrets.clone();
304        tokio::task::spawn_blocking(move || {
305            let key_str =
306                Zeroizing::new(std::fs::read_to_string(&key_path).map_err(AgeVaultError::KeyRead)?);
307            let identity = parse_identity(&key_str)?;
308            let ciphertext = encrypt_secrets(&identity, &secrets)?;
309            atomic_write(&vault_path, &ciphertext)
310        })
311        .await
312        .map_err(|e| {
313            AgeVaultError::Io(std::io::Error::other(format!(
314                "spawn_blocking panicked: {e}"
315            )))
316        })?
317    }
318
319    /// Insert or update a secret in the in-memory map.
320    ///
321    /// Refuses to replace an existing key unless `overwrite` is `true`, so that callers cannot
322    /// silently destroy a previously-stored secret by accident — see #5955 (and the sibling
323    /// incident #5874, which hit the same gap in the `zeph init` durable-execution wizard before
324    /// this guard existed at the vault layer). Callers that intend an unconditional update (e.g.
325    /// OAuth token refresh) pass `overwrite: true` explicitly.
326    ///
327    /// Call [`save`][Self::save] afterwards to persist the change to disk.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`AgeVaultError::AlreadyExists`] if `key` is already present and `overwrite` is
332    /// `false`. The in-memory map is left untouched in that case.
333    ///
334    /// # Examples
335    ///
336    /// ```no_run
337    /// use std::path::Path;
338    /// use zeph_vault::AgeVaultProvider;
339    ///
340    /// let mut vault = AgeVaultProvider::load(
341    ///     Path::new("/etc/zeph/vault-key.txt"),
342    ///     Path::new("/etc/zeph/secrets.age"),
343    /// )?;
344    /// vault.set_secret_mut("API_KEY".into(), "sk-...".into(), false)?;
345    /// vault.save()?;
346    /// # Ok::<_, zeph_vault::AgeVaultError>(())
347    /// ```
348    pub fn set_secret_mut(
349        &mut self,
350        key: String,
351        value: String,
352        overwrite: bool,
353    ) -> Result<(), AgeVaultError> {
354        if !overwrite && self.secrets.contains_key(&key) {
355            return Err(AgeVaultError::AlreadyExists(key));
356        }
357        self.secrets.insert(key, Zeroizing::new(value));
358        Ok(())
359    }
360
361    /// Remove a secret from the in-memory map.
362    ///
363    /// Returns `true` if the key existed and was removed, `false` if it was not present.
364    /// Call [`save`][Self::save] afterwards to persist the removal to disk.
365    ///
366    /// # Examples
367    ///
368    /// ```no_run
369    /// use std::path::Path;
370    /// use zeph_vault::AgeVaultProvider;
371    ///
372    /// let mut vault = AgeVaultProvider::load(
373    ///     Path::new("/etc/zeph/vault-key.txt"),
374    ///     Path::new("/etc/zeph/secrets.age"),
375    /// )?;
376    /// let removed = vault.remove_secret_mut("OLD_KEY");
377    /// if removed {
378    ///     vault.save()?;
379    /// }
380    /// # Ok::<_, zeph_vault::AgeVaultError>(())
381    /// ```
382    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
383        self.secrets.remove(key).is_some()
384    }
385
386    /// Return sorted list of secret keys (no values exposed).
387    ///
388    /// Keys are returned in ascending lexicographic order. Secret values are never included.
389    ///
390    /// # Examples
391    ///
392    /// ```no_run
393    /// use std::path::Path;
394    /// use zeph_vault::AgeVaultProvider;
395    ///
396    /// let vault = AgeVaultProvider::load(
397    ///     Path::new("/etc/zeph/vault-key.txt"),
398    ///     Path::new("/etc/zeph/secrets.age"),
399    /// )?;
400    /// for key in vault.list_keys() {
401    ///     println!("{key}");
402    /// }
403    /// # Ok::<_, zeph_vault::AgeVaultError>(())
404    /// ```
405    #[must_use]
406    pub fn list_keys(&self) -> Vec<&str> {
407        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
408        keys.sort_unstable();
409        keys
410    }
411
412    /// Look up a secret value by key, returning `None` if not present.
413    ///
414    /// Returns a borrowed `&str` tied to the lifetime of the vault. For async use across await
415    /// points, use [`VaultProvider::get_secret`] instead, which returns an owned `String`.
416    ///
417    /// # Examples
418    ///
419    /// ```no_run
420    /// use std::path::Path;
421    /// use zeph_vault::AgeVaultProvider;
422    ///
423    /// let vault = AgeVaultProvider::load(
424    ///     Path::new("/etc/zeph/vault-key.txt"),
425    ///     Path::new("/etc/zeph/secrets.age"),
426    /// )?;
427    /// match vault.get("ZEPH_OPENAI_API_KEY") {
428    ///     Some(key) => println!("key length: {}", key.len()),
429    ///     None => println!("key not configured"),
430    /// }
431    /// # Ok::<_, zeph_vault::AgeVaultError>(())
432    /// ```
433    #[must_use]
434    pub fn get(&self, key: &str) -> Option<&str> {
435        self.secrets.get(key).map(|v| v.as_str())
436    }
437
438    /// Generate a new x25519 keypair, write the key file (mode 0600), and create an empty
439    /// encrypted vault.
440    ///
441    /// Creates `dir` and all missing parent directories before writing files. Existing files
442    /// are not checked — calling this on an already-initialised directory will overwrite both
443    /// the key and the vault, making the old key irrecoverable.
444    ///
445    /// # Output files
446    ///
447    /// | File | Contents | Unix mode |
448    /// |------|----------|-----------|
449    /// | `<dir>/vault-key.txt` | age identity (private + public key comment) | `0600` |
450    /// | `<dir>/secrets.age`   | age-encrypted empty JSON object `{}` | default |
451    ///
452    /// Refuses to overwrite a pre-existing vault at `dir` — see [`AgeVaultProvider::init_vault_at`]
453    /// for the underlying guard and a `force` escape hatch.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`AgeVaultError::VaultAlreadyExists`] if `vault-key.txt` or `secrets.age` already
458    /// exists under `dir`, or [`AgeVaultError`] on key/vault write failure or encryption failure.
459    ///
460    /// # Examples
461    ///
462    /// ```no_run
463    /// use std::path::Path;
464    /// use zeph_vault::AgeVaultProvider;
465    ///
466    /// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
467    /// // /etc/zeph/vault-key.txt and /etc/zeph/secrets.age are now ready.
468    /// # Ok::<_, zeph_vault::AgeVaultError>(())
469    /// ```
470    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
471        Self::init_vault_at(&dir.join("vault-key.txt"), &dir.join("secrets.age"), false)
472    }
473
474    /// Generates a fresh age keypair and an empty encrypted vault at explicit `key_path` and
475    /// `vault_path` locations, mirroring [`AgeVaultProvider::load`]'s explicit-path signature.
476    ///
477    /// Unlike [`AgeVaultProvider::init_vault`] (which always derives the standard
478    /// `vault-key.txt`/`secrets.age` filenames from a directory), this accepts arbitrary target
479    /// paths — the correct entry point when the caller has resolved `--vault-key`/`--vault-path`
480    /// CLI overrides that may not follow the default directory/filename convention.
481    ///
482    /// # Overwrite guard
483    ///
484    /// If either `key_path` or `vault_path` already exists and `force` is `false`, the vault is
485    /// left untouched and [`AgeVaultError::VaultAlreadyExists`] is returned — a partial prior
486    /// state (only one of the two files present) is treated the same as a full prior vault,
487    /// since it is itself evidence of an earlier init attempt worth protecting. Pass `force:
488    /// true` to regenerate the keypair and overwrite both files unconditionally.
489    ///
490    /// The existence check and the subsequent write are not wrapped in a single filesystem lock,
491    /// so a racing concurrent call between the check and the write could still both pass the
492    /// guard; this is a best-effort, not a hard mutual-exclusion guarantee.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`AgeVaultError::VaultAlreadyExists`] when a vault already exists and `force` is
497    /// `false`, or [`AgeVaultError`] on key/vault write failure or encryption failure.
498    ///
499    /// # Examples
500    ///
501    /// ```no_run
502    /// use std::path::Path;
503    /// use zeph_vault::AgeVaultProvider;
504    ///
505    /// AgeVaultProvider::init_vault_at(
506    ///     Path::new("/etc/zeph/vault-key.txt"),
507    ///     Path::new("/etc/zeph/secrets.age"),
508    ///     false,
509    /// )?;
510    /// # Ok::<_, zeph_vault::AgeVaultError>(())
511    /// ```
512    pub fn init_vault_at(
513        key_path: &Path,
514        vault_path: &Path,
515        force: bool,
516    ) -> Result<(), AgeVaultError> {
517        use age::secrecy::ExposeSecret as _;
518
519        let existing = if key_path.exists() {
520            Some(key_path)
521        } else if vault_path.exists() {
522            Some(vault_path)
523        } else {
524            None
525        };
526
527        if let Some(existing_path) = existing {
528            if !force {
529                return Err(AgeVaultError::VaultAlreadyExists(
530                    existing_path.to_path_buf(),
531                ));
532            }
533            println!("Overwriting existing vault at {}.", existing_path.display());
534        }
535
536        if let Some(parent) = key_path.parent() {
537            std::fs::create_dir_all(parent).map_err(AgeVaultError::KeyWrite)?;
538        }
539        if let Some(parent) = vault_path.parent() {
540            std::fs::create_dir_all(parent).map_err(AgeVaultError::VaultWrite)?;
541        }
542
543        let identity = age::x25519::Identity::generate();
544        let public_key = identity.to_public();
545
546        let key_content = Zeroizing::new(format!(
547            "# public key: {}\n{}\n",
548            public_key,
549            identity.to_string().expose_secret()
550        ));
551
552        write_private_file(key_path, key_content.as_bytes())?;
553
554        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
555        let ciphertext = encrypt_secrets(&identity, &empty)?;
556        atomic_write(vault_path, &ciphertext)?;
557
558        println!("Vault initialized:");
559        println!("  Key:   {}", key_path.display());
560        println!("  Vault: {}", vault_path.display());
561
562        Ok(())
563    }
564}
565
566impl VaultProvider for AgeVaultProvider {
567    fn get_secret(
568        &self,
569        key: &str,
570    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
571        let result = self.secrets.get(key).map(|v| (**v).clone());
572        Box::pin(async move { Ok(result) })
573    }
574
575    fn list_keys(&self) -> Vec<String> {
576        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
577        keys.sort_unstable();
578        keys
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Internal helpers
584// ---------------------------------------------------------------------------
585
586pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
587    let key_line = key_str
588        .lines()
589        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
590        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
591    key_line
592        .trim()
593        .parse()
594        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
595}
596
597pub(crate) fn decrypt_secrets(
598    identity: &age::x25519::Identity,
599    ciphertext: &[u8],
600) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
601    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
602    let mut reader = decryptor
603        .decrypt(std::iter::once(identity as &dyn age::Identity))
604        .map_err(AgeVaultError::Decrypt)?;
605    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
606    reader
607        .read_to_end(&mut plaintext)
608        .map_err(AgeVaultError::Io)?;
609    let raw: BTreeMap<String, String> =
610        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
611    Ok(raw
612        .into_iter()
613        .map(|(k, v)| (k, Zeroizing::new(v)))
614        .collect())
615}
616
617pub(crate) fn encrypt_secrets(
618    identity: &age::x25519::Identity,
619    secrets: &BTreeMap<String, Zeroizing<String>>,
620) -> Result<Vec<u8>, AgeVaultError> {
621    let recipient = identity.to_public();
622    let encryptor =
623        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
624            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
625    let plain: BTreeMap<&str, &str> = secrets
626        .iter()
627        .map(|(k, v)| (k.as_str(), v.as_str()))
628        .collect();
629    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
630    let mut ciphertext = Vec::with_capacity(json.len() + 64);
631    let mut writer = encryptor
632        .wrap_output(&mut ciphertext)
633        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
634    writer.write_all(&json).map_err(AgeVaultError::Io)?;
635    writer
636        .finish()
637        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
638    Ok(ciphertext)
639}
640
641pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
642    zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
643}
644
645pub(crate) fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
646    zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use tempfile::tempdir;
653
654    fn init_temp_vault(dir: &Path) -> (PathBuf, PathBuf) {
655        AgeVaultProvider::init_vault(dir).expect("init_vault failed");
656        (dir.join("vault-key.txt"), dir.join("secrets.age"))
657    }
658
659    #[test]
660    fn round_trip() {
661        let dir = tempdir().unwrap();
662        let (key_path, vault_path) = init_temp_vault(dir.path());
663
664        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
665        vault
666            .set_secret_mut("KEY".into(), "val".into(), false)
667            .unwrap();
668        vault.save().unwrap();
669
670        let loaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
671        assert_eq!(loaded.get("KEY"), Some("val"));
672    }
673
674    #[test]
675    fn remove_secret() {
676        let dir = tempdir().unwrap();
677        let (key_path, vault_path) = init_temp_vault(dir.path());
678
679        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
680        vault
681            .set_secret_mut("KEY".into(), "val".into(), false)
682            .unwrap();
683
684        assert!(vault.remove_secret_mut("KEY"));
685        assert!(!vault.remove_secret_mut("KEY"));
686        assert_eq!(vault.get("KEY"), None);
687    }
688
689    #[test]
690    fn init_vault_creates_files() {
691        let dir = tempdir().unwrap();
692        AgeVaultProvider::init_vault(dir.path()).unwrap();
693
694        assert!(dir.path().join("vault-key.txt").exists());
695        assert!(dir.path().join("secrets.age").exists());
696    }
697
698    #[test]
699    fn init_vault_refuses_to_overwrite_existing_vault() {
700        let dir = tempdir().unwrap();
701        AgeVaultProvider::init_vault(dir.path()).unwrap();
702        let key_path = dir.path().join("vault-key.txt");
703        let vault_path = dir.path().join("secrets.age");
704        let original_key = std::fs::read(&key_path).unwrap();
705
706        let err = AgeVaultProvider::init_vault(dir.path()).unwrap_err();
707        assert!(matches!(err, AgeVaultError::VaultAlreadyExists(_)));
708
709        // Neither file was touched by the rejected re-init.
710        assert_eq!(std::fs::read(&key_path).unwrap(), original_key);
711        let _ = vault_path; // existence already implied by init_vault_creates_files
712    }
713
714    #[test]
715    fn init_vault_at_force_overwrites_existing_vault() {
716        let dir = tempdir().unwrap();
717        let key_path = dir.path().join("vault-key.txt");
718        let vault_path = dir.path().join("secrets.age");
719        AgeVaultProvider::init_vault_at(&key_path, &vault_path, false).unwrap();
720        let original_key = std::fs::read(&key_path).unwrap();
721
722        AgeVaultProvider::init_vault_at(&key_path, &vault_path, true).unwrap();
723        let new_key = std::fs::read(&key_path).unwrap();
724
725        assert_ne!(original_key, new_key, "force must regenerate the keypair");
726    }
727
728    #[test]
729    fn init_vault_at_respects_explicit_non_default_paths() {
730        let dir = tempdir().unwrap();
731        let key_path = dir.path().join("custom-key.txt");
732        let vault_path = dir.path().join("custom-vault.age");
733
734        AgeVaultProvider::init_vault_at(&key_path, &vault_path, false).unwrap();
735
736        assert!(key_path.exists());
737        assert!(vault_path.exists());
738        // The standard default-named files must not have been created as a side effect.
739        assert!(!dir.path().join("vault-key.txt").exists());
740        assert!(!dir.path().join("secrets.age").exists());
741    }
742
743    #[test]
744    fn init_vault_at_guards_when_only_vault_file_present() {
745        let dir = tempdir().unwrap();
746        let key_path = dir.path().join("vault-key.txt");
747        let vault_path = dir.path().join("secrets.age");
748        // Simulate a partial prior state: only `secrets.age` exists, `vault-key.txt` does not
749        // (e.g. an interrupted write, or a corrupted/incomplete prior init).
750        std::fs::write(&vault_path, b"not a real age file").unwrap();
751
752        let err = AgeVaultProvider::init_vault_at(&key_path, &vault_path, false).unwrap_err();
753        match err {
754            AgeVaultError::VaultAlreadyExists(path) => assert_eq!(path, vault_path),
755            other => panic!("expected VaultAlreadyExists, got {other:?}"),
756        }
757        // Neither the guard nor the refused init may have created/touched the key file.
758        assert!(!key_path.exists());
759        assert_eq!(std::fs::read(&vault_path).unwrap(), b"not a real age file");
760    }
761
762    #[test]
763    fn init_vault_at_creates_independent_parent_dirs_for_key_and_vault() {
764        let dir = tempdir().unwrap();
765        // key_path and vault_path live under two entirely separate, not-yet-existing nested
766        // parent directories — proves each parent is created independently rather than the two
767        // calls collapsing into a no-op because they happen to share an already-existing parent.
768        let key_path = dir.path().join("keys/sub/vault-key.txt");
769        let vault_path = dir.path().join("secrets/sub/secrets.age");
770
771        AgeVaultProvider::init_vault_at(&key_path, &vault_path, false).unwrap();
772
773        assert!(
774            key_path.exists(),
775            "key file must exist under its own parent chain"
776        );
777        assert!(
778            vault_path.exists(),
779            "vault file must exist under its own, separate parent chain"
780        );
781        assert!(dir.path().join("keys/sub").is_dir());
782        assert!(dir.path().join("secrets/sub").is_dir());
783    }
784
785    #[test]
786    fn load_missing_vault_errors() {
787        let dir = tempdir().unwrap();
788        let key_path = dir.path().join("vault-key.txt");
789        let vault_path = dir.path().join("secrets.age");
790
791        let result = AgeVaultProvider::load(&key_path, &vault_path);
792        assert!(result.is_err());
793    }
794
795    #[test]
796    #[cfg(unix)]
797    fn key_file_has_restricted_permissions() {
798        use std::os::unix::fs::PermissionsExt as _;
799
800        let dir = tempdir().unwrap();
801        let (key_path, _) = init_temp_vault(dir.path());
802
803        let mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
804        assert_eq!(
805            mode, 0o600,
806            "vault-key.txt must have mode 0600, got {mode:o}"
807        );
808    }
809
810    #[test]
811    fn load_blank_key_returns_key_parse_error() {
812        let dir = tempdir().unwrap();
813        let key_path = dir.path().join("vault-key.txt");
814        let vault_path = dir.path().join("secrets.age");
815
816        // Key file with only comments and blank lines — no valid identity line.
817        std::fs::write(&key_path, "# comment\n\n# another comment\n").unwrap();
818        // Vault file must exist so the error comes from key parsing, not vault read.
819        std::fs::write(&vault_path, b"").unwrap();
820
821        let result = AgeVaultProvider::load(&key_path, &vault_path);
822        assert!(
823            matches!(result, Err(AgeVaultError::KeyParse(_))),
824            "expected KeyParse, got {result:?}",
825        );
826    }
827
828    #[test]
829    fn decrypt_corrupted_ciphertext_returns_decrypt_error() {
830        let dir = tempdir().unwrap();
831        let (key_path, vault_path) = init_temp_vault(dir.path());
832
833        // Overwrite the encrypted vault with random garbage.
834        std::fs::write(&vault_path, b"not valid age ciphertext at all").unwrap();
835
836        let result = AgeVaultProvider::load(&key_path, &vault_path);
837        assert!(
838            matches!(result, Err(AgeVaultError::Decrypt(_))),
839            "expected Decrypt, got {result:?}",
840        );
841    }
842
843    #[test]
844    fn save_leaves_no_tmp_file() {
845        let dir = tempdir().unwrap();
846        let (key_path, vault_path) = init_temp_vault(dir.path());
847
848        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
849        vault
850            .set_secret_mut("TMP_TEST".into(), "value".into(), false)
851            .unwrap();
852        vault.save().unwrap();
853
854        let tmp_path = vault_path.with_added_extension("tmp");
855        assert!(!tmp_path.exists(), ".age.tmp must not exist after save()");
856        assert!(vault_path.exists(), "secrets.age must exist after save()");
857    }
858
859    /// Regression for #5955: `set_secret_mut` must refuse to replace an existing key when
860    /// `overwrite` is `false`, and must leave the previous value untouched.
861    #[test]
862    fn set_secret_mut_rejects_overwrite_when_not_requested() {
863        let dir = tempdir().unwrap();
864        let (key_path, vault_path) = init_temp_vault(dir.path());
865
866        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
867        vault
868            .set_secret_mut("KEY".into(), "original".into(), false)
869            .unwrap();
870
871        let result = vault.set_secret_mut("KEY".into(), "clobbered".into(), false);
872        assert!(
873            matches!(result, Err(AgeVaultError::AlreadyExists(ref k)) if k == "KEY"),
874            "expected AlreadyExists(\"KEY\"), got {result:?}",
875        );
876        assert_eq!(vault.get("KEY"), Some("original"));
877    }
878
879    /// Regression for #5955: `overwrite: true` must replace an existing value.
880    #[test]
881    fn set_secret_mut_replaces_when_overwrite_requested() {
882        let dir = tempdir().unwrap();
883        let (key_path, vault_path) = init_temp_vault(dir.path());
884
885        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
886        vault
887            .set_secret_mut("KEY".into(), "original".into(), false)
888            .unwrap();
889        vault
890            .set_secret_mut("KEY".into(), "updated".into(), true)
891            .unwrap();
892
893        assert_eq!(vault.get("KEY"), Some("updated"));
894    }
895}