passbox 2.0.8

A password store for agents, where a fingerprint releases one secret into one process
//! age encryption, including plugin identities such as Secure Enclave and YubiKey.

use age::{Decryptor, Encryptor, Identity, Recipient};
use anyhow::{Result, anyhow};
use std::io::{Read, Write};
use zeroize::Zeroizing;

/// Encrypt to every recipient. One recipient failing to wrap is fatal, a store
/// that silently drops its recovery key is worse than no store.
pub fn encrypt(plaintext: &[u8], recipients: &[Box<dyn Recipient + Send>]) -> Result<Vec<u8>> {
    if recipients.is_empty() {
        return Err(anyhow!(
            "no recipients, refusing to write an unreadable file"
        ));
    }
    let refs: Vec<&dyn Recipient> = recipients
        .iter()
        .map(|r| r.as_ref() as &dyn Recipient)
        .collect();
    let encryptor = Encryptor::with_recipients(refs.into_iter())
        .map_err(|e| anyhow!("could not build encryptor: {e}"))?;

    let mut out = Vec::new();
    let mut writer = encryptor.wrap_output(&mut out)?;
    writer.write_all(plaintext)?;
    writer.finish()?;
    Ok(out)
}

/// One record per line, hex rather than armour because age armour spans several lines
pub fn encrypt_line(plaintext: &[u8], recipient: &age::x25519::Recipient) -> Result<String> {
    Ok(hex::encode(encrypt(
        plaintext,
        &[Box::new(recipient.clone())],
    )?))
}

#[cfg(feature = "host")]
pub fn decrypt_line(line: &str, key: &age::x25519::Identity) -> Result<Zeroizing<Vec<u8>>> {
    let raw = hex::decode(line.trim()).map_err(|e| anyhow!("bad audit line: {e}"))?;
    decrypt(&raw, &[Box::new(key.clone())])
}

/// Plaintext is never larger than its ciphertext, so the buffer is sized once and never grown,
/// which would free a copy of the plaintext without wiping it. It wipes itself when dropped
pub fn decrypt(ciphertext: &[u8], identities: &[Box<dyn Identity>]) -> Result<Zeroizing<Vec<u8>>> {
    let decryptor = Decryptor::new_buffered(ciphertext)?;
    let refs: Vec<&dyn Identity> = identities
        .iter()
        .map(|i| i.as_ref() as &dyn Identity)
        .collect();
    let mut reader = decryptor.decrypt(refs.into_iter())?;
    let mut out = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
    reader.read_to_end(&mut out)?;
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_x25519() {
        let id = age::x25519::Identity::generate();
        let recipients: Vec<Box<dyn Recipient + Send>> = vec![Box::new(id.to_public())];
        let identities: Vec<Box<dyn Identity>> = vec![Box::new(id)];

        let ct = encrypt(b"correct horse battery staple", &recipients).unwrap();
        assert_ne!(ct, b"correct horse battery staple");
        let pt = decrypt(&ct, &identities).unwrap();
        assert_eq!(&pt[..], b"correct horse battery staple");
    }

    #[test]
    fn refuses_empty_recipients() {
        assert!(encrypt(b"x", &[]).is_err());
    }

    #[test]
    fn wrong_identity_fails() {
        let a = age::x25519::Identity::generate();
        let b = age::x25519::Identity::generate();
        let ct = encrypt(b"secret", &[Box::new(a.to_public())]).unwrap();
        assert!(decrypt(&ct, &[Box::new(b)]).is_err());
    }
}