use std::io::{Read, Write};
use age::Identity;
use crate::decrypt::{Decryptor, Encryptor};
use crate::error::Error;
const SOPS_KEY_FILE: &str = "SOPS_AGE_KEY_FILE";
const AGE_KEY_FILE: &str = "AGE_IDENTITY_FILE";
const AGE_KEY: &str = "AGE_SECRET_KEY";
pub struct Age {
identities: Vec<Box<dyn Identity + Send + Sync>>,
described: String,
}
impl Age {
pub fn from_identity_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
let path = path.as_ref();
let named = path.display().to_string();
let file = age::IdentityFile::from_file(named.clone())
.map_err(|error| Error::decrypt(format!("cannot read {named}: {error}")))?;
let identities = file.into_identities().map_err(|error| {
Error::decrypt(format!("{named} holds no usable identity: {error}"))
})?;
Self::from_identities(identities, format!("age, keys from {named}"))
}
pub fn from_key(text: &str) -> Result<Self, Error> {
let file = age::IdentityFile::from_buffer(std::io::BufReader::new(text.as_bytes()))
.map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
let identities = file
.into_identities()
.map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
Self::from_identities(identities, "age, key from the environment".to_owned())
}
pub fn from_environment() -> Result<Self, Error> {
for variable in [SOPS_KEY_FILE, AGE_KEY_FILE] {
if let Ok(path) = std::env::var(variable) {
if !path.is_empty() {
return Self::from_identity_file(path);
}
}
}
if let Ok(mut key) = std::env::var(AGE_KEY) {
if !key.is_empty() {
let parsed = Self::from_key(&key);
{
use zeroize::Zeroize;
key.zeroize();
}
return parsed;
}
}
Err(Error::decrypt(format!(
"no age key in the environment; set {SOPS_KEY_FILE}, {AGE_KEY_FILE} or {AGE_KEY}"
)))
}
#[must_use]
pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
let identity =
age::scrypt::Identity::new(age::secrecy::SecretString::from(passphrase.into()));
Self {
identities: vec![Box::new(identity)],
described: "age, passphrase".to_owned(),
}
}
fn from_identities(
identities: Vec<Box<dyn Identity + Send + Sync>>,
described: String,
) -> Result<Self, Error> {
if identities.is_empty() {
return Err(Error::decrypt(format!("{described}: no identities")));
}
Ok(Self {
identities,
described,
})
}
}
impl Decryptor for Age {
fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> {
let reader = age::armor::ArmoredReader::new(ciphertext);
let decryptor = age::Decryptor::new_buffered(reader)
.map_err(|error| Error::decrypt(format!("not a usable age file: {error}")))?;
let mut plaintext = decryptor
.decrypt(
self.identities
.iter()
.map(|identity| identity.as_ref() as _),
)
.map_err(|error| match error {
age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed => {
Error::decrypt(
"none of the configured identities is a recipient of this file; \
wrong key, or wrong passphrase",
)
}
error => Error::decrypt(error.to_string()),
})?;
let mut bytes = Vec::new();
if let Err(error) = plaintext.read_to_end(&mut bytes) {
{
use zeroize::Zeroize;
bytes.zeroize();
}
return Err(Error::decrypt(format!("the payload is damaged: {error}")));
}
Ok(bytes)
}
fn describe(&self) -> String {
self.described.clone()
}
}
pub struct Recipients {
recipients: Vec<Box<dyn age::Recipient + Send + Sync>>,
described: String,
}
impl Recipients {
pub fn from_public_keys<I, S>(keys: I) -> Result<Self, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
for key in keys {
let key = key.as_ref().trim();
if key.is_empty() || key.starts_with('#') {
continue;
}
let parsed: age::x25519::Recipient = key.parse().map_err(|error| {
Error::decrypt(format!("`{key}` is not an age recipient: {error}"))
})?;
recipients.push(Box::new(parsed));
}
if recipients.is_empty() {
return Err(Error::decrypt(
"no recipients; a file encrypted to nobody cannot be read by anybody",
));
}
let described = format!("age, {} recipient(s)", recipients.len());
Ok(Self {
recipients,
described,
})
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
let path = path.as_ref();
let text = std::fs::read_to_string(path)
.map_err(|error| Error::decrypt(format!("cannot read {}: {error}", path.display())))?;
let mut recipients = Self::from_public_keys(text.lines())?;
recipients.described = format!("age, recipients from {}", path.display());
Ok(recipients)
}
#[must_use]
pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
let recipient =
age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.into()));
Self {
recipients: vec![Box::new(recipient)],
described: "age, passphrase".to_owned(),
}
}
}
impl Encryptor for Recipients {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
let recipients = self
.recipients
.iter()
.map(|recipient| recipient.as_ref() as &dyn age::Recipient);
let encryptor = age::Encryptor::with_recipients(recipients)
.map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
let mut ciphertext = Vec::new();
let mut writer = encryptor
.wrap_output(&mut ciphertext)
.map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
writer
.write_all(plaintext)
.map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
writer
.finish()
.map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
Ok(ciphertext)
}
fn describe(&self) -> String {
self.described.clone()
}
}
impl std::fmt::Debug for Recipients {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Recipients")
.field("recipients", &self.recipients.len())
.field("from", &self.described)
.finish()
}
}
impl std::fmt::Debug for Age {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Age")
.field("identities", &self.identities.len())
.field("from", &self.described)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
let recipient =
age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.to_owned()));
age::encrypt(&recipient, plaintext).expect("encrypting should succeed")
}
#[test]
fn a_file_encrypted_to_a_passphrase_comes_back() {
let ciphertext = encrypt(br#"{"db": {"host": "localhost"}}"#, "hunter2");
let plaintext = Age::from_passphrase("hunter2")
.decrypt(&ciphertext)
.expect("the passphrase matches");
assert_eq!(plaintext, br#"{"db": {"host": "localhost"}}"#);
}
#[test]
fn the_wrong_passphrase_says_so_rather_than_returning_rubbish() {
let ciphertext = encrypt(b"{}", "hunter2");
let error = Age::from_passphrase("hunter3")
.decrypt(&ciphertext)
.expect_err("the passphrase does not match");
assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
assert!(error.to_string().contains("recipient"), "{error}");
}
#[test]
fn something_that_is_not_an_age_file_is_a_clear_error() {
let error = Age::from_passphrase("hunter2")
.decrypt(br#"{"db": {"host": "localhost"}}"#)
.expect_err("plaintext is not an age file");
assert!(
error.to_string().contains("not a usable age file"),
"{error}"
);
}
#[test]
fn an_armored_file_is_read_without_being_told() {
let recipient =
age::scrypt::Recipient::new(age::secrecy::SecretString::from("hunter2".to_owned()));
let armored =
age::encrypt_and_armor(&recipient, b"{\"db\": {}}").expect("armoring should succeed");
assert!(
armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"),
"{armored}"
);
let plaintext = Age::from_passphrase("hunter2")
.decrypt(armored.as_bytes())
.expect("armor is detected, not configured");
assert_eq!(plaintext, b"{\"db\": {}}");
}
#[test]
fn an_identity_file_is_read_from_disk() {
let identity = age::x25519::Identity::generate();
let key = identity.to_string();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("key.txt");
std::fs::write(
&path,
format!(
"# a comment\n{}\n",
age::secrecy::ExposeSecret::expose_secret(&key)
),
)
.unwrap();
let age = Age::from_identity_file(&path).expect("the file holds one identity");
assert!(age.describe().contains("key.txt"), "{}", age.describe());
let ciphertext = age::encrypt(&identity.to_public(), b"{\"db\": {}}").unwrap();
assert_eq!(age.decrypt(&ciphertext).unwrap(), b"{\"db\": {}}");
}
#[test]
fn a_file_that_holds_no_identity_says_so() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("key.txt");
std::fs::write(&path, "# nothing but a comment\n").unwrap();
let error = Age::from_identity_file(&path).expect_err("there is no key in there");
assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
}
#[test]
fn a_missing_identity_file_names_itself() {
let error = Age::from_identity_file("/no/such/key.txt").expect_err("there is no such file");
assert!(error.to_string().contains("/no/such/key.txt"), "{error}");
}
#[test]
fn debug_never_prints_a_key() {
let printed = format!("{:?}", Age::from_passphrase("hunter2"));
assert!(!printed.contains("hunter2"), "{printed}");
}
}