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 = ::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 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> { /// // 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 = CipherBox::read("password123", buffer.as_slice())?; /// assert_eq!(restored.as_str(), "secret message"); /// # Ok(()) /// # } /// ``` #[derive(Zeroize, ZeroizeOnDrop)] pub struct CipherBox { value: T, } impl Debug for CipherBox where T: CipherBoxContent, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "CipherBox {{ .. }}") } } impl CipherBox 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> { /// // 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 = CipherBox::read("secret", buffer.as_slice())?; /// assert_eq!(restored.as_str(), "hello"); /// # Ok(()) /// # } /// ``` pub fn read( password: impl AsRef, source: impl std::io::Read, ) -> Result, 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> { /// 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, 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 std::ops::Deref for CipherBox where T: CipherBoxContent, { type Target = T; fn deref(&self) -> &Self::Target { &self.value } } impl std::ops::DerefMut for CipherBox 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 = CipherBox::read("just-a-test", first.as_slice()).unwrap(); assert_eq!(restored.as_str(), "first draft"); let restored: CipherBox = 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 = 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::::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::::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, 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::::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::::read("wrong-password", file.as_slice()), Err(Error::CommitmentMismatch) )); } }