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