Skip to main content

yui/
secret.rs

1//! age-based file encryption for the secrets pipeline.
2//!
3//! `*.age` files in source are decrypted to a sibling without the
4//! `.age` suffix on every `apply`, and the sibling lands in the
5//! managed `# >>> yui rendered <<<` section of `.gitignore` so the
6//! plaintext never gets committed. From the apply walker's
7//! perspective the sibling is just another regular file — link it
8//! to target like any other dotfile.
9//!
10//! ## Why a separate module from `render.rs`
11//!
12//! `*.tera` and `*.age` both produce a sibling-without-suffix and
13//! both wire that sibling through the `.gitignore` managed section,
14//! but they're different operations: rendering needs Tera contexts
15//! and yui-when headers; decryption needs an age identity file and
16//! recipient validation. Keeping `secret::*` self-contained also
17//! means the crypto stays out of `render.rs`, which a casual
18//! reader expects to be pure-text manipulation.
19//!
20//! ## v1 scope: X25519 only
21//!
22//! `[secrets] identity` is an X25519 secret (`AGE-SECRET-KEY-1…`)
23//! and `recipients = […]` are X25519 public keys (`age1…`).
24//! `yui secret init` produces both.
25//!
26//! Plugin-backed identities (YubiKey / FIDO2 passkey / Touch ID /
27//! TPM / 1Password) need age's `plugin` feature plus callback
28//! plumbing to drive the plugin binaries' interactive prompts —
29//! a worthwhile follow-up but real implementation work, kept out
30//! of v1 to ship the simple flow first.
31
32use std::io::{Read as _, Write as _};
33use std::str::FromStr as _;
34
35use age::secrecy::ExposeSecret as _;
36use camino::Utf8Path;
37
38use crate::{Error, Result};
39
40/// Load an age X25519 identity from `path`. The file is expected
41/// to be the output of `age-keygen` / `yui secret init`: lines
42/// beginning with `#` are comments, the first non-comment line is
43/// the `AGE-SECRET-KEY-1…` secret.
44pub fn load_identity(path: &Utf8Path) -> Result<age::x25519::Identity> {
45    let raw = std::fs::read_to_string(path)
46        .map_err(|e| Error::Other(anyhow::anyhow!("read identity {path}: {e}")))?;
47    let line = raw
48        .lines()
49        .map(str::trim)
50        .find(|l| !l.is_empty() && !l.starts_with('#'))
51        .ok_or_else(|| {
52            Error::Other(anyhow::anyhow!(
53                "identity file {path} contains no key (only comments / blank lines)"
54            ))
55        })?;
56
57    age::x25519::Identity::from_str(line).map_err(|e| {
58        Error::Other(anyhow::anyhow!(
59            "identity file {path} is not a valid age X25519 secret \
60             (expected `AGE-SECRET-KEY-1…`): {e}"
61        ))
62    })
63}
64
65/// Parse an X25519 recipient string (`age1…`).
66pub fn parse_recipient(s: &str) -> Result<age::x25519::Recipient> {
67    let trimmed = s.trim();
68    age::x25519::Recipient::from_str(trimmed).map_err(|e| {
69        Error::Other(anyhow::anyhow!(
70            "not a valid age X25519 recipient {trimmed:?}: {e}"
71        ))
72    })
73}
74
75/// Encrypt `plaintext` to one or more recipients. The output is
76/// the binary age format (the same bytes a `*.age` file holds on
77/// disk).
78pub fn encrypt(plaintext: &[u8], recipients: &[age::x25519::Recipient]) -> Result<Vec<u8>> {
79    if recipients.is_empty() {
80        return Err(Error::Other(anyhow::anyhow!(
81            "no recipients configured — add at least one to `[secrets] recipients` \
82             (or run `yui secret init` to generate a key)"
83        )));
84    }
85    let encryptor =
86        age::Encryptor::with_recipients(recipients.iter().map(|r| r as &dyn age::Recipient))
87            .map_err(|e| Error::Other(anyhow::anyhow!("age encryptor: {e}")))?;
88
89    let mut out = Vec::with_capacity(plaintext.len() + 256);
90    let mut writer = encryptor
91        .wrap_output(&mut out)
92        .map_err(|e| Error::Other(anyhow::anyhow!("age wrap_output: {e}")))?;
93    writer
94        .write_all(plaintext)
95        .map_err(|e| Error::Other(anyhow::anyhow!("age write: {e}")))?;
96    writer
97        .finish()
98        .map_err(|e| Error::Other(anyhow::anyhow!("age finish: {e}")))?;
99    Ok(out)
100}
101
102/// Decrypt `ciphertext` (the bytes of a `*.age` file) using the
103/// supplied identity. Returns the plaintext on success.
104pub fn decrypt(ciphertext: &[u8], identity: &age::x25519::Identity) -> Result<Vec<u8>> {
105    let decryptor = age::Decryptor::new(ciphertext)
106        .map_err(|e| Error::Other(anyhow::anyhow!("age decryptor: {e}")))?;
107    let mut reader = decryptor
108        .decrypt(std::iter::once(identity as &dyn age::Identity))
109        .map_err(|e| Error::Other(anyhow::anyhow!("age decrypt: {e}")))?;
110    let mut out = Vec::new();
111    reader
112        .read_to_end(&mut out)
113        .map_err(|e| Error::Other(anyhow::anyhow!("age read: {e}")))?;
114    Ok(out)
115}
116
117/// Generate a fresh X25519 keypair. Returns the serialised secret
118/// (write this to the identity file) and the corresponding public
119/// recipient string (add this to `[secrets] recipients`).
120pub fn generate_x25519_keypair() -> (String, String) {
121    let id = age::x25519::Identity::generate();
122    let secret = id.to_string().expose_secret().to_string();
123    let public = id.to_public().to_string();
124    (secret, public)
125}
126
127/// Strip the `.age` suffix from a path, if present. Returns `None`
128/// when the path doesn't end in `.age` (so callers can short-circuit
129/// non-secret files in a uniform walk).
130pub fn strip_age_suffix(path: &Utf8Path) -> Option<camino::Utf8PathBuf> {
131    let name = path.file_name()?;
132    let stem = name.strip_suffix(".age")?;
133    if stem.is_empty() {
134        return None; // a literal `.age` file with no stem isn't a secret backup
135    }
136    let parent = path.parent()?;
137    Some(parent.join(stem))
138}
139
140/// Walk every `*.age` under `source`, decrypt to a sibling without
141/// the suffix, and report the plaintext paths so the caller can
142/// add them to the managed `.gitignore` section. Mirrors the
143/// `render::render_all` shape: ignore-files honoured via
144/// `paths::source_walker`, `.yuiignore` filters apply, `.yui/`
145/// and `.git/` skipped.
146///
147/// Returns `Ok(SecretReport::default())` when `[secrets]` is off
148/// (no recipients configured). Otherwise loads the identity once
149/// and decrypts each `.age` file. `dry_run = true` skips the
150/// disk write but still confirms the file decrypts (so a missing
151/// identity / corrupted ciphertext surfaces as an error early).
152pub fn decrypt_all(
153    source: &Utf8Path,
154    config: &crate::config::Config,
155    dry_run: bool,
156) -> Result<SecretReport> {
157    let mut report = SecretReport::default();
158    if !config.secrets.enabled() {
159        return Ok(report);
160    }
161
162    let identity_path = crate::paths::expand_tilde(&config.secrets.identity);
163    let identity = load_identity(&identity_path)?;
164
165    let walker = crate::paths::source_walker(source).build();
166    for entry in walker {
167        let entry = match entry {
168            Ok(e) => e,
169            Err(_) => continue,
170        };
171        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
172            continue;
173        }
174        let std_path = entry.path();
175        let Some(name) = std_path.file_name().and_then(|n| n.to_str()) else {
176            continue;
177        };
178        if !name.ends_with(".age") || name == ".age" {
179            continue;
180        }
181        let cipher_path = match camino::Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
182            Ok(p) => p,
183            Err(_) => continue,
184        };
185        let plaintext_path = match strip_age_suffix(&cipher_path) {
186            Some(p) => p,
187            None => continue,
188        };
189
190        let cipher_bytes = std::fs::read(&cipher_path)
191            .map_err(|e| Error::Other(anyhow::anyhow!("read {cipher_path}: {e}")))?;
192        let plain_bytes = decrypt(&cipher_bytes, &identity)?;
193
194        // Drift check against the on-disk plaintext sibling, mirroring
195        // the render-drift detection in `render::process_template`.
196        // If the user edited the plaintext directly (target-as-truth
197        // path), absorb already pulled the change into source; we
198        // surface it as `diverged` here so they know to re-encrypt
199        // (`yui secret encrypt <path>`) instead of silently
200        // overwriting their edit with the stale ciphertext content.
201        match std::fs::read(&plaintext_path) {
202            Ok(existing) if existing == plain_bytes => {
203                report.unchanged.push(plaintext_path);
204                continue;
205            }
206            Ok(_) => {
207                report.diverged.push(plaintext_path);
208                continue;
209            }
210            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
211            Err(e) => {
212                return Err(Error::Other(anyhow::anyhow!("read {plaintext_path}: {e}")));
213            }
214        }
215
216        if !dry_run {
217            if let Some(parent) = plaintext_path.parent() {
218                std::fs::create_dir_all(parent)?;
219            }
220            std::fs::write(&plaintext_path, &plain_bytes)?;
221        }
222        report.written.push(plaintext_path);
223    }
224    Ok(report)
225}
226
227/// Per-`apply` summary of what the secrets walker did. Mirrors
228/// `RenderReport`'s shape so the apply orchestrator can union
229/// managed-path lists across both pipelines.
230#[derive(Debug, Default)]
231pub struct SecretReport {
232    pub written: Vec<camino::Utf8PathBuf>,
233    pub unchanged: Vec<camino::Utf8PathBuf>,
234    /// Plaintext sibling diverged from current ciphertext. User
235    /// edited the plaintext target directly; they must
236    /// `yui secret encrypt <path>` to roll the change back into
237    /// the canonical `.age` before the next apply.
238    pub diverged: Vec<camino::Utf8PathBuf>,
239}
240
241impl SecretReport {
242    pub fn has_drift(&self) -> bool {
243        !self.diverged.is_empty()
244    }
245
246    /// Every plaintext sibling we know about — written, unchanged,
247    /// or diverged. The apply orchestrator unions this with the
248    /// render report's managed paths to build the `.gitignore`
249    /// managed section.
250    pub fn managed_paths(&self) -> impl Iterator<Item = &camino::Utf8PathBuf> {
251        self.written
252            .iter()
253            .chain(self.unchanged.iter())
254            .chain(self.diverged.iter())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use camino::Utf8PathBuf;
262    use tempfile::TempDir;
263
264    #[test]
265    fn x25519_round_trip() {
266        let (secret, public) = generate_x25519_keypair();
267        let id = age::x25519::Identity::from_str(&secret).unwrap();
268        let recipient = parse_recipient(&public).unwrap();
269        let plaintext = b"hello secret world\n";
270        let cipher = encrypt(plaintext, &[recipient]).unwrap();
271        // Ciphertext should look like an age file.
272        assert!(cipher.starts_with(b"age-encryption.org/v1\n"));
273        let recovered = decrypt(&cipher, &id).unwrap();
274        assert_eq!(recovered, plaintext);
275    }
276
277    #[test]
278    fn multi_recipient_decrypts_with_either_key() {
279        let (secret_a, public_a) = generate_x25519_keypair();
280        let (secret_b, public_b) = generate_x25519_keypair();
281        let id_a = age::x25519::Identity::from_str(&secret_a).unwrap();
282        let id_b = age::x25519::Identity::from_str(&secret_b).unwrap();
283        let recipients = vec![
284            parse_recipient(&public_a).unwrap(),
285            parse_recipient(&public_b).unwrap(),
286        ];
287        let plaintext = b"team secret";
288        let cipher = encrypt(plaintext, &recipients).unwrap();
289        // Either identity should decrypt — that's the whole point of
290        // multi-recipient encryption.
291        assert_eq!(decrypt(&cipher, &id_a).unwrap(), plaintext);
292        assert_eq!(decrypt(&cipher, &id_b).unwrap(), plaintext);
293    }
294
295    #[test]
296    fn load_identity_skips_comments_and_blanks() {
297        let tmp = TempDir::new().unwrap();
298        let path = Utf8PathBuf::from_path_buf(tmp.path().join("age.txt")).unwrap();
299        let (secret, _public) = generate_x25519_keypair();
300        let body = format!("# created: 2026-05-02\n# public key: ageXXX\n\n{secret}\n");
301        std::fs::write(&path, body).unwrap();
302        let id = load_identity(&path).unwrap();
303        // Round-trip through decrypt to confirm we got a usable
304        // identity back (not just any string-shaped placeholder).
305        let recipient = parse_recipient(&id.to_public().to_string()).unwrap();
306        let cipher = encrypt(b"x", &[recipient]).unwrap();
307        assert_eq!(decrypt(&cipher, &id).unwrap(), b"x");
308    }
309
310    #[test]
311    fn load_identity_errors_on_garbage() {
312        let tmp = TempDir::new().unwrap();
313        let path = Utf8PathBuf::from_path_buf(tmp.path().join("bad.txt")).unwrap();
314        std::fs::write(&path, "not a key at all\n").unwrap();
315        // `age::x25519::Identity` deliberately doesn't impl Debug
316        // (holds secret material), so `unwrap_err` won't compile —
317        // pattern match instead.
318        match load_identity(&path) {
319            Ok(_) => panic!("expected error on garbage identity file"),
320            Err(e) => assert!(format!("{e}").contains("not a valid age X25519 secret")),
321        }
322    }
323
324    #[test]
325    fn parse_recipient_rejects_garbage() {
326        let err = parse_recipient("ssh-rsa AAAA…").unwrap_err();
327        assert!(format!("{err}").contains("not a valid age X25519 recipient"));
328    }
329
330    #[test]
331    fn encrypt_with_no_recipients_errors() {
332        let err = encrypt(b"x", &[]).unwrap_err();
333        assert!(format!("{err}").contains("no recipients"));
334    }
335
336    #[test]
337    fn strip_age_suffix_basic() {
338        assert_eq!(
339            strip_age_suffix(Utf8PathBuf::from("home/.ssh/id_ed25519.age").as_path()),
340            Some(Utf8PathBuf::from("home/.ssh/id_ed25519"))
341        );
342        // Multiple dots: only the trailing `.age` is stripped.
343        assert_eq!(
344            strip_age_suffix(Utf8PathBuf::from("home/notes.tar.gz.age").as_path()),
345            Some(Utf8PathBuf::from("home/notes.tar.gz"))
346        );
347        // Not a secret.
348        assert_eq!(
349            strip_age_suffix(Utf8PathBuf::from("home/foo.txt").as_path()),
350            None
351        );
352        // A literal `.age` with no stem isn't a secret either.
353        assert_eq!(strip_age_suffix(Utf8PathBuf::from(".age").as_path()), None);
354    }
355}