use std::sync::OnceLock;
use crate::error::{Error, ErrorKind};
pub trait Decryptor: Send + Sync + 'static {
fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error>;
fn describe(&self) -> String;
}
pub trait Encryptor: Send + Sync {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error>;
fn describe(&self) -> String;
}
static DECRYPTOR: OnceLock<Box<dyn Decryptor>> = OnceLock::new();
pub fn set_decryptor(decryptor: impl Decryptor) -> Result<(), Box<dyn Decryptor>> {
match DECRYPTOR.set(Box::new(decryptor)) {
Ok(()) => Ok(()),
Err(rejected) => Err(rejected),
}
}
pub fn has_decryptor() -> bool {
DECRYPTOR.get().is_some()
}
pub(crate) fn decrypt(ciphertext: &[u8], path: &str) -> Result<Plaintext, Error> {
let Some(decryptor) = DECRYPTOR.get() else {
return Err(Error::new(
ErrorKind::Decrypt,
format!(
"{path} is encrypted and no decryptor is installed; call \
`dynamic_config::set_decryptor` before loading"
),
));
};
let plaintext = decryptor
.decrypt(ciphertext)
.map_err(|error| error.prepend_key(format!("{path} ({})", decryptor.describe())))?;
Plaintext::new(plaintext, path)
}
pub(crate) struct Plaintext {
text: String,
}
impl Plaintext {
fn new(bytes: Vec<u8>, path: &str) -> Result<Self, Error> {
match String::from_utf8(bytes) {
Ok(text) => Ok(Self { text }),
Err(error) => {
let rendered = error.utf8_error().to_string();
let mut bytes = error.into_bytes();
{
use zeroize::Zeroize;
bytes.zeroize();
}
Err(Error::new(
ErrorKind::Decrypt,
format!("{path}: the decrypted bytes are not UTF-8: {rendered}"),
))
}
}
}
pub(crate) fn text(&self) -> &str {
&self.text
}
}
impl Drop for Plaintext {
fn drop(&mut self) {
use zeroize::Zeroize;
self.text.zeroize();
}
}
impl std::fmt::Debug for Plaintext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Plaintext")
.field("bytes", &self.text.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plaintext_never_prints_what_it_holds() {
let plaintext = Plaintext::new(b"{\"password\": \"hunter2\"}".to_vec(), "x").unwrap();
let printed = format!("{plaintext:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(
printed.contains("23"),
"the length is fine to show: {printed}"
);
}
#[test]
fn bytes_that_are_not_text_name_the_file() {
let error = Plaintext::new(vec![0xff, 0xfe], "secrets.json.age").unwrap_err();
assert_eq!(error.kind(), ErrorKind::Decrypt);
assert!(error.to_string().contains("secrets.json.age"), "{error}");
}
#[test]
fn an_encrypted_file_with_no_decryptor_says_what_to_call() {
if has_decryptor() {
return;
}
let error = decrypt(b"whatever", "secrets.json.age").unwrap_err();
assert_eq!(error.kind(), ErrorKind::Decrypt);
assert!(error.to_string().contains("set_decryptor"), "{error}");
}
}