1use 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
51pub type BoxedIdentity = Box<dyn age::Identity>;
55
56pub 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
90pub 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
121pub type BoxedRecipient = Box<dyn age::Recipient + Send>;
124
125pub 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
152pub 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
172pub 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
184pub 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
192pub 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
233pub 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
248pub 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
282pub 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
297pub 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
312pub 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
322pub 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; }
331 let parent = path.parent()?;
332 Some(parent.join(stem))
333}
334
335pub 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 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 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 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#[derive(Debug, Default)]
445pub struct SecretReport {
446 pub written: Vec<camino::Utf8PathBuf>,
447 pub unchanged: Vec<camino::Utf8PathBuf>,
448 pub diverged: Vec<camino::Utf8PathBuf>,
453}
454
455impl SecretReport {
456 pub fn has_drift(&self) -> bool {
457 !self.diverged.is_empty()
458 }
459
460 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 #[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 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 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 #[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 #[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 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 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}