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}