use std::env;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::STANDARD, Engine};
use crate::{EnvroError, EnvroVars};
pub(crate) const IDENTITY_FILE_KEY: &str = "ENVRO_AGE_IDENTITY_FILE";
const MARKER_PREFIX: &str = "Encrypted[AGE:b64:";
const MARKER_SUFFIX: char = ']';
pub(crate) fn is_encrypted_marker(value: &str) -> bool {
value.starts_with(MARKER_PREFIX) && value.ends_with(MARKER_SUFFIX)
}
fn crypto_err(key: impl Into<String>, reason: impl ToString) -> EnvroError {
EnvroError::Decrypt {
key: key.into(),
reason: reason.to_string(),
}
}
pub fn encrypt_value<R: age::Recipient>(
plaintext: &str,
recipients: &[R],
) -> Result<String, EnvroError> {
if recipients.is_empty() {
return Err(crypto_err("(encrypt)", "missing recipients"));
}
let ciphertext = if recipients.len() == 1 {
age::encrypt(&recipients[0], plaintext.as_bytes())
.map_err(|e| crypto_err("(encrypt)", e))?
} else {
let encryptor =
age::Encryptor::with_recipients(recipients.iter().map(|r| r as &dyn age::Recipient))
.map_err(|e| crypto_err("(encrypt)", e))?;
let mut out = Vec::with_capacity(plaintext.len() + 128);
let mut writer = encryptor.wrap_output(&mut out).expect("vec wrap_output");
writer
.write_all(plaintext.as_bytes())
.expect("vec write_all");
writer.finish().expect("vec finish");
out
};
Ok(format!(
"{MARKER_PREFIX}{}{MARKER_SUFFIX}",
STANDARD.encode(ciphertext)
))
}
pub fn decrypt_value<I: age::Identity>(
value: &str,
identities: &[I],
) -> Result<String, EnvroError> {
decrypt_with_dyn(
value,
identities.iter().map(|i| i as &dyn age::Identity),
"(decrypt)",
)
}
fn decrypt_with_dyn<'a>(
value: &str,
identities: impl Iterator<Item = &'a dyn age::Identity>,
key: &str,
) -> Result<String, EnvroError> {
if !is_encrypted_marker(value) {
return Ok(value.to_string());
}
let b64 = &value[MARKER_PREFIX.len()..value.len() - 1];
let ciphertext = STANDARD
.decode(b64)
.map_err(|e| crypto_err(key, format!("invalid base64: {e}")))?;
let ids: Vec<&dyn age::Identity> = identities.collect();
let decryptor =
age::Decryptor::new_buffered(&ciphertext[..]).map_err(|e| crypto_err(key, e))?;
let mut reader = decryptor
.decrypt(ids.into_iter())
.map_err(|e| crypto_err(key, e))?;
let mut plaintext = Vec::new();
reader
.read_to_end(&mut plaintext)
.map_err(|e| crypto_err(key, e))?;
String::from_utf8(plaintext).map_err(|e| crypto_err(key, format!("plaintext not utf-8: {e}")))
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = home_dir() {
return home;
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}
fn load_identities(path: &Path) -> Result<Vec<Box<dyn age::Identity>>, EnvroError> {
let path_str = path.to_string_lossy().into_owned();
let file = age::IdentityFile::from_file(path_str.clone()).map_err(|e| {
crypto_err(
IDENTITY_FILE_KEY,
format!("cannot read identity file {path_str}: {e}"),
)
})?;
let identities = file
.into_identities()
.map_err(|e| crypto_err(IDENTITY_FILE_KEY, e))?;
if identities.is_empty() {
return Err(crypto_err(
IDENTITY_FILE_KEY,
format!("no identities in {path_str}"),
));
}
Ok(identities)
}
pub(crate) fn decrypt_dotenv_vars(mut vars: EnvroVars) -> Result<EnvroVars, EnvroError> {
let identity_path = vars.remove(IDENTITY_FILE_KEY);
let encrypted_keys: Vec<String> = vars
.iter()
.filter(|(_, v)| is_encrypted_marker(v))
.map(|(k, _)| k.clone())
.collect();
if encrypted_keys.is_empty() {
return Ok(vars);
}
let path = identity_path.ok_or_else(|| {
crypto_err(
IDENTITY_FILE_KEY,
"missing ENVRO_AGE_IDENTITY_FILE for Encrypted values",
)
})?;
let path = expand_tilde(path.trim());
let identities = load_identities(&path)?;
let id_refs: Vec<&dyn age::Identity> = identities.iter().map(|b| b.as_ref()).collect();
for key in encrypted_keys {
let value = vars.get(&key).expect("key from scan").clone();
let plain = decrypt_with_dyn(&value, id_refs.iter().copied(), &key)?;
vars.insert(key, plain);
}
drop(identities);
Ok(vars)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
env_lock().lock().unwrap_or_else(|p| p.into_inner())
}
#[test]
fn expand_tilde_home_and_relative() {
let _g = lock_env();
let home = home_dir().expect("HOME or USERPROFILE");
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
}
#[test]
fn expand_tilde_without_home_falls_back_to_literal() {
let _g = lock_env();
let prev_home = env::var_os("HOME");
let prev_profile = env::var_os("USERPROFILE");
env::remove_var("HOME");
env::remove_var("USERPROFILE");
assert_eq!(expand_tilde("~"), PathBuf::from("~"));
assert_eq!(expand_tilde("~/x"), PathBuf::from("~/x"));
match prev_home {
Some(v) => env::set_var("HOME", v),
None => env::remove_var("HOME"),
}
match prev_profile {
Some(v) => env::set_var("USERPROFILE", v),
None => env::remove_var("USERPROFILE"),
}
}
}