use age_core::{
format::{FileKey, Stanza},
io::{DebugReader, DebugWriter},
plugin::{Connection, Reply, Response, UnidirSend, IDENTITY_V1, RECIPIENT_V1},
secrecy::ExposeSecret,
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::Variant;
use std::borrow::Borrow;
use std::fmt;
use std::io;
use std::iter;
use std::path::PathBuf;
use std::process::{ChildStdin, ChildStdout};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime};
use crate::{
error::{DecryptError, EncryptError, PluginError},
fl,
util::parse_bech32,
wfl, wlnfl, Callbacks,
};
const PLUGIN_RECIPIENT_PREFIX: &str = "age1";
const PLUGIN_IDENTITY_PREFIX: &str = "age-plugin-";
const CMD_ERROR: &str = "error";
const CMD_RECIPIENT_STANZA: &str = "recipient-stanza";
const CMD_MSG: &str = "msg";
const CMD_CONFIRM: &str = "confirm";
const CMD_REQUEST_PUBLIC: &str = "request-public";
const CMD_REQUEST_SECRET: &str = "request-secret";
const CMD_FILE_KEY: &str = "file-key";
const ONE_HUNDRED_MS: Duration = Duration::from_millis(100);
const TEN_SECONDS: Duration = Duration::from_secs(10);
fn binary_name(plugin_name: &str) -> String {
format!("age-plugin-{}", plugin_name)
}
struct SlowPluginGuard(mpsc::Sender<()>);
impl SlowPluginGuard {
fn new<C: Callbacks>(callbacks: C, plugin_binary_name: String) -> Self {
let (send, recv) = mpsc::channel::<()>();
thread::spawn(move || {
let start = SystemTime::now();
loop {
if matches!(recv.try_recv(), Err(mpsc::TryRecvError::Disconnected)) {
break;
}
match SystemTime::now().duration_since(start) {
Ok(end) if end >= TEN_SECONDS => {
callbacks.display_message(&fl!(
"plugin-waiting-on-binary",
binary_name = plugin_binary_name,
));
break;
}
_ => thread::sleep(ONE_HUNDRED_MS),
}
}
});
SlowPluginGuard(send)
}
}
#[derive(Clone)]
pub struct Recipient {
name: String,
recipient: String,
}
impl std::str::FromStr for Recipient {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_bech32(s)
.ok_or("invalid Bech32 encoding")
.and_then(|(hrp, _)| {
if hrp.len() > PLUGIN_RECIPIENT_PREFIX.len()
&& hrp.starts_with(PLUGIN_RECIPIENT_PREFIX)
{
Ok(Recipient {
name: hrp.split_at(PLUGIN_RECIPIENT_PREFIX.len()).1.to_owned(),
recipient: s.to_owned(),
})
} else {
Err("invalid HRP")
}
})
}
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.recipient)
}
}
impl Recipient {
pub fn plugin(&self) -> &str {
&self.name
}
}
#[derive(Clone)]
pub struct Identity {
name: String,
identity: String,
}
impl std::str::FromStr for Identity {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_bech32(s)
.ok_or("invalid Bech32 encoding")
.and_then(|(hrp, _)| {
if hrp.len() > PLUGIN_IDENTITY_PREFIX.len()
&& hrp.starts_with(PLUGIN_IDENTITY_PREFIX)
{
Ok(Identity {
name: hrp
.split_at(PLUGIN_IDENTITY_PREFIX.len())
.1
.trim_end_matches('-')
.to_owned(),
identity: s.to_owned(),
})
} else {
Err("invalid HRP")
}
})
}
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.identity)
}
}
impl Identity {
pub fn default_for_plugin(plugin_name: &str) -> Self {
bech32::encode(
&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name),
[],
Variant::Bech32,
)
.expect("HRP is valid")
.to_uppercase()
.parse()
.unwrap()
}
pub fn plugin(&self) -> &str {
&self.name
}
}
struct Plugin {
binary_name: String,
path: PathBuf,
}
impl Plugin {
fn new(name: &str) -> Result<Self, String> {
let binary_name = binary_name(name);
match which::which(&binary_name).or_else(|e| {
if wsl::is_wsl() {
which::which(format!("{}.exe", binary_name)).map_err(|_| e)
} else {
Err(e)
}
}) {
Ok(path) => Ok(Plugin { binary_name, path }),
Err(_) => Err(binary_name),
}
}
fn connect(&self, state_machine: &str) -> io::Result<BlastFurnace> {
let conn = Connection::open(&self.path, state_machine)?;
Ok(BlastFurnace {
binary_name: self.binary_name.clone(),
conn,
})
}
}
struct BlastFurnace {
binary_name: String,
conn: Connection<DebugReader<ChildStdout>, DebugWriter<ChildStdin>>,
}
impl BlastFurnace {
fn handle_errors(&self, res: io::Result<()>) -> io::Result<()> {
res.map_err(|e| match e.kind() {
io::ErrorKind::UnexpectedEof => io::Error::new(
io::ErrorKind::ConnectionAborted,
PluginDiedError {
binary_name: self.binary_name.clone(),
},
),
_ => e,
})
}
fn unidir_send<
P: FnOnce(UnidirSend<DebugReader<ChildStdout>, DebugWriter<ChildStdin>>) -> io::Result<()>,
>(
&mut self,
phase_steps: P,
) -> io::Result<()> {
let res = self.conn.unidir_send(phase_steps);
self.handle_errors(res)
}
fn bidir_receive<H>(&mut self, commands: &[&str], handler: H) -> io::Result<()>
where
H: FnMut(Stanza, Reply<DebugReader<ChildStdout>, DebugWriter<ChildStdin>>) -> Response,
{
let res = self.conn.bidir_receive(commands, handler);
self.handle_errors(res)
}
}
#[derive(Debug)]
struct PluginDiedError {
binary_name: String,
}
impl fmt::Display for PluginDiedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
wlnfl!(
f,
"err-plugin-died",
plugin_name = self.binary_name.as_str(),
)?;
wlnfl!(f, "rec-plugin-died-1", env_var = "AGEDEBUG=plugin")?;
wfl!(f, "rec-plugin-died-2")
}
}
impl std::error::Error for PluginDiedError {}
fn handle_confirm<R: io::Read, W: io::Write, C: Callbacks>(
command: Stanza,
reply: Reply<R, W>,
errors: &mut Vec<PluginError>,
callbacks: &C,
) -> Response {
let message = String::from_utf8_lossy(&command.body);
let mut strings = command
.args
.iter()
.take(2)
.map(|s| BASE64_STANDARD_NO_PAD.decode(s));
let (yes_string, no_string) = match (strings.next(), strings.next()) {
(None, _) => {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: format!(
"{} command must have at least one metadata argument",
CMD_CONFIRM
),
});
return reply.fail();
}
(Some(Err(_)), _) | (_, Some(Err(_))) => {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: format!(
"The first two metadata arguments to the {} command must be Base64-encoded",
CMD_CONFIRM
),
});
return reply.fail();
}
(Some(Ok(yes_string)), None) => (yes_string, None),
(Some(Ok(yes_string)), Some(Ok(no_string))) => (yes_string, Some(no_string)),
};
if let Some(value) = callbacks.confirm(
&message,
&String::from_utf8_lossy(&yes_string),
no_string
.as_ref()
.map(|s| String::from_utf8_lossy(s))
.as_ref()
.map(|s| s.borrow()),
) {
reply.ok_with_metadata(&[if value { "yes" } else { "no" }], None)
} else {
reply.fail()
}
}
pub struct RecipientPluginV1<C: Callbacks> {
plugin: Plugin,
recipients: Vec<Recipient>,
identities: Vec<Identity>,
callbacks: C,
}
impl<C: Callbacks> RecipientPluginV1<C> {
pub fn new(
plugin_name: &str,
recipients: &[Recipient],
identities: &[Identity],
callbacks: C,
) -> Result<Self, EncryptError> {
Plugin::new(plugin_name)
.map_err(|binary_name| EncryptError::MissingPlugin { binary_name })
.map(|plugin| RecipientPluginV1 {
plugin,
recipients: recipients
.iter()
.filter(|r| r.name == plugin_name)
.cloned()
.collect(),
identities: identities
.iter()
.filter(|r| r.name == plugin_name)
.cloned()
.collect(),
callbacks,
})
}
}
impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
let mut conn = self.plugin.connect(RECIPIENT_V1)?;
let _guard = SlowPluginGuard::new(self.callbacks.clone(), self.plugin.binary_name.clone());
conn.unidir_send(|mut phase| {
for recipient in &self.recipients {
phase.send("add-recipient", &[&recipient.recipient], &[])?;
}
for identity in &self.identities {
phase.send("add-identity", &[&identity.identity], &[])?;
}
phase.send("wrap-file-key", &[], file_key.expose_secret())
})?;
let mut stanzas = vec![];
let mut errors = vec![];
if let Err(e) = conn.bidir_receive(
&[
CMD_MSG,
CMD_CONFIRM,
CMD_REQUEST_PUBLIC,
CMD_REQUEST_SECRET,
CMD_RECIPIENT_STANZA,
CMD_ERROR,
],
|mut command, reply| match command.tag.as_str() {
CMD_MSG => {
self.callbacks
.display_message(&String::from_utf8_lossy(&command.body));
reply.ok(None)
}
CMD_CONFIRM => handle_confirm(command, reply, &mut errors, &self.callbacks),
CMD_REQUEST_PUBLIC => {
if let Some(value) = self
.callbacks
.request_public_string(&String::from_utf8_lossy(&command.body))
{
reply.ok(Some(value.as_bytes()))
} else {
reply.fail()
}
}
CMD_REQUEST_SECRET => {
if let Some(secret) = self
.callbacks
.request_passphrase(&String::from_utf8_lossy(&command.body))
{
reply.ok(Some(secret.expose_secret().as_bytes()))
} else {
reply.fail()
}
}
CMD_RECIPIENT_STANZA => {
if command.args.len() >= 2 {
if command.args.remove(0) == "0" {
command.tag = command.args.remove(0);
stanzas.push(command);
} else {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: "plugin wrapped file key to a file we didn't provide"
.to_owned(),
});
}
} else {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: format!(
"{} command must have at least two metadata arguments",
CMD_RECIPIENT_STANZA
),
});
}
reply.ok(None)
}
CMD_ERROR => {
if command.args.len() == 2 && command.args[0] == "recipient" {
let index: usize = command.args[1].parse().unwrap();
errors.push(PluginError::Recipient {
binary_name: binary_name(&self.recipients[index].name),
recipient: self.recipients[index].recipient.clone(),
message: String::from_utf8_lossy(&command.body).to_string(),
});
} else if command.args.len() == 2 && command.args[0] == "identity" {
let index: usize = command.args[1].parse().unwrap();
errors.push(PluginError::Identity {
binary_name: binary_name(&self.identities[index].name),
message: String::from_utf8_lossy(&command.body).to_string(),
});
} else {
errors.push(PluginError::from(command));
}
reply.ok(None)
}
_ => unreachable!(),
},
) {
return Err(e.into());
};
match (stanzas.is_empty(), errors.is_empty()) {
(false, true) => Ok(stanzas),
(a, b) => {
if a & b {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: "Plugin returned neither stanzas nor errors".to_owned(),
});
} else if !a & !b {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: "Plugin returned both stanzas and errors".to_owned(),
});
}
Err(EncryptError::Plugin(errors))
}
}
}
}
pub struct IdentityPluginV1<C: Callbacks> {
plugin: Plugin,
identities: Vec<Identity>,
callbacks: C,
}
impl<C: Callbacks> IdentityPluginV1<C> {
pub fn new(
plugin_name: &str,
identities: &[Identity],
callbacks: C,
) -> Result<Self, DecryptError> {
Plugin::new(plugin_name)
.map_err(|binary_name| DecryptError::MissingPlugin { binary_name })
.map(|plugin| IdentityPluginV1 {
plugin,
identities: identities
.iter()
.filter(|r| r.name == plugin_name)
.cloned()
.collect(),
callbacks,
})
}
fn unwrap_stanzas<'a>(
&self,
stanzas: impl Iterator<Item = &'a Stanza>,
) -> Option<Result<FileKey, DecryptError>> {
let mut conn = self.plugin.connect(IDENTITY_V1).ok()?;
let _guard = SlowPluginGuard::new(self.callbacks.clone(), self.plugin.binary_name.clone());
if let Err(e) = conn.unidir_send(|mut phase| {
for identity in &self.identities {
phase.send("add-identity", &[identity.identity.as_str()], &[])?;
}
for stanza in stanzas {
phase.send_stanza("recipient-stanza", &["0"], stanza)?;
}
Ok(())
}) {
return Some(Err(e.into()));
};
let mut file_key = None;
let mut errors = vec![];
if let Err(e) = conn.bidir_receive(
&[
CMD_MSG,
CMD_CONFIRM,
CMD_REQUEST_PUBLIC,
CMD_REQUEST_SECRET,
CMD_FILE_KEY,
CMD_ERROR,
],
|command, reply| match command.tag.as_str() {
CMD_MSG => {
self.callbacks
.display_message(&String::from_utf8_lossy(&command.body));
reply.ok(None)
}
CMD_CONFIRM => handle_confirm(command, reply, &mut errors, &self.callbacks),
CMD_REQUEST_PUBLIC => {
if let Some(value) = self
.callbacks
.request_public_string(&String::from_utf8_lossy(&command.body))
{
reply.ok(Some(value.as_bytes()))
} else {
reply.fail()
}
}
CMD_REQUEST_SECRET => {
if let Some(secret) = self
.callbacks
.request_passphrase(&String::from_utf8_lossy(&command.body))
{
reply.ok(Some(secret.expose_secret().as_bytes()))
} else {
reply.fail()
}
}
CMD_FILE_KEY => {
assert!(command.args[0] == "0");
assert!(file_key.is_none());
file_key = Some(
TryInto::<[u8; 16]>::try_into(&command.body[..])
.map_err(|_| DecryptError::DecryptionFailed)
.map(FileKey::from),
);
reply.ok(None)
}
CMD_ERROR => {
if command.args.len() == 2 && command.args[0] == "identity" {
let index: usize = command.args[1].parse().unwrap();
errors.push(PluginError::Identity {
binary_name: binary_name(&self.identities[index].name),
message: String::from_utf8_lossy(&command.body).to_string(),
});
} else {
errors.push(PluginError::from(command));
}
reply.ok(None)
}
_ => unreachable!(),
},
) {
return Some(Err(e.into()));
};
if file_key.is_none() && !errors.is_empty() {
Some(Err(DecryptError::Plugin(errors)))
} else {
file_key
}
}
}
impl<C: Callbacks> crate::Identity for IdentityPluginV1<C> {
fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
self.unwrap_stanzas(iter::once(stanza))
}
fn unwrap_stanzas(&self, stanzas: &[Stanza]) -> Option<Result<FileKey, DecryptError>> {
self.unwrap_stanzas(stanzas.iter())
}
}
#[cfg(test)]
mod tests {
use super::Identity;
#[test]
fn default_for_plugin() {
assert_eq!(
Identity::default_for_plugin("foobar").to_string(),
"AGE-PLUGIN-FOOBAR-1QVHULF",
);
}
}