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}