storage.rs
raw
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<u8> for FormatVersion {
type Error = Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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<u8> {
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<Header, Error> {
// 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<const N: usize>(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<Storage<'d>, 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<u8> {
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"),
}
}
}