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(key) = std::env::var(AGE_KEY) {
117 if !key.is_empty() {
118 return Self::from_key(&key);
119 }
120 }
121
122 Err(Error::decrypt(format!(
123 "no age key in the environment; set {SOPS_KEY_FILE}, {AGE_KEY_FILE} or {AGE_KEY}"
124 )))
125 }
126
127 #[must_use]
133 pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
134 let identity =
135 age::scrypt::Identity::new(age::secrecy::SecretString::from(passphrase.into()));
136
137 Self {
138 identities: vec![Box::new(identity)],
139 described: "age, passphrase".to_owned(),
140 }
141 }
142
143 fn from_identities(
144 identities: Vec<Box<dyn Identity + Send + Sync>>,
145 described: String,
146 ) -> Result<Self, Error> {
147 if identities.is_empty() {
148 return Err(Error::decrypt(format!("{described}: no identities")));
149 }
150
151 Ok(Self {
152 identities,
153 described,
154 })
155 }
156}
157
158impl Decryptor for Age {
159 fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> {
160 let reader = age::armor::ArmoredReader::new(ciphertext);
164
165 let decryptor = age::Decryptor::new_buffered(reader)
166 .map_err(|error| Error::decrypt(format!("not a usable age file: {error}")))?;
167
168 let mut plaintext = decryptor
169 .decrypt(
170 self.identities
171 .iter()
172 .map(|identity| identity.as_ref() as _),
173 )
174 .map_err(|error| match error {
175 age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed => {
180 Error::decrypt(
181 "none of the configured identities is a recipient of this file; \
182 wrong key, or wrong passphrase",
183 )
184 }
185 error => Error::decrypt(error.to_string()),
186 })?;
187
188 let mut bytes = Vec::new();
189
190 plaintext
191 .read_to_end(&mut bytes)
192 .map_err(|error| Error::decrypt(format!("the payload is damaged: {error}")))?;
193
194 Ok(bytes)
195 }
196
197 fn describe(&self) -> String {
198 self.described.clone()
199 }
200}
201
202pub struct Recipients {
215 recipients: Vec<Box<dyn age::Recipient + Send + Sync>>,
216 described: String,
217}
218
219impl Recipients {
220 pub fn from_public_keys<I, S>(keys: I) -> Result<Self, Error>
231 where
232 I: IntoIterator<Item = S>,
233 S: AsRef<str>,
234 {
235 let mut recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
236
237 for key in keys {
238 let key = key.as_ref().trim();
239
240 if key.is_empty() || key.starts_with('#') {
242 continue;
243 }
244
245 let parsed: age::x25519::Recipient = key.parse().map_err(|error| {
246 Error::decrypt(format!("`{key}` is not an age recipient: {error}"))
247 })?;
248
249 recipients.push(Box::new(parsed));
250 }
251
252 if recipients.is_empty() {
253 return Err(Error::decrypt(
254 "no recipients; a file encrypted to nobody cannot be read by anybody",
255 ));
256 }
257
258 let described = format!("age, {} recipient(s)", recipients.len());
259
260 Ok(Self {
261 recipients,
262 described,
263 })
264 }
265
266 pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
275 let path = path.as_ref();
276
277 let text = std::fs::read_to_string(path)
278 .map_err(|error| Error::decrypt(format!("cannot read {}: {error}", path.display())))?;
279
280 let mut recipients = Self::from_public_keys(text.lines())?;
281 recipients.described = format!("age, recipients from {}", path.display());
282
283 Ok(recipients)
284 }
285
286 #[must_use]
292 pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
293 let recipient =
294 age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.into()));
295
296 Self {
297 recipients: vec![Box::new(recipient)],
298 described: "age, passphrase".to_owned(),
299 }
300 }
301}
302
303impl Encryptor for Recipients {
304 fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
305 let recipients = self
306 .recipients
307 .iter()
308 .map(|recipient| recipient.as_ref() as &dyn age::Recipient);
309
310 let encryptor = age::Encryptor::with_recipients(recipients)
314 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
315
316 let mut ciphertext = Vec::new();
317
318 let mut writer = encryptor
319 .wrap_output(&mut ciphertext)
320 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
321
322 writer
323 .write_all(plaintext)
324 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
325
326 writer
329 .finish()
330 .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
331
332 Ok(ciphertext)
333 }
334
335 fn describe(&self) -> String {
336 self.described.clone()
337 }
338}
339
340impl std::fmt::Debug for Recipients {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 f.debug_struct("Recipients")
343 .field("recipients", &self.recipients.len())
344 .field("from", &self.described)
345 .finish()
346 }
347}
348
349impl std::fmt::Debug for Age {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 f.debug_struct("Age")
352 .field("identities", &self.identities.len())
353 .field("from", &self.described)
354 .finish()
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
365 let recipient =
366 age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.to_owned()));
367
368 age::encrypt(&recipient, plaintext).expect("encrypting should succeed")
369 }
370
371 #[test]
372 fn a_file_encrypted_to_a_passphrase_comes_back() {
373 let ciphertext = encrypt(br#"{"db": {"host": "localhost"}}"#, "hunter2");
374
375 let plaintext = Age::from_passphrase("hunter2")
376 .decrypt(&ciphertext)
377 .expect("the passphrase matches");
378
379 assert_eq!(plaintext, br#"{"db": {"host": "localhost"}}"#);
380 }
381
382 #[test]
383 fn the_wrong_passphrase_says_so_rather_than_returning_rubbish() {
384 let ciphertext = encrypt(b"{}", "hunter2");
385
386 let error = Age::from_passphrase("hunter3")
387 .decrypt(&ciphertext)
388 .expect_err("the passphrase does not match");
389
390 assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
391 assert!(error.to_string().contains("recipient"), "{error}");
392 }
393
394 #[test]
395 fn something_that_is_not_an_age_file_is_a_clear_error() {
396 let error = Age::from_passphrase("hunter2")
397 .decrypt(br#"{"db": {"host": "localhost"}}"#)
398 .expect_err("plaintext is not an age file");
399
400 assert!(
401 error.to_string().contains("not a usable age file"),
402 "{error}"
403 );
404 }
405
406 #[test]
407 fn an_armored_file_is_read_without_being_told() {
408 let recipient =
409 age::scrypt::Recipient::new(age::secrecy::SecretString::from("hunter2".to_owned()));
410 let armored =
411 age::encrypt_and_armor(&recipient, b"{\"db\": {}}").expect("armoring should succeed");
412
413 assert!(
414 armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"),
415 "{armored}"
416 );
417
418 let plaintext = Age::from_passphrase("hunter2")
419 .decrypt(armored.as_bytes())
420 .expect("armor is detected, not configured");
421
422 assert_eq!(plaintext, b"{\"db\": {}}");
423 }
424
425 #[test]
426 fn an_identity_file_is_read_from_disk() {
427 let identity = age::x25519::Identity::generate();
428 let key = identity.to_string();
429
430 let directory = tempfile::tempdir().unwrap();
433
434 let path = directory.path().join("key.txt");
435 std::fs::write(
436 &path,
437 format!(
438 "# a comment\n{}\n",
439 age::secrecy::ExposeSecret::expose_secret(&key)
440 ),
441 )
442 .unwrap();
443
444 let age = Age::from_identity_file(&path).expect("the file holds one identity");
445
446 assert!(age.describe().contains("key.txt"), "{}", age.describe());
447
448 let ciphertext = age::encrypt(&identity.to_public(), b"{\"db\": {}}").unwrap();
449
450 assert_eq!(age.decrypt(&ciphertext).unwrap(), b"{\"db\": {}}");
451 }
452
453 #[test]
454 fn a_file_that_holds_no_identity_says_so() {
455 let directory = tempfile::tempdir().unwrap();
456
457 let path = directory.path().join("key.txt");
458 std::fs::write(&path, "# nothing but a comment\n").unwrap();
459
460 let error = Age::from_identity_file(&path).expect_err("there is no key in there");
461
462 assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
463 }
464
465 #[test]
466 fn a_missing_identity_file_names_itself() {
467 let error = Age::from_identity_file("/no/such/key.txt").expect_err("there is no such file");
468
469 assert!(error.to_string().contains("/no/such/key.txt"), "{error}");
470 }
471
472 #[test]
473 fn debug_never_prints_a_key() {
474 let printed = format!("{:?}", Age::from_passphrase("hunter2"));
475
476 assert!(!printed.contains("hunter2"), "{printed}");
477 }
478}