container.rs
raw
use std::fmt::Debug;
use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead as _, AeadCore, Generate as _, Payload, array::typenum::Unsigned as _},
};
use subtle::ConstantTimeEq as _;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
use crate::{
Error,
key::{KdfParams, Key},
secret_buffer::SecretBuffer,
storage::{FormatVersion, Header, Storage},
};
/// Length of the ChaCha20-Poly1305 authentication tag appended to the
/// ciphertext.
const TAG_LEN: usize = <ChaCha20Poly1305 as AeadCore>::TagSize::USIZE;
/// Trait for types that can be stored in a `CipherBox`.
///
/// Types must be serializable, deserializable, and zeroizable for security.
pub trait CipherBoxContent: Zeroize + serde::Serialize + for<'a> serde::Deserialize<'a> {}
impl<T> CipherBoxContent for T where T: Zeroize + serde::Serialize + for<'a> serde::Deserialize<'a> {}
/// A secure container that encrypts its contents using ChaCha20-Poly1305.
///
/// `CipherBox` provides encrypted storage for any serializable type. The contents
/// are automatically zeroized when the box is dropped for security.
///
/// # Examples
///
/// ```
/// # use cipherbox::CipherBox;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // Create a box with some data
/// let data = String::from("secret message");
/// let cipher_box = CipherBox::new(data);
///
/// // Access the data through Deref
/// assert_eq!(cipher_box.as_str(), "secret message");
///
/// // Write to encrypted storage
/// let mut buffer = Vec::new();
/// cipher_box.write("password123", &mut buffer)?;
///
/// // Read back from encrypted storage
/// let restored: CipherBox<String> = CipherBox::read("password123", buffer.as_slice())?;
/// assert_eq!(restored.as_str(), "secret message");
/// # Ok(())
/// # }
/// ```
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct CipherBox<T: CipherBoxContent> {
value: T,
}
impl<T> Debug for CipherBox<T>
where
T: CipherBoxContent,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CipherBox {{ .. }}")
}
}
impl<T> CipherBox<T>
where
T: CipherBoxContent,
{
/// Creates a new `CipherBox` containing the given value.
///
/// The value is stored in memory unencrypted until written to storage.
///
/// # Examples
///
/// ```
/// # use cipherbox::CipherBox;
/// let secret_data = vec![1, 2, 3, 4, 5];
/// let cipher_box = CipherBox::new(secret_data);
/// assert_eq!(cipher_box.len(), 5);
/// ```
pub fn new(value: T) -> Self {
CipherBox { value }
}
/// Reads and decrypts a `CipherBox` from a source.
///
/// Attempts to decrypt the data using the provided password. The source must contain
/// data that was previously written using `CipherBox::write` with the same password.
///
/// # Arguments
///
/// * `password` - The password used to encrypt the data
/// * `source` - A reader containing the encrypted data
///
/// # Returns
///
/// The decrypted `CipherBox` on success, or an error if decryption fails.
///
/// # Examples
///
/// ```
/// # use cipherbox::CipherBox;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // First, create and encrypt some data
/// let original = CipherBox::new(String::from("hello"));
/// let mut buffer = Vec::new();
/// original.write("secret", &mut buffer)?;
///
/// // Then read it back
/// let restored: CipherBox<String> = CipherBox::read("secret", buffer.as_slice())?;
/// assert_eq!(restored.as_str(), "hello");
/// # Ok(())
/// # }
/// ```
pub fn read(
password: impl AsRef<str>,
source: impl std::io::Read,
) -> Result<CipherBox<T>, Error> {
let storage = Storage::load(source)?;
let header = storage.header();
let key = Key::from_password(password, &header.salt, &header.kdf)?;
// Verify the key commitment before attempting decryption. This rejects a
// wrong key up front and gives the construction the key-commitment
// property that the Poly1305 tag alone cannot provide.
if !bool::from(key.commitment().as_slice().ct_eq(&header.commitment)) {
return Err(Error::CommitmentMismatch);
}
let plaintext = Zeroizing::new(
key.cipher()
.decrypt(
&Nonce::from(header.nonce),
Payload {
msg: storage.data(),
aad: &header.encode(),
},
)
.map_err(|_| Error::Authentication)?,
);
drop(storage);
// Strings and byte strings are borrowed from the plaintext buffer and
// copied once, at their exact size, into the deserialized value.
let value =
cbor4ii::serde::from_slice(&plaintext).map_err(|e| Error::Deserialization(e.into()))?;
Ok(CipherBox { value })
}
/// Encrypts and writes the `CipherBox` to a destination.
///
/// Encrypts the contents with the given password and writes the encrypted
/// data to the destination. The box is left intact, so its contents can
/// still be used and written again; drop it to wipe the contents from
/// memory. Intermediate copies of the contents made while encrypting are
/// zeroized before this returns.
///
/// # Arguments
///
/// * `password` - The password to use for encryption
/// * `destination` - A writer to store the encrypted data
///
/// # Examples
///
/// ```
/// # use cipherbox::CipherBox;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let data = vec![1, 2, 3, 4, 5];
/// let cipher_box = CipherBox::new(data);
///
/// let mut buffer = Vec::new();
/// cipher_box.write("my-password", &mut buffer)?;
///
/// // The buffer now contains encrypted data, and the box is still usable
/// assert!(!buffer.is_empty());
/// assert_eq!(cipher_box.len(), 5);
/// # Ok(())
/// # }
/// ```
pub fn write(
&self,
password: impl AsRef<str>,
destination: impl std::io::Write,
) -> Result<(), Error> {
let mut serializer = cbor4ii::serde::Serializer::new(SecretBuffer::new());
self.value
.serialize(&mut serializer)
.map_err(|e| Error::Serialization(e.into()))?;
let plaintext = serializer.into_inner();
let mut salt = [0u8; 16];
rand::fill(&mut salt);
let kdf = KdfParams::DEFAULT;
let key = Key::from_password(password, &salt, &kdf)?;
let header = Header {
version: FormatVersion::CURRENT,
kdf,
salt,
nonce: Nonce::generate().into(),
commitment: key.commitment(),
length: (plaintext.as_slice().len() + TAG_LEN) as u64,
};
let ciphertext = key
.cipher()
.encrypt(
&Nonce::from(header.nonce),
Payload {
msg: plaintext.as_slice(),
aad: &header.encode(),
},
)
.map_err(|_| Error::Encryption)?;
drop(plaintext);
Storage::new(header, &ciphertext).store(destination)
}
}
impl<T> std::ops::Deref for CipherBox<T>
where
T: CipherBoxContent,
{
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T> std::ops::DerefMut for CipherBox<T>
where
T: CipherBoxContent,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::SIGNATURE;
#[test]
fn save_and_keep_working() {
let mut vault = CipherBox::new(String::from("first draft"));
let mut first = Vec::new();
vault.write("just-a-test", &mut first).unwrap();
vault.push_str(", revised");
let mut second = Vec::new();
vault.write("just-a-test", &mut second).unwrap();
assert_eq!(vault.as_str(), "first draft, revised");
let restored: CipherBox<String> = CipherBox::read("just-a-test", first.as_slice()).unwrap();
assert_eq!(restored.as_str(), "first draft");
let restored: CipherBox<String> =
CipherBox::read("just-a-test", second.as_slice()).unwrap();
assert_eq!(restored.as_str(), "first draft, revised");
}
#[test]
fn roundtrip() {
let mut file = Vec::new();
let box1 = CipherBox::new(String::from("Hello World"));
assert_eq!(box1.as_str(), "Hello World");
assert_eq!(&file, &[]);
box1.write("just-a-test", &mut file).unwrap();
assert_ne!(&file, &[]);
assert!(String::from_utf8_lossy(&file).find("Hello").is_none());
let box2: CipherBox<String> = CipherBox::read("just-a-test", file.as_slice()).unwrap();
assert_eq!(box2.as_str(), "Hello World");
}
#[test]
fn validation() {
let mut file = Vec::new();
let box1 = CipherBox::new(String::from("Hello World"));
box1.write("just-a-test", &mut file).unwrap();
CipherBox::<String>::read("wrong-password", file.as_slice())
.expect_err("Decryption succeeded with the wrong password");
}
#[test]
fn tampering_detection() {
let mut file = Vec::new();
let box1 = CipherBox::new(String::from("Hello World"));
box1.write("test-password", &mut file).unwrap();
assert!(CipherBox::<String>::read("test-password", file.as_slice()).is_ok());
let length_offset = Header::V1_LEN - 8;
let mut shortened = file.clone();
let length =
u64::from_be_bytes(shortened[length_offset..Header::V1_LEN].try_into().unwrap());
shortened[length_offset..Header::V1_LEN].copy_from_slice(&(length - 1).to_be_bytes());
let flip = |offset: usize, mask: u8| {
let mut tampered = file.clone();
tampered[offset] ^= mask;
tampered
};
type IsExpected = fn(&Error) -> bool;
// Offsets follow the layout documented on `Header`; each integer's
// low byte is its last.
let version = SIGNATURE.len();
let memory = version + 1;
let iterations = memory + 4;
let parallelism = iterations + 4;
let salt = parallelism + 4;
let nonce = salt + 16;
let commitment = nonce + 12;
let cases: [(&str, Vec<u8>, IsExpected); 9] = [
("version", flip(version, 0x02), |e| {
matches!(e, Error::UnsupportedVersion(3))
}),
("memory cost", flip(memory + 3, 0x01), |e| {
matches!(e, Error::CommitmentMismatch)
}),
("iterations", flip(iterations + 3, 0x01), |e| {
matches!(e, Error::CommitmentMismatch)
}),
("parallelism", flip(parallelism + 3, 0x02), |e| {
matches!(e, Error::CommitmentMismatch)
}),
("salt", flip(salt, 0x01), |e| {
matches!(e, Error::CommitmentMismatch)
}),
("nonce", flip(nonce, 0x01), |e| {
matches!(e, Error::Authentication)
}),
("commitment", flip(commitment, 0x01), |e| {
matches!(e, Error::CommitmentMismatch)
}),
("length", shortened, |e| matches!(e, Error::Authentication)),
("ciphertext", flip(Header::V1_LEN, 0x01), |e| {
matches!(e, Error::Authentication)
}),
];
for (field, tampered, expected) in cases {
match CipherBox::<String>::read("test-password", tampered.as_slice()) {
Err(e) if expected(&e) => {}
Err(e) => panic!("tampering with the {field} gave an unexpected error: {e:?}"),
Ok(_) => panic!("tampering with the {field} was not detected"),
}
}
}
#[test]
fn wrong_password_fails_commitment_check() {
let mut file = Vec::new();
let box1 = CipherBox::new(String::from("Hello World"));
box1.write("just-a-test", &mut file).unwrap();
// A wrong password derives a different commitment, so decryption is
// rejected at the commitment check before the AEAD is ever invoked.
assert!(matches!(
CipherBox::<String>::read("wrong-password", file.as_slice()),
Err(Error::CommitmentMismatch)
));
}
}