use anyhow::{anyhow, bail, Context, Result};
use secrecy::{ExposeSecret, Secret};
use std::io::Write;
use std::path::Path;
#[cfg(test)]
thread_local! {
static PROMPT_OVERRIDE: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
fn set_prompt_override(v: Option<&str>) {
PROMPT_OVERRIDE.with(|slot| {
*slot.borrow_mut() = v.map(|s| s.to_string());
});
}
#[cfg_attr(test, allow(unused_variables))]
fn prompt_passphrase(confirm: bool) -> Result<String> {
#[cfg(test)]
{
let maybe = PROMPT_OVERRIDE.with(|slot| slot.borrow().clone());
if let Some(v) = maybe {
return Ok(v);
}
return Err(anyhow!("prompt override not set in test"));
}
#[cfg(not(test))]
{
let first = rpassword::prompt_password("Passphrase: ")
.map_err(|e| anyhow!(
"no passphrase source; set --passphrase-file <PATH>, $RECON_PASSPHRASE, or run with a terminal ({e})"
))?;
if confirm {
let second = rpassword::prompt_password("Confirm passphrase: ")
.map_err(|e| anyhow!("passphrase confirm prompt failed: {e}"))?;
if first != second {
return Err(anyhow!("passphrases do not match"));
}
}
Ok(first)
}
}
pub fn resolve_passphrase(
passphrase_file: Option<&Path>,
confirm: bool,
) -> Result<Secret<String>> {
if let Some(path) = passphrase_file {
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read passphrase file '{}'", path.display()))?;
let s = String::from_utf8(bytes)
.map_err(|_| anyhow!("passphrase file '{}' is not valid UTF-8", path.display()))?;
let trimmed = if s.ends_with('\n') { &s[..s.len() - 1] } else { &s[..] };
if trimmed.is_empty() {
return Err(anyhow!("passphrase file '{}' is empty", path.display()));
}
return Ok(Secret::new(trimmed.to_string()));
}
if let Ok(v) = std::env::var("RECON_PASSPHRASE") {
if !v.is_empty() {
return Ok(Secret::new(v));
}
}
let prompted = prompt_passphrase(confirm)?;
if prompted.is_empty() {
return Err(anyhow!("passphrase cannot be empty"));
}
Ok(Secret::new(prompted))
}
pub fn resolve_recipients(values: &[String]) -> Result<Vec<Box<dyn age::Recipient + Send>>> {
let mut out: Vec<Box<dyn age::Recipient + Send>> = Vec::new();
for v in values {
if let Some(rec) = parse_recipient_literal(v)? {
out.push(Box::new(rec));
continue;
}
let path = Path::new(v);
if !path.exists() {
return Err(anyhow!(
"invalid recipient '{v}': not an age1... public key or a readable file"
));
}
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read recipient file '{v}'"))?;
let mut found = 0usize;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let rec = parse_recipient_literal(trimmed)?
.ok_or_else(|| anyhow!(
"invalid recipient key in '{v}': '{trimmed}'"
))?;
out.push(Box::new(rec));
found += 1;
}
if found == 0 {
return Err(anyhow!(
"recipient file '{v}' contains no age1... keys"
));
}
}
Ok(out)
}
fn parse_recipient_literal(s: &str) -> Result<Option<age::x25519::Recipient>> {
if !s.starts_with("age1") {
return Ok(None);
}
let rec: age::x25519::Recipient = s.parse()
.map_err(|e| anyhow!("invalid recipient '{s}': {e}"))?;
Ok(Some(rec))
}
pub fn resolve_identities(
paths: &[std::path::PathBuf],
) -> Result<Vec<Box<dyn age::Identity>>> {
let mut out: Vec<Box<dyn age::Identity>> = Vec::new();
for path in paths {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read identity file '{}'", path.display()))?;
for (i, line) in contents.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let id: age::x25519::Identity = trimmed.parse().map_err(|e| {
anyhow!(
"invalid identity in '{}' at line {}: {e}",
path.display(),
i + 1,
)
})?;
out.push(Box::new(id));
}
}
Ok(out)
}
pub struct EncryptParams<'a> {
pub recipients: &'a [String],
pub passphrase_file: Option<&'a std::path::Path>,
pub armor: bool,
pub verbose: u8,
pub output: Option<&'a std::path::Path>,
}
pub struct DecryptParams<'a> {
pub passphrase_file: Option<&'a std::path::Path>,
pub identity_paths: &'a [std::path::PathBuf],
pub verbose: u8,
pub output: Option<&'a std::path::Path>,
}
fn source_label(kind: &crate::source::SourceKind) -> String {
match kind {
crate::source::SourceKind::Stdin => "stdin".to_string(),
crate::source::SourceKind::File(p) => p.display().to_string(),
crate::source::SourceKind::Http(u) => u.clone(),
}
}
fn open_output_path(path: Option<&std::path::Path>) -> Result<Box<dyn std::io::Write>> {
match path {
Some(p) => Ok(Box::new(std::fs::File::create(p)?)),
None => Ok(Box::new(std::io::stdout().lock())),
}
}
pub fn encrypt_streaming(
mut reader: impl std::io::Read,
source_kind: &crate::source::SourceKind,
params: &EncryptParams<'_>,
) -> Result<()> {
let encryptor = if !params.recipients.is_empty() {
let recipients = resolve_recipients(params.recipients)?;
age::Encryptor::with_recipients(
recipients.iter().map(|b| b.as_ref() as &dyn age::Recipient),
)
.map_err(|e| anyhow!("encrypt: {e}"))?
} else {
let passphrase = resolve_passphrase(params.passphrase_file, true)?;
age::Encryptor::with_user_passphrase(age::secrecy::SecretString::from(
passphrase.expose_secret().to_string(),
))
};
if params.verbose >= 1 {
let label = source_label(source_kind);
eprintln!(
"* encrypt: age {} ({})",
if params.armor { "armored" } else { "binary" },
label
);
}
let mut out = open_output_path(params.output)?;
if params.armor {
let armored = age::armor::ArmoredWriter::wrap_output(
&mut out,
age::armor::Format::AsciiArmor,
)
.map_err(|e| anyhow!("armor: {e}"))?;
let mut writer = encryptor
.wrap_output(armored)
.map_err(|e| anyhow!("encrypt: {e}"))?;
std::io::copy(&mut reader, &mut writer)?;
let armored = writer.finish().map_err(|e| anyhow!("encrypt finish: {e}"))?;
armored.finish().map_err(|e| anyhow!("armor finish: {e}"))?;
} else {
let mut writer = encryptor
.wrap_output(&mut out)
.map_err(|e| anyhow!("encrypt: {e}"))?;
std::io::copy(&mut reader, &mut writer)?;
writer.finish().map_err(|e| anyhow!("encrypt finish: {e}"))?;
}
Ok(())
}
pub fn decrypt_streaming(
buf: &[u8],
source_kind: &crate::source::SourceKind,
params: &DecryptParams<'_>,
) -> Result<()> {
let armored = age::armor::ArmoredReader::new(std::io::Cursor::new(buf));
let decryptor = age::Decryptor::new_buffered(armored)
.map_err(|e| anyhow!("decrypt header: {e}"))?;
let mut plaintext_reader: Box<dyn std::io::Read> = if decryptor.is_scrypt() {
let passphrase = resolve_passphrase(params.passphrase_file, false)?;
let pp = age::secrecy::SecretString::from(passphrase.expose_secret().to_string());
let identity = age::scrypt::Identity::new(pp);
let r = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow!("decryption failed: {e}"))?;
Box::new(r)
} else {
if params.identity_paths.is_empty() {
return Err(anyhow!(
"no matching identity for this payload; supply --identity or a passphrase"
));
}
let identities = resolve_identities(params.identity_paths)?;
let id_refs: Vec<&dyn age::Identity> =
identities.iter().map(|b| b.as_ref()).collect();
let r = decryptor
.decrypt(id_refs.into_iter())
.map_err(|e| anyhow!("decryption failed: {e}"))?;
Box::new(r)
};
if params.verbose >= 1 {
let label = source_label(source_kind);
eprintln!("* decrypt: age from {label}");
}
let mut out = open_output_path(params.output)?;
std::io::copy(&mut plaintext_reader, &mut out)?;
Ok(())
}
pub fn encrypt_bytes_recipients(
plaintext: &[u8],
recipients: &[String],
armor: bool,
) -> Result<Vec<u8>> {
if recipients.is_empty() {
return Err(anyhow!(
"encrypt_bytes_recipients: recipient list must not be empty"
));
}
let resolved = resolve_recipients(recipients)?;
let encryptor = age::Encryptor::with_recipients(
resolved.iter().map(|b| b.as_ref() as &dyn age::Recipient),
)
.map_err(|e| anyhow!("encrypt: {e}"))?;
let mut out: Vec<u8> = Vec::new();
if armor {
let armored =
age::armor::ArmoredWriter::wrap_output(&mut out, age::armor::Format::AsciiArmor)
.map_err(|e| anyhow!("armor: {e}"))?;
let mut writer = encryptor
.wrap_output(armored)
.map_err(|e| anyhow!("encrypt: {e}"))?;
std::io::Write::write_all(&mut writer, plaintext)?;
let armored = writer.finish().map_err(|e| anyhow!("encrypt finish: {e}"))?;
armored.finish().map_err(|e| anyhow!("armor finish: {e}"))?;
} else {
let mut writer = encryptor
.wrap_output(&mut out)
.map_err(|e| anyhow!("encrypt: {e}"))?;
std::io::Write::write_all(&mut writer, plaintext)?;
writer.finish().map_err(|e| anyhow!("encrypt finish: {e}"))?;
}
Ok(out)
}
pub fn decrypt_bytes_identities(
ciphertext: &[u8],
identity_paths: &[std::path::PathBuf],
) -> Result<Vec<u8>> {
if identity_paths.is_empty() {
return Err(anyhow!(
"decrypt_bytes_identities: identity list must not be empty"
));
}
let armored = age::armor::ArmoredReader::new(std::io::Cursor::new(ciphertext));
let decryptor =
age::Decryptor::new_buffered(armored).map_err(|e| anyhow!("decrypt header: {e}"))?;
let identities = resolve_identities(identity_paths)?;
let id_refs: Vec<&dyn age::Identity> = identities.iter().map(|b| b.as_ref()).collect();
let mut reader = decryptor
.decrypt(id_refs.into_iter())
.map_err(|e| anyhow!("decryption failed: {e}"))?;
let mut out = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut out)?;
Ok(out)
}
pub fn run_encrypt(args: &crate::cli::Args) -> Result<()> {
let backend = detect_backend(args)?;
if matches!(backend, Backend::Pgp) {
return run_encrypt_pgp(args);
}
let source_kind = crate::source::resolve(args)?;
let reader = crate::source::open(source_kind.clone(), args)?;
let params = EncryptParams {
recipients: &args.recipient,
passphrase_file: args.passphrase_file.as_deref(),
armor: args.armor,
verbose: args.verbose,
output: args.output.as_deref(),
};
encrypt_streaming(reader, &source_kind, ¶ms)
}
pub fn run_decrypt(args: &crate::cli::Args) -> Result<()> {
let source_kind = crate::source::resolve(args)?;
let mut reader = crate::source::open(source_kind.clone(), args)?;
let mut buf: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut buf)?;
if args.pgp || looks_like_pgp(&buf) {
return run_decrypt_pgp(args);
}
let params = DecryptParams {
passphrase_file: args.passphrase_file.as_deref(),
identity_paths: &args.identity,
verbose: args.verbose,
output: args.output.as_deref(),
};
decrypt_streaming(&buf, &source_kind, ¶ms)
}
pub fn run_keygen(args: &crate::cli::Args) -> Result<()> {
use std::io::Write;
let identity = age::x25519::Identity::generate();
let public = identity.to_public();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut out = open_output_path(args.output.as_deref())?;
writeln!(out, "# created: {}Z", format_iso8601_utc(now))?;
writeln!(out, "# public key: {}", public)?;
{
use age::secrecy::ExposeSecret as _;
writeln!(out, "{}", identity.to_string().expose_secret())?;
}
Ok(())
}
fn format_iso8601_utc(secs: u64) -> String {
let days = (secs / 86400) as i64;
let time_of_day = secs % 86400;
let hh = time_of_day / 3600;
let mm = (time_of_day % 3600) / 60;
let ss = time_of_day % 60;
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let year = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", year, m, d, hh, mm, ss)
}
pub fn run(args: &crate::cli::Args) -> Result<()> {
if args.rekey {
return run_rekey(args);
}
if args.encrypt {
return run_encrypt(args);
}
if args.decrypt {
return run_decrypt(args);
}
Err(anyhow!("internal: encrypt::run called without --encrypt, --decrypt, or --rekey"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Age,
Pgp,
}
pub fn detect_backend(args: &crate::cli::Args) -> Result<Backend> {
if args.pgp && args.age {
bail!("--pgp and --age are mutually exclusive");
}
if args.pgp {
return Ok(Backend::Pgp);
}
if args.age {
return Ok(Backend::Age);
}
if args.recipient.is_empty() {
return Ok(Backend::Age);
}
let any_non_age = args.recipient.iter().any(|r| {
let t = r.trim();
!(t.starts_with("age1") || std::path::Path::new(t).exists())
});
Ok(if any_non_age { Backend::Pgp } else { Backend::Age })
}
fn require_gpg_binary() -> Result<()> {
std::process::Command::new("gpg")
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| anyhow!("pgp: `gpg` binary not found on PATH ({e}). Install gnupg to use PGP encryption."))?;
Ok(())
}
fn run_encrypt_pgp(args: &crate::cli::Args) -> Result<()> {
let mut buf: Vec<u8> = Vec::new();
{
let source_kind = crate::source::resolve(args)?;
let mut reader = crate::source::open(source_kind, args)?;
std::io::Read::read_to_end(&mut reader, &mut buf)?;
}
let out = gpg_encrypt_bytes(&buf, &args.recipient, args.armor)?;
let mut sink = open_output_path(args.output.as_deref())?;
sink.write_all(&out)?;
Ok(())
}
fn run_decrypt_pgp(args: &crate::cli::Args) -> Result<()> {
let mut buf: Vec<u8> = Vec::new();
{
let source_kind = crate::source::resolve(args)?;
let mut reader = crate::source::open(source_kind, args)?;
std::io::Read::read_to_end(&mut reader, &mut buf)?;
}
let out = gpg_decrypt_bytes(&buf, args.passphrase_file.as_deref())?;
let mut sink = open_output_path(args.output.as_deref())?;
sink.write_all(&out)?;
Ok(())
}
pub fn gpg_encrypt_bytes(plaintext: &[u8], recipients: &[String], armor: bool) -> Result<Vec<u8>> {
require_gpg_binary()?;
if recipients.is_empty() {
bail!("pgp: --recipient is required for PGP encryption (symmetric-only not supported)");
}
let mut cmd = std::process::Command::new("gpg");
cmd.arg("--batch").arg("--yes").arg("--encrypt");
if armor {
cmd.arg("--armor");
}
cmd.arg("--trust-model").arg("always");
for r in recipients {
cmd.arg("--recipient").arg(r);
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("pgp: spawn gpg")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("pgp: gpg stdin missing"))?;
stdin.write_all(plaintext).context("pgp: write stdin")?;
}
let output = child.wait_with_output().context("pgp: await gpg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pgp: gpg encrypt failed (exit {}): {stderr}", output.status);
}
Ok(output.stdout)
}
pub fn gpg_decrypt_bytes(
ciphertext: &[u8],
passphrase_file: Option<&std::path::Path>,
) -> Result<Vec<u8>> {
require_gpg_binary()?;
let mut cmd = std::process::Command::new("gpg");
cmd.arg("--batch").arg("--yes").arg("--decrypt");
if let Some(p) = passphrase_file {
cmd.arg("--pinentry-mode").arg("loopback")
.arg("--passphrase-file").arg(p);
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("pgp: spawn gpg")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("pgp: gpg stdin missing"))?;
stdin.write_all(ciphertext).context("pgp: write stdin")?;
}
let output = child.wait_with_output().context("pgp: await gpg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pgp: gpg decrypt failed (exit {}): {stderr}", output.status);
}
Ok(output.stdout)
}
fn looks_like_pgp(bytes: &[u8]) -> bool {
bytes.starts_with(b"-----BEGIN PGP")
|| (!bytes.is_empty() && (bytes[0] & 0x80) != 0 && !looks_like_age(bytes))
}
fn looks_like_age(bytes: &[u8]) -> bool {
bytes.starts_with(b"age-encryption.org/v1")
|| bytes.starts_with(b"-----BEGIN AGE ENCRYPTED FILE-----")
}
pub fn run_rekey(args: &crate::cli::Args) -> Result<()> {
let mut input: Vec<u8> = Vec::new();
{
let source_kind = crate::source::resolve(args)?;
let mut reader = crate::source::open(source_kind, args)?;
std::io::Read::read_to_end(&mut reader, &mut input)?;
}
let source_is_pgp = looks_like_pgp(&input);
let plaintext: Vec<u8> = if source_is_pgp {
gpg_decrypt_bytes(&input, args.passphrase_file.as_deref())?
} else {
decrypt_bytes_age(&input, &args.identity, args.passphrase_file.as_deref())?
};
let target_backend = detect_backend(args)?;
let out: Vec<u8> = match target_backend {
Backend::Pgp => gpg_encrypt_bytes(&plaintext, &args.recipient, args.armor)?,
Backend::Age => {
if args.recipient.is_empty() {
bail!("--rekey: need --recipient for the new key set");
}
encrypt_bytes_recipients(&plaintext, &args.recipient, args.armor)?
}
};
let mut sink = open_output_path(args.output.as_deref())?;
sink.write_all(&out)?;
Ok(())
}
pub fn decrypt_bytes_age(
ciphertext: &[u8],
identity_paths: &[std::path::PathBuf],
passphrase_file: Option<&std::path::Path>,
) -> Result<Vec<u8>> {
let make_decryptor = || {
let armored = age::armor::ArmoredReader::new(std::io::Cursor::new(ciphertext));
age::Decryptor::new_buffered(armored).map_err(|e| anyhow!("decrypt header: {e}"))
};
let probe = make_decryptor()?;
let is_scrypt = probe.is_scrypt();
drop(probe);
let mut plaintext: Vec<u8> = Vec::new();
if is_scrypt {
let passphrase = resolve_passphrase(passphrase_file, false)?;
let pp = age::secrecy::SecretString::from(passphrase.expose_secret().to_string());
let identity = age::scrypt::Identity::new(pp);
let decryptor = make_decryptor()?;
let mut r = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow!("decryption failed: {e}"))?;
std::io::Read::read_to_end(&mut r, &mut plaintext)?;
} else {
let identities = resolve_identities(identity_paths)?;
let id_refs: Vec<&dyn age::Identity> = identities.iter().map(|b| b.as_ref()).collect();
let decryptor = make_decryptor()?;
let ident_result = decryptor.decrypt(id_refs.clone().into_iter());
match ident_result {
Ok(mut r) => {
std::io::Read::read_to_end(&mut r, &mut plaintext)?;
}
Err(e_ident) => {
if passphrase_file.is_some() || std::env::var("RECON_PASSPHRASE").is_ok() {
let passphrase = resolve_passphrase(passphrase_file, false)?;
let pp =
age::secrecy::SecretString::from(passphrase.expose_secret().to_string());
let identity = age::scrypt::Identity::new(pp);
let decryptor = make_decryptor()?;
let mut r = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow!("decryption failed (mixed-mode fallback): {e}"))?;
std::io::Read::read_to_end(&mut r, &mut plaintext)?;
} else {
return Err(anyhow!("decryption failed: {e_ident}"));
}
}
}
}
Ok(plaintext)
}
#[cfg(test)]
mod tests {
use super::*;
use age::secrecy::ExposeSecret as _;
use clap::Parser;
use std::io::Write;
use std::path::PathBuf;
fn write_tmp(name: &str, content: &[u8]) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"recon-encrypt-test-{}-{}.bin",
std::process::id(),
name,
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content).unwrap();
path
}
#[test]
fn passphrase_from_file() {
let path = write_tmp("pp1", b"hunter2\n");
let sec = resolve_passphrase(Some(&path), false).unwrap();
assert_eq!(sec.expose_secret(), "hunter2");
let _ = std::fs::remove_file(&path);
}
#[test]
fn backend_detection_age_when_all_age1() {
let mut args = crate::cli::Args::try_parse_from(["recon", "--encrypt", "x"]).unwrap();
args.recipient = vec!["age1foo".into(), "age1bar".into()];
let b = detect_backend(&args).unwrap();
assert_eq!(b, Backend::Age);
}
#[test]
fn backend_detection_pgp_on_hex_fingerprint() {
let mut args = crate::cli::Args::try_parse_from(["recon", "--encrypt", "x"]).unwrap();
args.recipient = vec!["0xDEADBEEF".into()];
let b = detect_backend(&args).unwrap();
assert_eq!(b, Backend::Pgp);
}
#[test]
fn backend_detection_pgp_on_email() {
let mut args = crate::cli::Args::try_parse_from(["recon", "--encrypt", "x"]).unwrap();
args.recipient = vec!["alice@example.com".into()];
let b = detect_backend(&args).unwrap();
assert_eq!(b, Backend::Pgp);
}
#[test]
fn backend_detection_explicit_age_overrides_hex() {
let mut args = crate::cli::Args::try_parse_from(["recon", "--encrypt", "x"]).unwrap();
args.recipient = vec!["0xDEADBEEF".into()];
args.age = true;
let b = detect_backend(&args).unwrap();
assert_eq!(b, Backend::Age);
}
#[test]
fn backend_detection_rejects_both_flags() {
let mut args = crate::cli::Args::try_parse_from(["recon", "--encrypt", "x"]).unwrap();
args.pgp = true;
args.age = true;
assert!(detect_backend(&args).is_err());
}
#[test]
fn looks_like_pgp_armored_header() {
assert!(looks_like_pgp(b"-----BEGIN PGP MESSAGE-----\n..."));
}
#[test]
fn looks_like_age_armored_header() {
assert!(looks_like_age(b"-----BEGIN AGE ENCRYPTED FILE-----\n..."));
assert!(looks_like_age(b"age-encryption.org/v1\n..."));
}
#[test]
fn rekey_round_trip_age_to_age() {
let old = age::x25519::Identity::generate();
let new = age::x25519::Identity::generate();
let old_path = write_tmp(
"rekey-old",
format!("{}\n", old.to_string().expose_secret()).as_bytes(),
);
let _new_path = write_tmp(
"rekey-new",
format!("{}\n", new.to_string().expose_secret()).as_bytes(),
);
let plaintext = b"rotate me";
let ct_old =
encrypt_bytes_recipients(plaintext, &[old.to_public().to_string()], false).unwrap();
let decoded = decrypt_bytes_age(&ct_old, &[old_path.clone()], None).unwrap();
let ct_new =
encrypt_bytes_recipients(&decoded, &[new.to_public().to_string()], false).unwrap();
let new_id_path = write_tmp(
"rekey-new-id",
format!("{}\n", new.to_string().expose_secret()).as_bytes(),
);
let round = decrypt_bytes_age(&ct_new, &[new_id_path.clone()], None).unwrap();
assert_eq!(round, plaintext);
let _ = std::fs::remove_file(&old_path);
let _ = std::fs::remove_file(&new_id_path);
}
#[test]
fn passphrase_file_no_trailing_newline() {
let path = write_tmp("pp2", b"hunter2");
let sec = resolve_passphrase(Some(&path), false).unwrap();
assert_eq!(sec.expose_secret(), "hunter2");
let _ = std::fs::remove_file(&path);
}
#[test]
fn passphrase_file_empty_errors() {
let path = write_tmp("pp3", b"\n");
let err = resolve_passphrase(Some(&path), false).unwrap_err().to_string();
assert!(err.contains("empty"), "got: {err}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn passphrase_file_missing_errors() {
let path = PathBuf::from("/tmp/recon-encrypt-does-not-exist");
let err = resolve_passphrase(Some(&path), false).unwrap_err().to_string();
assert!(err.contains("failed to read"), "got: {err}");
}
#[test]
fn passphrase_from_env_when_file_absent() {
let _guard = env_mutex().lock().unwrap();
std::env::set_var("RECON_PASSPHRASE", "envpass");
let sec = resolve_passphrase(None, false).unwrap();
assert_eq!(sec.expose_secret(), "envpass");
std::env::remove_var("RECON_PASSPHRASE");
}
#[test]
fn passphrase_file_beats_env() {
let _guard = env_mutex().lock().unwrap();
let path = write_tmp("pp4", b"filepass");
std::env::set_var("RECON_PASSPHRASE", "envpass");
let sec = resolve_passphrase(Some(&path), false).unwrap();
assert_eq!(sec.expose_secret(), "filepass");
std::env::remove_var("RECON_PASSPHRASE");
let _ = std::fs::remove_file(&path);
}
#[test]
fn passphrase_from_prompt_when_neither_set() {
let _guard = env_mutex().lock().unwrap();
std::env::remove_var("RECON_PASSPHRASE");
set_prompt_override(Some("promptpass"));
let sec = resolve_passphrase(None, false).unwrap();
assert_eq!(sec.expose_secret(), "promptpass");
set_prompt_override(None);
}
#[test]
fn passphrase_empty_prompt_errors() {
let _guard = env_mutex().lock().unwrap();
std::env::remove_var("RECON_PASSPHRASE");
set_prompt_override(Some(""));
let err = resolve_passphrase(None, false).unwrap_err().to_string();
assert!(err.contains("empty"), "got: {err}");
set_prompt_override(None);
}
fn write_text_tmp(name: &str, content: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"recon-encrypt-text-{}-{}.txt",
std::process::id(),
name,
));
std::fs::write(&path, content).unwrap();
path
}
fn make_keypair() -> (age::x25519::Identity, String) {
let id = age::x25519::Identity::generate();
let pub_key = id.to_public().to_string();
(id, pub_key)
}
#[test]
fn recipients_literal_age1_key() {
let (_, pub_key) = make_keypair();
let recs = resolve_recipients(&[pub_key])
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(recs.len(), 1);
}
#[test]
fn recipients_from_file() {
let (_, pub_key) = make_keypair();
let path = write_text_tmp("recips1", &format!("# comment\n{pub_key}\n"));
let recs = resolve_recipients(&[path.to_str().unwrap().to_string()])
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(recs.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn recipients_empty_file_errors() {
let path = write_text_tmp("recips2", "# only comments\n\n#\n");
let err = resolve_recipients(&[path.to_str().unwrap().to_string()])
.err()
.expect("expected an error")
.to_string();
assert!(err.contains("no age1") || err.contains("no age"), "got: {err}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn recipients_missing_path_errors() {
let err = resolve_recipients(&["/tmp/definitely-not-here.rec".to_string()])
.err()
.expect("expected an error")
.to_string();
assert!(err.contains("invalid recipient"), "got: {err}");
}
#[test]
fn recipients_malformed_literal_errors() {
let err = resolve_recipients(&["age1notvalid".to_string()])
.err()
.expect("expected an error")
.to_string();
assert!(err.contains("invalid recipient"), "got: {err}");
}
#[test]
fn identities_from_file() {
const TEST_SK: &str =
"AGE-SECRET-KEY-1GQ9778VQXMMJVE8SK7J6VT8UJ4HDQAJUVSFCWCM02D8GEWQ72PVQ2Y5J33";
let path = write_text_tmp("id1", &format!("# my key\n{TEST_SK}\n"));
let ids = resolve_identities(&[path.clone()])
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(ids.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn identities_malformed_line_reports_line_number() {
let path = write_text_tmp("id2", "# header\nNOT-AN-AGE-KEY\n");
let err = resolve_identities(&[path.clone()])
.err()
.expect("expected an error")
.to_string();
assert!(err.contains("line 2"), "got: {err}");
let _ = std::fs::remove_file(&path);
}
fn round_trip_passphrase(plaintext: &[u8], armor: bool) -> Vec<u8> {
let pp = age::secrecy::SecretString::from("hunter2".to_string());
let encryptor = age::Encryptor::with_user_passphrase(pp);
let mut ciphertext: Vec<u8> = Vec::new();
if armor {
let armored = age::armor::ArmoredWriter::wrap_output(
&mut ciphertext,
age::armor::Format::AsciiArmor,
)
.unwrap();
let mut writer = encryptor.wrap_output(armored).unwrap();
writer.write_all(plaintext).unwrap();
let armored = writer.finish().unwrap();
armored.finish().unwrap();
} else {
let mut writer = encryptor.wrap_output(&mut ciphertext).unwrap();
writer.write_all(plaintext).unwrap();
writer.finish().unwrap();
}
let armored =
age::armor::ArmoredReader::new(std::io::Cursor::new(&ciphertext[..]));
let decryptor = age::Decryptor::new_buffered(armored).unwrap();
let pp2 = age::secrecy::SecretString::from("hunter2".to_string());
let identity = age::scrypt::Identity::new(pp2);
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.unwrap();
let mut decrypted = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut decrypted).unwrap();
decrypted
}
#[test]
fn round_trip_passphrase_binary() {
let pt = b"hello encryption";
let got = round_trip_passphrase(pt, false);
assert_eq!(got, pt);
}
#[test]
fn round_trip_passphrase_armored() {
let pt = b"hello encryption";
let got = round_trip_passphrase(pt, true);
assert_eq!(got, pt);
}
#[test]
fn round_trip_x25519() {
let id = age::x25519::Identity::generate();
let pub_str = id.to_public().to_string();
let recipient: age::x25519::Recipient = pub_str.parse().unwrap();
let encryptor =
age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
.unwrap();
let plaintext = b"x25519 payload";
let mut ciphertext: Vec<u8> = Vec::new();
let mut writer = encryptor.wrap_output(&mut ciphertext).unwrap();
writer.write_all(plaintext).unwrap();
writer.finish().unwrap();
let armored =
age::armor::ArmoredReader::new(std::io::Cursor::new(&ciphertext[..]));
let decryptor = age::Decryptor::new_buffered(armored).unwrap();
let ids: Vec<&dyn age::Identity> = vec![&id];
let mut reader = decryptor.decrypt(ids.into_iter()).unwrap();
let mut decrypted = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut decrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_wrong_passphrase_errors() {
let pp = age::secrecy::SecretString::from("hunter2".to_string());
let encryptor = age::Encryptor::with_user_passphrase(pp);
let mut ciphertext: Vec<u8> = Vec::new();
let mut writer = encryptor.wrap_output(&mut ciphertext).unwrap();
writer.write_all(b"plaintext").unwrap();
writer.finish().unwrap();
let armored =
age::armor::ArmoredReader::new(std::io::Cursor::new(&ciphertext[..]));
let decryptor = age::Decryptor::new_buffered(armored).unwrap();
let wrong = age::secrecy::SecretString::from("wrong".to_string());
let identity = age::scrypt::Identity::new(wrong);
let err = decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity));
assert!(err.is_err(), "expected decryption failure with wrong passphrase");
}
#[test]
fn run_encrypt_decrypt_via_cli_args() {
use clap::Parser;
use crate::cli::Args;
let _guard = env_mutex().lock().unwrap();
let pp_path = write_tmp("cli-pp", b"ciphertest\n");
let pt_path = write_tmp("cli-pt", b"CLI round trip");
let ct_path = std::env::temp_dir().join(format!(
"recon-encrypt-cli-ct-{}.age",
std::process::id()
));
let dec_path = std::env::temp_dir().join(format!(
"recon-encrypt-cli-dec-{}.bin",
std::process::id()
));
let args = Args::try_parse_from([
"recon",
"--encrypt",
"--passphrase-file",
pp_path.to_str().unwrap(),
"-o",
ct_path.to_str().unwrap(),
pt_path.to_str().unwrap(),
]).unwrap();
run(&args).unwrap();
let args = Args::try_parse_from([
"recon",
"--decrypt",
"--passphrase-file",
pp_path.to_str().unwrap(),
"-o",
dec_path.to_str().unwrap(),
ct_path.to_str().unwrap(),
]).unwrap();
run(&args).unwrap();
let got = std::fs::read(&dec_path).unwrap();
assert_eq!(got, b"CLI round trip");
let _ = std::fs::remove_file(&pp_path);
let _ = std::fs::remove_file(&pt_path);
let _ = std::fs::remove_file(&ct_path);
let _ = std::fs::remove_file(&dec_path);
}
#[test]
fn run_keygen_produces_expected_lines() {
use clap::Parser;
use crate::cli::Args;
let out_path = std::env::temp_dir().join(format!(
"recon-encrypt-keygen-{}.txt",
std::process::id()
));
let args = Args::try_parse_from([
"recon",
"--encrypt-keygen",
"-o",
out_path.to_str().unwrap(),
]).unwrap();
run_keygen(&args).unwrap();
let text = std::fs::read_to_string(&out_path).unwrap();
assert!(text.contains("# created:"), "missing timestamp comment\n{text}");
assert!(text.contains("# public key: age1"), "missing public key comment\n{text}");
assert!(text.contains("AGE-SECRET-KEY-1"), "missing private key line\n{text}");
assert_eq!(text.lines().count(), 3);
let _ = std::fs::remove_file(&out_path);
}
fn env_mutex() -> &'static std::sync::Mutex<()> {
use std::sync::OnceLock;
static M: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
M.get_or_init(|| std::sync::Mutex::new(()))
}
}