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<()> {
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
89pub 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
120pub type BoxedRecipient = Box<dyn age::Recipient + Send>;
123
124pub 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
151pub 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
171pub 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
183pub 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
191pub 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
232pub 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
247pub 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
281pub 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
296pub 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
311pub 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
321pub 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; }
330 let parent = path.parent()?;
331 Some(parent.join(stem))
332}
333
334pub 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 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#[derive(Debug, Default)]
424pub struct SecretReport {
425 pub written: Vec<camino::Utf8PathBuf>,
426 pub unchanged: Vec<camino::Utf8PathBuf>,
427 pub diverged: Vec<camino::Utf8PathBuf>,
432}
433
434impl SecretReport {
435 pub fn has_drift(&self) -> bool {
436 !self.diverged.is_empty()
437 }
438
439 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 #[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 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 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 #[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 #[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 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 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}