From 93fde7c713a3aa103fd782df75c13d6b2b29015e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 17:02:23 +0000 Subject: [PATCH 01/11] Add a chunked encryption recipe implementing c2sp.org/chunked-encryption This adds cryptography.chunked_encryption, a top-level recipe (like Fernet) for streaming authenticated encryption of large messages, implementing the C2SP chunked-encryption specification (https://c2sp.org/chunked-encryption) instantiated with SHA-256 and AES-128-GCM. The core is implemented in Rust: for each message a fresh key, base nonce, and key commitment are derived with HKDF-Expand-SHA-256 from the input key, a random 24-byte salt, and a caller-provided context; the message is encrypted in 16 KiB chunks with AES-128-GCM, with the chunk counter XOR'd into the base nonce. Full chunks are encrypted/decrypted directly from the caller's input, so only sub-chunk remainders are buffered internally, and update_into variants allow callers to supply output buffers. The internals are parameterized over the AEAD so that additional AEADs could be supported later, but the public API is AES-128-GCM only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- CHANGELOG.rst | 4 + docs/chunked-encryption.rst | 199 ++++++ docs/index.rst | 1 + docs/spelling_wordlist.txt | 2 + src/cryptography/chunked_encryption.py | 17 + .../bindings/_rust/chunked_encryption.pyi | 21 + src/rust/src/chunked_encryption.rs | 575 ++++++++++++++++++ src/rust/src/lib.rs | 3 + tests/test_chunked_encryption.py | 419 +++++++++++++ 9 files changed, 1241 insertions(+) create mode 100644 docs/chunked-encryption.rst create mode 100644 src/cryptography/chunked_encryption.py create mode 100644 src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi create mode 100644 src/rust/src/chunked_encryption.rs create mode 100644 tests/test_chunked_encryption.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1f9b339e2883..df2ab93e933b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,10 @@ Changelog * The :mod:`X.509 verification ` APIs are now considered stable and are subject to our API stability policy. +* Added the :doc:`/chunked-encryption` recipe, an implementation of the + `C2SP chunked-encryption specification + `_ for streaming authenticated + encryption of large messages. * Parsing a Signed Certificate Timestamp list now rejects encodings that carry trailing bytes after the list or after an individual SCT, instead of silently ignoring them. diff --git a/docs/chunked-encryption.rst b/docs/chunked-encryption.rst new file mode 100644 index 000000000000..a22d32facfe8 --- /dev/null +++ b/docs/chunked-encryption.rst @@ -0,0 +1,199 @@ +Chunked encryption (streaming symmetric encryption) +==================================================== + +.. currentmodule:: cryptography.chunked_encryption + +Chunked encryption provides authenticated symmetric encryption of large +messages — up to 4 PiB — as a stream, without ever holding the whole +message in memory. It is an implementation of the `C2SP +chunked-encryption specification`_, instantiated with SHA-256 and +AES-128-GCM. + +A message is encrypted in 16 KiB chunks, each of which is individually +authenticated, so decryption can also be performed as a stream: +decrypted data is returned incrementally, and only authenticated +plaintext is ever returned. Reordering, truncating, or extending the +ciphertext is detected. The scheme is also key committing: a ciphertext +can only be decrypted with the key it was encrypted with. + +.. doctest:: + + >>> from cryptography.chunked_encryption import Decrypter, Encrypter + >>> key = Encrypter.generate_key() + >>> encrypter = Encrypter(key, context=b"example-app file encryption") + >>> ciphertext = encrypter.update(b"a secret message") + >>> ciphertext += encrypter.finalize() + >>> decrypter = Decrypter(key, context=b"example-app file encryption") + >>> decrypter.update(ciphertext) + decrypter.finalize() + b'a secret message' + +The ciphertext is 56 bytes (a salt and a key commitment), plus the +message itself, plus 16 bytes for each started 16 KiB chunk of the +message — an overhead of roughly 0.1%. + +.. class:: Encrypter(key, context) + + .. versionadded:: 50.0.0 + + Encrypts a single message under ``key``. Each instance must be used + for exactly one message: call :meth:`update` (or + :meth:`update_into`) any number of times, then call + :meth:`finalize` exactly once. The concatenation of the returned + bytes is the ciphertext. + + :param key: A 16-byte key. This **must** be kept secret, and + **must** be uniformly random (e.g. the output of + :meth:`generate_key`, :func:`os.urandom`, or a key derivation + function — never a password). A single key may be used to + encrypt a practically unlimited number of messages. + :type key: :term:`bytes-like` + :param context: Application-provided context, bound to the + ciphertext. Decryption fails unless the same value is passed to + :class:`Decrypter`. It is not secret, may be empty, and is not + part of the ciphertext, so it must be available to the + decrypting party independently. It can be used for domain + separation, e.g. ``b"myapp v2 backup encryption"``. + :type context: :term:`bytes-like` + :raises ValueError: If ``key`` is not 16 bytes. + + .. staticmethod:: generate_key() + + Generates a fresh 16-byte key. + + :return bytes: A new key. + + .. method:: update(data) + + Encrypts ``data``. Data is internally buffered into 16 KiB + chunks, so between 0 and ``len(data) + 16 KiB`` bytes of + ciphertext are returned. + + :param data: The data to encrypt. + :type data: :term:`bytes-like` + :return bytes: The next portion of the ciphertext. + + .. method:: update_into(data, buf) + + Encrypts ``data``, writing the resulting ciphertext into + ``buf``, and returns the number of bytes written. This avoids + allocating a new buffer for each call. + + :param data: The data to encrypt. + :type data: :term:`bytes-like` + :param buf: A writable buffer to write the ciphertext into. A + buffer of ``len(data) + len(data) // 1024 + 16456`` bytes + is always large enough; the exact number of bytes required + for a given call is included in the :class:`ValueError` + raised if the buffer is too small. + :type buf: :term:`bytes-like` + :return int: The number of bytes written to ``buf``. + :raises ValueError: If ``buf`` is too small. + + .. method:: finalize() + + Encrypts the final chunk and returns the last portion of the + ciphertext. This must always be called, even if ``update`` + returned all but the last few bytes of ciphertext, and the + instance cannot be used afterwards. + + :return bytes: The remainder of the ciphertext. + :raises cryptography.exceptions.AlreadyFinalized: If + ``finalize`` has already been called. + +.. class:: Decrypter(key, context) + + .. versionadded:: 50.0.0 + + Decrypts a single message encrypted by :class:`Encrypter` with the + same ``key`` and ``context``. Call :meth:`update` (or + :meth:`update_into`) with the ciphertext any number of times, then + call :meth:`finalize` exactly once. The concatenation of the + returned bytes is the plaintext. + + Any returned plaintext is authenticated, but until + :meth:`finalize` returns successfully the message could still turn + out to be truncated: an application acting on streamed plaintext + before that point must be prepared to discard its work if a later + call raises :class:`~cryptography.exceptions.InvalidTag`. + + Once any method raises + :class:`~cryptography.exceptions.InvalidTag`, the instance is + permanently unusable and all further calls raise + :class:`~cryptography.exceptions.InvalidTag` as well. + + :param key: The 16-byte key the message was encrypted with. + :type key: :term:`bytes-like` + :param context: The context value the message was encrypted with. + :type context: :term:`bytes-like` + :raises ValueError: If ``key`` is not 16 bytes. + + .. staticmethod:: generate_key() + + Generates a fresh 16-byte key. + + :return bytes: A new key. + + .. method:: update(data) + + Processes ``data``, which need not be aligned to any boundary, + and returns the plaintext of all complete chunks that have been + authenticated so far. + + :param data: The next portion of the ciphertext. + :type data: :term:`bytes-like` + :return bytes: The next portion of the plaintext. + :raises cryptography.exceptions.InvalidTag: If the ciphertext + was encrypted with a different key or context, or has been + modified. + + .. method:: update_into(data, buf) + + Like ``update``, but writes the plaintext into ``buf`` and + returns the number of bytes written. + + :param data: The next portion of the ciphertext. + :type data: :term:`bytes-like` + :param buf: A writable buffer to write the plaintext into. A + buffer of ``len(data) + 16400`` bytes is always large + enough; the exact number of bytes required for a given call + is included in the :class:`ValueError` raised if the buffer + is too small. + :type buf: :term:`bytes-like` + :return int: The number of bytes written to ``buf``. + :raises ValueError: If ``buf`` is too small. + :raises cryptography.exceptions.InvalidTag: If the ciphertext + was encrypted with a different key or context, or has been + modified. Note that in this case unauthenticated data may + have been written to ``buf`` and must not be used. + + .. method:: finalize() + + Decrypts and authenticates the final chunk, verifying that the + entire message has been processed, and returns the final + portion of the plaintext. This must always be called: a + successful return is what guarantees the complete message was + authentic and not truncated. + + :return bytes: The remainder of the plaintext. + :raises cryptography.exceptions.InvalidTag: If the ciphertext + was truncated or otherwise modified. + :raises cryptography.exceptions.AlreadyFinalized: If + ``finalize`` has already been called. + +Implementation +-------------- + +This module implements `version 1 of the C2SP chunked-encryption +specification`_ with the recommended SHA-256 and AES-128-GCM +instantiation, and is interoperable with other implementations of it. + +For each message, a fresh AES key, base nonce, and key commitment are +derived with HKDF-Expand-SHA-256 from the input key, a random 24-byte +salt, and the context. The message is split into 16 KiB chunks (the +final chunk is always shorter, and may be empty), and each chunk is +encrypted with AES-128-GCM, with a nonce derived from the base nonce +and the chunk counter. The ciphertext is the salt, followed by the 32-byte commitment, +followed by the encrypted chunks. + +.. _`C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption +.. _`version 1 of the C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption diff --git a/docs/index.rst b/docs/index.rst index a70a58886b6d..2cee17b03ae5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -68,6 +68,7 @@ hazmat layer only when necessary. :caption: The recipes layer fernet + chunked-encryption x509/index .. toctree:: diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 5e6d5433b919..105c14c0ebd7 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -45,6 +45,7 @@ decrypt decrypts Decrypts decrypted +Decrypter decrypting deprecations DER @@ -61,6 +62,7 @@ duplicative El embeddability Encodings +Encrypter endian Euler extendable diff --git a/src/cryptography/chunked_encryption.py b/src/cryptography/chunked_encryption.py new file mode 100644 index 000000000000..0a3ee12d9a30 --- /dev/null +++ b/src/cryptography/chunked_encryption.py @@ -0,0 +1,17 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +from cryptography.hazmat.bindings._rust import ( + chunked_encryption as _chunked_encryption, +) + +Decrypter = _chunked_encryption.Decrypter +Encrypter = _chunked_encryption.Encrypter + +__all__ = [ + "Decrypter", + "Encrypter", +] diff --git a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi new file mode 100644 index 000000000000..2b1beeee73cf --- /dev/null +++ b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi @@ -0,0 +1,21 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from cryptography.utils import Buffer + +class Encrypter: + def __init__(self, key: Buffer, context: Buffer) -> None: ... + @staticmethod + def generate_key() -> bytes: ... + def update(self, data: Buffer) -> bytes: ... + def update_into(self, data: Buffer, buf: Buffer) -> int: ... + def finalize(self) -> bytes: ... + +class Decrypter: + def __init__(self, key: Buffer, context: Buffer) -> None: ... + @staticmethod + def generate_key() -> bytes: ... + def update(self, data: Buffer) -> bytes: ... + def update_into(self, data: Buffer, buf: Buffer) -> int: ... + def finalize(self) -> bytes: ... diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs new file mode 100644 index 000000000000..95dec8e52707 --- /dev/null +++ b/src/rust/src/chunked_encryption.rs @@ -0,0 +1,575 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +// Implementation of the C2SP chunked-encryption specification +// (https://c2sp.org/chunked-encryption), instantiated with SHA-256 and +// AES-128-GCM. + +use crate::buf::{CffiBuf, CffiMutBuf}; +use crate::error::{CryptographyError, CryptographyResult}; +use crate::exceptions; + +const CHUNK_SIZE: usize = 16 * 1024; +const SALT_LEN: usize = 24; +const COMMITMENT_LEN: usize = 32; +const HEADER_LEN: usize = SALT_LEN + COMMITMENT_LEN; +// The chunk counter is a 38-bit big-endian integer, limiting messages to +// 4 PiB - 1. +const MAX_CHUNK_COUNT: u64 = 1 << 38; +const INFO_PREFIX: &[u8] = b"c2sp.org/chunked-encryption@v1+"; + +// The scheme requires an AEAD nonce of at least 96 bits, and every AEAD we +// might instantiate it with (AES-GCM, ChaCha20-Poly1305) uses exactly 96. +const NONCE_LEN: usize = 12; + +struct AeadParams { + // Name from the IANA AEAD Algorithms registry, used in the HKDF info. + iana_name: &'static [u8], + key_len: usize, + tag_len: usize, + cipher: fn() -> &'static openssl::cipher::CipherRef, +} + +impl AeadParams { + fn wire_chunk_size(&self) -> usize { + CHUNK_SIZE + self.tag_len + } +} + +static AES_128_GCM: AeadParams = AeadParams { + iana_name: b"AEAD_AES_128_GCM", + key_len: 16, + tag_len: 16, + cipher: openssl::cipher::Cipher::aes_128_gcm, +}; + +fn message_too_large_error() -> CryptographyError { + CryptographyError::from(pyo3::exceptions::PyValueError::new_err( + "Message exceeds the maximum chunked encryption length (2**38 chunks).", + )) +} + +// HKDF-Expand (RFC 5869) with SHA-256, with the info supplied as +// concatenated parts. +fn hkdf_expand_sha256(prk: &[u8], info_parts: &[&[u8]], out: &mut [u8]) -> CryptographyResult<()> { + let md = openssl::hash::MessageDigest::sha256(); + let mut previous: Option = None; + for (i, block) in out.chunks_mut(md.size()).enumerate() { + let mut h = cryptography_openssl::hmac::Hmac::new(prk, md)?; + if let Some(prev) = &previous { + h.update(prev)?; + } + for part in info_parts { + h.update(part)?; + } + h.update(&[(i + 1) as u8])?; + let t = h.finish()?; + block.copy_from_slice(&t[..block.len()]); + previous = Some(t); + } + Ok(()) +} + +struct DerivedKeys { + key: Vec, + base_nonce: [u8; NONCE_LEN], + commitment: [u8; COMMITMENT_LEN], +} + +fn derive_keys( + params: &AeadParams, + input_key: &[u8], + salt: &[u8], + context: &[u8], +) -> CryptographyResult { + let mut out = vec![0; params.key_len + NONCE_LEN + COMMITMENT_LEN]; + hkdf_expand_sha256( + input_key, + &[INFO_PREFIX, params.iana_name, &[0x00], salt, context], + &mut out, + )?; + let mut base_nonce = [0; NONCE_LEN]; + base_nonce.copy_from_slice(&out[params.key_len..params.key_len + NONCE_LEN]); + let mut commitment = [0; COMMITMENT_LEN]; + commitment.copy_from_slice(&out[params.key_len + NONCE_LEN..]); + out.truncate(params.key_len); + Ok(DerivedKeys { + key: out, + base_nonce, + commitment, + }) +} + +// A single-message AEAD context that encrypts or decrypts successive chunks +// with the base nonce XOR'd with a chunk counter. +struct ChunkCipher { + ctx: openssl::cipher_ctx::CipherCtx, + base_nonce: [u8; NONCE_LEN], + tag_len: usize, + counter: u64, +} + +impl ChunkCipher { + fn new(params: &AeadParams, keys: &DerivedKeys, encrypt: bool) -> CryptographyResult { + let mut ctx = openssl::cipher_ctx::CipherCtx::new()?; + let cipher = (params.cipher)(); + if encrypt { + ctx.encrypt_init(Some(cipher), Some(&keys.key), None)?; + } else { + ctx.decrypt_init(Some(cipher), Some(&keys.key), None)?; + } + ctx.set_iv_length(NONCE_LEN)?; + Ok(ChunkCipher { + ctx, + base_nonce: keys.base_nonce, + tag_len: params.tag_len, + counter: 0, + }) + } + + fn remaining_chunks(&self) -> u64 { + MAX_CHUNK_COUNT - self.counter + } + + fn next_nonce(&self) -> CryptographyResult<[u8; NONCE_LEN]> { + if self.counter >= MAX_CHUNK_COUNT { + return Err(message_too_large_error()); + } + let mut nonce = self.base_nonce; + for (n, c) in nonce[NONCE_LEN - 8..] + .iter_mut() + .zip(self.counter.to_be_bytes()) + { + *n ^= c; + } + Ok(nonce) + } + + // `out` must be exactly `plaintext.len() + self.tag_len` bytes. + fn encrypt_chunk(&mut self, plaintext: &[u8], out: &mut [u8]) -> CryptographyResult<()> { + let nonce = self.next_nonce()?; + self.ctx.encrypt_init(None, None, Some(&nonce))?; + let (ciphertext, tag) = out.split_at_mut(plaintext.len()); + let n = self.ctx.cipher_update(plaintext, Some(ciphertext))?; + assert_eq!(n, plaintext.len()); + let mut final_block = [0]; + let n = self.ctx.cipher_final(&mut final_block)?; + assert_eq!(n, 0); + self.ctx.tag(tag)?; + self.counter += 1; + Ok(()) + } + + // `ciphertext` includes the trailing tag; `out` must be exactly + // `ciphertext.len() - self.tag_len` bytes. + fn decrypt_chunk(&mut self, ciphertext: &[u8], out: &mut [u8]) -> CryptographyResult<()> { + let nonce = self.next_nonce()?; + let (data, tag) = ciphertext.split_at(ciphertext.len() - self.tag_len); + self.ctx.decrypt_init(None, None, Some(&nonce))?; + self.ctx.set_tag(tag)?; + let n = self + .ctx + .cipher_update(data, Some(out)) + .map_err(|_| exceptions::InvalidTag::new_err(()))?; + assert_eq!(n, data.len()); + let mut final_block = [0]; + self.ctx + .cipher_final(&mut final_block) + .map_err(|_| exceptions::InvalidTag::new_err(()))?; + self.counter += 1; + Ok(()) + } +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Encrypter { + cipher: ChunkCipher, + buffer: Vec, + header: [u8; HEADER_LEN], + header_pending: bool, + finalized: bool, + errored: bool, +} + +impl Encrypter { + fn check_active(&self) -> CryptographyResult<()> { + if self.errored { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("Context cannot be used after an error."), + )); + } + if self.finalized { + return Err(exceptions::already_finalized_error()); + } + Ok(()) + } + + fn update_out_len(&self, data_len: usize) -> usize { + let n_chunks = (self.buffer.len() + data_len) / CHUNK_SIZE; + let header = if self.header_pending { HEADER_LEN } else { 0 }; + header + n_chunks * (CHUNK_SIZE + self.cipher.tag_len) + } + + fn update_impl(&mut self, mut data: &[u8], out: &mut [u8]) -> CryptographyResult { + // Check the chunk counter limit up front so that the error doesn't + // leave a partially written output behind. + let n_chunks = ((self.buffer.len() + data.len()) / CHUNK_SIZE) as u64; + if n_chunks > self.cipher.remaining_chunks() { + return Err(message_too_large_error()); + } + + let wire_chunk = CHUNK_SIZE + self.cipher.tag_len; + let mut written = 0; + if self.header_pending { + out[..HEADER_LEN].copy_from_slice(&self.header); + written += HEADER_LEN; + self.header_pending = false; + } + if !self.buffer.is_empty() { + let take = std::cmp::min(CHUNK_SIZE - self.buffer.len(), data.len()); + self.buffer.extend_from_slice(&data[..take]); + data = &data[take..]; + if self.buffer.len() == CHUNK_SIZE { + self.cipher + .encrypt_chunk(&self.buffer, &mut out[written..written + wire_chunk])?; + written += wire_chunk; + self.buffer.clear(); + } + } + while data.len() >= CHUNK_SIZE { + let (chunk, rest) = data.split_at(CHUNK_SIZE); + self.cipher + .encrypt_chunk(chunk, &mut out[written..written + wire_chunk])?; + written += wire_chunk; + data = rest; + } + self.buffer.extend_from_slice(data); + Ok(written) + } +} + +#[pyo3::pymethods] +impl Encrypter { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + let params = &AES_128_GCM; + if key.as_bytes().len() != params.key_len { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("key must be 16 bytes."), + )); + } + let mut salt = [0; SALT_LEN]; + cryptography_openssl::rand::rand_bytes(&mut salt)?; + let keys = derive_keys(params, key.as_bytes(), &salt, context.as_bytes())?; + let cipher = ChunkCipher::new(params, &keys, true)?; + let mut header = [0; HEADER_LEN]; + header[..SALT_LEN].copy_from_slice(&salt); + header[SALT_LEN..].copy_from_slice(&keys.commitment); + Ok(Encrypter { + cipher, + buffer: Vec::with_capacity(CHUNK_SIZE), + header, + header_pending: true, + finalized: false, + errored: false, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.check_active()?; + let data = data.as_bytes(); + let out_len = self.update_out_len(data.len()); + let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { + let n = self.update_impl(data, b)?; + debug_assert_eq!(n, out_len); + Ok(()) + }); + if result.is_err() { + self.errored = true; + } + Ok(result?) + } + + fn update_into( + &mut self, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.check_active()?; + let data = data.as_bytes(); + let out_len = self.update_out_len(data.len()); + let out = buf.as_mut_bytes(); + if out.len() < out_len { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err(format!( + "buffer must be at least {out_len} bytes" + )), + )); + } + let result = self.update_impl(data, out); + if result.is_err() { + self.errored = true; + } + result + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.check_active()?; + let header = if self.header_pending { HEADER_LEN } else { 0 }; + let out_len = header + self.buffer.len() + self.cipher.tag_len; + let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { + if header != 0 { + b[..HEADER_LEN].copy_from_slice(&self.header); + } + self.cipher.encrypt_chunk(&self.buffer, &mut b[header..])?; + Ok(()) + }); + match result { + Ok(ciphertext) => { + self.header_pending = false; + self.buffer.clear(); + self.finalized = true; + Ok(ciphertext) + } + Err(e) => { + self.errored = true; + Err(e.into()) + } + } + } +} + +enum DecrypterState { + // Buffering the salt and commitment; `key` and `context` are retained + // until they arrive and the keys can be derived. + Header { + key: Vec, + context: Vec, + buf: Vec, + }, + Body { + cipher: ChunkCipher, + buffer: Vec, + }, + Finalized, + // A decryption error is permanent: no further data may be processed. + Errored, +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Decrypter { + state: DecrypterState, +} + +impl Decrypter { + fn check_active(&self) -> CryptographyResult<()> { + match self.state { + DecrypterState::Header { .. } | DecrypterState::Body { .. } => Ok(()), + DecrypterState::Finalized => Err(exceptions::already_finalized_error()), + DecrypterState::Errored => { + Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) + } + } + } + + fn update_out_len(&self, data_len: usize) -> usize { + let wire_chunk = AES_128_GCM.wire_chunk_size(); + let body_len = match &self.state { + DecrypterState::Header { buf, .. } => data_len.saturating_sub(HEADER_LEN - buf.len()), + DecrypterState::Body { buffer, .. } => buffer.len() + data_len, + DecrypterState::Finalized | DecrypterState::Errored => unreachable!(), + }; + (body_len / wire_chunk) * CHUNK_SIZE + } + + fn update_impl(&mut self, mut data: &[u8], out: &mut [u8]) -> CryptographyResult { + if let DecrypterState::Header { key, context, buf } = &mut self.state { + let take = std::cmp::min(HEADER_LEN - buf.len(), data.len()); + buf.extend_from_slice(&data[..take]); + data = &data[take..]; + if buf.len() < HEADER_LEN { + debug_assert!(data.is_empty()); + return Ok(0); + } + let (salt, commitment) = buf.split_at(SALT_LEN); + let keys = derive_keys(&AES_128_GCM, key, salt, context)?; + if !openssl::memcmp::eq(&keys.commitment, commitment) { + return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); + } + let cipher = ChunkCipher::new(&AES_128_GCM, &keys, false)?; + self.state = DecrypterState::Body { + cipher, + buffer: Vec::with_capacity(AES_128_GCM.wire_chunk_size()), + }; + } + let DecrypterState::Body { cipher, buffer } = &mut self.state else { + unreachable!() + }; + + let wire_chunk = CHUNK_SIZE + cipher.tag_len; + // Check the chunk counter limit up front so that the error doesn't + // leave a partially written output behind. + let n_chunks = ((buffer.len() + data.len()) / wire_chunk) as u64; + if n_chunks > cipher.remaining_chunks() { + return Err(message_too_large_error()); + } + + // Any complete wire chunk is necessarily not the final chunk (the + // final chunk is always shorter), so it can be decrypted, and its + // plaintext released, as soon as it is available. If the ciphertext + // ends on a chunk boundary, the missing final chunk is detected in + // finalize(). + let mut written = 0; + if !buffer.is_empty() { + let take = std::cmp::min(wire_chunk - buffer.len(), data.len()); + buffer.extend_from_slice(&data[..take]); + data = &data[take..]; + if buffer.len() == wire_chunk { + cipher.decrypt_chunk(buffer, &mut out[written..written + CHUNK_SIZE])?; + written += CHUNK_SIZE; + buffer.clear(); + } + } + while data.len() >= wire_chunk { + let (chunk, rest) = data.split_at(wire_chunk); + cipher.decrypt_chunk(chunk, &mut out[written..written + CHUNK_SIZE])?; + written += CHUNK_SIZE; + data = rest; + } + buffer.extend_from_slice(data); + Ok(written) + } +} + +#[pyo3::pymethods] +impl Decrypter { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + if key.as_bytes().len() != AES_128_GCM.key_len { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("key must be 16 bytes."), + )); + } + Ok(Decrypter { + state: DecrypterState::Header { + key: key.as_bytes().to_vec(), + context: context.as_bytes().to_vec(), + buf: Vec::with_capacity(HEADER_LEN), + }, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.check_active()?; + let data = data.as_bytes(); + let out_len = self.update_out_len(data.len()); + let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { + let n = self.update_impl(data, b)?; + debug_assert_eq!(n, out_len); + Ok(()) + }); + if result.is_err() { + self.state = DecrypterState::Errored; + } + Ok(result?) + } + + fn update_into( + &mut self, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.check_active()?; + let data = data.as_bytes(); + let out_len = self.update_out_len(data.len()); + let out = buf.as_mut_bytes(); + if out.len() < out_len { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err(format!( + "buffer must be at least {out_len} bytes" + )), + )); + } + let result = self.update_impl(data, out); + if result.is_err() { + self.state = DecrypterState::Errored; + } + result + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.check_active()?; + let result = match &mut self.state { + // The full salt and commitment never arrived: the message is + // truncated. + DecrypterState::Header { .. } => { + Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) + } + DecrypterState::Body { cipher, buffer } => { + if buffer.len() < cipher.tag_len { + // The final chunk is missing (the ciphertext ended + // exactly on a chunk boundary) or is too short to be + // valid: the message is truncated. + Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) + } else { + // buffer is always shorter than a wire chunk, so the + // final chunk's plaintext is necessarily shorter than + // CHUNK_SIZE, as the specification requires. + let out_len = buffer.len() - cipher.tag_len; + pyo3::types::PyBytes::new_with(py, out_len, |b| { + cipher.decrypt_chunk(buffer, b)?; + Ok(()) + }) + .map_err(CryptographyError::from) + } + } + DecrypterState::Finalized | DecrypterState::Errored => unreachable!(), + }; + match result { + Ok(plaintext) => { + self.state = DecrypterState::Finalized; + Ok(plaintext) + } + Err(e) => { + self.state = DecrypterState::Errored; + Err(e) + } + } + } +} + +#[pyo3::pymodule(gil_used = false)] +#[pyo3(name = "chunked_encryption")] +pub(crate) mod chunked_encryption_mod { + #[pymodule_export] + use super::{Decrypter, Encrypter}; +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index c72013d9c375..e3e9e22dda2e 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -34,6 +34,7 @@ use crate::error::CryptographyResult; mod asn1; mod backend; mod buf; +mod chunked_encryption; mod declarative_asn1; mod error; mod exceptions; @@ -132,6 +133,8 @@ mod _rust { #[pymodule_export] use crate::asn1::asn1_mod; #[pymodule_export] + use crate::chunked_encryption::chunked_encryption_mod; + #[pymodule_export] use crate::exceptions::exceptions; #[pymodule_export] use crate::oid::ObjectIdentifier; diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py new file mode 100644 index 000000000000..07eba80be009 --- /dev/null +++ b/tests/test_chunked_encryption.py @@ -0,0 +1,419 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import hashlib +import hmac +import os + +import pytest + +from cryptography.chunked_encryption import Decrypter, Encrypter +from cryptography.exceptions import AlreadyFinalized, InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +CHUNK_SIZE = 16 * 1024 +TAG_LEN = 16 +WIRE_CHUNK_SIZE = CHUNK_SIZE + TAG_LEN +SALT_LEN = 24 +COMMITMENT_LEN = 32 +HEADER_LEN = SALT_LEN + COMMITMENT_LEN + + +def _hkdf_expand_sha256(prk: bytes, info: bytes, length: int) -> bytes: + # Independent HKDF-Expand implementation (RFC 5869), using only the + # standard library. + out = b"" + t = b"" + i = 1 + while len(out) < length: + t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest() + out += t + i += 1 + return out[:length] + + +def _derive(key: bytes, salt: bytes, context: bytes): + info = ( + b"c2sp.org/chunked-encryption@v1+" + + b"AEAD_AES_128_GCM" + + b"\x00" + + salt + + context + ) + okm = _hkdf_expand_sha256(key, info, 16 + 12 + COMMITMENT_LEN) + return okm[:16], okm[16:28], okm[28:] + + +def _nonce(base_nonce: bytes, counter: int) -> bytes: + return (int.from_bytes(base_nonce, "big") ^ counter).to_bytes(12, "big") + + +def _chunks(data: bytes) -> list[bytes]: + chunks = [ + data[i : i + CHUNK_SIZE] for i in range(0, len(data), CHUNK_SIZE) + ] + if not chunks or len(chunks[-1]) == CHUNK_SIZE: + chunks.append(b"") + return chunks + + +def _reference_encrypt( + key: bytes, context: bytes, salt: bytes, plaintext: bytes +) -> bytes: + # A minimal reference implementation of c2sp.org/chunked-encryption, + # used to cross-check the real implementation. + aead_key, base_nonce, commitment = _derive(key, salt, context) + aead = AESGCM(aead_key) + result = salt + commitment + for i, chunk in enumerate(_chunks(plaintext)): + result += aead.encrypt(_nonce(base_nonce, i), chunk, None) + return result + + +def _reference_decrypt(key: bytes, context: bytes, ciphertext: bytes) -> bytes: + assert len(ciphertext) >= HEADER_LEN + salt = ciphertext[:SALT_LEN] + aead_key, base_nonce, commitment = _derive(key, salt, context) + assert ciphertext[SALT_LEN:HEADER_LEN] == commitment + aead = AESGCM(aead_key) + body = ciphertext[HEADER_LEN:] + result = b"" + for i in range(0, len(body), WIRE_CHUNK_SIZE): + chunk = body[i : i + WIRE_CHUNK_SIZE] + counter = i // WIRE_CHUNK_SIZE + result += aead.decrypt(_nonce(base_nonce, counter), chunk, None) + assert len(body) % WIRE_CHUNK_SIZE != 0 + return result + + +def _encrypt_all(key: bytes, context: bytes, plaintext: bytes) -> bytes: + enc = Encrypter(key, context) + return enc.update(plaintext) + enc.finalize() + + +def _decrypt_all(key: bytes, context: bytes, ciphertext: bytes) -> bytes: + dec = Decrypter(key, context) + return dec.update(ciphertext) + dec.finalize() + + +MESSAGE_LENGTHS = [ + 0, + 1, + 57, + CHUNK_SIZE - 1, + CHUNK_SIZE, + CHUNK_SIZE + 1, + 20 * 1024, + 2 * CHUNK_SIZE - 1, + 2 * CHUNK_SIZE, + 2 * CHUNK_SIZE + 1, + 3 * CHUNK_SIZE + 5000, +] + + +class TestChunkedEncryption: + @pytest.mark.parametrize("length", MESSAGE_LENGTHS) + def test_round_trip(self, length): + key = Encrypter.generate_key() + context = b"test context" + plaintext = os.urandom(length) + + ciphertext = _encrypt_all(key, context, plaintext) + n_chunks = length // CHUNK_SIZE + 1 + assert len(ciphertext) == HEADER_LEN + length + n_chunks * TAG_LEN + assert _decrypt_all(key, context, ciphertext) == plaintext + + @pytest.mark.parametrize("length", MESSAGE_LENGTHS) + def test_matches_reference_implementation(self, length): + key = Encrypter.generate_key() + context = b"reference check" + plaintext = os.urandom(length) + + # The encrypter generates a random salt internally, so recompute + # the expected ciphertext with the reference implementation using + # the salt it chose. + ciphertext = _encrypt_all(key, context, plaintext) + salt = ciphertext[:SALT_LEN] + assert ciphertext == _reference_encrypt(key, context, salt, plaintext) + assert _reference_decrypt(key, context, ciphertext) == plaintext + + # And decrypt a reference-produced ciphertext with a fixed salt. + reference = _reference_encrypt( + key, context, bytes(range(SALT_LEN)), plaintext + ) + assert _decrypt_all(key, context, reference) == plaintext + + @pytest.mark.parametrize("piece_size", [1, 57, 1024, 16384, 16400]) + def test_streaming(self, piece_size): + key = Encrypter.generate_key() + context = b"" + plaintext = os.urandom(2 * CHUNK_SIZE + 12345) + + enc = Encrypter(key, context) + ciphertext = b"" + for i in range(0, len(plaintext), piece_size): + ciphertext += enc.update(plaintext[i : i + piece_size]) + ciphertext += enc.finalize() + assert ciphertext == _reference_encrypt( + key, context, ciphertext[:SALT_LEN], plaintext + ) + + dec = Decrypter(key, context) + decrypted = b"" + for i in range(0, len(ciphertext), piece_size): + decrypted += dec.update(ciphertext[i : i + piece_size]) + decrypted += dec.finalize() + assert decrypted == plaintext + + def test_empty_message(self): + key = Encrypter.generate_key() + enc = Encrypter(key, b"ctx") + ciphertext = enc.finalize() + assert len(ciphertext) == HEADER_LEN + TAG_LEN + assert _decrypt_all(key, b"ctx", ciphertext) == b"" + + def test_update_with_empty_data_emits_header(self): + key = Encrypter.generate_key() + enc = Encrypter(key, b"") + header = enc.update(b"") + assert len(header) == HEADER_LEN + assert enc.update(b"") == b"" + ciphertext = header + enc.finalize() + assert _decrypt_all(key, b"", ciphertext) == b"" + + def test_exact_chunk_boundary_has_empty_final_chunk(self): + key = Encrypter.generate_key() + plaintext = os.urandom(CHUNK_SIZE) + ciphertext = _encrypt_all(key, b"", plaintext) + # One full chunk plus an empty final chunk. + assert len(ciphertext) == HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN + assert _decrypt_all(key, b"", ciphertext) == plaintext + + def test_decrypter_streams_plaintext_incrementally(self): + key = Encrypter.generate_key() + plaintext = os.urandom(3 * CHUNK_SIZE) + ciphertext = _encrypt_all(key, b"", plaintext) + + dec = Decrypter(key, b"") + out = dec.update(ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE]) + assert out == plaintext[:CHUNK_SIZE] + out = dec.update(ciphertext[HEADER_LEN + WIRE_CHUNK_SIZE :]) + assert out == plaintext[CHUNK_SIZE:] + assert dec.finalize() == b"" + + def test_wrong_key(self): + key = Encrypter.generate_key() + ciphertext = _encrypt_all(key, b"", b"message") + dec = Decrypter(Decrypter.generate_key(), b"") + with pytest.raises(InvalidTag): + dec.update(ciphertext) + + def test_wrong_context(self): + key = Encrypter.generate_key() + ciphertext = _encrypt_all(key, b"context a", b"message") + dec = Decrypter(key, b"context b") + with pytest.raises(InvalidTag): + dec.update(ciphertext) + + @pytest.mark.parametrize( + "position", + [ + 0, # salt + SALT_LEN, # commitment + HEADER_LEN, # first chunk ciphertext + HEADER_LEN + WIRE_CHUNK_SIZE - 1, # first chunk tag + HEADER_LEN + WIRE_CHUNK_SIZE + 3, # final chunk + ], + ) + def test_tampering_detected(self, position): + key = Encrypter.generate_key() + plaintext = os.urandom(CHUNK_SIZE + 100) + ciphertext = bytearray(_encrypt_all(key, b"", plaintext)) + ciphertext[position] ^= 1 + + dec = Decrypter(key, b"") + with pytest.raises(InvalidTag): + dec.update(bytes(ciphertext)) + dec.finalize() + + def test_swapped_chunks_detected(self): + key = Encrypter.generate_key() + plaintext = os.urandom(2 * CHUNK_SIZE + 100) + ciphertext = _encrypt_all(key, b"", plaintext) + + chunk0_start = HEADER_LEN + chunk1_start = HEADER_LEN + WIRE_CHUNK_SIZE + chunk2_start = HEADER_LEN + 2 * WIRE_CHUNK_SIZE + swapped = ( + ciphertext[:HEADER_LEN] + + ciphertext[chunk1_start:chunk2_start] + + ciphertext[chunk0_start:chunk1_start] + + ciphertext[chunk2_start:] + ) + dec = Decrypter(key, b"") + with pytest.raises(InvalidTag): + dec.update(swapped) + + @pytest.mark.parametrize( + "length", + [ + 0, + 1, + HEADER_LEN - 1, + HEADER_LEN, # no final chunk at all + HEADER_LEN + TAG_LEN - 1, # final chunk shorter than its tag + HEADER_LEN + WIRE_CHUNK_SIZE, # ends on a chunk boundary + HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN - 1, + ], + ) + def test_truncation_detected(self, length): + key = Encrypter.generate_key() + plaintext = os.urandom(CHUNK_SIZE + 100) + ciphertext = _encrypt_all(key, b"", plaintext) + + dec = Decrypter(key, b"") + with pytest.raises(InvalidTag): + dec.update(ciphertext[:length]) + dec.finalize() + + def test_extension_detected(self): + key = Encrypter.generate_key() + ciphertext = _encrypt_all(key, b"", b"message") + + dec = Decrypter(key, b"") + with pytest.raises(InvalidTag): + dec.update(ciphertext + b"extra garbage bytes!") + dec.finalize() + + def test_ciphertexts_are_randomized(self): + key = Encrypter.generate_key() + assert _encrypt_all(key, b"", b"data") != _encrypt_all( + key, b"", b"data" + ) + + def test_update_into(self): + key = Encrypter.generate_key() + plaintext = os.urandom(CHUNK_SIZE + 100) + + enc = Encrypter(key, b"") + buf = bytearray(HEADER_LEN + 2 * WIRE_CHUNK_SIZE) + n = enc.update_into(plaintext, buf) + assert n == HEADER_LEN + WIRE_CHUNK_SIZE + ciphertext = bytes(buf[:n]) + enc.finalize() + assert _decrypt_all(key, b"", ciphertext) == plaintext + + dec = Decrypter(key, b"") + out = bytearray(2 * CHUNK_SIZE) + n = dec.update_into(ciphertext, out) + assert n == CHUNK_SIZE + assert bytes(out[:n]) + dec.finalize() == plaintext + + def test_update_into_accepts_larger_buffer(self): + key = Encrypter.generate_key() + enc = Encrypter(key, b"") + buf = bytearray(10 * WIRE_CHUNK_SIZE) + n = enc.update_into(b"abc", buf) + assert n == HEADER_LEN + ciphertext = bytes(buf[:n]) + enc.finalize() + assert _decrypt_all(key, b"", ciphertext) == b"abc" + + def test_update_into_buffer_too_small(self): + key = Encrypter.generate_key() + enc = Encrypter(key, b"") + with pytest.raises(ValueError, match="buffer must be at least"): + enc.update_into(b"abc", bytearray(HEADER_LEN - 1)) + # The context remains usable after the failed call. + ciphertext = enc.update(b"abc") + enc.finalize() + + dec = Decrypter(key, b"") + with pytest.raises(ValueError, match="buffer must be at least"): + dec.update_into( + ciphertext + bytes(WIRE_CHUNK_SIZE), bytearray(CHUNK_SIZE - 1) + ) + assert dec.update(ciphertext) == b"" + assert dec.finalize() == b"abc" + + def test_update_into_zero_output(self): + key = Encrypter.generate_key() + dec = Decrypter(key, b"") + assert dec.update_into(b"", bytearray(0)) == 0 + + def test_use_after_finalize(self): + key = Encrypter.generate_key() + enc = Encrypter(key, b"") + ciphertext = enc.update(b"data") + enc.finalize() + with pytest.raises(AlreadyFinalized): + enc.update(b"more") + with pytest.raises(AlreadyFinalized): + enc.update_into(b"more", bytearray(WIRE_CHUNK_SIZE)) + with pytest.raises(AlreadyFinalized): + enc.finalize() + + dec = Decrypter(key, b"") + dec.update(ciphertext) + dec.finalize() + with pytest.raises(AlreadyFinalized): + dec.update(b"more") + with pytest.raises(AlreadyFinalized): + dec.update_into(b"more", bytearray(WIRE_CHUNK_SIZE)) + with pytest.raises(AlreadyFinalized): + dec.finalize() + + def test_decrypter_poisoned_after_invalid_tag(self): + key = Encrypter.generate_key() + ciphertext = bytearray(_encrypt_all(key, b"", b"data")) + ciphertext[-1] ^= 1 + + dec = Decrypter(key, b"") + dec.update(bytes(ciphertext)) + with pytest.raises(InvalidTag): + dec.finalize() + # All subsequent operations fail. + with pytest.raises(InvalidTag): + dec.update(b"") + with pytest.raises(InvalidTag): + dec.finalize() + + def test_finalize_only_decrypter_rejects_empty_stream(self): + key = Decrypter.generate_key() + dec = Decrypter(key, b"") + with pytest.raises(InvalidTag): + dec.finalize() + + def test_generate_key(self): + key = Encrypter.generate_key() + assert isinstance(key, bytes) + assert len(key) == 16 + assert len(Decrypter.generate_key()) == 16 + assert Encrypter.generate_key() != Encrypter.generate_key() + + @pytest.mark.parametrize("length", [0, 15, 17, 32]) + def test_invalid_key_size(self, length): + with pytest.raises(ValueError): + Encrypter(b"\x00" * length, b"") + with pytest.raises(ValueError): + Decrypter(b"\x00" * length, b"") + + def test_invalid_types(self): + key = Encrypter.generate_key() + with pytest.raises(TypeError): + Encrypter("not bytes", b"") # type: ignore[arg-type] + with pytest.raises(TypeError): + Encrypter(key, "not bytes") # type: ignore[arg-type] + with pytest.raises(TypeError): + Decrypter("not bytes", b"") # type: ignore[arg-type] + enc = Encrypter(key, b"") + with pytest.raises(TypeError): + enc.update("not bytes") # type: ignore[arg-type] + with pytest.raises(TypeError): + enc.update_into(b"", b"immutable") + + def test_accepts_buffers(self): + key = bytearray(Encrypter.generate_key()) + plaintext = os.urandom(1000) + enc = Encrypter(key, memoryview(b"ctx")) + ciphertext = enc.update(memoryview(plaintext)) + enc.finalize() + dec = Decrypter(memoryview(bytes(key)), bytearray(b"ctx")) + assert dec.update(bytearray(ciphertext)) + dec.finalize() == plaintext From 0dc129b45064b8725f2f84f983e654d68478410d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 17:40:28 +0000 Subject: [PATCH 02/11] Address review feedback on chunked encryption - Use the existing AesGcm AEAD implementation for chunk encryption/decryption instead of using OpenSSL's EVP interface directly (AESGCM.decrypt_into is now pub(crate) for this). - Use the existing HkdfExpand implementation for key derivation. - Fold the error state into the finalized state: a context that hit an error raises AlreadyFinalized on further use. - Replace the let-else in Decrypter::update_impl with a match that yields the cipher and buffer fields. - Replace the in-test reference implementation with the test vectors from the chunked reference implementation (https://github.com/FiloSottile/chunked), vendored into cryptography_vectors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- docs/chunked-encryption.rst | 2 +- src/rust/src/backend/aead.rs | 2 +- src/rust/src/chunked_encryption.rs | 254 ++++++++++++++--------------- tests/test_chunked_encryption.py | 137 +++++----------- 4 files changed, 169 insertions(+), 226 deletions(-) diff --git a/docs/chunked-encryption.rst b/docs/chunked-encryption.rst index a22d32facfe8..e84c443b83eb 100644 --- a/docs/chunked-encryption.rst +++ b/docs/chunked-encryption.rst @@ -119,7 +119,7 @@ message — an overhead of roughly 0.1%. Once any method raises :class:`~cryptography.exceptions.InvalidTag`, the instance is permanently unusable and all further calls raise - :class:`~cryptography.exceptions.InvalidTag` as well. + :class:`~cryptography.exceptions.AlreadyFinalized`. :param key: The 16-byte key the message was encrypted with. :type key: :term:`bytes-like` diff --git a/src/rust/src/backend/aead.rs b/src/rust/src/backend/aead.rs index ec9f2bd32340..e98ab96fb06b 100644 --- a/src/rust/src/backend/aead.rs +++ b/src/rust/src/backend/aead.rs @@ -673,7 +673,7 @@ impl AesGcm { } #[pyo3(signature = (nonce, data, associated_data, buf))] - fn decrypt_into( + pub(crate) fn decrypt_into( &self, py: pyo3::Python<'_>, nonce: CffiBuf<'_>, diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index 95dec8e52707..2d164cb77b86 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -6,9 +6,13 @@ // (https://c2sp.org/chunked-encryption), instantiated with SHA-256 and // AES-128-GCM. +use pyo3::types::{PyAnyMethods, PyBytesMethods}; + +use crate::backend::aead::AesGcm; +use crate::backend::kdf::HkdfExpand; use crate::buf::{CffiBuf, CffiMutBuf}; use crate::error::{CryptographyError, CryptographyResult}; -use crate::exceptions; +use crate::{exceptions, types}; const CHUNK_SIZE: usize = 16 * 1024; const SALT_LEN: usize = 24; @@ -28,7 +32,6 @@ struct AeadParams { iana_name: &'static [u8], key_len: usize, tag_len: usize, - cipher: fn() -> &'static openssl::cipher::CipherRef, } impl AeadParams { @@ -41,7 +44,6 @@ static AES_128_GCM: AeadParams = AeadParams { iana_name: b"AEAD_AES_128_GCM", key_len: 16, tag_len: 16, - cipher: openssl::cipher::Cipher::aes_128_gcm, }; fn message_too_large_error() -> CryptographyError { @@ -50,52 +52,46 @@ fn message_too_large_error() -> CryptographyError { )) } -// HKDF-Expand (RFC 5869) with SHA-256, with the info supplied as -// concatenated parts. -fn hkdf_expand_sha256(prk: &[u8], info_parts: &[&[u8]], out: &mut [u8]) -> CryptographyResult<()> { - let md = openssl::hash::MessageDigest::sha256(); - let mut previous: Option = None; - for (i, block) in out.chunks_mut(md.size()).enumerate() { - let mut h = cryptography_openssl::hmac::Hmac::new(prk, md)?; - if let Some(prev) = &previous { - h.update(prev)?; - } - for part in info_parts { - h.update(part)?; - } - h.update(&[(i + 1) as u8])?; - let t = h.finish()?; - block.copy_from_slice(&t[..block.len()]); - previous = Some(t); - } - Ok(()) -} - -struct DerivedKeys { - key: Vec, +struct DerivedKeys<'p> { + key: pyo3::Bound<'p, pyo3::types::PyBytes>, base_nonce: [u8; NONCE_LEN], commitment: [u8; COMMITMENT_LEN], } -fn derive_keys( +fn derive_keys<'p>( + py: pyo3::Python<'p>, params: &AeadParams, input_key: &[u8], salt: &[u8], context: &[u8], -) -> CryptographyResult { - let mut out = vec![0; params.key_len + NONCE_LEN + COMMITMENT_LEN]; - hkdf_expand_sha256( - input_key, - &[INFO_PREFIX, params.iana_name, &[0x00], salt, context], - &mut out, +) -> CryptographyResult> { + let mut info = Vec::with_capacity( + INFO_PREFIX.len() + params.iana_name.len() + 1 + salt.len() + context.len(), + ); + info.extend_from_slice(INFO_PREFIX); + info.extend_from_slice(params.iana_name); + info.push(0x00); + info.extend_from_slice(salt); + info.extend_from_slice(context); + + let algorithm = types::SHA256.get(py)?.call0()?; + let mut hkdf = HkdfExpand::new( + py, + algorithm.unbind(), + params.key_len + NONCE_LEN + COMMITMENT_LEN, + Some(pyo3::types::PyBytes::new(py, &info).unbind()), + None, )?; + let okm = hkdf.derive(py, CffiBuf::from_bytes(py, input_key))?; + let okm_bytes = okm.as_bytes(); + + let key = pyo3::types::PyBytes::new(py, &okm_bytes[..params.key_len]); let mut base_nonce = [0; NONCE_LEN]; - base_nonce.copy_from_slice(&out[params.key_len..params.key_len + NONCE_LEN]); + base_nonce.copy_from_slice(&okm_bytes[params.key_len..params.key_len + NONCE_LEN]); let mut commitment = [0; COMMITMENT_LEN]; - commitment.copy_from_slice(&out[params.key_len + NONCE_LEN..]); - out.truncate(params.key_len); + commitment.copy_from_slice(&okm_bytes[params.key_len + NONCE_LEN..]); Ok(DerivedKeys { - key: out, + key, base_nonce, commitment, }) @@ -104,24 +100,21 @@ fn derive_keys( // A single-message AEAD context that encrypts or decrypts successive chunks // with the base nonce XOR'd with a chunk counter. struct ChunkCipher { - ctx: openssl::cipher_ctx::CipherCtx, + aead: AesGcm, base_nonce: [u8; NONCE_LEN], tag_len: usize, counter: u64, } impl ChunkCipher { - fn new(params: &AeadParams, keys: &DerivedKeys, encrypt: bool) -> CryptographyResult { - let mut ctx = openssl::cipher_ctx::CipherCtx::new()?; - let cipher = (params.cipher)(); - if encrypt { - ctx.encrypt_init(Some(cipher), Some(&keys.key), None)?; - } else { - ctx.decrypt_init(Some(cipher), Some(&keys.key), None)?; - } - ctx.set_iv_length(NONCE_LEN)?; + fn new( + py: pyo3::Python<'_>, + params: &AeadParams, + keys: &DerivedKeys<'_>, + ) -> CryptographyResult { + let aead = AesGcm::new(py, keys.key.clone().unbind().into_any())?; Ok(ChunkCipher { - ctx, + aead, base_nonce: keys.base_nonce, tag_len: params.tag_len, counter: 0, @@ -147,36 +140,40 @@ impl ChunkCipher { } // `out` must be exactly `plaintext.len() + self.tag_len` bytes. - fn encrypt_chunk(&mut self, plaintext: &[u8], out: &mut [u8]) -> CryptographyResult<()> { + fn encrypt_chunk( + &mut self, + py: pyo3::Python<'_>, + plaintext: &[u8], + out: &mut [u8], + ) -> CryptographyResult<()> { let nonce = self.next_nonce()?; - self.ctx.encrypt_init(None, None, Some(&nonce))?; - let (ciphertext, tag) = out.split_at_mut(plaintext.len()); - let n = self.ctx.cipher_update(plaintext, Some(ciphertext))?; - assert_eq!(n, plaintext.len()); - let mut final_block = [0]; - let n = self.ctx.cipher_final(&mut final_block)?; - assert_eq!(n, 0); - self.ctx.tag(tag)?; + self.aead.encrypt_into( + py, + CffiBuf::from_bytes(py, &nonce), + CffiBuf::from_bytes(py, plaintext), + None, + CffiMutBuf::from_bytes(py, out), + )?; self.counter += 1; Ok(()) } // `ciphertext` includes the trailing tag; `out` must be exactly // `ciphertext.len() - self.tag_len` bytes. - fn decrypt_chunk(&mut self, ciphertext: &[u8], out: &mut [u8]) -> CryptographyResult<()> { + fn decrypt_chunk( + &mut self, + py: pyo3::Python<'_>, + ciphertext: &[u8], + out: &mut [u8], + ) -> CryptographyResult<()> { let nonce = self.next_nonce()?; - let (data, tag) = ciphertext.split_at(ciphertext.len() - self.tag_len); - self.ctx.decrypt_init(None, None, Some(&nonce))?; - self.ctx.set_tag(tag)?; - let n = self - .ctx - .cipher_update(data, Some(out)) - .map_err(|_| exceptions::InvalidTag::new_err(()))?; - assert_eq!(n, data.len()); - let mut final_block = [0]; - self.ctx - .cipher_final(&mut final_block) - .map_err(|_| exceptions::InvalidTag::new_err(()))?; + self.aead.decrypt_into( + py, + CffiBuf::from_bytes(py, &nonce), + CffiBuf::from_bytes(py, ciphertext), + None, + CffiMutBuf::from_bytes(py, out), + )?; self.counter += 1; Ok(()) } @@ -189,16 +186,10 @@ pub(crate) struct Encrypter { header: [u8; HEADER_LEN], header_pending: bool, finalized: bool, - errored: bool, } impl Encrypter { fn check_active(&self) -> CryptographyResult<()> { - if self.errored { - return Err(CryptographyError::from( - pyo3::exceptions::PyValueError::new_err("Context cannot be used after an error."), - )); - } if self.finalized { return Err(exceptions::already_finalized_error()); } @@ -211,7 +202,12 @@ impl Encrypter { header + n_chunks * (CHUNK_SIZE + self.cipher.tag_len) } - fn update_impl(&mut self, mut data: &[u8], out: &mut [u8]) -> CryptographyResult { + fn update_impl( + &mut self, + py: pyo3::Python<'_>, + mut data: &[u8], + out: &mut [u8], + ) -> CryptographyResult { // Check the chunk counter limit up front so that the error doesn't // leave a partially written output behind. let n_chunks = ((self.buffer.len() + data.len()) / CHUNK_SIZE) as u64; @@ -231,8 +227,11 @@ impl Encrypter { self.buffer.extend_from_slice(&data[..take]); data = &data[take..]; if self.buffer.len() == CHUNK_SIZE { - self.cipher - .encrypt_chunk(&self.buffer, &mut out[written..written + wire_chunk])?; + self.cipher.encrypt_chunk( + py, + &self.buffer, + &mut out[written..written + wire_chunk], + )?; written += wire_chunk; self.buffer.clear(); } @@ -240,7 +239,7 @@ impl Encrypter { while data.len() >= CHUNK_SIZE { let (chunk, rest) = data.split_at(CHUNK_SIZE); self.cipher - .encrypt_chunk(chunk, &mut out[written..written + wire_chunk])?; + .encrypt_chunk(py, chunk, &mut out[written..written + wire_chunk])?; written += wire_chunk; data = rest; } @@ -252,7 +251,11 @@ impl Encrypter { #[pyo3::pymethods] impl Encrypter { #[new] - fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + fn new( + py: pyo3::Python<'_>, + key: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult { let params = &AES_128_GCM; if key.as_bytes().len() != params.key_len { return Err(CryptographyError::from( @@ -261,8 +264,8 @@ impl Encrypter { } let mut salt = [0; SALT_LEN]; cryptography_openssl::rand::rand_bytes(&mut salt)?; - let keys = derive_keys(params, key.as_bytes(), &salt, context.as_bytes())?; - let cipher = ChunkCipher::new(params, &keys, true)?; + let keys = derive_keys(py, params, key.as_bytes(), &salt, context.as_bytes())?; + let cipher = ChunkCipher::new(py, params, &keys)?; let mut header = [0; HEADER_LEN]; header[..SALT_LEN].copy_from_slice(&salt); header[SALT_LEN..].copy_from_slice(&keys.commitment); @@ -272,7 +275,6 @@ impl Encrypter { header, header_pending: true, finalized: false, - errored: false, }) } @@ -292,18 +294,19 @@ impl Encrypter { let data = data.as_bytes(); let out_len = self.update_out_len(data.len()); let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { - let n = self.update_impl(data, b)?; + let n = self.update_impl(py, data, b)?; debug_assert_eq!(n, out_len); Ok(()) }); if result.is_err() { - self.errored = true; + self.finalized = true; } Ok(result?) } fn update_into( &mut self, + py: pyo3::Python<'_>, data: CffiBuf<'_>, mut buf: CffiMutBuf<'_>, ) -> CryptographyResult { @@ -318,9 +321,9 @@ impl Encrypter { )), )); } - let result = self.update_impl(data, out); + let result = self.update_impl(py, data, out); if result.is_err() { - self.errored = true; + self.finalized = true; } result } @@ -330,27 +333,19 @@ impl Encrypter { py: pyo3::Python<'p>, ) -> CryptographyResult> { self.check_active()?; + self.finalized = true; let header = if self.header_pending { HEADER_LEN } else { 0 }; let out_len = header + self.buffer.len() + self.cipher.tag_len; let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { if header != 0 { b[..HEADER_LEN].copy_from_slice(&self.header); } - self.cipher.encrypt_chunk(&self.buffer, &mut b[header..])?; + self.cipher + .encrypt_chunk(py, &self.buffer, &mut b[header..])?; Ok(()) - }); - match result { - Ok(ciphertext) => { - self.header_pending = false; - self.buffer.clear(); - self.finalized = true; - Ok(ciphertext) - } - Err(e) => { - self.errored = true; - Err(e.into()) - } - } + })?; + self.buffer.clear(); + Ok(result) } } @@ -366,9 +361,8 @@ enum DecrypterState { cipher: ChunkCipher, buffer: Vec, }, + // Also used after any error: a failed context cannot process more data. Finalized, - // A decryption error is permanent: no further data may be processed. - Errored, } #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] @@ -381,9 +375,6 @@ impl Decrypter { match self.state { DecrypterState::Header { .. } | DecrypterState::Body { .. } => Ok(()), DecrypterState::Finalized => Err(exceptions::already_finalized_error()), - DecrypterState::Errored => { - Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) - } } } @@ -392,12 +383,17 @@ impl Decrypter { let body_len = match &self.state { DecrypterState::Header { buf, .. } => data_len.saturating_sub(HEADER_LEN - buf.len()), DecrypterState::Body { buffer, .. } => buffer.len() + data_len, - DecrypterState::Finalized | DecrypterState::Errored => unreachable!(), + DecrypterState::Finalized => unreachable!(), }; (body_len / wire_chunk) * CHUNK_SIZE } - fn update_impl(&mut self, mut data: &[u8], out: &mut [u8]) -> CryptographyResult { + fn update_impl( + &mut self, + py: pyo3::Python<'_>, + mut data: &[u8], + out: &mut [u8], + ) -> CryptographyResult { if let DecrypterState::Header { key, context, buf } = &mut self.state { let take = std::cmp::min(HEADER_LEN - buf.len(), data.len()); buf.extend_from_slice(&data[..take]); @@ -407,18 +403,19 @@ impl Decrypter { return Ok(0); } let (salt, commitment) = buf.split_at(SALT_LEN); - let keys = derive_keys(&AES_128_GCM, key, salt, context)?; - if !openssl::memcmp::eq(&keys.commitment, commitment) { + let keys = derive_keys(py, &AES_128_GCM, key, salt, context)?; + if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); } - let cipher = ChunkCipher::new(&AES_128_GCM, &keys, false)?; + let cipher = ChunkCipher::new(py, &AES_128_GCM, &keys)?; self.state = DecrypterState::Body { cipher, buffer: Vec::with_capacity(AES_128_GCM.wire_chunk_size()), }; } - let DecrypterState::Body { cipher, buffer } = &mut self.state else { - unreachable!() + let (cipher, buffer) = match &mut self.state { + DecrypterState::Body { cipher, buffer } => (cipher, buffer), + DecrypterState::Header { .. } | DecrypterState::Finalized => unreachable!(), }; let wire_chunk = CHUNK_SIZE + cipher.tag_len; @@ -440,14 +437,14 @@ impl Decrypter { buffer.extend_from_slice(&data[..take]); data = &data[take..]; if buffer.len() == wire_chunk { - cipher.decrypt_chunk(buffer, &mut out[written..written + CHUNK_SIZE])?; + cipher.decrypt_chunk(py, buffer, &mut out[written..written + CHUNK_SIZE])?; written += CHUNK_SIZE; buffer.clear(); } } while data.len() >= wire_chunk { let (chunk, rest) = data.split_at(wire_chunk); - cipher.decrypt_chunk(chunk, &mut out[written..written + CHUNK_SIZE])?; + cipher.decrypt_chunk(py, chunk, &mut out[written..written + CHUNK_SIZE])?; written += CHUNK_SIZE; data = rest; } @@ -490,18 +487,19 @@ impl Decrypter { let data = data.as_bytes(); let out_len = self.update_out_len(data.len()); let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { - let n = self.update_impl(data, b)?; + let n = self.update_impl(py, data, b)?; debug_assert_eq!(n, out_len); Ok(()) }); if result.is_err() { - self.state = DecrypterState::Errored; + self.state = DecrypterState::Finalized; } Ok(result?) } fn update_into( &mut self, + py: pyo3::Python<'_>, data: CffiBuf<'_>, mut buf: CffiMutBuf<'_>, ) -> CryptographyResult { @@ -516,9 +514,9 @@ impl Decrypter { )), )); } - let result = self.update_impl(data, out); + let result = self.update_impl(py, data, out); if result.is_err() { - self.state = DecrypterState::Errored; + self.state = DecrypterState::Finalized; } result } @@ -546,24 +544,16 @@ impl Decrypter { // CHUNK_SIZE, as the specification requires. let out_len = buffer.len() - cipher.tag_len; pyo3::types::PyBytes::new_with(py, out_len, |b| { - cipher.decrypt_chunk(buffer, b)?; + cipher.decrypt_chunk(py, buffer, b)?; Ok(()) }) .map_err(CryptographyError::from) } } - DecrypterState::Finalized | DecrypterState::Errored => unreachable!(), + DecrypterState::Finalized => unreachable!(), }; - match result { - Ok(plaintext) => { - self.state = DecrypterState::Finalized; - Ok(plaintext) - } - Err(e) => { - self.state = DecrypterState::Errored; - Err(e) - } - } + self.state = DecrypterState::Finalized; + result } } diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py index 07eba80be009..3b0d3c8b3d7d 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_chunked_encryption.py @@ -3,15 +3,17 @@ # for complete details. +import base64 import hashlib -import hmac +import json import os +import zlib import pytest +import cryptography_vectors from cryptography.chunked_encryption import Decrypter, Encrypter from cryptography.exceptions import AlreadyFinalized, InvalidTag -from cryptography.hazmat.primitives.ciphers.aead import AESGCM CHUNK_SIZE = 16 * 1024 TAG_LEN = 16 @@ -21,71 +23,14 @@ HEADER_LEN = SALT_LEN + COMMITMENT_LEN -def _hkdf_expand_sha256(prk: bytes, info: bytes, length: int) -> bytes: - # Independent HKDF-Expand implementation (RFC 5869), using only the - # standard library. - out = b"" - t = b"" - i = 1 - while len(out) < length: - t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest() - out += t - i += 1 - return out[:length] - - -def _derive(key: bytes, salt: bytes, context: bytes): - info = ( - b"c2sp.org/chunked-encryption@v1+" - + b"AEAD_AES_128_GCM" - + b"\x00" - + salt - + context +def _load_vectors(): + vector_file = cryptography_vectors.open_vector_file( + os.path.join("chunked-encryption", "vectors.json"), "r" ) - okm = _hkdf_expand_sha256(key, info, 16 + 12 + COMMITMENT_LEN) - return okm[:16], okm[16:28], okm[28:] - - -def _nonce(base_nonce: bytes, counter: int) -> bytes: - return (int.from_bytes(base_nonce, "big") ^ counter).to_bytes(12, "big") - - -def _chunks(data: bytes) -> list[bytes]: - chunks = [ - data[i : i + CHUNK_SIZE] for i in range(0, len(data), CHUNK_SIZE) - ] - if not chunks or len(chunks[-1]) == CHUNK_SIZE: - chunks.append(b"") - return chunks - - -def _reference_encrypt( - key: bytes, context: bytes, salt: bytes, plaintext: bytes -) -> bytes: - # A minimal reference implementation of c2sp.org/chunked-encryption, - # used to cross-check the real implementation. - aead_key, base_nonce, commitment = _derive(key, salt, context) - aead = AESGCM(aead_key) - result = salt + commitment - for i, chunk in enumerate(_chunks(plaintext)): - result += aead.encrypt(_nonce(base_nonce, i), chunk, None) - return result - - -def _reference_decrypt(key: bytes, context: bytes, ciphertext: bytes) -> bytes: - assert len(ciphertext) >= HEADER_LEN - salt = ciphertext[:SALT_LEN] - aead_key, base_nonce, commitment = _derive(key, salt, context) - assert ciphertext[SALT_LEN:HEADER_LEN] == commitment - aead = AESGCM(aead_key) - body = ciphertext[HEADER_LEN:] - result = b"" - for i in range(0, len(body), WIRE_CHUNK_SIZE): - chunk = body[i : i + WIRE_CHUNK_SIZE] - counter = i // WIRE_CHUNK_SIZE - result += aead.decrypt(_nonce(base_nonce, counter), chunk, None) - assert len(body) % WIRE_CHUNK_SIZE != 0 - return result + with vector_file: + vectors = json.load(vector_file)["vectors"] + # The public API only supports AES-128-GCM. + return [v for v in vectors if v["aead"] == "AEAD_AES_128_GCM"] def _encrypt_all(key: bytes, context: bytes, plaintext: bytes) -> bytes: @@ -114,6 +59,33 @@ def _decrypt_all(key: bytes, context: bytes, ciphertext: bytes) -> bytes: class TestChunkedEncryption: + @pytest.mark.parametrize( + "vector", _load_vectors(), ids=lambda v: v["name"] + ) + def test_vectors(self, vector): + key = bytes.fromhex(vector["key"]) + context = vector["context"].encode() + if vector["ciphertext"] is None: + ciphertext = b"" + else: + ciphertext = base64.b64decode(vector["ciphertext"]) + if vector.get("compressed"): + ciphertext = zlib.decompress(ciphertext) + + if vector["expect"] == "success": + plaintext = _decrypt_all(key, context, ciphertext) + assert len(plaintext) == vector["payload_length"] + assert ( + hashlib.sha256(plaintext).hexdigest() + == vector["payload_sha256"] + ) + elif len(key) != 16: + with pytest.raises(ValueError): + Decrypter(key, context) + else: + with pytest.raises(InvalidTag): + _decrypt_all(key, context, ciphertext) + @pytest.mark.parametrize("length", MESSAGE_LENGTHS) def test_round_trip(self, length): key = Encrypter.generate_key() @@ -125,26 +97,6 @@ def test_round_trip(self, length): assert len(ciphertext) == HEADER_LEN + length + n_chunks * TAG_LEN assert _decrypt_all(key, context, ciphertext) == plaintext - @pytest.mark.parametrize("length", MESSAGE_LENGTHS) - def test_matches_reference_implementation(self, length): - key = Encrypter.generate_key() - context = b"reference check" - plaintext = os.urandom(length) - - # The encrypter generates a random salt internally, so recompute - # the expected ciphertext with the reference implementation using - # the salt it chose. - ciphertext = _encrypt_all(key, context, plaintext) - salt = ciphertext[:SALT_LEN] - assert ciphertext == _reference_encrypt(key, context, salt, plaintext) - assert _reference_decrypt(key, context, ciphertext) == plaintext - - # And decrypt a reference-produced ciphertext with a fixed salt. - reference = _reference_encrypt( - key, context, bytes(range(SALT_LEN)), plaintext - ) - assert _decrypt_all(key, context, reference) == plaintext - @pytest.mark.parametrize("piece_size", [1, 57, 1024, 16384, 16400]) def test_streaming(self, piece_size): key = Encrypter.generate_key() @@ -156,9 +108,10 @@ def test_streaming(self, piece_size): for i in range(0, len(plaintext), piece_size): ciphertext += enc.update(plaintext[i : i + piece_size]) ciphertext += enc.finalize() - assert ciphertext == _reference_encrypt( - key, context, ciphertext[:SALT_LEN], plaintext - ) + # The result matches a single-shot encryption's structure, and + # decrypts to the plaintext regardless of how the ciphertext is + # split up. + assert len(ciphertext) == HEADER_LEN + len(plaintext) + 3 * TAG_LEN dec = Decrypter(key, context) decrypted = b"" @@ -361,7 +314,7 @@ def test_use_after_finalize(self): with pytest.raises(AlreadyFinalized): dec.finalize() - def test_decrypter_poisoned_after_invalid_tag(self): + def test_decrypter_unusable_after_invalid_tag(self): key = Encrypter.generate_key() ciphertext = bytearray(_encrypt_all(key, b"", b"data")) ciphertext[-1] ^= 1 @@ -371,9 +324,9 @@ def test_decrypter_poisoned_after_invalid_tag(self): with pytest.raises(InvalidTag): dec.finalize() # All subsequent operations fail. - with pytest.raises(InvalidTag): + with pytest.raises(AlreadyFinalized): dec.update(b"") - with pytest.raises(InvalidTag): + with pytest.raises(AlreadyFinalized): dec.finalize() def test_finalize_only_decrypter_rejects_empty_stream(self): From 6d93eed9aecc4bccaebf0c5aa1654197e0e92ea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 17:43:57 +0000 Subject: [PATCH 03/11] Add PiB to the docs spelling wordlist Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 105c14c0ebd7..4723d0769d9a 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -124,6 +124,7 @@ parsers Parsers PEM PHC +PiB pickleable plaintext Poly From 5b5642181f8b80d067a335d257018fc6dbc5c10b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:05:47 +0000 Subject: [PATCH 04/11] Restructure chunked encryption for full test coverage - Extract the chunk counter/nonce logic into a ChunkNonces struct with Rust unit tests covering the 2**38-chunk limit, which can't be reached from Python tests. - Track the Decrypter state as Option and process both active states in a single exhaustive match, removing the unreachable!() arms; finalize() now consumes the state on all paths. - Drop the Encrypter's error poisoning: the only errors it guarded against are the capacity check (which fails before any state is modified) and internal OpenSSL errors. - Test that a Decrypter is unusable after update_into() raises InvalidTag. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- src/rust/src/chunked_encryption.rs | 262 +++++++++++++++++------------ tests/test_chunked_encryption.py | 16 ++ 2 files changed, 171 insertions(+), 107 deletions(-) diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index 2d164cb77b86..78e01e27ca0b 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -97,13 +97,40 @@ fn derive_keys<'p>( }) } -// A single-message AEAD context that encrypts or decrypts successive chunks -// with the base nonce XOR'd with a chunk counter. +// The per-chunk nonce sequence: the base nonce XOR'd with an incrementing +// chunk counter. +struct ChunkNonces { + base_nonce: [u8; NONCE_LEN], + counter: u64, +} + +impl ChunkNonces { + fn check_capacity(&self, n_chunks: u64) -> CryptographyResult<()> { + if n_chunks > MAX_CHUNK_COUNT - self.counter { + return Err(message_too_large_error()); + } + Ok(()) + } + + fn next(&mut self) -> CryptographyResult<[u8; NONCE_LEN]> { + self.check_capacity(1)?; + let mut nonce = self.base_nonce; + for (n, c) in nonce[NONCE_LEN - 8..] + .iter_mut() + .zip(self.counter.to_be_bytes()) + { + *n ^= c; + } + self.counter += 1; + Ok(nonce) + } +} + +// A single-message AEAD context that encrypts or decrypts successive chunks. struct ChunkCipher { aead: AesGcm, - base_nonce: [u8; NONCE_LEN], + nonces: ChunkNonces, tag_len: usize, - counter: u64, } impl ChunkCipher { @@ -115,30 +142,14 @@ impl ChunkCipher { let aead = AesGcm::new(py, keys.key.clone().unbind().into_any())?; Ok(ChunkCipher { aead, - base_nonce: keys.base_nonce, + nonces: ChunkNonces { + base_nonce: keys.base_nonce, + counter: 0, + }, tag_len: params.tag_len, - counter: 0, }) } - fn remaining_chunks(&self) -> u64 { - MAX_CHUNK_COUNT - self.counter - } - - fn next_nonce(&self) -> CryptographyResult<[u8; NONCE_LEN]> { - if self.counter >= MAX_CHUNK_COUNT { - return Err(message_too_large_error()); - } - let mut nonce = self.base_nonce; - for (n, c) in nonce[NONCE_LEN - 8..] - .iter_mut() - .zip(self.counter.to_be_bytes()) - { - *n ^= c; - } - Ok(nonce) - } - // `out` must be exactly `plaintext.len() + self.tag_len` bytes. fn encrypt_chunk( &mut self, @@ -146,7 +157,7 @@ impl ChunkCipher { plaintext: &[u8], out: &mut [u8], ) -> CryptographyResult<()> { - let nonce = self.next_nonce()?; + let nonce = self.nonces.next()?; self.aead.encrypt_into( py, CffiBuf::from_bytes(py, &nonce), @@ -154,7 +165,6 @@ impl ChunkCipher { None, CffiMutBuf::from_bytes(py, out), )?; - self.counter += 1; Ok(()) } @@ -166,7 +176,7 @@ impl ChunkCipher { ciphertext: &[u8], out: &mut [u8], ) -> CryptographyResult<()> { - let nonce = self.next_nonce()?; + let nonce = self.nonces.next()?; self.aead.decrypt_into( py, CffiBuf::from_bytes(py, &nonce), @@ -174,7 +184,6 @@ impl ChunkCipher { None, CffiMutBuf::from_bytes(py, out), )?; - self.counter += 1; Ok(()) } } @@ -211,9 +220,7 @@ impl Encrypter { // Check the chunk counter limit up front so that the error doesn't // leave a partially written output behind. let n_chunks = ((self.buffer.len() + data.len()) / CHUNK_SIZE) as u64; - if n_chunks > self.cipher.remaining_chunks() { - return Err(message_too_large_error()); - } + self.cipher.nonces.check_capacity(n_chunks)?; let wire_chunk = CHUNK_SIZE + self.cipher.tag_len; let mut written = 0; @@ -293,15 +300,11 @@ impl Encrypter { self.check_active()?; let data = data.as_bytes(); let out_len = self.update_out_len(data.len()); - let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { + Ok(pyo3::types::PyBytes::new_with(py, out_len, |b| { let n = self.update_impl(py, data, b)?; debug_assert_eq!(n, out_len); Ok(()) - }); - if result.is_err() { - self.finalized = true; - } - Ok(result?) + })?) } fn update_into( @@ -321,11 +324,7 @@ impl Encrypter { )), )); } - let result = self.update_impl(py, data, out); - if result.is_err() { - self.finalized = true; - } - result + self.update_impl(py, data, out) } fn finalize<'p>( @@ -361,70 +360,75 @@ enum DecrypterState { cipher: ChunkCipher, buffer: Vec, }, - // Also used after any error: a failed context cannot process more data. - Finalized, } #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] pub(crate) struct Decrypter { - state: DecrypterState, + // `None` once finalized, or after any error: a failed context cannot + // process more data. + state: Option, } impl Decrypter { - fn check_active(&self) -> CryptographyResult<()> { - match self.state { - DecrypterState::Header { .. } | DecrypterState::Body { .. } => Ok(()), - DecrypterState::Finalized => Err(exceptions::already_finalized_error()), + fn active_state(&mut self) -> CryptographyResult<&mut DecrypterState> { + match &mut self.state { + Some(state) => Ok(state), + None => Err(exceptions::already_finalized_error()), } } - fn update_out_len(&self, data_len: usize) -> usize { - let wire_chunk = AES_128_GCM.wire_chunk_size(); - let body_len = match &self.state { + fn update_out_len(state: &DecrypterState, data_len: usize) -> usize { + let body_len = match state { DecrypterState::Header { buf, .. } => data_len.saturating_sub(HEADER_LEN - buf.len()), DecrypterState::Body { buffer, .. } => buffer.len() + data_len, - DecrypterState::Finalized => unreachable!(), }; - (body_len / wire_chunk) * CHUNK_SIZE + (body_len / AES_128_GCM.wire_chunk_size()) * CHUNK_SIZE } fn update_impl( - &mut self, py: pyo3::Python<'_>, + state: &mut DecrypterState, mut data: &[u8], out: &mut [u8], ) -> CryptographyResult { - if let DecrypterState::Header { key, context, buf } = &mut self.state { - let take = std::cmp::min(HEADER_LEN - buf.len(), data.len()); - buf.extend_from_slice(&data[..take]); - data = &data[take..]; - if buf.len() < HEADER_LEN { - debug_assert!(data.is_empty()); - return Ok(0); + match state { + DecrypterState::Header { key, context, buf } => { + let take = std::cmp::min(HEADER_LEN - buf.len(), data.len()); + buf.extend_from_slice(&data[..take]); + data = &data[take..]; + if buf.len() < HEADER_LEN { + debug_assert!(data.is_empty()); + return Ok(0); + } + let (salt, commitment) = buf.split_at(SALT_LEN); + let keys = derive_keys(py, &AES_128_GCM, key, salt, context)?; + if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { + return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); + } + let mut cipher = ChunkCipher::new(py, &AES_128_GCM, &keys)?; + let mut buffer = Vec::with_capacity(AES_128_GCM.wire_chunk_size()); + let written = Self::decrypt_chunks(py, &mut cipher, &mut buffer, data, out)?; + *state = DecrypterState::Body { cipher, buffer }; + Ok(written) } - let (salt, commitment) = buf.split_at(SALT_LEN); - let keys = derive_keys(py, &AES_128_GCM, key, salt, context)?; - if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { - return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); + DecrypterState::Body { cipher, buffer } => { + Self::decrypt_chunks(py, cipher, buffer, data, out) } - let cipher = ChunkCipher::new(py, &AES_128_GCM, &keys)?; - self.state = DecrypterState::Body { - cipher, - buffer: Vec::with_capacity(AES_128_GCM.wire_chunk_size()), - }; } - let (cipher, buffer) = match &mut self.state { - DecrypterState::Body { cipher, buffer } => (cipher, buffer), - DecrypterState::Header { .. } | DecrypterState::Finalized => unreachable!(), - }; + } + fn decrypt_chunks( + py: pyo3::Python<'_>, + cipher: &mut ChunkCipher, + buffer: &mut Vec, + mut data: &[u8], + out: &mut [u8], + ) -> CryptographyResult { let wire_chunk = CHUNK_SIZE + cipher.tag_len; // Check the chunk counter limit up front so that the error doesn't // leave a partially written output behind. let n_chunks = ((buffer.len() + data.len()) / wire_chunk) as u64; - if n_chunks > cipher.remaining_chunks() { - return Err(message_too_large_error()); - } + cipher.nonces.check_capacity(n_chunks)?; // Any complete wire chunk is necessarily not the final chunk (the // final chunk is always shorter), so it can be decrypted, and its @@ -463,11 +467,11 @@ impl Decrypter { )); } Ok(Decrypter { - state: DecrypterState::Header { + state: Some(DecrypterState::Header { key: key.as_bytes().to_vec(), context: context.as_bytes().to_vec(), buf: Vec::with_capacity(HEADER_LEN), - }, + }), }) } @@ -483,16 +487,16 @@ impl Decrypter { py: pyo3::Python<'p>, data: CffiBuf<'_>, ) -> CryptographyResult> { - self.check_active()?; let data = data.as_bytes(); - let out_len = self.update_out_len(data.len()); + let state = self.active_state()?; + let out_len = Self::update_out_len(state, data.len()); let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { - let n = self.update_impl(py, data, b)?; + let n = Self::update_impl(py, state, data, b)?; debug_assert_eq!(n, out_len); Ok(()) }); if result.is_err() { - self.state = DecrypterState::Finalized; + self.state = None; } Ok(result?) } @@ -503,9 +507,9 @@ impl Decrypter { data: CffiBuf<'_>, mut buf: CffiMutBuf<'_>, ) -> CryptographyResult { - self.check_active()?; let data = data.as_bytes(); - let out_len = self.update_out_len(data.len()); + let state = self.active_state()?; + let out_len = Self::update_out_len(state, data.len()); let out = buf.as_mut_bytes(); if out.len() < out_len { return Err(CryptographyError::from( @@ -514,9 +518,9 @@ impl Decrypter { )), )); } - let result = self.update_impl(py, data, out); + let result = Self::update_impl(py, state, data, out); if result.is_err() { - self.state = DecrypterState::Finalized; + self.state = None; } result } @@ -525,35 +529,32 @@ impl Decrypter { &mut self, py: pyo3::Python<'p>, ) -> CryptographyResult> { - self.check_active()?; - let result = match &mut self.state { + // Whether it succeeds or fails, finalization consumes the state: no + // further data may be processed. + match self.state.take() { + None => Err(exceptions::already_finalized_error()), // The full salt and commitment never arrived: the message is // truncated. - DecrypterState::Header { .. } => { + Some(DecrypterState::Header { .. }) => { Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) } - DecrypterState::Body { cipher, buffer } => { + Some(DecrypterState::Body { mut cipher, buffer }) => { if buffer.len() < cipher.tag_len { // The final chunk is missing (the ciphertext ended // exactly on a chunk boundary) or is too short to be // valid: the message is truncated. - Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))) - } else { - // buffer is always shorter than a wire chunk, so the - // final chunk's plaintext is necessarily shorter than - // CHUNK_SIZE, as the specification requires. - let out_len = buffer.len() - cipher.tag_len; - pyo3::types::PyBytes::new_with(py, out_len, |b| { - cipher.decrypt_chunk(py, buffer, b)?; - Ok(()) - }) - .map_err(CryptographyError::from) + return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); } + // buffer is always shorter than a wire chunk, so the final + // chunk's plaintext is necessarily shorter than CHUNK_SIZE, + // as the specification requires. + let out_len = buffer.len() - cipher.tag_len; + Ok(pyo3::types::PyBytes::new_with(py, out_len, |b| { + cipher.decrypt_chunk(py, &buffer, b)?; + Ok(()) + })?) } - DecrypterState::Finalized => unreachable!(), - }; - self.state = DecrypterState::Finalized; - result + } } } @@ -563,3 +564,50 @@ pub(crate) mod chunked_encryption_mod { #[pymodule_export] use super::{Decrypter, Encrypter}; } + +#[cfg(test)] +mod tests { + use super::{ChunkNonces, MAX_CHUNK_COUNT, NONCE_LEN}; + + #[test] + fn test_chunk_nonces_xor_counter() { + let mut nonces = ChunkNonces { + base_nonce: [0xab; NONCE_LEN], + counter: 0, + }; + assert_eq!(nonces.next().ok().unwrap(), [0xab; NONCE_LEN]); + + let mut expected = [0xab; NONCE_LEN]; + expected[NONCE_LEN - 1] ^= 0x01; + assert_eq!(nonces.next().ok().unwrap(), expected); + + let mut nonces = ChunkNonces { + base_nonce: [0xab; NONCE_LEN], + counter: 0x0123456789, + }; + let mut expected = [0xab; NONCE_LEN]; + for (n, c) in expected[NONCE_LEN - 8..] + .iter_mut() + .zip(0x0123456789u64.to_be_bytes()) + { + *n ^= c; + } + assert_eq!(nonces.next().ok().unwrap(), expected); + } + + #[test] + fn test_chunk_nonces_counter_limit() { + let mut nonces = ChunkNonces { + base_nonce: [0; NONCE_LEN], + counter: MAX_CHUNK_COUNT - 1, + }; + assert!(nonces.check_capacity(1).is_ok()); + assert!(nonces.check_capacity(2).is_err()); + // The final counter value is usable... + assert!(nonces.next().is_ok()); + // ...but nothing past it. + assert!(nonces.check_capacity(0).is_ok()); + assert!(nonces.check_capacity(1).is_err()); + assert!(nonces.next().is_err()); + } +} diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py index 3b0d3c8b3d7d..b61fff9c187e 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_chunked_encryption.py @@ -329,6 +329,22 @@ def test_decrypter_unusable_after_invalid_tag(self): with pytest.raises(AlreadyFinalized): dec.finalize() + def test_decrypter_update_into_unusable_after_invalid_tag(self): + key = Encrypter.generate_key() + plaintext = os.urandom(CHUNK_SIZE + 100) + ciphertext = bytearray(_encrypt_all(key, b"", plaintext)) + ciphertext[HEADER_LEN] ^= 1 # corrupt the first full chunk + + dec = Decrypter(key, b"") + buf = bytearray(2 * CHUNK_SIZE) + with pytest.raises(InvalidTag): + dec.update_into(bytes(ciphertext), buf) + # All subsequent operations fail. + with pytest.raises(AlreadyFinalized): + dec.update_into(b"", buf) + with pytest.raises(AlreadyFinalized): + dec.finalize() + def test_finalize_only_decrypter_rejects_empty_stream(self): key = Decrypter.generate_key() dec = Decrypter(key, b"") From 83967cd46a2a54371210ed793ab4a865353a4dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:29:36 +0000 Subject: [PATCH 05/11] Address review feedback: drop overhead paragraph, document buffer invariant Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- docs/chunked-encryption.rst | 4 ---- src/rust/src/chunked_encryption.rs | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/chunked-encryption.rst b/docs/chunked-encryption.rst index e84c443b83eb..79c54044222d 100644 --- a/docs/chunked-encryption.rst +++ b/docs/chunked-encryption.rst @@ -27,10 +27,6 @@ can only be decrypted with the key it was encrypted with. >>> decrypter.update(ciphertext) + decrypter.finalize() b'a secret message' -The ciphertext is 56 bytes (a salt and a key commitment), plus the -message itself, plus 16 bytes for each started 16 KiB chunk of the -message — an overhead of roughly 0.1%. - .. class:: Encrypter(key, context) .. versionadded:: 50.0.0 diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index 78e01e27ca0b..42d1fcd8bf00 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -211,6 +211,9 @@ impl Encrypter { header + n_chunks * (CHUNK_SIZE + self.cipher.tag_len) } + // `out` must be at least `self.update_out_len(data.len())` bytes; both + // callers check this before any state is modified, so the header and + // every chunk written below are guaranteed to fit. fn update_impl( &mut self, py: pyo3::Python<'_>, From a796398bb61b5aee9e62f3a94686964acb54db53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 23:40:47 +0000 Subject: [PATCH 06/11] Expose the Cobblestone-128 and Cobblestone-256 instantiations C2SP/C2SP#296 names the chunked-encryption spec's two instantiations: Cobblestone-128 (SHA-512 and AES-128-GCM, recommended) and Cobblestone-256 (SHA-512 and AES-256-GCM, compliance-oriented). Replace the Encrypter/Decrypter classes with Cobblestone128Encryptor, Cobblestone128Decryptor, Cobblestone256Encryptor, and Cobblestone256Decryptor, sharing the core implementation, and switch the HKDF-Expand hash from SHA-256 to SHA-512 per the updated spec. The vendored reference test vectors predate this spec change (they were generated with HKDF-SHA-256), so the vector tests are removed until the reference implementation regenerates them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- CHANGELOG.rst | 3 +- docs/chunked-encryption.rst | 85 +++-- docs/spelling_wordlist.txt | 5 +- src/cryptography/chunked_encryption.py | 12 +- .../bindings/_rust/chunked_encryption.pyi | 20 +- src/rust/src/chunked_encryption.rs | 292 +++++++++++----- tests/test_chunked_encryption.py | 326 ++++++++++-------- 7 files changed, 456 insertions(+), 287 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index df2ab93e933b..bb099670f361 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,7 +13,8 @@ Changelog * Added the :doc:`/chunked-encryption` recipe, an implementation of the `C2SP chunked-encryption specification `_ for streaming authenticated - encryption of large messages. + encryption of large messages, with its Cobblestone-128 and + Cobblestone-256 instantiations. * Parsing a Signed Certificate Timestamp list now rejects encodings that carry trailing bytes after the list or after an individual SCT, instead of silently ignoring them. diff --git a/docs/chunked-encryption.rst b/docs/chunked-encryption.rst index 79c54044222d..1b7638666db2 100644 --- a/docs/chunked-encryption.rst +++ b/docs/chunked-encryption.rst @@ -6,8 +6,10 @@ Chunked encryption (streaming symmetric encryption) Chunked encryption provides authenticated symmetric encryption of large messages — up to 4 PiB — as a stream, without ever holding the whole message in memory. It is an implementation of the `C2SP -chunked-encryption specification`_, instantiated with SHA-256 and -AES-128-GCM. +chunked-encryption specification`_, providing its two named +instantiations: **Cobblestone-128** (SHA-512 and AES-128-GCM, the +recommended choice) and **Cobblestone-256** (SHA-512 and AES-256-GCM, +for environments that mandate 256-bit keys). A message is encrypted in 16 KiB chunks, each of which is individually authenticated, so decryption can also be performed as a stream: @@ -18,22 +20,28 @@ can only be decrypted with the key it was encrypted with. .. doctest:: - >>> from cryptography.chunked_encryption import Decrypter, Encrypter - >>> key = Encrypter.generate_key() - >>> encrypter = Encrypter(key, context=b"example-app file encryption") - >>> ciphertext = encrypter.update(b"a secret message") - >>> ciphertext += encrypter.finalize() - >>> decrypter = Decrypter(key, context=b"example-app file encryption") - >>> decrypter.update(ciphertext) + decrypter.finalize() + >>> from cryptography.chunked_encryption import ( + ... Cobblestone128Decryptor, Cobblestone128Encryptor + ... ) + >>> key = Cobblestone128Encryptor.generate_key() + >>> encryptor = Cobblestone128Encryptor( + ... key, context=b"example-app file encryption" + ... ) + >>> ciphertext = encryptor.update(b"a secret message") + >>> ciphertext += encryptor.finalize() + >>> decryptor = Cobblestone128Decryptor( + ... key, context=b"example-app file encryption" + ... ) + >>> decryptor.update(ciphertext) + decryptor.finalize() b'a secret message' -.. class:: Encrypter(key, context) +.. class:: Cobblestone128Encryptor(key, context) .. versionadded:: 50.0.0 - Encrypts a single message under ``key``. Each instance must be used - for exactly one message: call :meth:`update` (or - :meth:`update_into`) any number of times, then call + Encrypts a single message under ``key`` with Cobblestone-128. Each + instance must be used for exactly one message: call :meth:`update` + (or :meth:`update_into`) any number of times, then call :meth:`finalize` exactly once. The concatenation of the returned bytes is the ciphertext. @@ -45,10 +53,10 @@ can only be decrypted with the key it was encrypted with. :type key: :term:`bytes-like` :param context: Application-provided context, bound to the ciphertext. Decryption fails unless the same value is passed to - :class:`Decrypter`. It is not secret, may be empty, and is not - part of the ciphertext, so it must be available to the - decrypting party independently. It can be used for domain - separation, e.g. ``b"myapp v2 backup encryption"``. + :class:`Cobblestone128Decryptor`. It is not secret, may be + empty, and is not part of the ciphertext, so it must be + available to the decrypting party independently. It can be used + for domain separation, e.g. ``b"myapp v2 backup encryption"``. :type context: :term:`bytes-like` :raises ValueError: If ``key`` is not 16 bytes. @@ -96,15 +104,15 @@ can only be decrypted with the key it was encrypted with. :raises cryptography.exceptions.AlreadyFinalized: If ``finalize`` has already been called. -.. class:: Decrypter(key, context) +.. class:: Cobblestone128Decryptor(key, context) .. versionadded:: 50.0.0 - Decrypts a single message encrypted by :class:`Encrypter` with the - same ``key`` and ``context``. Call :meth:`update` (or - :meth:`update_into`) with the ciphertext any number of times, then - call :meth:`finalize` exactly once. The concatenation of the - returned bytes is the plaintext. + Decrypts a single message encrypted by + :class:`Cobblestone128Encryptor` with the same ``key`` and + ``context``. Call :meth:`update` (or :meth:`update_into`) with the + ciphertext any number of times, then call :meth:`finalize` exactly + once. The concatenation of the returned bytes is the plaintext. Any returned plaintext is authenticated, but until :meth:`finalize` returns successfully the message could still turn @@ -176,20 +184,37 @@ can only be decrypted with the key it was encrypted with. :raises cryptography.exceptions.AlreadyFinalized: If ``finalize`` has already been called. +.. class:: Cobblestone256Encryptor(key, context) + + .. versionadded:: 50.0.0 + + Exactly like :class:`Cobblestone128Encryptor`, but implements + Cobblestone-256: the ``key`` is 32 bytes and messages are encrypted + with AES-256-GCM. Use this when a 256-bit key is mandated; + otherwise Cobblestone-128 is recommended. + +.. class:: Cobblestone256Decryptor(key, context) + + .. versionadded:: 50.0.0 + + Exactly like :class:`Cobblestone128Decryptor`, but decrypts + messages produced by :class:`Cobblestone256Encryptor` with a + 32-byte key. + Implementation -------------- This module implements `version 1 of the C2SP chunked-encryption -specification`_ with the recommended SHA-256 and AES-128-GCM -instantiation, and is interoperable with other implementations of it. +specification`_, and is interoperable with other implementations of +its Cobblestone-128 and Cobblestone-256 instantiations. -For each message, a fresh AES key, base nonce, and key commitment are -derived with HKDF-Expand-SHA-256 from the input key, a random 24-byte +For each message, a fresh AEAD key, base nonce, and key commitment are +derived with HKDF-Expand-SHA-512 from the input key, a random 24-byte salt, and the context. The message is split into 16 KiB chunks (the final chunk is always shorter, and may be empty), and each chunk is -encrypted with AES-128-GCM, with a nonce derived from the base nonce -and the chunk counter. The ciphertext is the salt, followed by the 32-byte commitment, -followed by the encrypted chunks. +encrypted with the AEAD, with a nonce derived from the base nonce and +the chunk counter. The ciphertext is the salt, followed by the 32-byte +commitment, followed by the encrypted chunks. .. _`C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption .. _`version 1 of the C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 4723d0769d9a..df4c2e037e92 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -24,6 +24,7 @@ CentOS changelog Changelog ciphertext +Cobblestone codebook committer committers @@ -42,10 +43,10 @@ Decapsulate Decapsulation declaratively decrypt +Decryptor decrypts Decrypts decrypted -Decrypter decrypting deprecations DER @@ -62,7 +63,7 @@ duplicative El embeddability Encodings -Encrypter +Encryptor endian Euler extendable diff --git a/src/cryptography/chunked_encryption.py b/src/cryptography/chunked_encryption.py index 0a3ee12d9a30..cefb18da18c8 100644 --- a/src/cryptography/chunked_encryption.py +++ b/src/cryptography/chunked_encryption.py @@ -8,10 +8,14 @@ chunked_encryption as _chunked_encryption, ) -Decrypter = _chunked_encryption.Decrypter -Encrypter = _chunked_encryption.Encrypter +Cobblestone128Decryptor = _chunked_encryption.Cobblestone128Decryptor +Cobblestone128Encryptor = _chunked_encryption.Cobblestone128Encryptor +Cobblestone256Decryptor = _chunked_encryption.Cobblestone256Decryptor +Cobblestone256Encryptor = _chunked_encryption.Cobblestone256Encryptor __all__ = [ - "Decrypter", - "Encrypter", + "Cobblestone128Decryptor", + "Cobblestone128Encryptor", + "Cobblestone256Decryptor", + "Cobblestone256Encryptor", ] diff --git a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi index 2b1beeee73cf..4577bd39b177 100644 --- a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi +++ b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi @@ -4,7 +4,7 @@ from cryptography.utils import Buffer -class Encrypter: +class Cobblestone128Encryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... @staticmethod def generate_key() -> bytes: ... @@ -12,7 +12,23 @@ class Encrypter: def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... -class Decrypter: +class Cobblestone128Decryptor: + def __init__(self, key: Buffer, context: Buffer) -> None: ... + @staticmethod + def generate_key() -> bytes: ... + def update(self, data: Buffer) -> bytes: ... + def update_into(self, data: Buffer, buf: Buffer) -> int: ... + def finalize(self) -> bytes: ... + +class Cobblestone256Encryptor: + def __init__(self, key: Buffer, context: Buffer) -> None: ... + @staticmethod + def generate_key() -> bytes: ... + def update(self, data: Buffer) -> bytes: ... + def update_into(self, data: Buffer, buf: Buffer) -> int: ... + def finalize(self) -> bytes: ... + +class Cobblestone256Decryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... @staticmethod def generate_key() -> bytes: ... diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index 42d1fcd8bf00..8eef05e6ad71 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -3,8 +3,9 @@ // for complete details. // Implementation of the C2SP chunked-encryption specification -// (https://c2sp.org/chunked-encryption), instantiated with SHA-256 and -// AES-128-GCM. +// (https://c2sp.org/chunked-encryption), providing its two named +// instantiations: Cobblestone-128 (SHA-512 and AES-128-GCM) and +// Cobblestone-256 (SHA-512 and AES-256-GCM). use pyo3::types::{PyAnyMethods, PyBytesMethods}; @@ -46,12 +47,30 @@ static AES_128_GCM: AeadParams = AeadParams { tag_len: 16, }; +static AES_256_GCM: AeadParams = AeadParams { + iana_name: b"AEAD_AES_256_GCM", + key_len: 32, + tag_len: 16, +}; + fn message_too_large_error() -> CryptographyError { CryptographyError::from(pyo3::exceptions::PyValueError::new_err( "Message exceeds the maximum chunked encryption length (2**38 chunks).", )) } +fn check_key_length(params: &AeadParams, key: &[u8]) -> CryptographyResult<()> { + if key.len() != params.key_len { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err(format!( + "key must be {} bytes.", + params.key_len + )), + )); + } + Ok(()) +} + struct DerivedKeys<'p> { key: pyo3::Bound<'p, pyo3::types::PyBytes>, base_nonce: [u8; NONCE_LEN], @@ -74,7 +93,7 @@ fn derive_keys<'p>( info.extend_from_slice(salt); info.extend_from_slice(context); - let algorithm = types::SHA256.get(py)?.call0()?; + let algorithm = types::SHA512.get(py)?.call0()?; let mut hkdf = HkdfExpand::new( py, algorithm.unbind(), @@ -188,8 +207,7 @@ impl ChunkCipher { } } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] -pub(crate) struct Encrypter { +struct EncrypterCore { cipher: ChunkCipher, buffer: Vec, header: [u8; HEADER_LEN], @@ -197,7 +215,30 @@ pub(crate) struct Encrypter { finalized: bool, } -impl Encrypter { +impl EncrypterCore { + fn new( + py: pyo3::Python<'_>, + params: &AeadParams, + key: &[u8], + context: &[u8], + ) -> CryptographyResult { + check_key_length(params, key)?; + let mut salt = [0; SALT_LEN]; + cryptography_openssl::rand::rand_bytes(&mut salt)?; + let keys = derive_keys(py, params, key, &salt, context)?; + let cipher = ChunkCipher::new(py, params, &keys)?; + let mut header = [0; HEADER_LEN]; + header[..SALT_LEN].copy_from_slice(&salt); + header[SALT_LEN..].copy_from_slice(&keys.commitment); + Ok(EncrypterCore { + cipher, + buffer: Vec::with_capacity(CHUNK_SIZE), + header, + header_pending: true, + finalized: false, + }) + } + fn check_active(&self) -> CryptographyResult<()> { if self.finalized { return Err(exceptions::already_finalized_error()); @@ -256,52 +297,13 @@ impl Encrypter { self.buffer.extend_from_slice(data); Ok(written) } -} - -#[pyo3::pymethods] -impl Encrypter { - #[new] - fn new( - py: pyo3::Python<'_>, - key: CffiBuf<'_>, - context: CffiBuf<'_>, - ) -> CryptographyResult { - let params = &AES_128_GCM; - if key.as_bytes().len() != params.key_len { - return Err(CryptographyError::from( - pyo3::exceptions::PyValueError::new_err("key must be 16 bytes."), - )); - } - let mut salt = [0; SALT_LEN]; - cryptography_openssl::rand::rand_bytes(&mut salt)?; - let keys = derive_keys(py, params, key.as_bytes(), &salt, context.as_bytes())?; - let cipher = ChunkCipher::new(py, params, &keys)?; - let mut header = [0; HEADER_LEN]; - header[..SALT_LEN].copy_from_slice(&salt); - header[SALT_LEN..].copy_from_slice(&keys.commitment); - Ok(Encrypter { - cipher, - buffer: Vec::with_capacity(CHUNK_SIZE), - header, - header_pending: true, - finalized: false, - }) - } - - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) - } fn update<'p>( &mut self, py: pyo3::Python<'p>, - data: CffiBuf<'_>, + data: &[u8], ) -> CryptographyResult> { self.check_active()?; - let data = data.as_bytes(); let out_len = self.update_out_len(data.len()); Ok(pyo3::types::PyBytes::new_with(py, out_len, |b| { let n = self.update_impl(py, data, b)?; @@ -313,13 +315,11 @@ impl Encrypter { fn update_into( &mut self, py: pyo3::Python<'_>, - data: CffiBuf<'_>, - mut buf: CffiMutBuf<'_>, + data: &[u8], + out: &mut [u8], ) -> CryptographyResult { self.check_active()?; - let data = data.as_bytes(); let out_len = self.update_out_len(data.len()); - let out = buf.as_mut_bytes(); if out.len() < out_len { return Err(CryptographyError::from( pyo3::exceptions::PyValueError::new_err(format!( @@ -365,14 +365,26 @@ enum DecrypterState { }, } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] -pub(crate) struct Decrypter { +struct DecrypterCore { + params: &'static AeadParams, // `None` once finalized, or after any error: a failed context cannot // process more data. state: Option, } -impl Decrypter { +impl DecrypterCore { + fn new(params: &'static AeadParams, key: &[u8], context: &[u8]) -> CryptographyResult { + check_key_length(params, key)?; + Ok(DecrypterCore { + params, + state: Some(DecrypterState::Header { + key: key.to_vec(), + context: context.to_vec(), + buf: Vec::with_capacity(HEADER_LEN), + }), + }) + } + fn active_state(&mut self) -> CryptographyResult<&mut DecrypterState> { match &mut self.state { Some(state) => Ok(state), @@ -380,16 +392,17 @@ impl Decrypter { } } - fn update_out_len(state: &DecrypterState, data_len: usize) -> usize { + fn update_out_len(params: &AeadParams, state: &DecrypterState, data_len: usize) -> usize { let body_len = match state { DecrypterState::Header { buf, .. } => data_len.saturating_sub(HEADER_LEN - buf.len()), DecrypterState::Body { buffer, .. } => buffer.len() + data_len, }; - (body_len / AES_128_GCM.wire_chunk_size()) * CHUNK_SIZE + (body_len / params.wire_chunk_size()) * CHUNK_SIZE } fn update_impl( py: pyo3::Python<'_>, + params: &AeadParams, state: &mut DecrypterState, mut data: &[u8], out: &mut [u8], @@ -404,12 +417,12 @@ impl Decrypter { return Ok(0); } let (salt, commitment) = buf.split_at(SALT_LEN); - let keys = derive_keys(py, &AES_128_GCM, key, salt, context)?; + let keys = derive_keys(py, params, key, salt, context)?; if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); } - let mut cipher = ChunkCipher::new(py, &AES_128_GCM, &keys)?; - let mut buffer = Vec::with_capacity(AES_128_GCM.wire_chunk_size()); + let mut cipher = ChunkCipher::new(py, params, &keys)?; + let mut buffer = Vec::with_capacity(params.wire_chunk_size()); let written = Self::decrypt_chunks(py, &mut cipher, &mut buffer, data, out)?; *state = DecrypterState::Body { cipher, buffer }; Ok(written) @@ -458,43 +471,17 @@ impl Decrypter { buffer.extend_from_slice(data); Ok(written) } -} - -#[pyo3::pymethods] -impl Decrypter { - #[new] - fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { - if key.as_bytes().len() != AES_128_GCM.key_len { - return Err(CryptographyError::from( - pyo3::exceptions::PyValueError::new_err("key must be 16 bytes."), - )); - } - Ok(Decrypter { - state: Some(DecrypterState::Header { - key: key.as_bytes().to_vec(), - context: context.as_bytes().to_vec(), - buf: Vec::with_capacity(HEADER_LEN), - }), - }) - } - - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) - } fn update<'p>( &mut self, py: pyo3::Python<'p>, - data: CffiBuf<'_>, + data: &[u8], ) -> CryptographyResult> { - let data = data.as_bytes(); + let params = self.params; let state = self.active_state()?; - let out_len = Self::update_out_len(state, data.len()); + let out_len = Self::update_out_len(params, state, data.len()); let result = pyo3::types::PyBytes::new_with(py, out_len, |b| { - let n = Self::update_impl(py, state, data, b)?; + let n = Self::update_impl(py, params, state, data, b)?; debug_assert_eq!(n, out_len); Ok(()) }); @@ -507,13 +494,12 @@ impl Decrypter { fn update_into( &mut self, py: pyo3::Python<'_>, - data: CffiBuf<'_>, - mut buf: CffiMutBuf<'_>, + data: &[u8], + out: &mut [u8], ) -> CryptographyResult { - let data = data.as_bytes(); + let params = self.params; let state = self.active_state()?; - let out_len = Self::update_out_len(state, data.len()); - let out = buf.as_mut_bytes(); + let out_len = Self::update_out_len(params, state, data.len()); if out.len() < out_len { return Err(CryptographyError::from( pyo3::exceptions::PyValueError::new_err(format!( @@ -521,7 +507,7 @@ impl Decrypter { )), )); } - let result = Self::update_impl(py, state, data, out); + let result = Self::update_impl(py, params, state, data, out); if result.is_err() { self.state = None; } @@ -561,11 +547,127 @@ impl Decrypter { } } +macro_rules! define_variant { + ($encryptor:ident, $decryptor:ident, $params:ident) => { + #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] + pub(crate) struct $encryptor { + core: EncrypterCore, + } + + #[pyo3::pymethods] + impl $encryptor { + #[new] + fn new( + py: pyo3::Python<'_>, + key: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult { + Ok($encryptor { + core: EncrypterCore::new(py, &$params, key.as_bytes(), context.as_bytes())?, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, $params.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.core.update(py, data.as_bytes()) + } + + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.core + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.core.finalize(py) + } + } + + #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] + pub(crate) struct $decryptor { + core: DecrypterCore, + } + + #[pyo3::pymethods] + impl $decryptor { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + Ok($decryptor { + core: DecrypterCore::new(&$params, key.as_bytes(), context.as_bytes())?, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, $params.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.core.update(py, data.as_bytes()) + } + + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.core + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.core.finalize(py) + } + } + }; +} + +define_variant!( + Cobblestone128Encryptor, + Cobblestone128Decryptor, + AES_128_GCM +); +define_variant!( + Cobblestone256Encryptor, + Cobblestone256Decryptor, + AES_256_GCM +); + #[pyo3::pymodule(gil_used = false)] #[pyo3(name = "chunked_encryption")] pub(crate) mod chunked_encryption_mod { #[pymodule_export] - use super::{Decrypter, Encrypter}; + use super::{ + Cobblestone128Decryptor, Cobblestone128Encryptor, Cobblestone256Decryptor, + Cobblestone256Encryptor, + }; } #[cfg(test)] diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py index b61fff9c187e..4dfd549c705c 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_chunked_encryption.py @@ -3,16 +3,16 @@ # for complete details. -import base64 -import hashlib -import json import os -import zlib import pytest -import cryptography_vectors -from cryptography.chunked_encryption import Decrypter, Encrypter +from cryptography.chunked_encryption import ( + Cobblestone128Decryptor, + Cobblestone128Encryptor, + Cobblestone256Decryptor, + Cobblestone256Encryptor, +) from cryptography.exceptions import AlreadyFinalized, InvalidTag CHUNK_SIZE = 16 * 1024 @@ -22,24 +22,25 @@ COMMITMENT_LEN = 32 HEADER_LEN = SALT_LEN + COMMITMENT_LEN - -def _load_vectors(): - vector_file = cryptography_vectors.open_vector_file( - os.path.join("chunked-encryption", "vectors.json"), "r" - ) - with vector_file: - vectors = json.load(vector_file)["vectors"] - # The public API only supports AES-128-GCM. - return [v for v in vectors if v["aead"] == "AEAD_AES_128_GCM"] +VARIANTS = [ + pytest.param( + (Cobblestone128Encryptor, Cobblestone128Decryptor, 16), + id="cobblestone128", + ), + pytest.param( + (Cobblestone256Encryptor, Cobblestone256Decryptor, 32), + id="cobblestone256", + ), +] -def _encrypt_all(key: bytes, context: bytes, plaintext: bytes) -> bytes: - enc = Encrypter(key, context) +def _encrypt_all(encryptor_cls, key: bytes, context: bytes, plaintext: bytes): + enc = encryptor_cls(key, context) return enc.update(plaintext) + enc.finalize() -def _decrypt_all(key: bytes, context: bytes, ciphertext: bytes) -> bytes: - dec = Decrypter(key, context) +def _decrypt_all(decryptor_cls, key: bytes, context: bytes, ciphertext: bytes): + dec = decryptor_cls(key, context) return dec.update(ciphertext) + dec.finalize() @@ -58,52 +59,30 @@ def _decrypt_all(key: bytes, context: bytes, ciphertext: bytes) -> bytes: ] +@pytest.mark.parametrize("variant", VARIANTS) class TestChunkedEncryption: - @pytest.mark.parametrize( - "vector", _load_vectors(), ids=lambda v: v["name"] - ) - def test_vectors(self, vector): - key = bytes.fromhex(vector["key"]) - context = vector["context"].encode() - if vector["ciphertext"] is None: - ciphertext = b"" - else: - ciphertext = base64.b64decode(vector["ciphertext"]) - if vector.get("compressed"): - ciphertext = zlib.decompress(ciphertext) - - if vector["expect"] == "success": - plaintext = _decrypt_all(key, context, ciphertext) - assert len(plaintext) == vector["payload_length"] - assert ( - hashlib.sha256(plaintext).hexdigest() - == vector["payload_sha256"] - ) - elif len(key) != 16: - with pytest.raises(ValueError): - Decrypter(key, context) - else: - with pytest.raises(InvalidTag): - _decrypt_all(key, context, ciphertext) - @pytest.mark.parametrize("length", MESSAGE_LENGTHS) - def test_round_trip(self, length): - key = Encrypter.generate_key() + def test_round_trip(self, variant, length): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() context = b"test context" plaintext = os.urandom(length) - ciphertext = _encrypt_all(key, context, plaintext) + ciphertext = _encrypt_all(encryptor_cls, key, context, plaintext) n_chunks = length // CHUNK_SIZE + 1 assert len(ciphertext) == HEADER_LEN + length + n_chunks * TAG_LEN - assert _decrypt_all(key, context, ciphertext) == plaintext + assert ( + _decrypt_all(decryptor_cls, key, context, ciphertext) == plaintext + ) @pytest.mark.parametrize("piece_size", [1, 57, 1024, 16384, 16400]) - def test_streaming(self, piece_size): - key = Encrypter.generate_key() + def test_streaming(self, variant, piece_size): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() context = b"" plaintext = os.urandom(2 * CHUNK_SIZE + 12345) - enc = Encrypter(key, context) + enc = encryptor_cls(key, context) ciphertext = b"" for i in range(0, len(plaintext), piece_size): ciphertext += enc.update(plaintext[i : i + piece_size]) @@ -113,60 +92,66 @@ def test_streaming(self, piece_size): # split up. assert len(ciphertext) == HEADER_LEN + len(plaintext) + 3 * TAG_LEN - dec = Decrypter(key, context) + dec = decryptor_cls(key, context) decrypted = b"" for i in range(0, len(ciphertext), piece_size): decrypted += dec.update(ciphertext[i : i + piece_size]) decrypted += dec.finalize() assert decrypted == plaintext - def test_empty_message(self): - key = Encrypter.generate_key() - enc = Encrypter(key, b"ctx") + def test_empty_message(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + enc = encryptor_cls(key, b"ctx") ciphertext = enc.finalize() assert len(ciphertext) == HEADER_LEN + TAG_LEN - assert _decrypt_all(key, b"ctx", ciphertext) == b"" + assert _decrypt_all(decryptor_cls, key, b"ctx", ciphertext) == b"" - def test_update_with_empty_data_emits_header(self): - key = Encrypter.generate_key() - enc = Encrypter(key, b"") + def test_update_with_empty_data_emits_header(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + enc = encryptor_cls(key, b"") header = enc.update(b"") assert len(header) == HEADER_LEN assert enc.update(b"") == b"" ciphertext = header + enc.finalize() - assert _decrypt_all(key, b"", ciphertext) == b"" + assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b"" - def test_exact_chunk_boundary_has_empty_final_chunk(self): - key = Encrypter.generate_key() + def test_exact_chunk_boundary_has_empty_final_chunk(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(CHUNK_SIZE) - ciphertext = _encrypt_all(key, b"", plaintext) + ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext) # One full chunk plus an empty final chunk. assert len(ciphertext) == HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN - assert _decrypt_all(key, b"", ciphertext) == plaintext + assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext - def test_decrypter_streams_plaintext_incrementally(self): - key = Encrypter.generate_key() + def test_decrypter_streams_plaintext_incrementally(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(3 * CHUNK_SIZE) - ciphertext = _encrypt_all(key, b"", plaintext) + ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext) - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") out = dec.update(ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE]) assert out == plaintext[:CHUNK_SIZE] out = dec.update(ciphertext[HEADER_LEN + WIRE_CHUNK_SIZE :]) assert out == plaintext[CHUNK_SIZE:] assert dec.finalize() == b"" - def test_wrong_key(self): - key = Encrypter.generate_key() - ciphertext = _encrypt_all(key, b"", b"message") - dec = Decrypter(Decrypter.generate_key(), b"") + def test_wrong_key(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message") + dec = decryptor_cls(decryptor_cls.generate_key(), b"") with pytest.raises(InvalidTag): dec.update(ciphertext) - def test_wrong_context(self): - key = Encrypter.generate_key() - ciphertext = _encrypt_all(key, b"context a", b"message") - dec = Decrypter(key, b"context b") + def test_wrong_context(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + ciphertext = _encrypt_all(encryptor_cls, key, b"context a", b"msg") + dec = decryptor_cls(key, b"context b") with pytest.raises(InvalidTag): dec.update(ciphertext) @@ -180,21 +165,25 @@ def test_wrong_context(self): HEADER_LEN + WIRE_CHUNK_SIZE + 3, # final chunk ], ) - def test_tampering_detected(self, position): - key = Encrypter.generate_key() + def test_tampering_detected(self, variant, position): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(CHUNK_SIZE + 100) - ciphertext = bytearray(_encrypt_all(key, b"", plaintext)) + ciphertext = bytearray( + _encrypt_all(encryptor_cls, key, b"", plaintext) + ) ciphertext[position] ^= 1 - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.update(bytes(ciphertext)) dec.finalize() - def test_swapped_chunks_detected(self): - key = Encrypter.generate_key() + def test_swapped_chunks_detected(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(2 * CHUNK_SIZE + 100) - ciphertext = _encrypt_all(key, b"", plaintext) + ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext) chunk0_start = HEADER_LEN chunk1_start = HEADER_LEN + WIRE_CHUNK_SIZE @@ -205,7 +194,7 @@ def test_swapped_chunks_detected(self): + ciphertext[chunk0_start:chunk1_start] + ciphertext[chunk2_start:] ) - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.update(swapped) @@ -221,66 +210,72 @@ def test_swapped_chunks_detected(self): HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN - 1, ], ) - def test_truncation_detected(self, length): - key = Encrypter.generate_key() + def test_truncation_detected(self, variant, length): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(CHUNK_SIZE + 100) - ciphertext = _encrypt_all(key, b"", plaintext) + ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext) - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.update(ciphertext[:length]) dec.finalize() - def test_extension_detected(self): - key = Encrypter.generate_key() - ciphertext = _encrypt_all(key, b"", b"message") + def test_extension_detected(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message") - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.update(ciphertext + b"extra garbage bytes!") dec.finalize() - def test_ciphertexts_are_randomized(self): - key = Encrypter.generate_key() - assert _encrypt_all(key, b"", b"data") != _encrypt_all( - key, b"", b"data" + def test_ciphertexts_are_randomized(self, variant): + encryptor_cls, _, _ = variant + key = encryptor_cls.generate_key() + assert _encrypt_all(encryptor_cls, key, b"", b"data") != _encrypt_all( + encryptor_cls, key, b"", b"data" ) - def test_update_into(self): - key = Encrypter.generate_key() + def test_update_into(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(CHUNK_SIZE + 100) - enc = Encrypter(key, b"") + enc = encryptor_cls(key, b"") buf = bytearray(HEADER_LEN + 2 * WIRE_CHUNK_SIZE) n = enc.update_into(plaintext, buf) assert n == HEADER_LEN + WIRE_CHUNK_SIZE ciphertext = bytes(buf[:n]) + enc.finalize() - assert _decrypt_all(key, b"", ciphertext) == plaintext + assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") out = bytearray(2 * CHUNK_SIZE) n = dec.update_into(ciphertext, out) assert n == CHUNK_SIZE assert bytes(out[:n]) + dec.finalize() == plaintext - def test_update_into_accepts_larger_buffer(self): - key = Encrypter.generate_key() - enc = Encrypter(key, b"") + def test_update_into_accepts_larger_buffer(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + enc = encryptor_cls(key, b"") buf = bytearray(10 * WIRE_CHUNK_SIZE) n = enc.update_into(b"abc", buf) assert n == HEADER_LEN ciphertext = bytes(buf[:n]) + enc.finalize() - assert _decrypt_all(key, b"", ciphertext) == b"abc" + assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b"abc" - def test_update_into_buffer_too_small(self): - key = Encrypter.generate_key() - enc = Encrypter(key, b"") + def test_update_into_buffer_too_small(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + enc = encryptor_cls(key, b"") with pytest.raises(ValueError, match="buffer must be at least"): enc.update_into(b"abc", bytearray(HEADER_LEN - 1)) # The context remains usable after the failed call. ciphertext = enc.update(b"abc") + enc.finalize() - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") with pytest.raises(ValueError, match="buffer must be at least"): dec.update_into( ciphertext + bytes(WIRE_CHUNK_SIZE), bytearray(CHUNK_SIZE - 1) @@ -288,14 +283,16 @@ def test_update_into_buffer_too_small(self): assert dec.update(ciphertext) == b"" assert dec.finalize() == b"abc" - def test_update_into_zero_output(self): - key = Encrypter.generate_key() - dec = Decrypter(key, b"") + def test_update_into_zero_output(self, variant): + _, decryptor_cls, _ = variant + key = decryptor_cls.generate_key() + dec = decryptor_cls(key, b"") assert dec.update_into(b"", bytearray(0)) == 0 - def test_use_after_finalize(self): - key = Encrypter.generate_key() - enc = Encrypter(key, b"") + def test_use_after_finalize(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + enc = encryptor_cls(key, b"") ciphertext = enc.update(b"data") + enc.finalize() with pytest.raises(AlreadyFinalized): enc.update(b"more") @@ -304,7 +301,7 @@ def test_use_after_finalize(self): with pytest.raises(AlreadyFinalized): enc.finalize() - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") dec.update(ciphertext) dec.finalize() with pytest.raises(AlreadyFinalized): @@ -314,12 +311,13 @@ def test_use_after_finalize(self): with pytest.raises(AlreadyFinalized): dec.finalize() - def test_decrypter_unusable_after_invalid_tag(self): - key = Encrypter.generate_key() - ciphertext = bytearray(_encrypt_all(key, b"", b"data")) + def test_decryptor_unusable_after_invalid_tag(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() + ciphertext = bytearray(_encrypt_all(encryptor_cls, key, b"", b"data")) ciphertext[-1] ^= 1 - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") dec.update(bytes(ciphertext)) with pytest.raises(InvalidTag): dec.finalize() @@ -329,13 +327,16 @@ def test_decrypter_unusable_after_invalid_tag(self): with pytest.raises(AlreadyFinalized): dec.finalize() - def test_decrypter_update_into_unusable_after_invalid_tag(self): - key = Encrypter.generate_key() + def test_decryptor_update_into_unusable_after_invalid_tag(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() plaintext = os.urandom(CHUNK_SIZE + 100) - ciphertext = bytearray(_encrypt_all(key, b"", plaintext)) + ciphertext = bytearray( + _encrypt_all(encryptor_cls, key, b"", plaintext) + ) ciphertext[HEADER_LEN] ^= 1 # corrupt the first full chunk - dec = Decrypter(key, b"") + dec = decryptor_cls(key, b"") buf = bytearray(2 * CHUNK_SIZE) with pytest.raises(InvalidTag): dec.update_into(bytes(ciphertext), buf) @@ -345,44 +346,63 @@ def test_decrypter_update_into_unusable_after_invalid_tag(self): with pytest.raises(AlreadyFinalized): dec.finalize() - def test_finalize_only_decrypter_rejects_empty_stream(self): - key = Decrypter.generate_key() - dec = Decrypter(key, b"") + def test_finalize_only_decryptor_rejects_empty_stream(self, variant): + _, decryptor_cls, _ = variant + key = decryptor_cls.generate_key() + dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.finalize() - def test_generate_key(self): - key = Encrypter.generate_key() + def test_generate_key(self, variant): + encryptor_cls, decryptor_cls, key_len = variant + key = encryptor_cls.generate_key() assert isinstance(key, bytes) - assert len(key) == 16 - assert len(Decrypter.generate_key()) == 16 - assert Encrypter.generate_key() != Encrypter.generate_key() - - @pytest.mark.parametrize("length", [0, 15, 17, 32]) - def test_invalid_key_size(self, length): - with pytest.raises(ValueError): - Encrypter(b"\x00" * length, b"") - with pytest.raises(ValueError): - Decrypter(b"\x00" * length, b"") - - def test_invalid_types(self): - key = Encrypter.generate_key() + assert len(key) == key_len + assert len(decryptor_cls.generate_key()) == key_len + assert encryptor_cls.generate_key() != encryptor_cls.generate_key() + + def test_invalid_key_size(self, variant): + encryptor_cls, decryptor_cls, key_len = variant + for length in [0, key_len - 1, key_len + 1, 64]: + with pytest.raises(ValueError): + encryptor_cls(b"\x00" * length, b"") + with pytest.raises(ValueError): + decryptor_cls(b"\x00" * length, b"") + + def test_invalid_types(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() with pytest.raises(TypeError): - Encrypter("not bytes", b"") # type: ignore[arg-type] + encryptor_cls("not bytes", b"") with pytest.raises(TypeError): - Encrypter(key, "not bytes") # type: ignore[arg-type] + encryptor_cls(key, "not bytes") with pytest.raises(TypeError): - Decrypter("not bytes", b"") # type: ignore[arg-type] - enc = Encrypter(key, b"") + decryptor_cls("not bytes", b"") + enc = encryptor_cls(key, b"") with pytest.raises(TypeError): - enc.update("not bytes") # type: ignore[arg-type] + enc.update("not bytes") with pytest.raises(TypeError): enc.update_into(b"", b"immutable") - def test_accepts_buffers(self): - key = bytearray(Encrypter.generate_key()) + def test_accepts_buffers(self, variant): + encryptor_cls, decryptor_cls, _ = variant + key = bytearray(encryptor_cls.generate_key()) plaintext = os.urandom(1000) - enc = Encrypter(key, memoryview(b"ctx")) + enc = encryptor_cls(key, memoryview(b"ctx")) ciphertext = enc.update(memoryview(plaintext)) + enc.finalize() - dec = Decrypter(memoryview(bytes(key)), bytearray(b"ctx")) + dec = decryptor_cls(memoryview(bytes(key)), bytearray(b"ctx")) assert dec.update(bytearray(ciphertext)) + dec.finalize() == plaintext + + +class TestVariantsAreDistinct: + def test_key_sizes_differ(self): + assert len(Cobblestone128Encryptor.generate_key()) == 16 + assert len(Cobblestone256Encryptor.generate_key()) == 32 + + def test_cross_variant_key_sizes_rejected(self): + key128 = Cobblestone128Encryptor.generate_key() + key256 = Cobblestone256Encryptor.generate_key() + with pytest.raises(ValueError, match="key must be 16 bytes"): + Cobblestone128Encryptor(key256, b"") + with pytest.raises(ValueError, match="key must be 32 bytes"): + Cobblestone256Decryptor(key128, b"") From b6029a2f9a47f2d91922bf5af3742bb3a8dfda64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 23:46:28 +0000 Subject: [PATCH 07/11] Replace the variant macro with explicit class definitions The four Cobblestone classes are now plain compositions over internal ChunkedEncryptor/ChunkedDecryptor types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- src/rust/src/chunked_encryption.rs | 293 ++++++++++++++++++----------- 1 file changed, 188 insertions(+), 105 deletions(-) diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index 8eef05e6ad71..cf8d90b2d2c6 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -207,7 +207,7 @@ impl ChunkCipher { } } -struct EncrypterCore { +struct ChunkedEncryptor { cipher: ChunkCipher, buffer: Vec, header: [u8; HEADER_LEN], @@ -215,7 +215,7 @@ struct EncrypterCore { finalized: bool, } -impl EncrypterCore { +impl ChunkedEncryptor { fn new( py: pyo3::Python<'_>, params: &AeadParams, @@ -230,7 +230,7 @@ impl EncrypterCore { let mut header = [0; HEADER_LEN]; header[..SALT_LEN].copy_from_slice(&salt); header[SALT_LEN..].copy_from_slice(&keys.commitment); - Ok(EncrypterCore { + Ok(ChunkedEncryptor { cipher, buffer: Vec::with_capacity(CHUNK_SIZE), header, @@ -365,17 +365,17 @@ enum DecrypterState { }, } -struct DecrypterCore { +struct ChunkedDecryptor { params: &'static AeadParams, // `None` once finalized, or after any error: a failed context cannot // process more data. state: Option, } -impl DecrypterCore { +impl ChunkedDecryptor { fn new(params: &'static AeadParams, key: &[u8], context: &[u8]) -> CryptographyResult { check_key_length(params, key)?; - Ok(DecrypterCore { + Ok(ChunkedDecryptor { params, state: Some(DecrypterState::Header { key: key.to_vec(), @@ -547,118 +547,201 @@ impl DecrypterCore { } } -macro_rules! define_variant { - ($encryptor:ident, $decryptor:ident, $params:ident) => { - #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] - pub(crate) struct $encryptor { - core: EncrypterCore, - } +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Cobblestone128Encryptor { + inner: ChunkedEncryptor, +} - #[pyo3::pymethods] - impl $encryptor { - #[new] - fn new( - py: pyo3::Python<'_>, - key: CffiBuf<'_>, - context: CffiBuf<'_>, - ) -> CryptographyResult { - Ok($encryptor { - core: EncrypterCore::new(py, &$params, key.as_bytes(), context.as_bytes())?, - }) - } +#[pyo3::pymethods] +impl Cobblestone128Encryptor { + #[new] + fn new( + py: pyo3::Python<'_>, + key: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult { + Ok(Cobblestone128Encryptor { + inner: ChunkedEncryptor::new(py, &AES_128_GCM, key.as_bytes(), context.as_bytes())?, + }) + } - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, $params.key_len) - } + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) + } - fn update<'p>( - &mut self, - py: pyo3::Python<'p>, - data: CffiBuf<'_>, - ) -> CryptographyResult> { - self.core.update(py, data.as_bytes()) - } + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.inner.update(py, data.as_bytes()) + } - fn update_into( - &mut self, - py: pyo3::Python<'_>, - data: CffiBuf<'_>, - mut buf: CffiMutBuf<'_>, - ) -> CryptographyResult { - self.core - .update_into(py, data.as_bytes(), buf.as_mut_bytes()) - } + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.inner + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } - fn finalize<'p>( - &mut self, - py: pyo3::Python<'p>, - ) -> CryptographyResult> { - self.core.finalize(py) - } - } + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.inner.finalize(py) + } +} - #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] - pub(crate) struct $decryptor { - core: DecrypterCore, - } +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Cobblestone128Decryptor { + inner: ChunkedDecryptor, +} - #[pyo3::pymethods] - impl $decryptor { - #[new] - fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { - Ok($decryptor { - core: DecrypterCore::new(&$params, key.as_bytes(), context.as_bytes())?, - }) - } +#[pyo3::pymethods] +impl Cobblestone128Decryptor { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + Ok(Cobblestone128Decryptor { + inner: ChunkedDecryptor::new(&AES_128_GCM, key.as_bytes(), context.as_bytes())?, + }) + } - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, $params.key_len) - } + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) + } - fn update<'p>( - &mut self, - py: pyo3::Python<'p>, - data: CffiBuf<'_>, - ) -> CryptographyResult> { - self.core.update(py, data.as_bytes()) - } + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.inner.update(py, data.as_bytes()) + } - fn update_into( - &mut self, - py: pyo3::Python<'_>, - data: CffiBuf<'_>, - mut buf: CffiMutBuf<'_>, - ) -> CryptographyResult { - self.core - .update_into(py, data.as_bytes(), buf.as_mut_bytes()) - } + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.inner + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } - fn finalize<'p>( - &mut self, - py: pyo3::Python<'p>, - ) -> CryptographyResult> { - self.core.finalize(py) - } - } - }; + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.inner.finalize(py) + } +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Cobblestone256Encryptor { + inner: ChunkedEncryptor, } -define_variant!( - Cobblestone128Encryptor, - Cobblestone128Decryptor, - AES_128_GCM -); -define_variant!( - Cobblestone256Encryptor, - Cobblestone256Decryptor, - AES_256_GCM -); +#[pyo3::pymethods] +impl Cobblestone256Encryptor { + #[new] + fn new( + py: pyo3::Python<'_>, + key: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult { + Ok(Cobblestone256Encryptor { + inner: ChunkedEncryptor::new(py, &AES_256_GCM, key.as_bytes(), context.as_bytes())?, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_256_GCM.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.inner.update(py, data.as_bytes()) + } + + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.inner + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.inner.finalize(py) + } +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +pub(crate) struct Cobblestone256Decryptor { + inner: ChunkedDecryptor, +} + +#[pyo3::pymethods] +impl Cobblestone256Decryptor { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + Ok(Cobblestone256Decryptor { + inner: ChunkedDecryptor::new(&AES_256_GCM, key.as_bytes(), context.as_bytes())?, + }) + } + + #[staticmethod] + fn generate_key( + py: pyo3::Python<'_>, + ) -> CryptographyResult> { + crate::backend::rand::get_rand_bytes(py, AES_256_GCM.key_len) + } + + fn update<'p>( + &mut self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + self.inner.update(py, data.as_bytes()) + } + + fn update_into( + &mut self, + py: pyo3::Python<'_>, + data: CffiBuf<'_>, + mut buf: CffiMutBuf<'_>, + ) -> CryptographyResult { + self.inner + .update_into(py, data.as_bytes(), buf.as_mut_bytes()) + } + + fn finalize<'p>( + &mut self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + self.inner.finalize(py) + } +} #[pyo3::pymodule(gil_used = false)] #[pyo3(name = "chunked_encryption")] From 5998166047f7d8032d55fd38b9486bbcdf46d37a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 01:49:24 +0000 Subject: [PATCH 08/11] Address review feedback: trim docs, drop Decryptor.generate_key, prune Rust tests - Simplify the docs per review: drop the intro properties paragraph, the os.urandom mention, and the redundant clauses in the update_into and finalize descriptions. - Remove generate_key from the Decryptor classes; key generation is an encryption-side operation. - Drop the nonce XOR unit test: that path is fully covered by the Python round-trip tests. The remaining Rust test covers only the chunk counter limit, which is unreachable from Python. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- docs/chunked-encryption.rst | 31 +++----------- .../bindings/_rust/chunked_encryption.pyi | 4 -- src/rust/src/chunked_encryption.rs | 42 +------------------ tests/test_chunked_encryption.py | 12 +++--- 4 files changed, 14 insertions(+), 75 deletions(-) diff --git a/docs/chunked-encryption.rst b/docs/chunked-encryption.rst index 1b7638666db2..30bfd939f5e4 100644 --- a/docs/chunked-encryption.rst +++ b/docs/chunked-encryption.rst @@ -11,13 +11,6 @@ instantiations: **Cobblestone-128** (SHA-512 and AES-128-GCM, the recommended choice) and **Cobblestone-256** (SHA-512 and AES-256-GCM, for environments that mandate 256-bit keys). -A message is encrypted in 16 KiB chunks, each of which is individually -authenticated, so decryption can also be performed as a stream: -decrypted data is returned incrementally, and only authenticated -plaintext is ever returned. Reordering, truncating, or extending the -ciphertext is detected. The scheme is also key committing: a ciphertext -can only be decrypted with the key it was encrypted with. - .. doctest:: >>> from cryptography.chunked_encryption import ( @@ -47,9 +40,8 @@ can only be decrypted with the key it was encrypted with. :param key: A 16-byte key. This **must** be kept secret, and **must** be uniformly random (e.g. the output of - :meth:`generate_key`, :func:`os.urandom`, or a key derivation - function — never a password). A single key may be used to - encrypt a practically unlimited number of messages. + :meth:`generate_key` — never a password). A single key may be + used to encrypt a practically unlimited number of messages. :type key: :term:`bytes-like` :param context: Application-provided context, bound to the ciphertext. Decryption fails unless the same value is passed to @@ -86,9 +78,7 @@ can only be decrypted with the key it was encrypted with. :type data: :term:`bytes-like` :param buf: A writable buffer to write the ciphertext into. A buffer of ``len(data) + len(data) // 1024 + 16456`` bytes - is always large enough; the exact number of bytes required - for a given call is included in the :class:`ValueError` - raised if the buffer is too small. + is always large enough. :type buf: :term:`bytes-like` :return int: The number of bytes written to ``buf``. :raises ValueError: If ``buf`` is too small. @@ -96,9 +86,8 @@ can only be decrypted with the key it was encrypted with. .. method:: finalize() Encrypts the final chunk and returns the last portion of the - ciphertext. This must always be called, even if ``update`` - returned all but the last few bytes of ciphertext, and the - instance cannot be used afterwards. + ciphertext. This must always be called, and the instance cannot + be used afterwards. :return bytes: The remainder of the ciphertext. :raises cryptography.exceptions.AlreadyFinalized: If @@ -131,12 +120,6 @@ can only be decrypted with the key it was encrypted with. :type context: :term:`bytes-like` :raises ValueError: If ``key`` is not 16 bytes. - .. staticmethod:: generate_key() - - Generates a fresh 16-byte key. - - :return bytes: A new key. - .. method:: update(data) Processes ``data``, which need not be aligned to any boundary, @@ -159,9 +142,7 @@ can only be decrypted with the key it was encrypted with. :type data: :term:`bytes-like` :param buf: A writable buffer to write the plaintext into. A buffer of ``len(data) + 16400`` bytes is always large - enough; the exact number of bytes required for a given call - is included in the :class:`ValueError` raised if the buffer - is too small. + enough. :type buf: :term:`bytes-like` :return int: The number of bytes written to ``buf``. :raises ValueError: If ``buf`` is too small. diff --git a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi index 4577bd39b177..95e958382904 100644 --- a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi +++ b/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi @@ -14,8 +14,6 @@ class Cobblestone128Encryptor: class Cobblestone128Decryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... - @staticmethod - def generate_key() -> bytes: ... def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... @@ -30,8 +28,6 @@ class Cobblestone256Encryptor: class Cobblestone256Decryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... - @staticmethod - def generate_key() -> bytes: ... def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/chunked_encryption.rs index cf8d90b2d2c6..81c9c92a98d0 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/chunked_encryption.rs @@ -612,13 +612,6 @@ impl Cobblestone128Decryptor { }) } - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, AES_128_GCM.key_len) - } - fn update<'p>( &mut self, py: pyo3::Python<'p>, @@ -710,13 +703,6 @@ impl Cobblestone256Decryptor { }) } - #[staticmethod] - fn generate_key( - py: pyo3::Python<'_>, - ) -> CryptographyResult> { - crate::backend::rand::get_rand_bytes(py, AES_256_GCM.key_len) - } - fn update<'p>( &mut self, py: pyo3::Python<'p>, @@ -757,32 +743,8 @@ pub(crate) mod chunked_encryption_mod { mod tests { use super::{ChunkNonces, MAX_CHUNK_COUNT, NONCE_LEN}; - #[test] - fn test_chunk_nonces_xor_counter() { - let mut nonces = ChunkNonces { - base_nonce: [0xab; NONCE_LEN], - counter: 0, - }; - assert_eq!(nonces.next().ok().unwrap(), [0xab; NONCE_LEN]); - - let mut expected = [0xab; NONCE_LEN]; - expected[NONCE_LEN - 1] ^= 0x01; - assert_eq!(nonces.next().ok().unwrap(), expected); - - let mut nonces = ChunkNonces { - base_nonce: [0xab; NONCE_LEN], - counter: 0x0123456789, - }; - let mut expected = [0xab; NONCE_LEN]; - for (n, c) in expected[NONCE_LEN - 8..] - .iter_mut() - .zip(0x0123456789u64.to_be_bytes()) - { - *n ^= c; - } - assert_eq!(nonces.next().ok().unwrap(), expected); - } - + // Reaching the chunk counter limit requires processing a 4 PiB message, + // so this error path can't be exercised from the Python tests. #[test] fn test_chunk_nonces_counter_limit() { let mut nonces = ChunkNonces { diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py index 4dfd549c705c..723d4587e7e6 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_chunked_encryption.py @@ -143,7 +143,7 @@ def test_wrong_key(self, variant): encryptor_cls, decryptor_cls, _ = variant key = encryptor_cls.generate_key() ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message") - dec = decryptor_cls(decryptor_cls.generate_key(), b"") + dec = decryptor_cls(encryptor_cls.generate_key(), b"") with pytest.raises(InvalidTag): dec.update(ciphertext) @@ -284,8 +284,8 @@ def test_update_into_buffer_too_small(self, variant): assert dec.finalize() == b"abc" def test_update_into_zero_output(self, variant): - _, decryptor_cls, _ = variant - key = decryptor_cls.generate_key() + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() dec = decryptor_cls(key, b"") assert dec.update_into(b"", bytearray(0)) == 0 @@ -347,8 +347,8 @@ def test_decryptor_update_into_unusable_after_invalid_tag(self, variant): dec.finalize() def test_finalize_only_decryptor_rejects_empty_stream(self, variant): - _, decryptor_cls, _ = variant - key = decryptor_cls.generate_key() + encryptor_cls, decryptor_cls, _ = variant + key = encryptor_cls.generate_key() dec = decryptor_cls(key, b"") with pytest.raises(InvalidTag): dec.finalize() @@ -358,7 +358,7 @@ def test_generate_key(self, variant): key = encryptor_cls.generate_key() assert isinstance(key, bytes) assert len(key) == key_len - assert len(decryptor_cls.generate_key()) == key_len + assert not hasattr(decryptor_cls, "generate_key") assert encryptor_cls.generate_key() != encryptor_cls.generate_key() def test_invalid_key_size(self, variant): From 2424c5e3b1a50c2b656a9725c07c420f053efb9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:03:50 +0000 Subject: [PATCH 09/11] Remove hasattr assert from test_generate_key Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- tests/test_chunked_encryption.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_chunked_encryption.py b/tests/test_chunked_encryption.py index 723d4587e7e6..9ac2db4785be 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_chunked_encryption.py @@ -354,11 +354,10 @@ def test_finalize_only_decryptor_rejects_empty_stream(self, variant): dec.finalize() def test_generate_key(self, variant): - encryptor_cls, decryptor_cls, key_len = variant + encryptor_cls, _, key_len = variant key = encryptor_cls.generate_key() assert isinstance(key, bytes) assert len(key) == key_len - assert not hasattr(decryptor_cls, "generate_key") assert encryptor_cls.generate_key() != encryptor_cls.generate_key() def test_invalid_key_size(self, variant): From f56e037b156f6cda21d66c8c5ac36841c997c885 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:56:43 +0000 Subject: [PATCH 10/11] Rename the module to cryptography.cobblestone, trim docs Per review: the module, docs page, stubs, and Rust module are renamed from chunked_encryption to cobblestone (the classes were already named for the Cobblestone instantiations), and the docs' Implementation section is dropped since the page already states it implements the C2SP chunked-encryption specification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- CHANGELOG.rst | 8 ++--- ...chunked-encryption.rst => cobblestone.rst} | 34 +++++-------------- docs/index.rst | 2 +- .../{chunked_encryption.py => cobblestone.py} | 10 +++--- ...chunked_encryption.pyi => cobblestone.pyi} | 0 .../{chunked_encryption.rs => cobblestone.rs} | 12 +++---- src/rust/src/lib.rs | 4 +-- ...nked_encryption.py => test_cobblestone.py} | 4 +-- 8 files changed, 29 insertions(+), 45 deletions(-) rename docs/{chunked-encryption.rst => cobblestone.rst} (84%) rename src/cryptography/{chunked_encryption.py => cobblestone.py} (55%) rename src/cryptography/hazmat/bindings/_rust/{chunked_encryption.pyi => cobblestone.pyi} (100%) rename src/rust/src/{chunked_encryption.rs => cobblestone.rs} (98%) rename tests/{test_chunked_encryption.py => test_cobblestone.py} (99%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb099670f361..8e1479b5d800 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,11 +10,11 @@ Changelog * The :mod:`X.509 verification ` APIs are now considered stable and are subject to our API stability policy. -* Added the :doc:`/chunked-encryption` recipe, an implementation of the - `C2SP chunked-encryption specification +* Added the :doc:`/cobblestone` recipe, an implementation of the + Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP + chunked-encryption specification `_ for streaming authenticated - encryption of large messages, with its Cobblestone-128 and - Cobblestone-256 instantiations. + encryption of large messages. * Parsing a Signed Certificate Timestamp list now rejects encodings that carry trailing bytes after the list or after an individual SCT, instead of silently ignoring them. diff --git a/docs/chunked-encryption.rst b/docs/cobblestone.rst similarity index 84% rename from docs/chunked-encryption.rst rename to docs/cobblestone.rst index 30bfd939f5e4..35c98116b8eb 100644 --- a/docs/chunked-encryption.rst +++ b/docs/cobblestone.rst @@ -1,19 +1,19 @@ -Chunked encryption (streaming symmetric encryption) -==================================================== +Cobblestone (streaming symmetric encryption) +============================================= -.. currentmodule:: cryptography.chunked_encryption +.. currentmodule:: cryptography.cobblestone -Chunked encryption provides authenticated symmetric encryption of large +Cobblestone provides authenticated symmetric encryption of large messages — up to 4 PiB — as a stream, without ever holding the whole message in memory. It is an implementation of the `C2SP -chunked-encryption specification`_, providing its two named -instantiations: **Cobblestone-128** (SHA-512 and AES-128-GCM, the -recommended choice) and **Cobblestone-256** (SHA-512 and AES-256-GCM, -for environments that mandate 256-bit keys). +chunked-encryption specification`_'s two named instantiations: +**Cobblestone-128** (SHA-512 and AES-128-GCM, the recommended choice) +and **Cobblestone-256** (SHA-512 and AES-256-GCM, for environments +that mandate 256-bit keys). .. doctest:: - >>> from cryptography.chunked_encryption import ( + >>> from cryptography.cobblestone import ( ... Cobblestone128Decryptor, Cobblestone128Encryptor ... ) >>> key = Cobblestone128Encryptor.generate_key() @@ -182,20 +182,4 @@ for environments that mandate 256-bit keys). messages produced by :class:`Cobblestone256Encryptor` with a 32-byte key. -Implementation --------------- - -This module implements `version 1 of the C2SP chunked-encryption -specification`_, and is interoperable with other implementations of -its Cobblestone-128 and Cobblestone-256 instantiations. - -For each message, a fresh AEAD key, base nonce, and key commitment are -derived with HKDF-Expand-SHA-512 from the input key, a random 24-byte -salt, and the context. The message is split into 16 KiB chunks (the -final chunk is always shorter, and may be empty), and each chunk is -encrypted with the AEAD, with a nonce derived from the base nonce and -the chunk counter. The ciphertext is the salt, followed by the 32-byte -commitment, followed by the encrypted chunks. - .. _`C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption -.. _`version 1 of the C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption diff --git a/docs/index.rst b/docs/index.rst index 2cee17b03ae5..10c69b1545ab 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -68,7 +68,7 @@ hazmat layer only when necessary. :caption: The recipes layer fernet - chunked-encryption + cobblestone x509/index .. toctree:: diff --git a/src/cryptography/chunked_encryption.py b/src/cryptography/cobblestone.py similarity index 55% rename from src/cryptography/chunked_encryption.py rename to src/cryptography/cobblestone.py index cefb18da18c8..09f67af9b1c0 100644 --- a/src/cryptography/chunked_encryption.py +++ b/src/cryptography/cobblestone.py @@ -5,13 +5,13 @@ from __future__ import annotations from cryptography.hazmat.bindings._rust import ( - chunked_encryption as _chunked_encryption, + cobblestone as _cobblestone, ) -Cobblestone128Decryptor = _chunked_encryption.Cobblestone128Decryptor -Cobblestone128Encryptor = _chunked_encryption.Cobblestone128Encryptor -Cobblestone256Decryptor = _chunked_encryption.Cobblestone256Decryptor -Cobblestone256Encryptor = _chunked_encryption.Cobblestone256Encryptor +Cobblestone128Decryptor = _cobblestone.Cobblestone128Decryptor +Cobblestone128Encryptor = _cobblestone.Cobblestone128Encryptor +Cobblestone256Decryptor = _cobblestone.Cobblestone256Decryptor +Cobblestone256Encryptor = _cobblestone.Cobblestone256Encryptor __all__ = [ "Cobblestone128Decryptor", diff --git a/src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi b/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi similarity index 100% rename from src/cryptography/hazmat/bindings/_rust/chunked_encryption.pyi rename to src/cryptography/hazmat/bindings/_rust/cobblestone.pyi diff --git a/src/rust/src/chunked_encryption.rs b/src/rust/src/cobblestone.rs similarity index 98% rename from src/rust/src/chunked_encryption.rs rename to src/rust/src/cobblestone.rs index 81c9c92a98d0..24fc6a66b1b7 100644 --- a/src/rust/src/chunked_encryption.rs +++ b/src/rust/src/cobblestone.rs @@ -547,7 +547,7 @@ impl ChunkedDecryptor { } } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] pub(crate) struct Cobblestone128Encryptor { inner: ChunkedEncryptor, } @@ -598,7 +598,7 @@ impl Cobblestone128Encryptor { } } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] pub(crate) struct Cobblestone128Decryptor { inner: ChunkedDecryptor, } @@ -638,7 +638,7 @@ impl Cobblestone128Decryptor { } } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] pub(crate) struct Cobblestone256Encryptor { inner: ChunkedEncryptor, } @@ -689,7 +689,7 @@ impl Cobblestone256Encryptor { } } -#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.chunked_encryption")] +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] pub(crate) struct Cobblestone256Decryptor { inner: ChunkedDecryptor, } @@ -730,8 +730,8 @@ impl Cobblestone256Decryptor { } #[pyo3::pymodule(gil_used = false)] -#[pyo3(name = "chunked_encryption")] -pub(crate) mod chunked_encryption_mod { +#[pyo3(name = "cobblestone")] +pub(crate) mod cobblestone_mod { #[pymodule_export] use super::{ Cobblestone128Decryptor, Cobblestone128Encryptor, Cobblestone256Decryptor, diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index e3e9e22dda2e..c7bcb3827201 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -34,7 +34,7 @@ use crate::error::CryptographyResult; mod asn1; mod backend; mod buf; -mod chunked_encryption; +mod cobblestone; mod declarative_asn1; mod error; mod exceptions; @@ -133,7 +133,7 @@ mod _rust { #[pymodule_export] use crate::asn1::asn1_mod; #[pymodule_export] - use crate::chunked_encryption::chunked_encryption_mod; + use crate::cobblestone::cobblestone_mod; #[pymodule_export] use crate::exceptions::exceptions; #[pymodule_export] diff --git a/tests/test_chunked_encryption.py b/tests/test_cobblestone.py similarity index 99% rename from tests/test_chunked_encryption.py rename to tests/test_cobblestone.py index 9ac2db4785be..e407509f6161 100644 --- a/tests/test_chunked_encryption.py +++ b/tests/test_cobblestone.py @@ -7,7 +7,7 @@ import pytest -from cryptography.chunked_encryption import ( +from cryptography.cobblestone import ( Cobblestone128Decryptor, Cobblestone128Encryptor, Cobblestone256Decryptor, @@ -60,7 +60,7 @@ def _decrypt_all(decryptor_cls, key: bytes, context: bytes, ciphertext: bytes): @pytest.mark.parametrize("variant", VARIANTS) -class TestChunkedEncryption: +class TestCobblestone: @pytest.mark.parametrize("length", MESSAGE_LENGTHS) def test_round_trip(self, variant, length): encryptor_cls, decryptor_cls, _ = variant From 8f0b031cd742f97afa56947ff4779cf68385cd0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:37:00 +0000 Subject: [PATCH 11/11] Add Wycheproof tests for Cobblestone Bumps the wycheproof ref to pick up the c2sp.org/chunked-encryption (Cobblestone) test vectors added in C2SP/wycheproof#265. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uyk58oD6F8BJwKHE4qCMA8 --- tests/wycheproof/test_cobblestone.py | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/wycheproof/test_cobblestone.py diff --git a/tests/wycheproof/test_cobblestone.py b/tests/wycheproof/test_cobblestone.py new file mode 100644 index 000000000000..5787491c78e8 --- /dev/null +++ b/tests/wycheproof/test_cobblestone.py @@ -0,0 +1,56 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import binascii +import hashlib +import typing +import zlib + +import pytest + +from cryptography.cobblestone import ( + Cobblestone128Decryptor, + Cobblestone256Decryptor, +) +from cryptography.exceptions import AlreadyFinalized, InvalidTag + +from .utils import wycheproof_tests + +_DECRYPTORS: dict[str, typing.Any] = { + "AEAD_AES_128_GCM": Cobblestone128Decryptor, + "AEAD_AES_256_GCM": Cobblestone256Decryptor, +} + + +@wycheproof_tests( + "c2sp_chunked_encryption_aes_128_gcm_test.json", + "c2sp_chunked_encryption_aes_256_gcm_test.json", +) +def test_cobblestone(backend, wycheproof): + assert wycheproof.testgroup["sha"] == "SHA-512" + decryptor_cls = _DECRYPTORS[wycheproof.testgroup["aead"]] + key = binascii.unhexlify(wycheproof.testcase["key"]) + ctx = binascii.unhexlify(wycheproof.testcase["ctx"]) + ct = zlib.decompress(binascii.unhexlify(wycheproof.testcase["ct"])) + + if wycheproof.valid: + dec = decryptor_cls(key, ctx) + msg = dec.update(ct) + dec.finalize() + assert len(msg) == wycheproof.testcase["msgLength"] + assert ( + hashlib.sha512(msg).hexdigest() == wycheproof.testcase["msgSha512"] + ) + elif wycheproof.has_flag("InvalidKeySize"): + with pytest.raises(ValueError): + decryptor_cls(key, ctx) + else: + dec = decryptor_cls(key, ctx) + with pytest.raises(InvalidTag): + dec.update(ct) + dec.finalize() + # The failed context must keep failing rather than report a clean + # end of message. + with pytest.raises((InvalidTag, AlreadyFinalized)): + dec.finalize()