1use 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
40pub 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
65pub 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
75pub 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
102pub 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
117pub 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
127pub 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; }
136 let parent = path.parent()?;
137 Some(parent.join(stem))
138}
139
140pub 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 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#[derive(Debug, Default)]
231pub struct SecretReport {
232 pub written: Vec<camino::Utf8PathBuf>,
233 pub unchanged: Vec<camino::Utf8PathBuf>,
234 pub diverged: Vec<camino::Utf8PathBuf>,
239}
240
241impl SecretReport {
242 pub fn has_drift(&self) -> bool {
243 !self.diverged.is_empty()
244 }
245
246 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 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 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 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 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 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 assert_eq!(
349 strip_age_suffix(Utf8PathBuf::from("home/foo.txt").as_path()),
350 None
351 );
352 assert_eq!(strip_age_suffix(Utf8PathBuf::from(".age").as_path()), None);
354 }
355}