use std::io::{Read, Write};
use anyhow::{bail, Context, Result};
use base64::Engine;
use crate::env::{Entry, EnvFile};
const PER_VAR_PREFIX: &str = "ENC[age:";
const PER_VAR_SUFFIX: &str = "]";
pub fn encrypt_whole_file(
plaintext: &[u8],
recipients: &[&age::x25519::Recipient],
) -> Result<Vec<u8>> {
age_encrypt_multi(plaintext, recipients)
}
pub fn decrypt_whole_file(ciphertext: &[u8], identity: &age::x25519::Identity) -> Result<Vec<u8>> {
age_decrypt(ciphertext, identity)
}
pub fn encrypt_per_var(env: &EnvFile, recipients: &[&age::x25519::Recipient]) -> Result<EnvFile> {
let mut result = EnvFile::new();
for entry in &env.entries {
match entry {
Entry::KeyValue { key, value } => {
let ciphertext = age_encrypt_multi(value.as_bytes(), recipients)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&ciphertext);
result.entries.push(Entry::KeyValue {
key: key.clone(),
value: format!("{}{}{}", PER_VAR_PREFIX, encoded, PER_VAR_SUFFIX),
});
}
other => {
result.entries.push(other.clone());
}
}
}
Ok(result)
}
pub fn decrypt_per_var(env: &EnvFile, identity: &age::x25519::Identity) -> Result<EnvFile> {
let mut result = EnvFile::new();
for entry in &env.entries {
match entry {
Entry::KeyValue { key, value } => {
let decrypted_value = if is_encrypted_value(value) {
let encoded = &value[PER_VAR_PREFIX.len()..value.len() - PER_VAR_SUFFIX.len()];
if encoded.len() > 1024 * 1024 {
bail!(
"encrypted value for '{}' exceeds maximum size (1 MB encoded)",
key
);
}
let ciphertext = base64::engine::general_purpose::STANDARD
.decode(encoded)
.with_context(|| {
format!("invalid base64 in encrypted value for '{}'", key)
})?;
let plaintext = age_decrypt(&ciphertext, identity)
.with_context(|| format!("failed to decrypt value for '{}'", key))?;
String::from_utf8(plaintext).with_context(|| {
format!("decrypted value for '{}' is not valid UTF-8", key)
})?
} else {
value.clone()
};
result.entries.push(Entry::KeyValue {
key: key.clone(),
value: decrypted_value,
});
}
other => {
result.entries.push(other.clone());
}
}
}
Ok(result)
}
pub fn is_encrypted_value(value: &str) -> bool {
value.starts_with(PER_VAR_PREFIX)
&& value.ends_with(PER_VAR_SUFFIX)
&& value.len() > PER_VAR_PREFIX.len() + PER_VAR_SUFFIX.len()
}
pub fn is_per_var_encrypted(content: &str) -> bool {
content.lines().any(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return false;
}
if let Some(eq_pos) = line.find('=') {
let value = line[eq_pos + 1..].trim();
is_encrypted_value(value)
} else {
false
}
})
}
pub fn is_age_encrypted(content: &[u8]) -> bool {
content.starts_with(b"age-encryption.org/v1")
}
fn age_encrypt_multi(data: &[u8], recipients: &[&age::x25519::Recipient]) -> Result<Vec<u8>> {
if recipients.is_empty() {
bail!("at least one recipient is required for encryption");
}
let recipients_iter = recipients.iter().map(|r| *r as &dyn age::Recipient);
let encryptor = age::Encryptor::with_recipients(recipients_iter)
.map_err(|e| anyhow::anyhow!("failed to create encryptor: {}", e))?;
let mut encrypted = vec![];
let mut writer = encryptor
.wrap_output(&mut encrypted)
.context("failed to create age encryptor")?;
writer
.write_all(data)
.context("failed to write age ciphertext")?;
writer
.finish()
.context("failed to finalize age encryption")?;
Ok(encrypted)
}
fn age_decrypt(ciphertext: &[u8], identity: &age::x25519::Identity) -> Result<Vec<u8>> {
let decryptor = age::Decryptor::new(ciphertext).context("failed to read age header")?;
let mut reader = decryptor
.decrypt(std::iter::once(identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("age decryption failed: {}", e))?;
let mut plaintext = vec![];
reader
.read_to_end(&mut plaintext)
.context("failed to read decrypted data")?;
Ok(plaintext)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::parser;
use crate::keys::identity::EnsealIdentity;
#[test]
fn whole_file_round_trip() {
let id = EnsealIdentity::generate();
let plaintext = b"SECRET=hunter2\nAPI_KEY=abc123\n";
let ciphertext = encrypt_whole_file(plaintext, &[&id.age_recipient]).unwrap();
assert!(!ciphertext.is_empty());
assert_ne!(&ciphertext[..], plaintext);
let decrypted = decrypt_whole_file(&ciphertext, &id.age_identity).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn whole_file_is_age_format() {
let id = EnsealIdentity::generate();
let ciphertext = encrypt_whole_file(b"data", &[&id.age_recipient]).unwrap();
assert!(is_age_encrypted(&ciphertext));
}
#[test]
fn per_var_round_trip() {
let id = EnsealIdentity::generate();
let env = parser::parse("SECRET=hunter2\nAPI_KEY=abc123\n").unwrap();
let encrypted = encrypt_per_var(&env, &[&id.age_recipient]).unwrap();
let encrypted_str = encrypted.to_string();
assert!(encrypted_str.contains("SECRET="));
assert!(encrypted_str.contains("API_KEY="));
for (_, value) in encrypted.vars() {
assert!(
is_encrypted_value(value),
"value should be encrypted: {}",
value
);
}
assert!(!encrypted_str.contains("hunter2"));
assert!(!encrypted_str.contains("abc123"));
let decrypted = decrypt_per_var(&encrypted, &id.age_identity).unwrap();
assert_eq!(decrypted.vars(), env.vars());
}
#[test]
fn per_var_preserves_structure() {
let id = EnsealIdentity::generate();
let env = parser::parse("# comment\nKEY=value\n\nOTHER=stuff\n").unwrap();
let encrypted = encrypt_per_var(&env, &[&id.age_recipient]).unwrap();
assert_eq!(encrypted.entries.len(), 4); assert!(matches!(encrypted.entries[0], Entry::Comment(_)));
assert!(matches!(encrypted.entries[2], Entry::Blank));
}
#[test]
fn per_var_is_valid_env_syntax() {
let id = EnsealIdentity::generate();
let env = parser::parse("KEY=value\nSECRET=hunter2\n").unwrap();
let encrypted = encrypt_per_var(&env, &[&id.age_recipient]).unwrap();
let encrypted_str = encrypted.to_string();
let reparsed = parser::parse(&encrypted_str).unwrap();
assert_eq!(reparsed.var_count(), 2);
}
#[test]
fn multi_recipient_any_can_decrypt() {
let id1 = EnsealIdentity::generate();
let id2 = EnsealIdentity::generate();
let plaintext = b"SHARED_SECRET=value123\n";
let ciphertext =
encrypt_whole_file(plaintext, &[&id1.age_recipient, &id2.age_recipient]).unwrap();
let d1 = decrypt_whole_file(&ciphertext, &id1.age_identity).unwrap();
assert_eq!(d1, plaintext);
let d2 = decrypt_whole_file(&ciphertext, &id2.age_identity).unwrap();
assert_eq!(d2, plaintext);
}
#[test]
fn multi_recipient_per_var() {
let id1 = EnsealIdentity::generate();
let id2 = EnsealIdentity::generate();
let env = parser::parse("SECRET=value\n").unwrap();
let encrypted = encrypt_per_var(&env, &[&id1.age_recipient, &id2.age_recipient]).unwrap();
let d1 = decrypt_per_var(&encrypted, &id1.age_identity).unwrap();
assert_eq!(d1.vars(), env.vars());
let d2 = decrypt_per_var(&encrypted, &id2.age_identity).unwrap();
assert_eq!(d2.vars(), env.vars());
}
#[test]
fn wrong_key_cannot_decrypt_whole() {
let id = EnsealIdentity::generate();
let wrong = EnsealIdentity::generate();
let ciphertext = encrypt_whole_file(b"secret", &[&id.age_recipient]).unwrap();
assert!(decrypt_whole_file(&ciphertext, &wrong.age_identity).is_err());
}
#[test]
fn wrong_key_cannot_decrypt_per_var() {
let id = EnsealIdentity::generate();
let wrong = EnsealIdentity::generate();
let env = parser::parse("SECRET=value\n").unwrap();
let encrypted = encrypt_per_var(&env, &[&id.age_recipient]).unwrap();
assert!(decrypt_per_var(&encrypted, &wrong.age_identity).is_err());
}
#[test]
fn no_plaintext_in_whole_file_output() {
let id = EnsealIdentity::generate();
let plaintext = b"VERY_SECRET_TOKEN=sk_live_should_not_appear";
let ciphertext = encrypt_whole_file(plaintext, &[&id.age_recipient]).unwrap();
let ciphertext_str = String::from_utf8_lossy(&ciphertext);
assert!(!ciphertext_str.contains("sk_live_should_not_appear"));
assert!(!ciphertext_str.contains("VERY_SECRET_TOKEN"));
}
#[test]
fn detection_per_var() {
assert!(is_per_var_encrypted("KEY=ENC[age:abc123]"));
assert!(!is_per_var_encrypted("KEY=plainvalue"));
assert!(!is_per_var_encrypted("# just a comment"));
}
#[test]
fn detection_age_format() {
assert!(is_age_encrypted(b"age-encryption.org/v1\nsomething"));
assert!(!is_age_encrypted(b"KEY=value\n"));
}
}