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"),
        }
    }
}