use std::{borrow::Cow, io::Read as _}; use crate::{Error, key::KdfParams}; /// The signature that begins every piece of CipherBox data. Long enough /// that ordinary text and other formats practically never begin with it, /// so it identifies CipherBox data on its own (see [`recognizes`]). pub(crate) const SIGNATURE: [u8; 9] = *b"CIPHERBOX"; /// Whether `data` is CipherBox data: whether it begins with the CipherBox /// signature. This needs no password and does no cryptographic work, so it /// is the way to tell CipherBox data apart from anything else before /// asking for a password. Data in a format version this build can't read /// is still recognized; reading it then reports /// [`Error::UnsupportedVersion`]. pub fn recognizes(data: &[u8]) -> bool { data.starts_with(&SIGNATURE) } /// A version of the CipherBox storage format. /// /// The version is written immediately after the signature so that readers can /// reject data in a format they do not understand, rather than misinterpreting /// it. A version fixes the header layout, the key derivation algorithm, the /// cipher, and the serialization format; anything that changes one of those /// requires a new version. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub(crate) enum FormatVersion { /// Argon2id (version 0x13) over the NFC-normalized password with stored /// cost parameters, BLAKE3-derived encryption subkey and key commitment, /// ChaCha20-Poly1305 with the entire header as associated data, and CBOR /// (RFC 8949) serialization. V1 = 1, } impl FormatVersion { /// The version written by this build of CipherBox. pub(crate) const CURRENT: FormatVersion = FormatVersion::V1; } impl TryFrom for FormatVersion { type Error = Error; fn try_from(value: u8) -> Result { match value { 1 => Ok(FormatVersion::V1), other => Err(Error::UnsupportedVersion(other)), } } } /// The fixed-size header that precedes the ciphertext. /// /// Layout (all integers big-endian): /// - Signature: 9 bytes ("CIPHERBOX") /// - Format version: 1 byte /// - Argon2 memory cost in KiB: 4 bytes /// - Argon2 iterations: 4 bytes /// - Argon2 parallelism: 4 bytes /// - Salt: 16 bytes /// - Nonce: 12 bytes /// - Key commitment: 32 bytes /// - Ciphertext length: 8 bytes /// /// The encoded header is used verbatim as the AEAD associated data, so every /// field is authenticated. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Header { pub(crate) version: FormatVersion, pub(crate) kdf: KdfParams, pub(crate) salt: [u8; 16], pub(crate) nonce: [u8; 12], pub(crate) commitment: [u8; 32], pub(crate) length: u64, } impl Header { /// Size of an encoded version 1 header in bytes. pub(crate) const V1_LEN: usize = SIGNATURE.len() + 1 + 4 + 4 + 4 + 16 + 12 + 32 + 8; /// Encodes the header in the storage layout. pub(crate) fn encode(&self) -> Vec { let mut out = Vec::with_capacity(Self::V1_LEN); out.extend_from_slice(&SIGNATURE); out.push(self.version as u8); out.extend_from_slice(&self.kdf.memory_kib().to_be_bytes()); out.extend_from_slice(&self.kdf.iterations().to_be_bytes()); out.extend_from_slice(&self.kdf.parallelism().to_be_bytes()); out.extend_from_slice(&self.salt); out.extend_from_slice(&self.nonce); out.extend_from_slice(&self.commitment); out.extend_from_slice(&self.length.to_be_bytes()); out } /// Reads and validates a header. /// /// The signature and version are checked as soon as they are read, so /// input that is not CipherBox data, or is in an unsupported version, is /// reported as such even if it is shorter than a full header. fn decode(source: &mut impl std::io::Read) -> Result { // Input that ends before a whole signature isn't CipherBox data // either, so a short read is an invalid signature, not an I/O error. let mut signature = Vec::with_capacity(SIGNATURE.len()); source .by_ref() .take(SIGNATURE.len() as u64) .read_to_end(&mut signature)?; if signature != SIGNATURE { return Err(Error::InvalidSignature); } let [version] = read_array(source)?; let version = FormatVersion::try_from(version)?; let kdf = KdfParams::new( u32::from_be_bytes(read_array(source)?), u32::from_be_bytes(read_array(source)?), u32::from_be_bytes(read_array(source)?), )?; Ok(Header { version, kdf, salt: read_array(source)?, nonce: read_array(source)?, commitment: read_array(source)?, length: u64::from_be_bytes(read_array(source)?), }) } } fn read_array(source: &mut impl std::io::Read) -> Result<[u8; N], Error> { let mut buf = [0u8; N]; source.read_exact(&mut buf)?; Ok(buf) } /// A header together with the ciphertext it describes. pub(crate) struct Storage<'d> { header: Header, data: Cow<'d, [u8]>, } impl<'d> Storage<'d> { /// Pairs a header with its ciphertext. /// /// The header's length field must match the ciphertext. pub(crate) fn new(header: Header, data: &'d [u8]) -> Storage<'d> { debug_assert_eq!(header.length, data.len() as u64); Storage { header, data: data.into(), } } /// Loads a Storage instance from a reader containing encrypted CipherBox data. /// /// The encrypted data is read incrementally, so memory use is bounded by /// the amount of data the source actually supplies rather than by the /// untrusted length field. Returns an error if the header is invalid, if /// the source ends before the declared length, or if there are I/O errors /// during reading. pub(crate) fn load(mut source: impl std::io::Read) -> Result, Error> { let header = Header::decode(&mut source)?; let mut data = Vec::new(); let received = source.take(header.length).read_to_end(&mut data)?; if received as u64 != header.length { return Err(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "encrypted data is shorter than its declared length", ) .into()); } Ok(Storage { header, data: data.into(), }) } /// Writes the header followed by the ciphertext. pub(crate) fn store(self, mut destination: impl std::io::Write) -> Result<(), Error> { destination.write_all(&self.header.encode())?; destination.write_all(&self.data)?; Ok(()) } /// Returns the header. pub(crate) fn header(&self) -> &Header { &self.header } /// Returns the encrypted data payload. pub(crate) fn data(&self) -> &[u8] { &self.data } } #[cfg(test)] mod tests { use super::*; /// Offset of the format version byte, immediately after the signature. const VERSION_OFFSET: usize = SIGNATURE.len(); /// Offset of the Argon2 memory cost, immediately after the version. const MEMORY_OFFSET: usize = VERSION_OFFSET + 1; /// Offset of the ciphertext length, the last field of the header. const LENGTH_OFFSET: usize = Header::V1_LEN - 8; fn header(length: u64) -> Header { Header { version: FormatVersion::CURRENT, kdf: KdfParams::DEFAULT, salt: [1u8; 16], nonce: [2u8; 12], commitment: [3u8; 32], length, } } fn stored(data: &[u8]) -> Vec { let mut out = Vec::new(); Storage::new(header(data.len() as u64), data) .store(&mut out) .unwrap(); out } #[test] fn roundtrip() { let bytes = stored(b"ciphertext"); assert_eq!(bytes.len(), Header::V1_LEN + b"ciphertext".len()); let storage = Storage::load(bytes.as_slice()).unwrap(); assert_eq!(storage.header(), &header(10)); assert_eq!(storage.data(), b"ciphertext"); } #[test] fn encoding_reproduces_the_stored_header() { // The associated data is re-encoded from the parsed header, so parsing // and encoding must be exact inverses. let bytes = stored(b"ciphertext"); let storage = Storage::load(bytes.as_slice()).unwrap(); assert_eq!(storage.header().encode(), bytes[..Header::V1_LEN]); } #[test] fn rejects_invalid_signature() { assert!(matches!( Storage::load(&b"XY"[..]), Err(Error::InvalidSignature) )); // The old two-byte signature is no longer CipherBox data. assert!(matches!( Storage::load(&b"CB\x01rest of an old header"[..]), Err(Error::InvalidSignature) )); } #[test] fn recognizes_only_data_beginning_with_the_signature() { assert!(recognizes(&stored(b"ciphertext"))); // A future format version is still CipherBox data. let mut future = stored(b"ciphertext"); future[VERSION_OFFSET] = 0xff; assert!(recognizes(&future)); assert!(!recognizes(b"")); assert!(!recognizes(b"CIPHERBO")); assert!(!recognizes(b"CB\x01")); assert!(!recognizes(b"CBS News reported")); assert!(!recognizes(b"cipherbox")); } #[test] fn rejects_unsupported_version() { let mut bytes = stored(b"ciphertext"); bytes[VERSION_OFFSET] = 0xff; assert!(matches!( Storage::load(&bytes[..=VERSION_OFFSET]), Err(Error::UnsupportedVersion(0xff)) )); } #[test] fn rejects_excessive_kdf_parameters() { let mut bytes = stored(b"ciphertext"); bytes[MEMORY_OFFSET..MEMORY_OFFSET + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!(matches!( Storage::load(bytes.as_slice()), Err(Error::UnsupportedKdfParameters) )); } #[test] fn rejects_truncated_data() { let mut bytes = stored(b"ciphertext"); bytes.pop(); match Storage::load(bytes.as_slice()) { Err(Error::IO(e)) => assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof), Err(e) => panic!("unexpected error: {e}"), Ok(_) => panic!("truncated data was accepted"), } } #[test] fn huge_declared_length_does_not_preallocate() { let mut bytes = stored(b"ciphertext"); bytes[LENGTH_OFFSET..LENGTH_OFFSET + 8].copy_from_slice(&u64::MAX.to_be_bytes()); // Allocating the declared length up front would abort the process. match Storage::load(bytes.as_slice()) { Err(Error::IO(e)) => assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof), Err(e) => panic!("unexpected error: {e}"), Ok(_) => panic!("truncated data was accepted"), } } }