use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use age::ssh;
use age::{Decryptor, Encryptor, Identity, IdentityFile, Recipient};
use base64::Engine;
use eyre::{Result, WrapErr, eyre};
use indexmap::IndexSet;
use crate::config::Settings;
use crate::config::env_directive::{AgeFormat, EnvDirective, EnvDirectiveOptions};
use crate::file::{self, replace_path};
use crate::{dirs, env};
const ZSTD_COMPRESSION_LEVEL: i32 = 3;
const COMPRESSION_THRESHOLD: usize = 1024;
pub async fn create_age_directive(
key: String,
value: &str,
recipients: &[Box<dyn Recipient + Send>],
) -> Result<EnvDirective> {
if recipients.is_empty() {
return Err(eyre!(
"[experimental] No age recipients provided for encryption"
));
}
let encryptor =
match Encryptor::with_recipients(recipients.iter().map(|r| r.as_ref() as &dyn Recipient)) {
Ok(encryptor) => encryptor,
Err(e) => return Err(eyre!("[experimental] Failed to create encryptor: {}", e)),
};
let mut encrypted = Vec::new();
let mut writer = encryptor.wrap_output(&mut encrypted)?;
writer.write_all(value.as_bytes())?;
writer.finish()?;
let (encoded, format) = if encrypted.len() > COMPRESSION_THRESHOLD {
let compressed = zstd::encode_all(&encrypted[..], ZSTD_COMPRESSION_LEVEL)?;
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&compressed);
(encoded, Some(AgeFormat::Zstd))
} else {
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&encrypted);
(encoded, None) };
Ok(EnvDirective::Age {
key,
value: encoded,
format,
options: EnvDirectiveOptions::default(),
})
}
pub async fn decrypt_age_directive(directive: &EnvDirective) -> Result<String> {
Settings::get().ensure_experimental("age encryption")?;
match directive {
EnvDirective::Age { value, format, .. } => {
let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(value)
.wrap_err("[experimental] Failed to decode base64")?;
let ciphertext = match format {
Some(AgeFormat::Zstd) => zstd::decode_all(&decoded[..])
.wrap_err("[experimental] Failed to decompress zstd")?,
Some(AgeFormat::Raw) | None => decoded,
};
let identities = load_all_identities().await?;
if identities.is_empty() {
return Err(eyre!(
"[experimental] No age identities found for decryption"
));
}
let decryptor = Decryptor::new(&ciphertext[..])?;
let mut decrypted = Vec::new();
let identity_refs: Vec<&dyn Identity> = identities
.iter()
.map(|i| i.as_ref() as &dyn Identity)
.collect();
match decryptor.decrypt(identity_refs.into_iter()) {
Ok(mut reader) => {
reader.read_to_end(&mut decrypted)?;
}
Err(e) => {
return Err(eyre!("[experimental] Failed to decrypt: {}", e));
}
}
String::from_utf8(decrypted)
.wrap_err("[experimental] Decrypted value is not valid UTF-8")
}
_ => Err(eyre!("[experimental] Not an Age directive")),
}
}
pub async fn load_recipients_from_defaults() -> Result<Vec<Box<dyn Recipient + Send>>> {
let mut recipients: IndexSet<String> = IndexSet::new();
if let Some(key_file) = get_default_key_file().await
&& key_file.exists()
{
let content = file::read_to_string(&key_file)?;
for line in content.lines() {
let line = line.trim();
if line.starts_with("AGE-SECRET-KEY-")
&& let Ok(identity) = line.parse::<age::x25519::Identity>()
{
recipients.insert(identity.to_public().to_string());
}
}
}
let ssh_key_paths = get_default_ssh_key_paths();
for path in ssh_key_paths {
if path.exists()
&& let Ok(recipient) = load_ssh_recipient_from_private_key(&path).await
{
recipients.insert(recipient);
}
}
let mut parsed_recipients: Vec<Box<dyn Recipient + Send>> = Vec::new();
for recipient_str in recipients {
if let Some(recipient) = parse_recipient(&recipient_str)? {
parsed_recipients.push(recipient);
}
}
if parsed_recipients.is_empty() {
return Err(eyre!(
"[experimental] No age recipients found. Provide --age-recipient, --age-ssh-recipient, or configure settings.age.key_file"
));
}
Ok(parsed_recipients)
}
pub async fn load_recipients_from_key_file(path: &Path) -> Result<Vec<Box<dyn Recipient + Send>>> {
let mut recipients: Vec<Box<dyn Recipient + Send>> = Vec::new();
if !path.exists() {
return Err(eyre!(
"[experimental] Age key file not found: {}",
path.display()
));
}
let content = file::read_to_string(path)?;
for line in content.lines() {
let line = line.trim();
if line.starts_with("AGE-SECRET-KEY-")
&& let Ok(identity) = line.parse::<age::x25519::Identity>()
{
let public_key = identity.to_public();
recipients.push(Box::new(public_key));
}
}
if recipients.is_empty() {
return Err(eyre!(
"[experimental] No valid age identities found in {}",
path.display()
));
}
Ok(recipients)
}
pub fn parse_recipient(recipient_str: &str) -> Result<Option<Box<dyn Recipient + Send>>> {
let trimmed = recipient_str.trim();
if trimmed.starts_with("age1") {
match trimmed.parse::<age::x25519::Recipient>() {
Ok(r) => Ok(Some(Box::new(r))),
Err(e) => Err(eyre!("[experimental] Invalid age recipient: {}", e)),
}
} else if trimmed.starts_with("ssh-") {
match trimmed.parse::<ssh::Recipient>() {
Ok(r) => Ok(Some(Box::new(r))),
Err(e) => Err(eyre!("[experimental] Invalid SSH recipient: {:?}", e)),
}
} else {
Ok(None)
}
}
pub async fn load_ssh_recipient_from_path(path: &Path) -> Result<Box<dyn Recipient + Send>> {
let content = file::read_to_string(path)?;
let trimmed = content.trim();
if trimmed.starts_with("ssh-") {
match trimmed.parse::<ssh::Recipient>() {
Ok(r) => return Ok(Box::new(r)),
Err(e) => {
return Err(eyre!(
"[experimental] Invalid SSH public key at {}: {:?}",
path.display(),
e
));
}
}
}
if path.extension().and_then(|s| s.to_str()) == Some("pub") {
Err(eyre!(
"[experimental] Invalid SSH public key at {}",
path.display()
))
} else {
load_ssh_recipient_from_private_key(path)
.await
.and_then(|s| {
parse_recipient(&s)?
.ok_or_else(|| eyre!("[experimental] Failed to parse SSH recipient"))
})
}
}
async fn load_ssh_recipient_from_private_key(path: &Path) -> Result<String> {
let pub_path = path.with_extension("pub");
if pub_path.exists() {
let content = file::read_to_string(&pub_path)?;
let trimmed = content.trim();
if trimmed.starts_with("ssh-") {
return Ok(trimmed.to_string());
}
}
Err(eyre!(
"[experimental] Could not find public key for SSH private key at {}. Expected {}.pub",
path.display(),
path.display()
))
}
async fn load_all_identities() -> Result<Vec<Box<dyn Identity>>> {
let identity_files = get_all_identity_files().await;
let ssh_identity_files = get_all_ssh_identity_files();
let mut identities: Vec<Box<dyn Identity>> = Vec::new();
if let Ok(age_key) = env::var("MISE_AGE_KEY")
&& !age_key.is_empty()
{
for line in age_key.lines() {
let line = line.trim();
if line.starts_with("AGE-SECRET-KEY-")
&& let Ok(identity) = line.parse::<age::x25519::Identity>()
{
identities.push(Box::new(identity));
}
}
if identities.is_empty()
&& let Ok(identity_file) = IdentityFile::from_buffer(age_key.as_bytes())
&& let Ok(mut file_identities) = identity_file.into_identities()
{
identities.append(&mut file_identities);
}
}
for path in identity_files {
if path.exists() {
match file::read_to_string(&path) {
Ok(content) => {
if let Ok(identity_file) = IdentityFile::from_buffer(content.as_bytes())
&& let Ok(mut file_identities) = identity_file.into_identities()
{
identities.append(&mut file_identities);
}
}
Err(e) => {
debug!(
"[experimental] Failed to read identity file {:?}: {}",
path, e
);
}
}
}
}
for path in ssh_identity_files {
if path.exists() {
match std::fs::File::open(&path) {
Ok(file) => {
let mut reader = BufReader::new(file);
match ssh::Identity::from_buffer(&mut reader, Some(path.display().to_string()))
{
Ok(identity) => {
identities.push(Box::new(identity));
}
Err(e) => {
debug!(
"[experimental] Failed to parse SSH identity from {:?}: {}",
path, e
);
}
}
}
Err(e) => {
debug!(
"[experimental] Failed to read SSH identity file {:?}: {}",
path, e
);
}
}
}
}
Ok(identities)
}
async fn get_default_key_file() -> Option<PathBuf> {
Settings::get()
.age
.key_file
.clone()
.map(replace_path)
.or_else(|| {
let default_path = dirs::CONFIG.join("age.txt");
if default_path.exists() {
Some(default_path)
} else {
None
}
})
}
async fn get_all_identity_files() -> Vec<PathBuf> {
let mut files = Vec::new();
if let Some(ref identity_files) = Settings::get().age.identity_files {
for path in identity_files {
files.push(replace_path(path.clone()));
}
}
if let Some(key_file) = Settings::get().age.key_file.clone() {
files.push(replace_path(key_file));
}
let default_age_txt = dirs::CONFIG.join("age.txt");
if default_age_txt.exists() && !files.contains(&default_age_txt) {
files.push(default_age_txt);
}
files
}
fn get_all_ssh_identity_files() -> Vec<PathBuf> {
let mut files = Vec::new();
if let Some(ref ssh_identity_files) = Settings::get().age.ssh_identity_files {
for path in ssh_identity_files {
files.push(replace_path(path.clone()));
}
}
files.extend(get_default_ssh_key_paths());
files
}
fn get_default_ssh_key_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let home = &*dirs::HOME;
let ssh_dir = home.join(".ssh");
paths.push(ssh_dir.join("id_ed25519"));
paths.push(ssh_dir.join("id_rsa"));
paths
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_age_x25519_round_trip_small() -> Result<()> {
let key = age::x25519::Identity::generate();
let recipient = key.to_public();
let plaintext = "secret value";
let recipients: Vec<Box<dyn Recipient + Send>> = vec![Box::new(recipient)];
let directive =
create_age_directive("TEST_VAR".to_string(), plaintext, &recipients).await?;
if let crate::config::env_directive::EnvDirective::Age { value, format, .. } = directive {
assert!(
format.is_none()
|| matches!(format, Some(crate::config::env_directive::AgeFormat::Raw))
);
use age::secrecy::ExposeSecret;
env::set_var("MISE_AGE_KEY", key.to_string().expose_secret());
let decrypted =
decrypt_age_directive(&crate::config::env_directive::EnvDirective::Age {
key: "TEST_VAR".to_string(),
value,
format,
options: Default::default(),
})
.await?;
env::remove_var("MISE_AGE_KEY");
assert_eq!(decrypted, plaintext);
} else {
panic!("Expected Age directive");
}
Ok(())
}
#[tokio::test]
async fn test_age_x25519_round_trip_large() -> Result<()> {
let key = age::x25519::Identity::generate();
let recipient = key.to_public();
let plaintext = "x".repeat(2000);
let recipients: Vec<Box<dyn Recipient + Send>> = vec![Box::new(recipient)];
let directive =
create_age_directive("TEST_VAR".to_string(), &plaintext, &recipients).await?;
if let crate::config::env_directive::EnvDirective::Age { value, format, .. } = directive {
assert_eq!(format, Some(crate::config::env_directive::AgeFormat::Zstd));
use age::secrecy::ExposeSecret;
env::set_var("MISE_AGE_KEY", key.to_string().expose_secret());
let decrypted =
decrypt_age_directive(&crate::config::env_directive::EnvDirective::Age {
key: "TEST_VAR".to_string(),
value,
format,
options: Default::default(),
})
.await?;
env::remove_var("MISE_AGE_KEY");
assert_eq!(decrypted, plaintext);
} else {
panic!("Expected Age directive");
}
Ok(())
}
#[test]
fn test_parse_recipient() -> Result<()> {
let age_recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
let parsed = parse_recipient(age_recipient)?;
assert!(parsed.is_some());
let ssh_recipient =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmkfJ8VZq4m5k7tJVts7+nR01fbRvLHLgeQCF6FWYr5";
let parsed = parse_recipient(ssh_recipient)?;
assert!(parsed.is_some());
let invalid = "invalid_recipient";
let parsed = parse_recipient(invalid)?;
assert!(parsed.is_none());
Ok(())
}
}