pub(crate) use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
pub(crate) const ENC_PREFIX: &str = "ENC[age,";
pub(crate) const ENC_SUFFIX: &str = "]";
pub fn has_encrypted_markers(s: &str) -> bool {
for (start, end) in find_markers(s) {
let inner = &s[start + ENC_PREFIX.len()..end - ENC_SUFFIX.len()];
if inner.len() >= 20 && B64.decode(inner).is_ok() {
return true;
}
}
false
}
pub(crate) fn find_markers(s: &str) -> Vec<(usize, usize)> {
let mut markers = Vec::new();
let mut start = 0;
while let Some(pos) = s[start..].find(ENC_PREFIX) {
let abs_start = start + pos;
let after_prefix = abs_start + ENC_PREFIX.len();
if let Some(end_pos) = s[after_prefix..].find(ENC_SUFFIX) {
let abs_end = after_prefix + end_pos + ENC_SUFFIX.len();
markers.push((abs_start, abs_end));
start = abs_end;
} else {
break; }
}
markers
}
#[cfg(feature = "encryption")]
use std::io::{Read, Write};
#[cfg(feature = "encryption")]
pub fn encrypt(plaintext: &str, recipient_strs: &[&str]) -> Result<String, String> {
if recipient_strs.is_empty() {
return Err("at least one recipient required".to_string());
}
let recipients: Vec<Box<dyn age::Recipient + Send>> = recipient_strs
.iter()
.map(|r| {
r.parse::<age::x25519::Recipient>()
.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
.map_err(|e| format!("invalid recipient '{r}': {e}"))
})
.collect::<Result<Vec<_>, _>>()?;
let encryptor = age::Encryptor::with_recipients(
recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient),
)
.map_err(|e| format!("encryptor init failed: {e}"))?;
let mut encrypted = vec![];
let mut writer = encryptor
.wrap_output(&mut encrypted)
.map_err(|e| format!("encryption error: {e}"))?;
writer
.write_all(plaintext.as_bytes())
.map_err(|e| format!("write error: {e}"))?;
writer
.finish()
.map_err(|e| format!("encryption finish error: {e}"))?;
let encoded = B64.encode(&encrypted);
Ok(format!("{ENC_PREFIX}{encoded}{ENC_SUFFIX}"))
}
#[cfg(feature = "encryption")]
pub fn decrypt_marker(
marker: &str,
identities: &[age::x25519::Identity],
) -> Result<String, String> {
let inner = marker
.strip_prefix(ENC_PREFIX)
.and_then(|s| s.strip_suffix(ENC_SUFFIX))
.ok_or_else(|| "invalid ENC marker: does not match ENC[age,...] format".to_string())?;
let encrypted = B64
.decode(inner)
.map_err(|e| format!("base64 decode error: {e}"))?;
let decryptor = age::Decryptor::new(encrypted.as_slice())
.map_err(|e| format!("age decryptor error: {e}"))?;
let mut reader = decryptor
.decrypt(identities.iter().map(|i| i as &dyn age::Identity))
.map_err(|e| format!("decryption failed: {e}"))?;
let mut plaintext = String::new();
reader
.read_to_string(&mut plaintext)
.map_err(|e| format!("read error: {e}"))?;
Ok(plaintext)
}
#[cfg(feature = "encryption")]
pub fn decrypt_all(s: &str, identities: &[age::x25519::Identity]) -> Result<String, String> {
decrypt_all_counted(s, identities).map(|(s, _)| s)
}
#[cfg(feature = "encryption")]
pub fn decrypt_all_counted(
s: &str,
identities: &[age::x25519::Identity],
) -> Result<(String, usize), String> {
if !has_encrypted_markers(s) {
return Ok((s.to_string(), 0));
}
let mut result = s.to_string();
let markers = find_markers(&result);
let count = markers.len();
for (start, end) in markers.into_iter().rev() {
let marker = &result[start..end];
let plaintext = decrypt_marker(marker, identities)?;
result.replace_range(start..end, &plaintext);
}
Ok((result, count))
}
#[cfg(feature = "encryption")]
pub fn load_identity_from_env() -> Result<age::x25519::Identity, String> {
let key_str = std::env::var("FORJAR_AGE_KEY")
.map_err(|_| "FORJAR_AGE_KEY not set (required for ENC[age,...] decryption)".to_string())?;
parse_identity(&key_str)
}
#[cfg(feature = "encryption")]
pub fn load_identity_file(path: &std::path::Path) -> Result<age::x25519::Identity, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read identity file '{}': {}", path.display(), e))?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("AGE-SECRET-KEY-") {
return parse_identity(trimmed);
}
}
Err(format!("no AGE-SECRET-KEY found in '{}'", path.display()))
}
#[cfg(feature = "encryption")]
pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, String> {
key_str
.trim()
.parse::<age::x25519::Identity>()
.map_err(|e| format!("invalid age identity: {e}"))
}
#[cfg(feature = "encryption")]
pub fn identity_to_recipient(identity: &age::x25519::Identity) -> String {
identity.to_public().to_string()
}
#[cfg(feature = "encryption")]
pub fn generate_identity() -> age::x25519::Identity {
age::x25519::Identity::generate()
}
#[cfg(feature = "encryption")]
pub fn load_identities(
identity_path: Option<&std::path::Path>,
) -> Result<Vec<age::x25519::Identity>, String> {
if let Some(path) = identity_path {
return Ok(vec![load_identity_file(path)?]);
}
Ok(vec![load_identity_from_env()?])
}
#[cfg(not(feature = "encryption"))]
pub fn decrypt_all_inline(s: &str) -> Result<String, String> {
if has_encrypted_markers(s) {
return Err("ENC[age,...] markers found but forjar was compiled without encryption support. Rebuild with `--features encryption`.".to_string());
}
Ok(s.to_string())
}