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//! ## Two distinct encryption paths
21//!
22//! 1. **`*.age` files in apply** — encrypted to `[secrets] recipients`,
23//!    decrypted with the plain X25519 secret at
24//!    `[secrets] identity` (e.g. `~/.config/yui/age.txt`). Runs every
25//!    apply, must be friction-free, must NOT trigger device prompts.
26//!    Identities here are X25519 only by convention.
27//!
28//! 2. **passkey wrap of the X25519 secret itself** — the user's
29//!    `~/.config/yui/age.txt` (plain X25519) gets encrypted to one
30//!    or more passkey recipients (Pixel / Bitwarden / YubiKey, via
31//!    the `age-plugin-fido2-hmac` etc.) so it can travel with the
32//!    dotfiles repo as ciphertext. Used only by `yui secret wrap`
33//!    and `yui secret unlock` — never by apply. Plugin identities
34//!    appear ONLY here, so the apply path stays plugin-free.
35//!
36//! Recipient strings split the same way: `age1…` for X25519 and
37//! `age1<plugin>1…` for plugin recipients. Multiple recipient types
38//! can mix in a single ciphertext — useful for wrap, where the
39//! user might want both Pixel and Bitwarden as recovery devices.
40
41use std::io::{BufReader, Read as _, Write as _};
42use std::str::FromStr as _;
43
44use age::IdentityFile;
45use age::cli_common::UiCallbacks;
46use age::secrecy::ExposeSecret as _;
47use camino::Utf8Path;
48
49use crate::{Error, Result};
50
51/// Boxed dyn-trait identity. age's `Decryptor::decrypt` takes a
52/// trait-object iterator, so we hand it boxed identities; X25519
53/// and plugin variants share the same type at the boundary.
54pub type BoxedIdentity = Box<dyn age::Identity>;
55
56/// Validate that `bytes` is a parseable X25519 identity file —
57/// at least one non-comment line is `AGE-SECRET-KEY-1…`. Used by
58/// `yui secret unlock` to fail before persisting bytes that
59/// happen to decrypt but aren't actually an identity (wrong
60/// `passkey_wrapped` path / corrupted blob). PR #60 review by
61/// coderabbitai.
62pub fn validate_x25519_identity_bytes(bytes: &[u8]) -> Result<()> {
63    let text = std::str::from_utf8(bytes).map_err(|_| {
64        Error::Other(anyhow::anyhow!(
65            "decrypted payload is not valid UTF-8 — \
66             passkey_wrapped doesn't look like an age identity file"
67        ))
68    })?;
69    let line = text
70        .lines()
71        .map(str::trim)
72        .find(|l| !l.is_empty() && !l.starts_with('#'))
73        .ok_or_else(|| {
74            Error::Other(anyhow::anyhow!(
75                "decrypted payload contains no key line \
76                 (only comments / blank lines) — passkey_wrapped \
77                 is not an age identity file"
78            ))
79        })?;
80    age::x25519::Identity::from_str(line)
81        .map(drop)
82        .map_err(|e| {
83            Error::Other(anyhow::anyhow!(
84                "decrypted payload is not a valid age X25519 secret \
85             (`AGE-SECRET-KEY-1…` expected): {e}"
86            ))
87        })
88}
89
90/// Write `bytes` to `path` with owner-only permissions on Unix
91/// (0600). On Windows we fall back to a plain write because file
92/// permissions don't translate cleanly — the user's `AGE-SECRET-KEY`
93/// is still in their `~/.config/yui/` directory which isn't shared
94/// by default. Used by both `secret_init` and `secret_unlock` so
95/// neither flow leaves the X25519 secret world-readable. PR #60
96/// review by coderabbitai.
97pub fn write_private_file(path: &Utf8Path, bytes: &[u8]) -> Result<()> {
98    if let Some(parent) = path.parent() {
99        std::fs::create_dir_all(parent)?;
100    }
101    #[cfg(unix)]
102    {
103        use std::fs::OpenOptions;
104        use std::io::Write as _;
105        use std::os::unix::fs::OpenOptionsExt;
106        let mut file = OpenOptions::new()
107            .write(true)
108            .create(true)
109            .truncate(true)
110            .mode(0o600)
111            .open(path)
112            .map_err(|e| Error::Other(anyhow::anyhow!("create {path}: {e}")))?;
113        file.write_all(bytes)
114            .map_err(|e| Error::Other(anyhow::anyhow!("write {path}: {e}")))?;
115    }
116    #[cfg(not(unix))]
117    std::fs::write(path, bytes).map_err(|e| Error::Other(anyhow::anyhow!("write {path}: {e}")))?;
118    Ok(())
119}
120
121/// Boxed dyn-trait recipient. Same reasoning as `BoxedIdentity` —
122/// `Encryptor::with_recipients` works on trait objects.
123pub type BoxedRecipient = Box<dyn age::Recipient + Send>;
124
125/// Load an age X25519 identity from `path`, the way `apply` needs
126/// it. Refuses anything other than a plain `AGE-SECRET-KEY-1…`
127/// secret — apply must NEVER drop into a plugin flow because that
128/// would prompt for a touch / PIN / biometric on every run.
129/// (The user's mental model is "Pixel only at unlock time, not
130/// every apply", so apply stays X25519-only on principle.)
131pub fn load_x25519_identity(path: &Utf8Path) -> Result<age::x25519::Identity> {
132    let raw = std::fs::read_to_string(path)
133        .map_err(|e| Error::Other(anyhow::anyhow!("read identity {path}: {e}")))?;
134    let line = raw
135        .lines()
136        .map(str::trim)
137        .find(|l| !l.is_empty() && !l.starts_with('#'))
138        .ok_or_else(|| {
139            Error::Other(anyhow::anyhow!(
140                "identity file {path} contains no key (only comments / blank lines)"
141            ))
142        })?;
143
144    age::x25519::Identity::from_str(line).map_err(|e| {
145        Error::Other(anyhow::anyhow!(
146            "identity file {path} is not a valid age X25519 secret \
147             (expected `AGE-SECRET-KEY-1…`): {e}"
148        ))
149    })
150}
151
152/// Load every identity from `path`, allowing plugin entries
153/// (`AGE-PLUGIN-…`). Used by `yui secret unlock` where the file
154/// holds passkey identities (Pixel, Bitwarden, …) that age must
155/// drive interactively at decrypt time.
156///
157/// `IdentityFile` parses comments / blank lines / multiple entries
158/// per the standard age format; `with_callbacks(UiCallbacks)`
159/// hands plugin invocations a terminal-based prompt for "press
160/// the button now" / etc.
161pub fn load_passkey_identities(path: &Utf8Path) -> Result<Vec<BoxedIdentity>> {
162    let file = std::fs::File::open(path)
163        .map_err(|e| Error::Other(anyhow::anyhow!("read passkey identities {path}: {e}")))?;
164    let id_file = IdentityFile::from_buffer(BufReader::new(file))
165        .map_err(|e| Error::Other(anyhow::anyhow!("parse {path}: {e}")))?;
166    id_file
167        .with_callbacks(UiCallbacks)
168        .into_identities()
169        .map_err(|e| Error::Other(anyhow::anyhow!("load identities from {path}: {e}")))
170}
171
172/// Parse an X25519 recipient string (`age1…`). Used for the
173/// `[secrets] recipients` list which encrypts the user's `*.age`
174/// files — those must stay plugin-free so apply doesn't prompt.
175pub fn parse_x25519_recipient(s: &str) -> Result<age::x25519::Recipient> {
176    let trimmed = s.trim();
177    age::x25519::Recipient::from_str(trimmed).map_err(|e| {
178        Error::Other(anyhow::anyhow!(
179            "not a valid age X25519 recipient {trimmed:?}: {e}"
180        ))
181    })
182}
183
184/// Parse a single recipient string — X25519 or plugin. Used in
185/// tests and for debugging; production wrap goes through
186/// `parse_passkey_recipients` which batches plugin recipients.
187pub fn parse_passkey_recipient(s: &str) -> Result<BoxedRecipient> {
188    parse_passkey_recipients(std::slice::from_ref(&s.to_string()))
189        .map(|mut v| v.pop().expect("single input → single output"))
190}
191
192/// Parse a list of recipient strings, grouping plugin recipients
193/// by plugin name into a single `RecipientPluginV1` per group.
194/// Without grouping, each plugin recipient would spawn the
195/// `age-plugin-*` binary independently — wasteful and (for some
196/// plugins) prompts the user multiple times. (PR #60 review by
197/// gemini-code-assist.)
198///
199/// X25519 recipients pass through one-Box-per-string since they
200/// have no plugin process to batch.
201pub fn parse_passkey_recipients(strings: &[String]) -> Result<Vec<BoxedRecipient>> {
202    use std::collections::BTreeMap;
203
204    let mut out: Vec<BoxedRecipient> = Vec::new();
205    let mut by_plugin: BTreeMap<String, Vec<age::plugin::Recipient>> = BTreeMap::new();
206
207    for s in strings {
208        let trimmed = s.trim();
209        if let Ok(r) = age::x25519::Recipient::from_str(trimmed) {
210            out.push(Box::new(r));
211            continue;
212        }
213        if let Ok(r) = age::plugin::Recipient::from_str(trimmed) {
214            let name = r.plugin().to_string();
215            by_plugin.entry(name).or_default().push(r);
216            continue;
217        }
218        return Err(Error::Other(anyhow::anyhow!(
219            "not a valid age recipient {trimmed:?} \
220             (expected `age1…` or `age1<plugin>1…`)"
221        )));
222    }
223
224    for (name, recipients) in by_plugin {
225        let plugin = age::plugin::RecipientPluginV1::new(&name, &recipients, &[], UiCallbacks)
226            .map_err(|e| Error::Other(anyhow::anyhow!("plugin recipient group {name:?}: {e}")))?;
227        out.push(Box::new(plugin));
228    }
229
230    Ok(out)
231}
232
233/// Encrypt `plaintext` to one or more X25519 recipients. Used for
234/// `*.age` files in the apply pipeline.
235pub fn encrypt_x25519(plaintext: &[u8], recipients: &[age::x25519::Recipient]) -> Result<Vec<u8>> {
236    if recipients.is_empty() {
237        return Err(Error::Other(anyhow::anyhow!(
238            "no recipients configured — add at least one to `[secrets] recipients` \
239             (or run `yui secret init` to generate a key)"
240        )));
241    }
242    let encryptor =
243        age::Encryptor::with_recipients(recipients.iter().map(|r| r as &dyn age::Recipient))
244            .map_err(|e| Error::Other(anyhow::anyhow!("age encryptor: {e}")))?;
245    write_encrypted(encryptor, plaintext)
246}
247
248/// Encrypt `plaintext` to one or more potentially-plugin
249/// recipients. Used by `yui secret wrap` to encrypt the X25519
250/// identity to passkey devices (Pixel + Bitwarden + …).
251pub fn encrypt_to_passkeys(plaintext: &[u8], recipients: &[BoxedRecipient]) -> Result<Vec<u8>> {
252    if recipients.is_empty() {
253        return Err(Error::Other(anyhow::anyhow!(
254            "no passkey recipients configured — add at least one to \
255             `[secrets] passkey_recipients` (each entry is the public \
256             key of a Pixel / Bitwarden / etc. device)"
257        )));
258    }
259    let encryptor = age::Encryptor::with_recipients(
260        recipients
261            .iter()
262            .map(|r| -> &dyn age::Recipient { r.as_ref() }),
263    )
264    .map_err(|e| Error::Other(anyhow::anyhow!("age encryptor: {e}")))?;
265    write_encrypted(encryptor, plaintext)
266}
267
268fn write_encrypted(encryptor: age::Encryptor, plaintext: &[u8]) -> Result<Vec<u8>> {
269    let mut out = Vec::with_capacity(plaintext.len() + 256);
270    let mut writer = encryptor
271        .wrap_output(&mut out)
272        .map_err(|e| Error::Other(anyhow::anyhow!("age wrap_output: {e}")))?;
273    writer
274        .write_all(plaintext)
275        .map_err(|e| Error::Other(anyhow::anyhow!("age write: {e}")))?;
276    writer
277        .finish()
278        .map_err(|e| Error::Other(anyhow::anyhow!("age finish: {e}")))?;
279    Ok(out)
280}
281
282/// Decrypt `ciphertext` (the bytes of a `*.age` file) using a
283/// single X25519 identity. Used by the apply pipeline.
284pub fn decrypt_x25519(ciphertext: &[u8], identity: &age::x25519::Identity) -> Result<Vec<u8>> {
285    let decryptor = age::Decryptor::new(ciphertext)
286        .map_err(|e| Error::Other(anyhow::anyhow!("age decryptor: {e}")))?;
287    let mut reader = decryptor
288        .decrypt(std::iter::once(identity as &dyn age::Identity))
289        .map_err(|e| Error::Other(anyhow::anyhow!("age decrypt: {e}")))?;
290    let mut out = Vec::new();
291    reader
292        .read_to_end(&mut out)
293        .map_err(|e| Error::Other(anyhow::anyhow!("age read: {e}")))?;
294    Ok(out)
295}
296
297/// Decrypt `ciphertext` using any of the supplied (potentially
298/// plugin-backed) identities. Used by `yui secret unlock`.
299pub fn decrypt_with_passkeys(ciphertext: &[u8], identities: &[BoxedIdentity]) -> Result<Vec<u8>> {
300    let decryptor = age::Decryptor::new(ciphertext)
301        .map_err(|e| Error::Other(anyhow::anyhow!("age decryptor: {e}")))?;
302    let mut reader = decryptor
303        .decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity))
304        .map_err(|e| Error::Other(anyhow::anyhow!("age decrypt: {e}")))?;
305    let mut out = Vec::new();
306    reader
307        .read_to_end(&mut out)
308        .map_err(|e| Error::Other(anyhow::anyhow!("age read: {e}")))?;
309    Ok(out)
310}
311
312/// Generate a fresh X25519 keypair. Returns the serialised secret
313/// (write this to the identity file) and the corresponding public
314/// recipient string (add this to `[secrets] recipients`).
315pub fn generate_x25519_keypair() -> (String, String) {
316    let id = age::x25519::Identity::generate();
317    let secret = id.to_string().expose_secret().to_string();
318    let public = id.to_public().to_string();
319    (secret, public)
320}
321
322/// Strip the `.age` suffix from a path, if present. Returns `None`
323/// when the path doesn't end in `.age` (so callers can short-circuit
324/// non-secret files in a uniform walk).
325pub fn strip_age_suffix(path: &Utf8Path) -> Option<camino::Utf8PathBuf> {
326    let name = path.file_name()?;
327    let stem = name.strip_suffix(".age")?;
328    if stem.is_empty() {
329        return None; // a literal `.age` file with no stem isn't a secret backup
330    }
331    let parent = path.parent()?;
332    Some(parent.join(stem))
333}
334
335/// Walk every `*.age` under `source`, decrypt to a sibling without
336/// the suffix, and report the plaintext paths so the caller can
337/// add them to the managed `.gitignore` section. Mirrors the
338/// `render::render_all` shape: ignore-files honoured via
339/// `paths::source_walker`, `.yuiignore` filters apply, `.yui/`
340/// and `.git/` skipped.
341///
342/// Returns `Ok(SecretReport::default())` when `[secrets]` is off
343/// (no recipients configured). Otherwise loads the identity once
344/// and decrypts each `.age` file. The identity is X25519-only
345/// here on purpose — apply must NOT trigger plugin / passkey
346/// prompts every run.
347///
348/// Skips the `passkey_wrapped` ciphertext file: it's encrypted to
349/// passkey recipients (NOT the X25519), so trying to decrypt it
350/// here would fail loudly. The unlock path handles it instead.
351pub fn decrypt_all(
352    source: &Utf8Path,
353    config: &crate::config::Config,
354    dry_run: bool,
355) -> Result<SecretReport> {
356    let mut report = SecretReport::default();
357    if !config.secrets.enabled() {
358        return Ok(report);
359    }
360
361    let identity_path = crate::paths::expand_tilde(&config.secrets.identity);
362    let identity = load_x25519_identity(&identity_path)?;
363
364    // Resolve `passkey_wrapped` to an absolute path so we can skip
365    // it inside the walker (it's encrypted to a passkey recipient,
366    // not the X25519 identity, so it's not a regular `.age` file
367    // that apply should decrypt).
368    let passkey_wrapped_abs = config.secrets.passkey_wrapped.as_ref().map(|p| {
369        let path = crate::paths::expand_tilde(p);
370        if path.is_absolute() {
371            path
372        } else {
373            source.join(path)
374        }
375    });
376
377    let walker = crate::paths::source_walker(source).build();
378    for entry in walker {
379        let entry = match entry {
380            Ok(e) => e,
381            Err(_) => continue,
382        };
383        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
384            continue;
385        }
386        let std_path = entry.path();
387        let Some(name) = std_path.file_name().and_then(|n| n.to_str()) else {
388            continue;
389        };
390        if !name.ends_with(".age") || name == ".age" {
391            continue;
392        }
393        let cipher_path = match camino::Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
394            Ok(p) => p,
395            Err(_) => continue,
396        };
397        // Skip the passkey-wrapped identity file — it's not a
398        // regular `.age` we should decrypt during apply.
399        if let Some(skip) = &passkey_wrapped_abs {
400            if &cipher_path == skip {
401                continue;
402            }
403        }
404        let plaintext_path = match strip_age_suffix(&cipher_path) {
405            Some(p) => p,
406            None => continue,
407        };
408
409        let cipher_bytes = std::fs::read(&cipher_path)
410            .map_err(|e| Error::Other(anyhow::anyhow!("read {cipher_path}: {e}")))?;
411        let plain_bytes = decrypt_x25519(&cipher_bytes, &identity)?;
412
413        // Drift check against the on-disk plaintext sibling, mirroring
414        // the render-drift detection in `render::process_template`.
415        match std::fs::read(&plaintext_path) {
416            Ok(existing) if existing == plain_bytes => {
417                report.unchanged.push(plaintext_path);
418                continue;
419            }
420            Ok(_) => {
421                report.diverged.push(plaintext_path);
422                continue;
423            }
424            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
425            Err(e) => {
426                return Err(Error::Other(anyhow::anyhow!("read {plaintext_path}: {e}")));
427            }
428        }
429
430        if !dry_run {
431            if let Some(parent) = plaintext_path.parent() {
432                std::fs::create_dir_all(parent)?;
433            }
434            std::fs::write(&plaintext_path, &plain_bytes)?;
435        }
436        report.written.push(plaintext_path);
437    }
438    Ok(report)
439}
440
441/// Per-`apply` summary of what the secrets walker did. Mirrors
442/// `RenderReport`'s shape so the apply orchestrator can union
443/// managed-path lists across both pipelines.
444#[derive(Debug, Default)]
445pub struct SecretReport {
446    pub written: Vec<camino::Utf8PathBuf>,
447    pub unchanged: Vec<camino::Utf8PathBuf>,
448    /// Plaintext sibling diverged from current ciphertext. User
449    /// edited the plaintext target directly; they must
450    /// `yui secret encrypt <path>` to roll the change back into
451    /// the canonical `.age` before the next apply.
452    pub diverged: Vec<camino::Utf8PathBuf>,
453}
454
455impl SecretReport {
456    pub fn has_drift(&self) -> bool {
457        !self.diverged.is_empty()
458    }
459
460    /// Every plaintext sibling we know about — written, unchanged,
461    /// or diverged. The apply orchestrator unions this with the
462    /// render report's managed paths to build the `.gitignore`
463    /// managed section.
464    pub fn managed_paths(&self) -> impl Iterator<Item = &camino::Utf8PathBuf> {
465        self.written
466            .iter()
467            .chain(self.unchanged.iter())
468            .chain(self.diverged.iter())
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use camino::Utf8PathBuf;
476    use tempfile::TempDir;
477
478    fn write_x25519_identity_file(tmp: &TempDir, name: &str) -> (Utf8PathBuf, String) {
479        let path = Utf8PathBuf::from_path_buf(tmp.path().join(name)).unwrap();
480        let (secret, public) = generate_x25519_keypair();
481        std::fs::write(&path, format!("{secret}\n")).unwrap();
482        (path, public)
483    }
484
485    #[test]
486    fn x25519_round_trip() {
487        let tmp = TempDir::new().unwrap();
488        let (id_path, public) = write_x25519_identity_file(&tmp, "age.txt");
489        let identity = load_x25519_identity(&id_path).unwrap();
490        let recipient = parse_x25519_recipient(&public).unwrap();
491        let cipher = encrypt_x25519(b"hello secret world\n", &[recipient]).unwrap();
492        assert!(cipher.starts_with(b"age-encryption.org/v1\n"));
493        let recovered = decrypt_x25519(&cipher, &identity).unwrap();
494        assert_eq!(recovered, b"hello secret world\n");
495    }
496
497    /// Wrap / unlock round-trip via a *boxed* X25519 identity (the
498    /// passkey path uses Box<dyn Identity>, but plugin binaries
499    /// aren't available in CI — exercise the same code path with
500    /// X25519, which is plugin-free but uses the same general
501    /// dyn-trait API).
502    #[test]
503    fn passkey_wrap_round_trip_via_x25519_proxy() {
504        let tmp = TempDir::new().unwrap();
505        let (id_path, public) = write_x25519_identity_file(&tmp, "age.txt");
506        let recipients = vec![parse_passkey_recipient(&public).unwrap()];
507        let plaintext = std::fs::read(&id_path).unwrap();
508        let wrapped = encrypt_to_passkeys(&plaintext, &recipients).unwrap();
509        // Boxed identity for the unlock side.
510        let identities: Vec<BoxedIdentity> = {
511            let id = load_x25519_identity(&id_path).unwrap();
512            vec![Box::new(id)]
513        };
514        let recovered = decrypt_with_passkeys(&wrapped, &identities).unwrap();
515        assert_eq!(recovered, plaintext);
516    }
517
518    #[test]
519    fn multi_recipient_decrypts_with_either_key() {
520        let tmp = TempDir::new().unwrap();
521        let (_id_a_path, public_a) = write_x25519_identity_file(&tmp, "a.txt");
522        let (_id_b_path, public_b) = write_x25519_identity_file(&tmp, "b.txt");
523        let recipients = vec![
524            parse_x25519_recipient(&public_a).unwrap(),
525            parse_x25519_recipient(&public_b).unwrap(),
526        ];
527        let cipher = encrypt_x25519(b"team secret", &recipients).unwrap();
528        // Either identity should decrypt.
529        let id_a =
530            load_x25519_identity(&Utf8PathBuf::from_path_buf(tmp.path().join("a.txt")).unwrap())
531                .unwrap();
532        let id_b =
533            load_x25519_identity(&Utf8PathBuf::from_path_buf(tmp.path().join("b.txt")).unwrap())
534                .unwrap();
535        assert_eq!(decrypt_x25519(&cipher, &id_a).unwrap(), b"team secret");
536        assert_eq!(decrypt_x25519(&cipher, &id_b).unwrap(), b"team secret");
537    }
538
539    #[test]
540    fn load_x25519_skips_comments_and_blanks() {
541        let tmp = TempDir::new().unwrap();
542        let path = Utf8PathBuf::from_path_buf(tmp.path().join("age.txt")).unwrap();
543        let (secret, _public) = generate_x25519_keypair();
544        let body = format!("# created: 2026-05-02\n# public key: ageXXX\n\n{secret}\n");
545        std::fs::write(&path, body).unwrap();
546        let _id = load_x25519_identity(&path).unwrap();
547    }
548
549    #[test]
550    fn load_x25519_errors_on_garbage() {
551        let tmp = TempDir::new().unwrap();
552        let path = Utf8PathBuf::from_path_buf(tmp.path().join("bad.txt")).unwrap();
553        std::fs::write(&path, "not a key at all\n").unwrap();
554        match load_x25519_identity(&path) {
555            Ok(_) => panic!("expected parse error"),
556            Err(e) => assert!(format!("{e}").contains("not a valid age X25519")),
557        }
558    }
559
560    #[test]
561    fn parse_recipient_rejects_garbage() {
562        let err = parse_x25519_recipient("ssh-rsa AAAA…").unwrap_err();
563        assert!(format!("{err}").contains("not a valid age X25519 recipient"));
564    }
565
566    /// PR #60 review: don't persist a decrypted blob that isn't
567    /// actually an age identity. Successful decrypt + bad payload
568    /// must fail, never get to disk.
569    #[test]
570    fn validate_x25519_identity_bytes_round_trip() {
571        let (secret, _public) = generate_x25519_keypair();
572        let body = format!("# header\n{secret}\n");
573        validate_x25519_identity_bytes(body.as_bytes()).unwrap();
574    }
575
576    #[test]
577    fn validate_x25519_identity_bytes_rejects_non_identity() {
578        let err = validate_x25519_identity_bytes(b"this is not an age identity\n").unwrap_err();
579        let msg = format!("{err}");
580        assert!(
581            msg.contains("not a valid age X25519 secret") || msg.contains("contains no key line"),
582            "unexpected error: {msg}",
583        );
584    }
585
586    #[test]
587    fn validate_x25519_identity_bytes_rejects_non_utf8() {
588        let err = validate_x25519_identity_bytes(&[0xff, 0xfe, 0x00]).unwrap_err();
589        assert!(format!("{err}").contains("not valid UTF-8"));
590    }
591
592    /// PR #60 review: write_private_file should never leave the
593    /// X25519 secret world-readable. We can only assert mode 0o600
594    /// on Unix; on Windows the helper falls back to plain write.
595    #[test]
596    fn write_private_file_round_trip() {
597        let tmp = TempDir::new().unwrap();
598        let path = Utf8PathBuf::from_path_buf(tmp.path().join("nested/age.txt")).unwrap();
599        write_private_file(&path, b"hello\n").unwrap();
600        assert_eq!(std::fs::read(&path).unwrap(), b"hello\n");
601        #[cfg(unix)]
602        {
603            use std::os::unix::fs::PermissionsExt as _;
604            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
605            // mode includes file type bits; mask down to perms.
606            assert_eq!(
607                mode & 0o777,
608                0o600,
609                "expected 0o600, got {:o}",
610                mode & 0o777
611            );
612        }
613    }
614
615    #[test]
616    fn write_private_file_overwrites_existing() {
617        let tmp = TempDir::new().unwrap();
618        let path = Utf8PathBuf::from_path_buf(tmp.path().join("age.txt")).unwrap();
619        write_private_file(&path, b"v1").unwrap();
620        write_private_file(&path, b"v2").unwrap();
621        assert_eq!(std::fs::read(&path).unwrap(), b"v2");
622    }
623
624    #[test]
625    fn parse_passkey_recipient_rejects_garbage() {
626        // `Box<dyn Recipient + Send>` doesn't impl Debug, so
627        // `unwrap_err` won't compile — match the result instead.
628        match parse_passkey_recipient("ssh-rsa AAAA…") {
629            Ok(_) => panic!("expected parse failure"),
630            Err(e) => assert!(format!("{e}").contains("not a valid age recipient")),
631        }
632    }
633
634    #[test]
635    fn encrypt_with_no_recipients_errors() {
636        let err = encrypt_x25519(b"x", &[]).unwrap_err();
637        assert!(format!("{err}").contains("no recipients"));
638    }
639
640    #[test]
641    fn encrypt_to_passkeys_with_no_recipients_errors() {
642        let err = encrypt_to_passkeys(b"x", &[]).unwrap_err();
643        assert!(format!("{err}").contains("no passkey recipients"));
644    }
645
646    #[test]
647    fn strip_age_suffix_basic() {
648        assert_eq!(
649            strip_age_suffix(Utf8PathBuf::from("home/.ssh/id_ed25519.age").as_path()),
650            Some(Utf8PathBuf::from("home/.ssh/id_ed25519"))
651        );
652        assert_eq!(
653            strip_age_suffix(Utf8PathBuf::from("home/notes.tar.gz.age").as_path()),
654            Some(Utf8PathBuf::from("home/notes.tar.gz"))
655        );
656        assert_eq!(
657            strip_age_suffix(Utf8PathBuf::from("home/foo.txt").as_path()),
658            None
659        );
660        assert_eq!(strip_age_suffix(Utf8PathBuf::from(".age").as_path()), None);
661    }
662}