templating: Add decrypt filter

The `decrypt` filter can be used in Tera templates to decrypt a string
using `age`.  This will allow the template context to contain encrypted
vaules that are only decrypted if they are actually used to render a
template.

I considered using the [age] crate to implement this filter, rather
than using the `age` command, but I decided against it for two reasons:

1. The way I intend for the `keys.txt` file to be populated is by
   fetching the keys from *keyserv.pyrocufflink.blue*, which returns
   an encrypted file that has to be decrypted with the `age` command
   before it can be used.  Since that means `age` will always be
   available, it makes less sense to increase the size of the `tmpl`
   binary with what is effectively duplicate code.
2. The Rust API for *age* is rather un-ergonomic, particularly with
   respect to loading identites from a file.  It's much easier to

[age]: https://crates.io/crates/age
master
Dustin 2024-01-11 17:16:58 -06:00
parent 47d138a9c6
commit dba9037230
3 changed files with 95 additions and 1 deletions

View File

@ -1,4 +1,5 @@
mod model;
mod templating;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
@ -120,7 +121,12 @@ fn process_instructions(
instructions: Instructions,
values: Value,
) -> Result<(), ProcessInstructionsError> {
let tera = Tera::new(&format!("{}/**", templates))?;
let mut tera = Tera::new(&format!("{}/**", templates))?;
tera.register_filter(
templating::decrypt::NAME,
templating::decrypt::DecryptFilter,
);
let ctx = Context::from_serialize(&values)?;
for i in instructions.render {
let out = match tera.render(&i.template, &ctx) {

87
src/templating/decrypt.rs Normal file
View File

@ -0,0 +1,87 @@
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use tera::Value;
#[derive(Debug, thiserror::Error)]
pub(crate) enum DecryptError {
#[error("Decryption failed: {0}")]
Io(#[from] std::io::Error),
#[error("Decryption failed: {0}")]
DecryptFailed(String),
}
pub(crate) static NAME: &str = "decrypt";
pub(crate) struct DecryptFilter;
impl tera::Filter for DecryptFilter {
fn filter(
&self,
value: &Value,
args: &HashMap<String, Value>,
) -> tera::Result<Value> {
let data = match value {
Value::String(s) => s.clone().into_bytes(),
_ => {
return Err(tera::Error::msg(
"Can only decrypt string values",
));
}
};
let identity = match args.get("identity") {
Some(Value::String(s)) => s,
Some(_) => {
return Err(tera::Error::msg("identity must be string"));
}
None => "keys.txt",
};
let decrypted = Self::decrypt(&data, identity)
.map_err(|e| tera::Error::chain("Decryption failed", e))?;
Ok(Value::String(
String::from_utf8(decrypted)
.map_err(|_| tera::Error::msg("Invalid UTF-8 string"))?,
))
}
fn is_safe(&self) -> bool {
false
}
}
impl DecryptFilter {
fn decrypt(
data: &[u8],
identity: impl AsRef<Path>,
) -> Result<Vec<u8>, DecryptError> {
let mut cmd = Command::new("age")
.arg("-d")
.arg("-i")
.arg(identity.as_ref())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = cmd.stdin.take() {
stdin.write_all(data)?;
drop(stdin);
}
let mut decrypted = vec![];
if let Some(stdout) = cmd.stdout.as_mut() {
stdout.read_to_end(&mut decrypted)?;
}
let status = cmd.wait()?;
if status.success() {
Ok(decrypted)
} else {
let mut error = vec![];
if let Some(stderr) = cmd.stderr.as_mut() {
let _ = stderr.read_to_end(&mut error);
}
let msg = String::from_utf8_lossy(&error[..]).trim_end().into();
Err(DecryptError::DecryptFailed(msg))
}
}
}

1
src/templating/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod decrypt;