1use std::io::{Read, Write};
30
31use age::Identity;
32
33use crate::decrypt::{Decryptor, Encryptor};
34use crate::error::Error;
35
36const SOPS_KEY_FILE: &str = "SOPS_AGE_KEY_FILE";
39
40const AGE_KEY_FILE: &str = "AGE_IDENTITY_FILE";
42
43const AGE_KEY: &str = "AGE_SECRET_KEY";
45
46pub struct Age {
52 identities: Vec<Box<dyn Identity + Send + Sync>>,
53 described: String,
54}
55
56impl Age {
57 pub fn from_identity_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
67 let path = path.as_ref();
68 let named = path.display().to_string();
69
70 let file = age::IdentityFile::from_file(named.clone())
71 .map_err(|error| Error::decrypt(format!("cannot read {named}: {error}")))?;
72
73 let identities = file.into_identities().map_err(|error| {
74 Error::decrypt(format!("{named} holds no usable identity: {error}"))
75 })?;
76
77 Self::from_identities(identities, format!("age, keys from {named}"))
78 }
79
80 pub fn from_key(text: &str) -> Result<Self, Error> {
87 let file = age::IdentityFile::from_buffer(std::io::BufReader::new(text.as_bytes()))
88 .map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
89
90 let identities = file
91 .into_identities()
92 .map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
93
94 Self::from_identities(identities, "age, key from the environment".to_owned())
95 }
96
97 pub fn from_environment() -> Result<Self, Error> {
108 for variable in [SOPS_KEY_FILE, AGE_KEY_FILE] {
109 if let Ok(path) = std::env::var(variable) {
110 if !path.is_empty() {
111 return Self::from_identity_file(path);
112 }
113 }
114 }
115
116 if let Ok(mut key) = std::env::var(AGE_KEY) {
117 if !key.is_empty() {
118 let parsed = Self::from_key(&key);
119
120 {
124 use zeroize::Zeroize;
125
126 key.zeroize();
127 }
128
129 return parsed;
130 }
131 }
132
133 Err(Error::decrypt(format!(
134 "no age key in the environment; set {SOPS_KEY_FILE}, {AGE_KEY_FILE} or {AGE_KEY}"
135 )))
136 }
137
138 #[must_use]
144 pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
145 let identity =
146 age::scrypt::Identity::new(age::secrecy::SecretString::from(passphrase.into()));
147
148 Self {
149 identities: vec![Box::new(identity)],
150 described: "age, passphrase".to_owned(),
151 }
152 }
153
154 fn from_identities(
155 identities: Vec<Box<dyn Identity + Send + Sync>>,
156 described: String,
157 ) -> Result<Self, Error> {
158 if identities.is_empty() {
159 return Err(Error::decrypt(format!("{described}: no identities")));
160 }
161
162 Ok(Self {
163 identities,
164 described,
165 })
166 }
167}
168
169impl Decryptor for Age {
170 fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> {
171 let reader = age::armor::ArmoredReader::new(ciphertext);
175
176 let decryptor = age::Decryptor::new_buffered(reader)
177 .map_err(|error| Error::decrypt(format!("not a usable age file: {error}")))?;
178
179 let mut plaintext = decryptor
180 .decrypt(
181 self.identities
182 .iter()
183 .map(|identity| identity.as_ref() as _),
184 )
185 .map_err(|error| match error {
186 age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed => {
191 Error::decrypt(
192 "none of the configured identities is a recipient of this file; \
193 wrong key, or wrong passphrase",
194 )
195 }
196 error => Error::decrypt(error.to_string()),
197 })?;
198
199 let mut bytes = Vec::new();
200
201 if let Err(error) = plaintext.read_to_end(&mut bytes) {
202 {
205 use zeroize::Zeroize;
206
207 bytes.zeroize();
208 }
209
210 return Err(Error::decrypt(format!("the payload is damaged: {error}")));
211 }
212
213 Ok(bytes)
214 }
215
216 fn describe(&self) -> String {
217 self.described.clone()
218 }
219}
220
221pub struct Recipients {
234 recipients: Vec<Box<dyn age::Recipient + Send + Sync>>,
235 described: String,
236}
237
238impl Recipients {
239 pub fn from_public_keys<I, S>(keys: I) -> Result<Self, Error>
250 where
251 I: IntoIterator<Item = S>,
252 S: AsRef<str>,
253 {
254 let mut recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
255
256 for key in keys {
257 let key = key.as_ref().trim();
258
259 if key.is_empty() || key.starts_with('#') {
261 continue;
262 }
263
264 let parsed: age::x25519::Recipient = key.parse().map_err(|error| {
265 Error::decrypt(format!("`{key}` is not an age recipient: {error}"))
266 })?;
267
268 recipients.push(Box::new(parsed));
269 }
270
271 if recipients.is_empty() {
272 return Err(Error::decrypt(
273 "no recipients; a file encrypted to nobody cannot be read by anybody",
274 ));
275 }
276
277 let described = format!("age, {} recipient(s)", recipients.len());
278
279 Ok(Self {
280 recipients,
281 described,
282 })
283 }
284
285 pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
294 let path = path.as_ref();
295
296 let text = std::fs::read_to_string(path)
297 .map_err(|error| Error::decrypt(format!("cannot read {}: {error}", path.display())))?;
298
299 let mut recipients = Self::from_public_keys(text.lines())?;
300 recipients.described = format!("age, recipients from {}", path.display());
301
302 Ok(recipients)
303 }
304
305 #[must_use]
311 pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
312 let recipient =
313 age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.into()));
314
315 Self {
316 recipients: vec![Box::new(recipient)],
317 described: "age, passphrase".to_owned(),
318 }
319 }
320}
321
322impl Encryptor for Recipients {
323 fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
324 let recipients = self
325 .recipients
326 .iter()
327 .map(|recipient| recipient.as_ref() as &dyn age::Recipient);
328
329 let encryptor = age::Encryptor::with_recipients(recipients)
333 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
334
335 let mut ciphertext = Vec::new();
336
337 let mut writer = encryptor
338 .wrap_output(&mut ciphertext)
339 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
340
341 writer
342 .write_all(plaintext)
343 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
344
345 writer
348 .finish()
349 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
350
351 Ok(ciphertext)
352 }
353
354 fn describe(&self) -> String {
355 self.described.clone()
356 }
357}
358
359impl std::fmt::Debug for Recipients {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 f.debug_struct("Recipients")
362 .field("recipients", &self.recipients.len())
363 .field("from", &self.described)
364 .finish()
365 }
366}
367
368impl std::fmt::Debug for Age {
369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370 f.debug_struct("Age")
371 .field("identities", &self.identities.len())
372 .field("from", &self.described)
373 .finish()
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
384 let recipient =
385 age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.to_owned()));
386
387 age::encrypt(&recipient, plaintext).expect("encrypting should succeed")
388 }
389
390 #[test]
391 fn a_file_encrypted_to_a_passphrase_comes_back() {
392 let ciphertext = encrypt(br#"{"db": {"host": "localhost"}}"#, "hunter2");
393
394 let plaintext = Age::from_passphrase("hunter2")
395 .decrypt(&ciphertext)
396 .expect("the passphrase matches");
397
398 assert_eq!(plaintext, br#"{"db": {"host": "localhost"}}"#);
399 }
400
401 #[test]
402 fn the_wrong_passphrase_says_so_rather_than_returning_rubbish() {
403 let ciphertext = encrypt(b"{}", "hunter2");
404
405 let error = Age::from_passphrase("hunter3")
406 .decrypt(&ciphertext)
407 .expect_err("the passphrase does not match");
408
409 assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
410 assert!(error.to_string().contains("recipient"), "{error}");
411 }
412
413 #[test]
414 fn something_that_is_not_an_age_file_is_a_clear_error() {
415 let error = Age::from_passphrase("hunter2")
416 .decrypt(br#"{"db": {"host": "localhost"}}"#)
417 .expect_err("plaintext is not an age file");
418
419 assert!(
420 error.to_string().contains("not a usable age file"),
421 "{error}"
422 );
423 }
424
425 #[test]
426 fn an_armored_file_is_read_without_being_told() {
427 let recipient =
428 age::scrypt::Recipient::new(age::secrecy::SecretString::from("hunter2".to_owned()));
429 let armored =
430 age::encrypt_and_armor(&recipient, b"{\"db\": {}}").expect("armoring should succeed");
431
432 assert!(
433 armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"),
434 "{armored}"
435 );
436
437 let plaintext = Age::from_passphrase("hunter2")
438 .decrypt(armored.as_bytes())
439 .expect("armor is detected, not configured");
440
441 assert_eq!(plaintext, b"{\"db\": {}}");
442 }
443
444 #[test]
445 fn an_identity_file_is_read_from_disk() {
446 let identity = age::x25519::Identity::generate();
447 let key = identity.to_string();
448
449 let directory = tempfile::tempdir().unwrap();
452
453 let path = directory.path().join("key.txt");
454 std::fs::write(
455 &path,
456 format!(
457 "# a comment\n{}\n",
458 age::secrecy::ExposeSecret::expose_secret(&key)
459 ),
460 )
461 .unwrap();
462
463 let age = Age::from_identity_file(&path).expect("the file holds one identity");
464
465 assert!(age.describe().contains("key.txt"), "{}", age.describe());
466
467 let ciphertext = age::encrypt(&identity.to_public(), b"{\"db\": {}}").unwrap();
468
469 assert_eq!(age.decrypt(&ciphertext).unwrap(), b"{\"db\": {}}");
470 }
471
472 #[test]
473 fn a_file_that_holds_no_identity_says_so() {
474 let directory = tempfile::tempdir().unwrap();
475
476 let path = directory.path().join("key.txt");
477 std::fs::write(&path, "# nothing but a comment\n").unwrap();
478
479 let error = Age::from_identity_file(&path).expect_err("there is no key in there");
480
481 assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
482 }
483
484 #[test]
485 fn a_missing_identity_file_names_itself() {
486 let error = Age::from_identity_file("/no/such/key.txt").expect_err("there is no such file");
487
488 assert!(error.to_string().contains("/no/such/key.txt"), "{error}");
489 }
490
491 #[test]
492 fn debug_never_prints_a_key() {
493 let printed = format!("{:?}", Age::from_passphrase("hunter2"));
494
495 assert!(!printed.contains("hunter2"), "{printed}");
496 }
497}