From 0c91937c36356d9f127fd4961752388d3b1dd872 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:11:02 +0000 Subject: [PATCH 1/6] Add seekable (random-access) decryption to Cobblestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing Cobblestone128/256 decryptors with a `decrypt_range` method for authenticated random access into a message, without a new class. The C2SP chunked-encryption wire format is already random-access by construction (each 16 KiB chunk is independently AEAD-sealed with the chunk index bound into the nonce), so this adds only decryption capability; the encryptors and wire format are unchanged. `decrypt_range(source, offset, length)` reads only the chunks covering the requested plaintext range. A `source` may be a buffer-protocol object (e.g. an mmap of a file, for local zero-RAM access) or a binary file-like object exposing just `seek` and `read` (e.g. a small adapter issuing ranged requests to remote/object storage) — one method covers both use cases. To never return unauthenticated bytes, the requested range is silently widened to whole chunk boundaries, every covering chunk is decrypted and authenticated, and only then is the requested sub-range sliced out. A range that cannot be fully authenticated (tampering, or truncation within the range) raises InvalidTag. The commitment is verified once, before any plaintext is released. Streaming (`update`/`finalize`) and random-access decryption are mutually exclusive on a single instance. A range read authenticates the bytes it returns but not the message as a whole, so callers must obtain the total length from a trusted source; this caveat is documented. Rust changes: a stateless `ChunkNonces::nonce_for(index)` (shared with the streaming `next`), a `ChunkCipher::decrypt_chunk_at`, and the `decrypt_range` implementation with buffer/file-like reads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- CHANGELOG.rst | 4 +- docs/cobblestone.rst | 124 ++++++++- .../hazmat/bindings/_rust/cobblestone.pyi | 25 ++ src/rust/src/cobblestone.rs | 256 +++++++++++++++++- tests/test_cobblestone.py | 249 +++++++++++++++++ 5 files changed, 650 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8e1479b5d800..655d1be94151 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,7 +14,9 @@ Changelog Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP chunked-encryption specification `_ for streaming authenticated - encryption of large messages. + encryption of large messages. The decryptor classes also provide a + ``decrypt_range`` method for authenticated random access into a + message from a buffer, file, or remote source. * 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/cobblestone.rst b/docs/cobblestone.rst index 35c98116b8eb..6ec3077da835 100644 --- a/docs/cobblestone.rst +++ b/docs/cobblestone.rst @@ -165,6 +165,58 @@ that mandate 256-bit keys). :raises cryptography.exceptions.AlreadyFinalized: If ``finalize`` has already been called. + .. method:: decrypt_range(source, offset, length) + + .. versionadded:: 50.0.0 + + Decrypts and returns the ``length`` plaintext bytes beginning at + ``offset``, reading only the ciphertext needed to cover that + range rather than the whole message. See :ref:`random access + ` below. + + The requested range is silently widened to whole 16 KiB chunk + boundaries so that every chunk it touches can be authenticated + by its tag; the requested sub-range is then sliced out of the + authenticated plaintext. Unauthenticated bytes are never + returned. + + This method is an alternative to :meth:`update`/:meth:`finalize`: + a single instance may be used for streaming **or** random-access + decryption, not both. It does not consume the instance and may be + called repeatedly for different ranges. + + .. warning:: + + A range read authenticates the bytes it returns, but **not + the message as a whole**. It cannot detect that chunks beyond + the requested range were removed, so it provides no + protection against truncation of the overall message. The + total plaintext length must come from a trusted source; do + not infer it from the (possibly truncated) size of + ``source``. Whole-message truncation protection is only + provided by streaming through :meth:`finalize`. + + :param source: The ciphertext, as either a :term:`bytes-like` + object (for example an :class:`mmap.mmap` of a file, which + avoids reading it all into memory) or a binary file-like + object. A file-like ``source`` only needs to implement + ``seek(offset)`` and ``read(size)``; nothing else is used, so + an object that fetches byte ranges from remote storage on + demand works as well as an open file. + :param int offset: The plaintext offset to start at. + :param int length: The number of plaintext bytes to return. + :return bytes: The authenticated plaintext for the requested + range. + :raises cryptography.exceptions.InvalidTag: If a covering chunk + was encrypted with a different key or context, has been + modified, or is missing because ``source`` is truncated + within the requested range (which includes reading past the + end of the message). + :raises ValueError: If ``length`` is negative, ``offset`` is + negative, the range exceeds the maximum message length, or + the instance has already been used for streaming + decryption. + .. class:: Cobblestone256Encryptor(key, context) .. versionadded:: 50.0.0 @@ -180,6 +232,76 @@ that mandate 256-bit keys). Exactly like :class:`Cobblestone128Decryptor`, but decrypts messages produced by :class:`Cobblestone256Encryptor` with a - 32-byte key. + 32-byte key. It provides the same :meth:`~Cobblestone128Decryptor.decrypt_range` + method for random access. + +.. _cobblestone-random-access: + +Random-access decryption +------------------------- + +Because each 16 KiB chunk is encrypted independently, a range of a large +message can be decrypted without processing everything before it, using +:meth:`~Cobblestone128Decryptor.decrypt_range`. The decryptor reads only +the chunks that cover the requested range, authenticates each of them, +and returns just the requested bytes. + +Any object supporting the buffer protocol, or any binary file-like +object, can be the source. To read a range out of a large file without +loading it into memory, pass an :class:`mmap.mmap`: + +.. doctest:: + + >>> import io + >>> from cryptography.cobblestone import ( + ... Cobblestone128Decryptor, Cobblestone128Encryptor + ... ) + >>> key = Cobblestone128Encryptor.generate_key() + >>> encryptor = Cobblestone128Encryptor(key, context=b"ranged") + >>> plaintext = b"the quick brown fox" * 10000 + >>> ciphertext = encryptor.update(plaintext) + encryptor.finalize() + >>> decryptor = Cobblestone128Decryptor(key, context=b"ranged") + >>> decryptor.decrypt_range(io.BytesIO(ciphertext), 40005, 9) + b'brown fox' + >>> _ == plaintext[40005:40014] + True + +For data held remotely, a file-like ``source`` only needs to implement +``seek`` and ``read``, so a small adapter that turns those into ranged +requests (for example an HTTP ``Range`` request or an object-storage +ranged ``GET``) is enough — ``decrypt_range`` issues one contiguous read +for the covering chunks: + +.. code-block:: python + + class RangeReader: + def __init__(self, client, key): + self._client = client + self._key = key + self._pos = 0 + + def seek(self, offset, whence=0): + assert whence == 0 + self._pos = offset + return self._pos + + def read(self, size): + end = self._pos + size - 1 + data = self._client.get_range(self._key, self._pos, end) + self._pos += len(data) + return data + + decryptor = Cobblestone128Decryptor(key, context=b"ranged") + chunk = decryptor.decrypt_range(RangeReader(client, "blob"), 40000, 4096) + +.. warning:: + + A range read authenticates the bytes it returns, but not the + message as a whole: it cannot detect that chunks beyond the + requested range were removed. Obtain the total plaintext length from + a trusted source rather than inferring it from the ciphertext, and + rely on streaming through + :meth:`~Cobblestone128Decryptor.finalize` when you need to verify + that a whole message is intact and has not been truncated. .. _`C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption diff --git a/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi b/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi index 95e958382904..f1d426007c05 100644 --- a/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi +++ b/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi @@ -2,8 +2,21 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +import typing + from cryptography.utils import Buffer +class SeekableReader(typing.Protocol): + """The minimal binary file-like interface ``decrypt_range`` requires. + + Only ``seek`` and ``read`` are used; an implementation need not provide + anything else. ``mmap.mmap`` and files opened in binary mode satisfy this, + as does a small adapter that fetches byte ranges from remote storage. + """ + + def seek(self, offset: int, whence: int = ..., /) -> object: ... + def read(self, size: int, /) -> bytes: ... + class Cobblestone128Encryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... @staticmethod @@ -17,6 +30,12 @@ class Cobblestone128Decryptor: def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... + def decrypt_range( + self, + source: Buffer | SeekableReader, + offset: int, + length: int, + ) -> bytes: ... class Cobblestone256Encryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... @@ -31,3 +50,9 @@ class Cobblestone256Decryptor: def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... + def decrypt_range( + self, + source: Buffer | SeekableReader, + offset: int, + length: int, + ) -> bytes: ... diff --git a/src/rust/src/cobblestone.rs b/src/rust/src/cobblestone.rs index 24fc6a66b1b7..3c3e67d5dbf3 100644 --- a/src/rust/src/cobblestone.rs +++ b/src/rust/src/cobblestone.rs @@ -71,6 +71,56 @@ fn check_key_length(params: &AeadParams, key: &[u8]) -> CryptographyResult<()> { Ok(()) } +fn mixing_error() -> CryptographyError { + CryptographyError::from(pyo3::exceptions::PyValueError::new_err( + "A Cobblestone decryptor cannot be used for both streaming \ + (update/finalize) and random-access (decrypt_range) decryption.", + )) +} + +fn truncated_error() -> CryptographyError { + CryptographyError::from(exceptions::InvalidTag::new_err(())) +} + +// Reads up to `want` bytes starting at byte `pos` from a random-access +// ciphertext source. When `buffer` is `Some`, the source is a buffer-protocol +// object and is indexed directly; otherwise it is a binary file-like object and +// is read via seek()/read(). Fewer than `want` bytes are returned only at the +// end of the source. +fn read_at( + py: pyo3::Python<'_>, + source: &pyo3::Bound<'_, pyo3::PyAny>, + buffer: Option<&CffiBuf<'_>>, + pos: u64, + want: usize, +) -> CryptographyResult> { + if let Some(buf) = buffer { + let bytes = buf.as_bytes(); + let start = std::cmp::min(pos, bytes.len() as u64) as usize; + let end = std::cmp::min(start.saturating_add(want), bytes.len()); + return Ok(bytes[start..end].to_vec()); + } + + source.call_method1(pyo3::intern!(py, "seek"), (pos,))?; + let mut out = Vec::with_capacity(want); + while out.len() < want { + let chunk = source.call_method1(pyo3::intern!(py, "read"), (want - out.len(),))?; + // A non-blocking stream may return None to signal "no data available"; + // treat it, like an empty read, as the end of the input. + if chunk.is_none() { + break; + } + let read = chunk.extract::>()?; + let read_bytes = read.as_bytes(); + if read_bytes.is_empty() { + break; + } + let take = std::cmp::min(read_bytes.len(), want - out.len()); + out.extend_from_slice(&read_bytes[..take]); + } + Ok(out) +} + struct DerivedKeys<'p> { key: pyo3::Bound<'p, pyo3::types::PyBytes>, base_nonce: [u8; NONCE_LEN], @@ -131,15 +181,22 @@ impl ChunkNonces { Ok(()) } - fn next(&mut self) -> CryptographyResult<[u8; NONCE_LEN]> { - self.check_capacity(1)?; + // The nonce for an arbitrary chunk index: the base nonce XOR'd with the + // index as a big-endian integer. This is stateless, so it serves both the + // sequential streaming path (via `next`) and random-access decryption. + fn nonce_for(&self, index: u64) -> CryptographyResult<[u8; NONCE_LEN]> { + if index >= 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()) - { + for (n, c) in nonce[NONCE_LEN - 8..].iter_mut().zip(index.to_be_bytes()) { *n ^= c; } + Ok(nonce) + } + + fn next(&mut self) -> CryptographyResult<[u8; NONCE_LEN]> { + let nonce = self.nonce_for(self.counter)?; self.counter += 1; Ok(nonce) } @@ -205,6 +262,29 @@ impl ChunkCipher { )?; Ok(()) } + + // Decrypts the chunk at an explicit `index`, without advancing the + // sequential counter, for random-access decryption. `ciphertext` includes + // the trailing tag; `out` must be exactly `ciphertext.len() - self.tag_len` + // bytes. Returns InvalidTag if the chunk does not authenticate (e.g. it was + // tampered with, or belongs at a different index). + fn decrypt_chunk_at( + &self, + py: pyo3::Python<'_>, + index: u64, + ciphertext: &[u8], + out: &mut [u8], + ) -> CryptographyResult<()> { + let nonce = self.nonces.nonce_for(index)?; + self.aead.decrypt_into( + py, + CffiBuf::from_bytes(py, &nonce), + CffiBuf::from_bytes(py, ciphertext), + None, + CffiMutBuf::from_bytes(py, out), + )?; + Ok(()) + } } struct ChunkedEncryptor { @@ -370,6 +450,18 @@ struct ChunkedDecryptor { // `None` once finalized, or after any error: a failed context cannot // process more data. state: Option, + // Retained for random-access decryption (`decrypt_range`), which derives + // keys from a header it reads directly from the ciphertext source rather + // than from the streamed input. + key: Vec, + context: Vec, + // The seek cipher, derived and commitment-verified on the first + // `decrypt_range` call and reused for subsequent ranges. + seek_cipher: Option, + // Streaming (`update`/`finalize`) and random-access (`decrypt_range`) + // decryption are mutually exclusive on a single instance. + streaming_used: bool, + seeking_used: bool, } impl ChunkedDecryptor { @@ -382,6 +474,11 @@ impl ChunkedDecryptor { context: context.to_vec(), buf: Vec::with_capacity(HEADER_LEN), }), + key: key.to_vec(), + context: context.to_vec(), + seek_cipher: None, + streaming_used: false, + seeking_used: false, }) } @@ -477,6 +574,10 @@ impl ChunkedDecryptor { py: pyo3::Python<'p>, data: &[u8], ) -> CryptographyResult> { + if self.seeking_used { + return Err(mixing_error()); + } + self.streaming_used = true; let params = self.params; let state = self.active_state()?; let out_len = Self::update_out_len(params, state, data.len()); @@ -497,6 +598,10 @@ impl ChunkedDecryptor { data: &[u8], out: &mut [u8], ) -> CryptographyResult { + if self.seeking_used { + return Err(mixing_error()); + } + self.streaming_used = true; let params = self.params; let state = self.active_state()?; let out_len = Self::update_out_len(params, state, data.len()); @@ -518,6 +623,10 @@ impl ChunkedDecryptor { &mut self, py: pyo3::Python<'p>, ) -> CryptographyResult> { + if self.seeking_used { + return Err(mixing_error()); + } + self.streaming_used = true; // Whether it succeeds or fails, finalization consumes the state: no // further data may be processed. match self.state.take() { @@ -545,6 +654,121 @@ impl ChunkedDecryptor { } } } + + // Random-access decryption. Returns the authenticated plaintext bytes in + // `[offset, offset + length)`, reading only the ciphertext chunks that + // cover that range from `source`. + // + // `source` is either a buffer-protocol object (e.g. bytes or an mmap) or a + // binary file-like object exposing `seek(pos)` and `read(n)`. The requested + // range is silently expanded to whole 16 KiB chunk boundaries so that every + // chunk touched is authenticated by its AEAD tag; only after that check + // passes is the requested sub-range sliced out and returned. Unauthenticated + // bytes are never returned. + // + // Note: a range read authenticates the bytes it returns, but not the + // message as a whole. It cannot detect truncation of chunks beyond the + // requested range, so the total plaintext length must come from a trusted + // source, not be inferred from the (possibly truncated) ciphertext. + fn decrypt_range<'p>( + &mut self, + py: pyo3::Python<'p>, + source: &pyo3::Bound<'p, pyo3::PyAny>, + offset: u64, + length: usize, + ) -> CryptographyResult> { + if self.streaming_used { + return Err(mixing_error()); + } + self.seeking_used = true; + + if length == 0 { + return Ok(pyo3::types::PyBytes::new(py, &[])); + } + + let params = self.params; + let wire = params.wire_chunk_size(); + let first_chunk = offset / CHUNK_SIZE as u64; + // `offset + length` can overflow for a hostile offset; treat that, like + // any range past the message limit, as too large. + let last_byte = offset + .checked_add(length as u64) + .filter(|end| *end <= MAX_CHUNK_COUNT * CHUNK_SIZE as u64) + .ok_or_else(message_too_large_error)? + - 1; + let last_chunk = last_byte / CHUNK_SIZE as u64; + // decrypt_chunk_at rejects indices past the limit, but the final chunk + // index bounds the whole read, so reject up front for a clear error. + if last_chunk >= MAX_CHUNK_COUNT { + return Err(message_too_large_error()); + } + let n_chunks = (last_chunk - first_chunk + 1) as usize; + + // A buffer-protocol source (bytes, mmap, ...) is indexed directly; any + // other object is treated as a seek()/read() file-like. + let as_buffer = source.extract::>().ok(); + + // Derive the seek cipher (and verify the commitment) once, from the + // 56-byte header at the front of the ciphertext. + if self.seek_cipher.is_none() { + let header = read_at(py, source, as_buffer.as_ref(), 0, HEADER_LEN)?; + if header.len() < HEADER_LEN { + return Err(truncated_error()); + } + let (salt, commitment) = header.split_at(SALT_LEN); + let keys = derive_keys(py, params, &self.key, salt, &self.context)?; + if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { + return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); + } + self.seek_cipher = Some(ChunkCipher::new(py, params, &keys)?); + } + let cipher = self.seek_cipher.as_ref().unwrap(); + + // Read the contiguous ciphertext window covering the requested chunks in + // a single read, so a multi-chunk range is one round trip to `source`. + let ct_start = HEADER_LEN as u64 + first_chunk * wire as u64; + let data = read_at(py, source, as_buffer.as_ref(), ct_start, n_chunks * wire)?; + + // Decrypt whole chunks, releasing plaintext only for those that + // authenticate. Any trailing partial wire chunk is the message's short + // final chunk. + let mut plaintext = Vec::with_capacity(n_chunks * CHUNK_SIZE); + let mut consumed = 0; + let mut index = first_chunk; + while consumed + wire <= data.len() { + let start = plaintext.len(); + plaintext.resize(start + CHUNK_SIZE, 0); + cipher.decrypt_chunk_at( + py, + index, + &data[consumed..consumed + wire], + &mut plaintext[start..], + )?; + consumed += wire; + index += 1; + } + let remainder = data.len() - consumed; + if remainder > 0 { + if remainder < params.tag_len { + // Too short to be a valid chunk: the message is truncated. + return Err(truncated_error()); + } + let start = plaintext.len(); + plaintext.resize(start + remainder - params.tag_len, 0); + cipher.decrypt_chunk_at(py, index, &data[consumed..], &mut plaintext[start..])?; + } + + // Slice out the requested range. If the authenticated plaintext does + // not reach `offset + length`, the message is shorter than the request + // (truncated, or the caller read past the end); either way we have no + // authenticated bytes to return there. + let intra = (offset - first_chunk * CHUNK_SIZE as u64) as usize; + let end = intra + length; + if end > plaintext.len() { + return Err(truncated_error()); + } + Ok(pyo3::types::PyBytes::new(py, &plaintext[intra..end])) + } } #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] @@ -636,6 +860,16 @@ impl Cobblestone128Decryptor { ) -> CryptographyResult> { self.inner.finalize(py) } + + fn decrypt_range<'p>( + &mut self, + py: pyo3::Python<'p>, + source: pyo3::Bound<'p, pyo3::PyAny>, + offset: u64, + length: usize, + ) -> CryptographyResult> { + self.inner.decrypt_range(py, &source, offset, length) + } } #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] @@ -727,6 +961,16 @@ impl Cobblestone256Decryptor { ) -> CryptographyResult> { self.inner.finalize(py) } + + fn decrypt_range<'p>( + &mut self, + py: pyo3::Python<'p>, + source: pyo3::Bound<'p, pyo3::PyAny>, + offset: u64, + length: usize, + ) -> CryptographyResult> { + self.inner.decrypt_range(py, &source, offset, length) + } } #[pyo3::pymodule(gil_used = false)] diff --git a/tests/test_cobblestone.py b/tests/test_cobblestone.py index e407509f6161..c4989d0925d6 100644 --- a/tests/test_cobblestone.py +++ b/tests/test_cobblestone.py @@ -3,6 +3,8 @@ # for complete details. +import io +import mmap import os import pytest @@ -393,6 +395,253 @@ def test_accepts_buffers(self, variant): assert dec.update(bytearray(ciphertext)) + dec.finalize() == plaintext +MAX_CHUNK_COUNT = 1 << 38 + + +class _RangeFetcher: + """A minimal seek()/read() source, standing in for remote range fetches.""" + + def __init__(self, data: bytes): + self._data = bytes(data) + self._pos = 0 + self.reads = 0 + + def seek(self, offset, whence=0): + assert whence == 0 + self._pos = offset + return self._pos + + def read(self, size): + self.reads += 1 + chunk = self._data[self._pos : self._pos + size] + self._pos += len(chunk) + return chunk + + +def _buffer_and_filelike_sources(ciphertext: bytes): + # Buffer-protocol sources take the direct-indexing path; io.BytesIO and the + # range fetcher exercise the seek()/read() path. + yield "bytes", bytes(ciphertext) + yield "bytearray", bytearray(ciphertext) + yield "memoryview", memoryview(bytes(ciphertext)) + yield "bytesio", io.BytesIO(ciphertext) + yield "range_fetcher", _RangeFetcher(ciphertext) + + +SEEK_RANGES = [ + (0, 10), # start of the first chunk + (100, 50), # within the first chunk + (CHUNK_SIZE - 10, 20), # spanning the 0/1 chunk boundary + (CHUNK_SIZE, CHUNK_SIZE), # exactly one full interior chunk + (CHUNK_SIZE + 5, 2 * CHUNK_SIZE), # spanning several chunks + (50000, 100), # within the final (short) chunk + (3 * CHUNK_SIZE, 5000), # the whole final chunk + (3 * CHUNK_SIZE + 4990, 10), # the very end of the message +] + + +@pytest.mark.parametrize("variant", VARIANTS) +class TestCobblestoneSeekable: + def _encrypt(self, variant, context, plaintext): + encryptor_cls, _, _ = variant + key = encryptor_cls.generate_key() + return key, _encrypt_all(encryptor_cls, key, context, plaintext) + + @pytest.mark.parametrize("offset,length", SEEK_RANGES) + def test_decrypt_range_matches_plaintext(self, variant, offset, length): + _, decryptor_cls, _ = variant + context = b"seekable context" + plaintext = os.urandom(3 * CHUNK_SIZE + 5000) + key, ciphertext = self._encrypt(variant, context, plaintext) + expected = plaintext[offset : offset + length] + + for source_id, source in _buffer_and_filelike_sources(ciphertext): + dec = decryptor_cls(key, context) + assert dec.decrypt_range(source, offset, length) == expected, ( + source_id + ) + + def test_decrypt_range_whole_message(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(2 * CHUNK_SIZE + 1234) + key, ciphertext = self._encrypt(variant, b"", plaintext) + dec = decryptor_cls(key, b"") + assert dec.decrypt_range(ciphertext, 0, len(plaintext)) == plaintext + + def test_decrypt_range_on_exact_chunk_boundary_message(self, variant): + # A message whose length is a multiple of CHUNK_SIZE has an empty + # final chunk; ranges not touching it still decrypt. + _, decryptor_cls, _ = variant + plaintext = os.urandom(2 * CHUNK_SIZE) + key, ciphertext = self._encrypt(variant, b"", plaintext) + dec = decryptor_cls(key, b"") + assert dec.decrypt_range(ciphertext, 0, 2 * CHUNK_SIZE) == plaintext + assert ( + dec.decrypt_range(ciphertext, 2 * CHUNK_SIZE - 5, 5) + == plaintext[-5:] + ) + + def test_decrypt_range_via_mmap(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(2 * CHUNK_SIZE + 777) + key, ciphertext = self._encrypt(variant, b"ctx", plaintext) + mm = mmap.mmap(-1, len(ciphertext)) + try: + mm[:] = ciphertext + dec = decryptor_cls(key, b"ctx") + assert ( + dec.decrypt_range(mm, CHUNK_SIZE - 3, 10) + == plaintext[CHUNK_SIZE - 3 : CHUNK_SIZE + 7] + ) + finally: + mm.close() + + def test_zero_length_returns_empty(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", b"some data") + dec = decryptor_cls(key, b"") + # No source access is required for a zero-length range. + assert dec.decrypt_range(b"", 0, 0) == b"" + assert dec.decrypt_range(ciphertext, 3, 0) == b"" + + def test_instance_is_reusable_for_many_ranges(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(3 * CHUNK_SIZE) + key, ciphertext = self._encrypt(variant, b"", plaintext) + dec = decryptor_cls(key, b"") + assert dec.decrypt_range(ciphertext, 10, 20) == plaintext[10:30] + assert ( + dec.decrypt_range(ciphertext, 2 * CHUNK_SIZE, 100) + == plaintext[2 * CHUNK_SIZE : 2 * CHUNK_SIZE + 100] + ) + + def test_multi_chunk_range_is_a_single_body_read(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(4 * CHUNK_SIZE) + key, ciphertext = self._encrypt(variant, b"", plaintext) + fetcher = _RangeFetcher(ciphertext) + dec = decryptor_cls(key, b"") + dec.decrypt_range(fetcher, 100, 2 * CHUNK_SIZE) + # One read for the header, one contiguous read for the covering chunks. + assert fetcher.reads == 2 + + def test_wrong_key_rejected(self, variant): + encryptor_cls, decryptor_cls, _ = variant + _, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE + 1)) + dec = decryptor_cls(encryptor_cls.generate_key(), b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(ciphertext, 0, 10) + + def test_wrong_context_rejected(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt( + variant, b"context a", os.urandom(CHUNK_SIZE + 1) + ) + dec = decryptor_cls(key, b"context b") + with pytest.raises(InvalidTag): + dec.decrypt_range(ciphertext, 0, 10) + + @pytest.mark.parametrize( + "position", + [ + 0, # salt + SALT_LEN, # commitment + HEADER_LEN, # first chunk ciphertext + HEADER_LEN + WIRE_CHUNK_SIZE - 1, # first chunk tag + ], + ) + def test_tampering_in_range_detected(self, variant, position): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt( + variant, b"", os.urandom(CHUNK_SIZE + 100) + ) + ciphertext = bytearray(ciphertext) + ciphertext[position] ^= 1 + dec = decryptor_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(bytes(ciphertext), 0, 100) + + def test_reading_past_end_rejected(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(CHUNK_SIZE + 500) + key, ciphertext = self._encrypt(variant, b"", plaintext) + dec = decryptor_cls(key, b"") + # Starting exactly at the end, and overshooting the end, both fail + # rather than returning short: past the authenticated end there are no + # bytes to return. + with pytest.raises(InvalidTag): + dec.decrypt_range(ciphertext, len(plaintext), 1) + with pytest.raises(InvalidTag): + dec.decrypt_range(ciphertext, len(plaintext) - 2, 5) + + def test_truncated_source_in_range_rejected(self, variant): + _, decryptor_cls, _ = variant + plaintext = os.urandom(2 * CHUNK_SIZE + 10) + key, ciphertext = self._encrypt(variant, b"", plaintext) + # Drop everything after the first chunk. + truncated = ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE] + + dec = decryptor_cls(key, b"") + # A range served entirely by the surviving first chunk still succeeds: + # decrypt_range cannot detect truncation beyond the requested range. + assert dec.decrypt_range(truncated, 0, 100) == plaintext[:100] + # A range needing a dropped chunk is rejected. + with pytest.raises(InvalidTag): + dec.decrypt_range(truncated, CHUNK_SIZE, 10) + + def test_too_large_offset_rejected(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", b"data") + dec = decryptor_cls(key, b"") + with pytest.raises(ValueError): + dec.decrypt_range(ciphertext, MAX_CHUNK_COUNT * CHUNK_SIZE, 1) + + def test_negative_arguments_rejected(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", b"data") + dec = decryptor_cls(key, b"") + with pytest.raises((ValueError, OverflowError)): + dec.decrypt_range(ciphertext, -1, 1) + with pytest.raises((ValueError, OverflowError)): + dec.decrypt_range(ciphertext, 0, -1) + + def test_invalid_source_type_rejected(self, variant): + _, decryptor_cls, _ = variant + key, _ = self._encrypt(variant, b"", b"data") + dec = decryptor_cls(key, b"") + with pytest.raises((TypeError, AttributeError)): + dec.decrypt_range(12345, 0, 10) + + def test_cannot_seek_after_streaming(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE)) + dec = decryptor_cls(key, b"") + dec.update(ciphertext[:HEADER_LEN]) + with pytest.raises(ValueError, match="cannot be used for both"): + dec.decrypt_range(ciphertext, 0, 10) + + def test_cannot_stream_after_seeking(self, variant): + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE)) + dec = decryptor_cls(key, b"") + dec.decrypt_range(ciphertext, 0, 10) + with pytest.raises(ValueError, match="cannot be used for both"): + dec.update(ciphertext) + with pytest.raises(ValueError, match="cannot be used for both"): + dec.finalize() + + def test_seeking_instance_survives_out_of_range_error(self, variant): + # Unlike the streaming decryptor, a random-access failure (here, a + # read past the end) does not poison the instance. + _, decryptor_cls, _ = variant + plaintext = os.urandom(CHUNK_SIZE + 200) + key, ciphertext = self._encrypt(variant, b"", plaintext) + dec = decryptor_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(ciphertext, len(plaintext), 1) + assert dec.decrypt_range(ciphertext, 0, 50) == plaintext[:50] + + class TestVariantsAreDistinct: def test_key_sizes_differ(self): assert len(Cobblestone128Encryptor.generate_key()) == 16 From fe04f840a2d17d8989119e7867df4b4d8c3e2ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:14:04 +0000 Subject: [PATCH 2/6] Add "decryptor" to docs spelling wordlist The docs spell-check (sphinx-build -b spelling, warnings-as-errors) only knew the capitalized class-name form "Decryptor"; the new prose in CHANGELOG.rst and docs/cobblestone.rst uses "decryptor" as a common noun. Add the lowercase form to the wordlist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index df4c2e037e92..51f1fcaa909e 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -43,6 +43,7 @@ Decapsulate Decapsulation declaratively decrypt +decryptor Decryptor decrypts Decrypts From 83ebdda82085d88c036fd206183e0809e5be35e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:30:28 +0000 Subject: [PATCH 3/6] Cover decrypt_range edge branches to satisfy 100% coverage gate The coverage job flagged five uncovered lines in the new random-access code. Address them: - Remove a now-dead bounds check: the `checked_add(...).filter(...)` guard on the requested range already ensures every chunk index is below MAX_CHUNK_COUNT, so the subsequent `last_chunk >= MAX_CHUNK_COUNT` branch was unreachable. - Add tests exercising the remaining edge branches: a file-like source that signals EOF with None, `update_into` after seeking (mixing guard), a source too short to hold the header, and a final wire chunk shorter than the tag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- src/rust/src/cobblestone.rs | 10 +++------- tests/test_cobblestone.py | 38 ++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/rust/src/cobblestone.rs b/src/rust/src/cobblestone.rs index 3c3e67d5dbf3..31200d37f71e 100644 --- a/src/rust/src/cobblestone.rs +++ b/src/rust/src/cobblestone.rs @@ -689,19 +689,15 @@ impl ChunkedDecryptor { let params = self.params; let wire = params.wire_chunk_size(); let first_chunk = offset / CHUNK_SIZE as u64; - // `offset + length` can overflow for a hostile offset; treat that, like - // any range past the message limit, as too large. + // Bound the range to the maximum message length (which also guards + // against `offset + length` overflowing for a hostile offset). This + // keeps every chunk index below MAX_CHUNK_COUNT. let last_byte = offset .checked_add(length as u64) .filter(|end| *end <= MAX_CHUNK_COUNT * CHUNK_SIZE as u64) .ok_or_else(message_too_large_error)? - 1; let last_chunk = last_byte / CHUNK_SIZE as u64; - // decrypt_chunk_at rejects indices past the limit, but the final chunk - // index bounds the whole read, so reject up front for a clear error. - if last_chunk >= MAX_CHUNK_COUNT { - return Err(message_too_large_error()); - } let n_chunks = (last_chunk - first_chunk + 1) as usize; // A buffer-protocol source (bytes, mmap, ...) is indexed directly; any diff --git a/tests/test_cobblestone.py b/tests/test_cobblestone.py index c4989d0925d6..c5f0ac635739 100644 --- a/tests/test_cobblestone.py +++ b/tests/test_cobblestone.py @@ -401,10 +401,12 @@ def test_accepts_buffers(self, variant): class _RangeFetcher: """A minimal seek()/read() source, standing in for remote range fetches.""" - def __init__(self, data: bytes): + def __init__(self, data: bytes, none_at_eof: bool = False): self._data = bytes(data) self._pos = 0 self.reads = 0 + # Some streams return None (rather than b"") to signal no data. + self._none_at_eof = none_at_eof def seek(self, offset, whence=0): assert whence == 0 @@ -415,6 +417,8 @@ def read(self, size): self.reads += 1 chunk = self._data[self._pos : self._pos + size] self._pos += len(chunk) + if not chunk and self._none_at_eof: + return None return chunk @@ -627,9 +631,41 @@ def test_cannot_stream_after_seeking(self, variant): dec.decrypt_range(ciphertext, 0, 10) with pytest.raises(ValueError, match="cannot be used for both"): dec.update(ciphertext) + with pytest.raises(ValueError, match="cannot be used for both"): + dec.update_into(ciphertext, bytearray(2 * CHUNK_SIZE)) with pytest.raises(ValueError, match="cannot be used for both"): dec.finalize() + def test_final_chunk_read_via_none_returning_source(self, variant): + # A file-like source that signals end-of-input with None (rather than + # b"") is handled: reading the short final chunk asks past its end. + _, decryptor_cls, _ = variant + plaintext = os.urandom(CHUNK_SIZE + 1234) + key, ciphertext = self._encrypt(variant, b"", plaintext) + source = _RangeFetcher(ciphertext, none_at_eof=True) + dec = decryptor_cls(key, b"") + assert ( + dec.decrypt_range(source, CHUNK_SIZE, 1234) == plaintext[CHUNK_SIZE:] + ) + + def test_short_header_rejected(self, variant): + # A source too short to even hold the 56-byte header is truncated. + _, decryptor_cls, _ = variant + key, _ = self._encrypt(variant, b"", b"data") + dec = decryptor_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(b"\x00" * (HEADER_LEN - 1), 0, 1) + + def test_final_chunk_shorter_than_tag_rejected(self, variant): + # A final wire chunk shorter than the tag cannot be authenticated. + _, decryptor_cls, _ = variant + key, ciphertext = self._encrypt(variant, b"", os.urandom(100)) + # Keep the full header, but leave only a few (< TAG_LEN) body bytes. + truncated = ciphertext[: HEADER_LEN + TAG_LEN - 1] + dec = decryptor_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(truncated, 0, 1) + def test_seeking_instance_survives_out_of_range_error(self, variant): # Unlike the streaming decryptor, a random-access failure (here, a # read past the end) does not poison the instance. From 2c7f65820b6ae452d9da584e234e42163a983416 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:31:46 +0000 Subject: [PATCH 4/6] Wrap an over-length line in the cobblestone tests Fixes an E501 (line too long) flagged by ruff in a test added in the previous commit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- tests/test_cobblestone.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_cobblestone.py b/tests/test_cobblestone.py index c5f0ac635739..c19b9c4fe6e8 100644 --- a/tests/test_cobblestone.py +++ b/tests/test_cobblestone.py @@ -644,9 +644,8 @@ def test_final_chunk_read_via_none_returning_source(self, variant): key, ciphertext = self._encrypt(variant, b"", plaintext) source = _RangeFetcher(ciphertext, none_at_eof=True) dec = decryptor_cls(key, b"") - assert ( - dec.decrypt_range(source, CHUNK_SIZE, 1234) == plaintext[CHUNK_SIZE:] - ) + result = dec.decrypt_range(source, CHUNK_SIZE, 1234) + assert result == plaintext[CHUNK_SIZE:] def test_short_header_rejected(self, variant): # A source too short to even hold the 56-byte header is truncated. From fa66119a145748079310cabe5f884df8c2575dde Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 20:56:18 +0000 Subject: [PATCH 5/6] Move random-access decryption to its own class and reader protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the random-access API in response to review. decrypt_range is no longer a method on the streaming decryptors. It is the only instance method on new Cobblestone128RangeDecryptor and Cobblestone256RangeDecryptor classes, so streaming and random access are separate objects that cannot be interleaved. The streaming ChunkedDecryptor is left exactly as it was. Ciphertext is now read through a single RangeReader protocol — read_at(offset, length) — rather than accepting either a buffer or a seek()/read() file-like. Reads are positional, so a reader holds no cursor and can serve concurrent ranges. Two implementations are provided: BufferReader, for any bytes-like (including an mmap of a file), and FileReader, which uses os.pread where the platform has it and otherwise falls back to a locked seek/read. Implementing the protocol for remote storage is documented with an object-storage example. Because a reader is supplied per call, the message header is re-read on each call and the derived keys are cached against it, so one decryptor can safely be used with readers for different messages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- CHANGELOG.rst | 9 +- docs/cobblestone.rst | 215 ++++++---- src/cryptography/cobblestone.py | 71 +++ .../hazmat/bindings/_rust/cobblestone.pyi | 30 +- src/rust/src/cobblestone.rs | 264 ++++++------ tests/test_cobblestone.py | 406 +++++++++++------- 6 files changed, 619 insertions(+), 376 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 655d1be94151..308688006c9d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,9 +14,12 @@ Changelog Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP chunked-encryption specification `_ for streaming authenticated - encryption of large messages. The decryptor classes also provide a - ``decrypt_range`` method for authenticated random access into a - message from a buffer, file, or remote source. + encryption of large messages. It also provides + ``Cobblestone128RangeDecryptor`` and ``Cobblestone256RangeDecryptor`` + for authenticated random access into a message, reading ciphertext + through a ``RangeReader`` (``BufferReader`` and ``FileReader`` are + provided, and remote sources such as object storage can implement the + protocol themselves). * 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/cobblestone.rst b/docs/cobblestone.rst index 6ec3077da835..6144d0b023ef 100644 --- a/docs/cobblestone.rst +++ b/docs/cobblestone.rst @@ -165,14 +165,47 @@ that mandate 256-bit keys). :raises cryptography.exceptions.AlreadyFinalized: If ``finalize`` has already been called. - .. method:: decrypt_range(source, offset, length) +.. 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. + +.. class:: Cobblestone128RangeDecryptor(key, context) - .. versionadded:: 50.0.0 + .. versionadded:: 50.0.0 + + Decrypts arbitrary ranges of a message encrypted by + :class:`Cobblestone128Encryptor`, reading only the ciphertext that + covers each requested range rather than the whole message. See + :ref:`random access ` below. + + This is a separate class from :class:`Cobblestone128Decryptor`: + random access and streaming are distinct modes and cannot be + interleaved on one object. Unlike the streaming decryptor, an + instance is not consumed and may be used for any number of ranges. + + :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. + + .. method:: decrypt_range(reader, offset, length) Decrypts and returns the ``length`` plaintext bytes beginning at - ``offset``, reading only the ciphertext needed to cover that - range rather than the whole message. See :ref:`random access - ` below. + ``offset``. The requested range is silently widened to whole 16 KiB chunk boundaries so that every chunk it touches can be authenticated @@ -180,11 +213,6 @@ that mandate 256-bit keys). authenticated plaintext. Unauthenticated bytes are never returned. - This method is an alternative to :meth:`update`/:meth:`finalize`: - a single instance may be used for streaming **or** random-access - decryption, not both. It does not consume the instance and may be - called repeatedly for different ranges. - .. warning:: A range read authenticates the bytes it returns, but **not @@ -192,48 +220,31 @@ that mandate 256-bit keys). the requested range were removed, so it provides no protection against truncation of the overall message. The total plaintext length must come from a trusted source; do - not infer it from the (possibly truncated) size of - ``source``. Whole-message truncation protection is only - provided by streaming through :meth:`finalize`. - - :param source: The ciphertext, as either a :term:`bytes-like` - object (for example an :class:`mmap.mmap` of a file, which - avoids reading it all into memory) or a binary file-like - object. A file-like ``source`` only needs to implement - ``seek(offset)`` and ``read(size)``; nothing else is used, so - an object that fetches byte ranges from remote storage on - demand works as well as an open file. + not infer it from the size of the ciphertext. Whole-message + truncation protection is only provided by streaming through + :meth:`Cobblestone128Decryptor.finalize`. + + :param reader: The ciphertext, as a :class:`RangeReader` — + typically a :class:`BufferReader` or :class:`FileReader`. :param int offset: The plaintext offset to start at. :param int length: The number of plaintext bytes to return. :return bytes: The authenticated plaintext for the requested range. :raises cryptography.exceptions.InvalidTag: If a covering chunk was encrypted with a different key or context, has been - modified, or is missing because ``source`` is truncated + modified, or is missing because the ciphertext is truncated within the requested range (which includes reading past the end of the message). - :raises ValueError: If ``length`` is negative, ``offset`` is - negative, the range exceeds the maximum message length, or - the instance has already been used for streaming - decryption. - -.. class:: Cobblestone256Encryptor(key, context) - - .. versionadded:: 50.0.0 + :raises ValueError: If ``offset`` or ``length`` is negative, or + the range exceeds the maximum message length. - 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) +.. class:: Cobblestone256RangeDecryptor(key, context) .. versionadded:: 50.0.0 - Exactly like :class:`Cobblestone128Decryptor`, but decrypts - messages produced by :class:`Cobblestone256Encryptor` with a - 32-byte key. It provides the same :meth:`~Cobblestone128Decryptor.decrypt_range` - method for random access. + Exactly like :class:`Cobblestone128RangeDecryptor`, but decrypts + ranges of messages produced by :class:`Cobblestone256Encryptor` with + a 32-byte key. .. _cobblestone-random-access: @@ -241,58 +252,116 @@ Random-access decryption ------------------------- Because each 16 KiB chunk is encrypted independently, a range of a large -message can be decrypted without processing everything before it, using -:meth:`~Cobblestone128Decryptor.decrypt_range`. The decryptor reads only -the chunks that cover the requested range, authenticates each of them, -and returns just the requested bytes. +message can be decrypted without processing everything before it. A +:class:`Cobblestone128RangeDecryptor` reads only the chunks covering the +requested range, authenticates each of them, and returns just the bytes +asked for. -Any object supporting the buffer protocol, or any binary file-like -object, can be the source. To read a range out of a large file without -loading it into memory, pass an :class:`mmap.mmap`: +Ciphertext is read through a :class:`RangeReader`. Wrap whatever holds +the ciphertext in the matching reader and pass it to +:meth:`~Cobblestone128RangeDecryptor.decrypt_range`: .. doctest:: - >>> import io >>> from cryptography.cobblestone import ( - ... Cobblestone128Decryptor, Cobblestone128Encryptor + ... BufferReader, Cobblestone128Encryptor, + ... Cobblestone128RangeDecryptor ... ) >>> key = Cobblestone128Encryptor.generate_key() >>> encryptor = Cobblestone128Encryptor(key, context=b"ranged") >>> plaintext = b"the quick brown fox" * 10000 >>> ciphertext = encryptor.update(plaintext) + encryptor.finalize() - >>> decryptor = Cobblestone128Decryptor(key, context=b"ranged") - >>> decryptor.decrypt_range(io.BytesIO(ciphertext), 40005, 9) + >>> decryptor = Cobblestone128RangeDecryptor(key, context=b"ranged") + >>> decryptor.decrypt_range(BufferReader(ciphertext), 40005, 9) b'brown fox' >>> _ == plaintext[40005:40014] True -For data held remotely, a file-like ``source`` only needs to implement -``seek`` and ``read``, so a small adapter that turns those into ranged -requests (for example an HTTP ``Range`` request or an object-storage -ranged ``GET``) is enough — ``decrypt_range`` issues one contiguous read -for the covering chunks: +.. class:: RangeReader + + .. versionadded:: 50.0.0 + + The interface a range decryptor reads ciphertext through. This is a + :class:`typing.Protocol`: implement it to read from a source this + module does not provide a reader for, such as an object store. + + .. method:: read_at(offset, length) + + Returns up to ``length`` bytes of ciphertext starting at + ``offset``. + + Reads are *positional*: this method must not depend on, or + disturb, any cursor, so that concurrent calls on one reader + cannot interfere with each other. + + Returning fewer than ``length`` bytes signals the end of the + ciphertext, so a short read must not be used for any other + reason. The decryptor will ask again for whatever remains. + + :param int offset: The ciphertext offset to read from. + :param int length: The maximum number of bytes to return. + :return: The bytes read. + :rtype: :term:`bytes-like` + +.. class:: BufferReader(data) + + .. versionadded:: 50.0.0 + + A :class:`RangeReader` over an in-memory buffer. + + :param data: The ciphertext. This may be any :term:`bytes-like` + object, including an :class:`mmap.mmap` of a file, which allows + reading ranges out of a large file without loading it into + memory. + +.. class:: FileReader(fileobj) + + .. versionadded:: 50.0.0 + + A :class:`RangeReader` over an open binary file. + + Where the platform provides ``pread`` (via :func:`os.pread`) the + file's cursor is neither used nor modified, so one reader can serve + concurrent range requests; elsewhere reads are serialized around a + seek. + + :param fileobj: A file object opened in binary mode, which must have + a file descriptor (:meth:`~io.IOBase.fileno`). + +Reading from remote storage +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For ciphertext held remotely, implement :class:`RangeReader` in terms of +whatever ranged read the service offers — an HTTP ``Range`` request, or +an object-storage ranged ``GET``. Because ``read_at`` takes the offset as +an argument, no cursor is kept and the reader is safe to share: .. code-block:: python - class RangeReader: - def __init__(self, client, key): + class S3Reader: + def __init__(self, client, bucket, key): self._client = client + self._bucket = bucket self._key = key - self._pos = 0 - - def seek(self, offset, whence=0): - assert whence == 0 - self._pos = offset - return self._pos - - def read(self, size): - end = self._pos + size - 1 - data = self._client.get_range(self._key, self._pos, end) - self._pos += len(data) - return data - decryptor = Cobblestone128Decryptor(key, context=b"ranged") - chunk = decryptor.decrypt_range(RangeReader(client, "blob"), 40000, 4096) + def read_at(self, offset, length): + if length == 0: + return b"" + end = offset + length - 1 + response = self._client.get_object( + Bucket=self._bucket, + Key=self._key, + Range=f"bytes={offset}-{end}", + ) + return response["Body"].read() + + decryptor = Cobblestone128RangeDecryptor(key, context=b"ranged") + reader = S3Reader(client, "my-bucket", "backup.bin") + data = decryptor.decrypt_range(reader, 40000, 4096) + +Each call makes two reads: one for the 56-byte header, which binds the +plaintext to this particular message, and one contiguous read covering +every chunk the range touches. .. warning:: @@ -301,7 +370,7 @@ for the covering chunks: requested range were removed. Obtain the total plaintext length from a trusted source rather than inferring it from the ciphertext, and rely on streaming through - :meth:`~Cobblestone128Decryptor.finalize` when you need to verify + :meth:`Cobblestone128Decryptor.finalize` when you need to verify that a whole message is intact and has not been truncated. .. _`C2SP chunked-encryption specification`: https://c2sp.org/chunked-encryption diff --git a/src/cryptography/cobblestone.py b/src/cryptography/cobblestone.py index 09f67af9b1c0..113f7edb8ff1 100644 --- a/src/cryptography/cobblestone.py +++ b/src/cryptography/cobblestone.py @@ -4,18 +4,89 @@ from __future__ import annotations +import os +import threading +import typing + from cryptography.hazmat.bindings._rust import ( cobblestone as _cobblestone, ) +from cryptography.utils import Buffer Cobblestone128Decryptor = _cobblestone.Cobblestone128Decryptor Cobblestone128Encryptor = _cobblestone.Cobblestone128Encryptor +Cobblestone128RangeDecryptor = _cobblestone.Cobblestone128RangeDecryptor Cobblestone256Decryptor = _cobblestone.Cobblestone256Decryptor Cobblestone256Encryptor = _cobblestone.Cobblestone256Encryptor +Cobblestone256RangeDecryptor = _cobblestone.Cobblestone256RangeDecryptor + +# os.pread is POSIX-only. Where it exists a range can be read without a cursor +# at all, so one reader can serve concurrent requests. +_HAS_PREAD = hasattr(os, "pread") + + +class RangeReader(typing.Protocol): + """The interface a range decryptor reads ciphertext through. + + Implement this to decrypt ranges out of a source this module does not + provide a reader for, such as an object store. ``read_at`` is positional: + it must not depend on, or disturb, any cursor, so that concurrent calls + cannot interfere with each other. + """ + + def read_at(self, offset: int, length: int) -> Buffer: + """Returns up to ``length`` bytes starting at ``offset``. + + Returning fewer than ``length`` bytes signals the end of the + ciphertext, so a short read must not be used for any other reason. + """ + + +class BufferReader: + """Reads ranges out of an in-memory buffer. + + ``data`` may be any bytes-like object, including an :class:`mmap.mmap` of + a file, which allows reading ranges out of a large file without loading it + into memory. + """ + + def __init__(self, data: Buffer) -> None: + self._data = memoryview(data).cast("B") + + def read_at(self, offset: int, length: int) -> Buffer: + return self._data[offset : offset + length] + + +class FileReader: + """Reads ranges out of an open binary file. + + ``fileobj`` must be open in binary mode and have a file descriptor. Where + the platform provides ``pread`` the file's cursor is neither used nor + modified, so a single reader can serve concurrent range requests; + elsewhere reads are serialized around a seek. + """ + + def __init__(self, fileobj: typing.BinaryIO) -> None: + self._fileobj = fileobj + self._fd = fileobj.fileno() + self._lock = threading.Lock() + + def read_at(self, offset: int, length: int) -> Buffer: + if _HAS_PREAD: + return os.pread(self._fd, length, offset) + with self._lock: + self._fileobj.seek(offset) + return self._fileobj.read(length) + __all__ = [ + "BufferReader", "Cobblestone128Decryptor", "Cobblestone128Encryptor", + "Cobblestone128RangeDecryptor", "Cobblestone256Decryptor", "Cobblestone256Encryptor", + "Cobblestone256RangeDecryptor", + "FileReader", + "RangeReader", ] diff --git a/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi b/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi index f1d426007c05..261bb5eec08d 100644 --- a/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi +++ b/src/cryptography/hazmat/bindings/_rust/cobblestone.pyi @@ -6,16 +6,10 @@ import typing from cryptography.utils import Buffer -class SeekableReader(typing.Protocol): - """The minimal binary file-like interface ``decrypt_range`` requires. - - Only ``seek`` and ``read`` are used; an implementation need not provide - anything else. ``mmap.mmap`` and files opened in binary mode satisfy this, - as does a small adapter that fetches byte ranges from remote storage. - """ - - def seek(self, offset: int, whence: int = ..., /) -> object: ... - def read(self, size: int, /) -> bytes: ... +class _RangeReader(typing.Protocol): + # Structural mirror of cryptography.cobblestone.RangeReader, kept local so + # this stub does not import back into the package it is a binding for. + def read_at(self, offset: int, length: int) -> Buffer: ... class Cobblestone128Encryptor: def __init__(self, key: Buffer, context: Buffer) -> None: ... @@ -30,11 +24,11 @@ class Cobblestone128Decryptor: def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... + +class Cobblestone128RangeDecryptor: + def __init__(self, key: Buffer, context: Buffer) -> None: ... def decrypt_range( - self, - source: Buffer | SeekableReader, - offset: int, - length: int, + self, reader: _RangeReader, offset: int, length: int ) -> bytes: ... class Cobblestone256Encryptor: @@ -50,9 +44,9 @@ class Cobblestone256Decryptor: def update(self, data: Buffer) -> bytes: ... def update_into(self, data: Buffer, buf: Buffer) -> int: ... def finalize(self) -> bytes: ... + +class Cobblestone256RangeDecryptor: + def __init__(self, key: Buffer, context: Buffer) -> None: ... def decrypt_range( - self, - source: Buffer | SeekableReader, - offset: int, - length: int, + self, reader: _RangeReader, offset: int, length: int ) -> bytes: ... diff --git a/src/rust/src/cobblestone.rs b/src/rust/src/cobblestone.rs index 31200d37f71e..d71f076657f4 100644 --- a/src/rust/src/cobblestone.rs +++ b/src/rust/src/cobblestone.rs @@ -71,47 +71,29 @@ fn check_key_length(params: &AeadParams, key: &[u8]) -> CryptographyResult<()> { Ok(()) } -fn mixing_error() -> CryptographyError { - CryptographyError::from(pyo3::exceptions::PyValueError::new_err( - "A Cobblestone decryptor cannot be used for both streaming \ - (update/finalize) and random-access (decrypt_range) decryption.", - )) -} - fn truncated_error() -> CryptographyError { CryptographyError::from(exceptions::InvalidTag::new_err(())) } -// Reads up to `want` bytes starting at byte `pos` from a random-access -// ciphertext source. When `buffer` is `Some`, the source is a buffer-protocol -// object and is indexed directly; otherwise it is a binary file-like object and -// is read via seek()/read(). Fewer than `want` bytes are returned only at the -// end of the source. -fn read_at( +// Reads up to `want` bytes at byte offset `offset` from a reader, by calling +// its `read_at(offset, length)` method. The read is positional: the reader +// holds no cursor, so a single reader can serve concurrent range requests. +// Returns fewer than `want` bytes only at the end of the ciphertext. +fn range_read( py: pyo3::Python<'_>, - source: &pyo3::Bound<'_, pyo3::PyAny>, - buffer: Option<&CffiBuf<'_>>, - pos: u64, + reader: &pyo3::Bound<'_, pyo3::PyAny>, + offset: u64, want: usize, ) -> CryptographyResult> { - if let Some(buf) = buffer { - let bytes = buf.as_bytes(); - let start = std::cmp::min(pos, bytes.len() as u64) as usize; - let end = std::cmp::min(start.saturating_add(want), bytes.len()); - return Ok(bytes[start..end].to_vec()); - } - - source.call_method1(pyo3::intern!(py, "seek"), (pos,))?; let mut out = Vec::with_capacity(want); while out.len() < want { - let chunk = source.call_method1(pyo3::intern!(py, "read"), (want - out.len(),))?; - // A non-blocking stream may return None to signal "no data available"; - // treat it, like an empty read, as the end of the input. - if chunk.is_none() { - break; - } - let read = chunk.extract::>()?; + let result = reader.call_method1( + pyo3::intern!(py, "read_at"), + (offset + out.len() as u64, want - out.len()), + )?; + let read = result.extract::>()?; let read_bytes = read.as_bytes(); + // A short read means the end of the ciphertext was reached. if read_bytes.is_empty() { break; } @@ -244,15 +226,19 @@ impl ChunkCipher { Ok(()) } - // `ciphertext` includes the trailing tag; `out` must be exactly - // `ciphertext.len() - self.tag_len` bytes. - fn decrypt_chunk( - &mut self, + // Decrypts the chunk at an explicit `index`, without advancing the + // sequential counter, for random-access decryption. `ciphertext` includes + // the trailing tag; `out` must be exactly `ciphertext.len() - self.tag_len` + // bytes. Returns InvalidTag if the chunk does not authenticate (e.g. it was + // tampered with, or belongs at a different index). + fn decrypt_chunk_at( + &self, py: pyo3::Python<'_>, + index: u64, ciphertext: &[u8], out: &mut [u8], ) -> CryptographyResult<()> { - let nonce = self.nonces.next()?; + let nonce = self.nonces.nonce_for(index)?; self.aead.decrypt_into( py, CffiBuf::from_bytes(py, &nonce), @@ -263,19 +249,15 @@ impl ChunkCipher { Ok(()) } - // Decrypts the chunk at an explicit `index`, without advancing the - // sequential counter, for random-access decryption. `ciphertext` includes - // the trailing tag; `out` must be exactly `ciphertext.len() - self.tag_len` - // bytes. Returns InvalidTag if the chunk does not authenticate (e.g. it was - // tampered with, or belongs at a different index). - fn decrypt_chunk_at( - &self, + // `ciphertext` includes the trailing tag; `out` must be exactly + // `ciphertext.len() - self.tag_len` bytes. + fn decrypt_chunk( + &mut self, py: pyo3::Python<'_>, - index: u64, ciphertext: &[u8], out: &mut [u8], ) -> CryptographyResult<()> { - let nonce = self.nonces.nonce_for(index)?; + let nonce = self.nonces.next()?; self.aead.decrypt_into( py, CffiBuf::from_bytes(py, &nonce), @@ -450,18 +432,6 @@ struct ChunkedDecryptor { // `None` once finalized, or after any error: a failed context cannot // process more data. state: Option, - // Retained for random-access decryption (`decrypt_range`), which derives - // keys from a header it reads directly from the ciphertext source rather - // than from the streamed input. - key: Vec, - context: Vec, - // The seek cipher, derived and commitment-verified on the first - // `decrypt_range` call and reused for subsequent ranges. - seek_cipher: Option, - // Streaming (`update`/`finalize`) and random-access (`decrypt_range`) - // decryption are mutually exclusive on a single instance. - streaming_used: bool, - seeking_used: bool, } impl ChunkedDecryptor { @@ -474,11 +444,6 @@ impl ChunkedDecryptor { context: context.to_vec(), buf: Vec::with_capacity(HEADER_LEN), }), - key: key.to_vec(), - context: context.to_vec(), - seek_cipher: None, - streaming_used: false, - seeking_used: false, }) } @@ -574,10 +539,6 @@ impl ChunkedDecryptor { py: pyo3::Python<'p>, data: &[u8], ) -> CryptographyResult> { - if self.seeking_used { - return Err(mixing_error()); - } - self.streaming_used = true; let params = self.params; let state = self.active_state()?; let out_len = Self::update_out_len(params, state, data.len()); @@ -598,10 +559,6 @@ impl ChunkedDecryptor { data: &[u8], out: &mut [u8], ) -> CryptographyResult { - if self.seeking_used { - return Err(mixing_error()); - } - self.streaming_used = true; let params = self.params; let state = self.active_state()?; let out_len = Self::update_out_len(params, state, data.len()); @@ -623,10 +580,6 @@ impl ChunkedDecryptor { &mut self, py: pyo3::Python<'p>, ) -> CryptographyResult> { - if self.seeking_used { - return Err(mixing_error()); - } - self.streaming_used = true; // Whether it succeeds or fails, finalization consumes the state: no // further data may be processed. match self.state.take() { @@ -654,34 +607,75 @@ impl ChunkedDecryptor { } } } +} + +// Random-access decryption of a single message. Unlike ChunkedDecryptor this +// holds no per-message stream state: each call reads, authenticates, and +// returns an independent range. +struct RangeDecryptor { + params: &'static AeadParams, + key: Vec, + context: Vec, + // The derived cipher, cached alongside the header it came from so that + // repeated ranges of one message skip the key derivation while a reader + // for a *different* message is still handled correctly. + cached: Option<([u8; HEADER_LEN], ChunkCipher)>, +} + +impl RangeDecryptor { + fn new(params: &'static AeadParams, key: &[u8], context: &[u8]) -> CryptographyResult { + check_key_length(params, key)?; + Ok(RangeDecryptor { + params, + key: key.to_vec(), + context: context.to_vec(), + cached: None, + }) + } + + // Derives the message's keys from `header` and verifies the commitment, + // reusing the cached cipher when the header is unchanged. + fn ensure_cipher( + &mut self, + py: pyo3::Python<'_>, + header: &[u8], + ) -> CryptographyResult<&ChunkCipher> { + let fresh = match &self.cached { + Some((cached_header, _)) => cached_header != header, + None => true, + }; + if fresh { + let (salt, commitment) = header.split_at(SALT_LEN); + let keys = derive_keys(py, self.params, &self.key, salt, &self.context)?; + // The commitment is checked before any plaintext is produced. + if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { + return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); + } + let cipher = ChunkCipher::new(py, self.params, &keys)?; + let mut owned = [0; HEADER_LEN]; + owned.copy_from_slice(header); + self.cached = Some((owned, cipher)); + } + Ok(&self.cached.as_ref().expect("just populated").1) + } - // Random-access decryption. Returns the authenticated plaintext bytes in - // `[offset, offset + length)`, reading only the ciphertext chunks that - // cover that range from `source`. + // Returns the authenticated plaintext bytes in `[offset, offset + length)`. // - // `source` is either a buffer-protocol object (e.g. bytes or an mmap) or a - // binary file-like object exposing `seek(pos)` and `read(n)`. The requested - // range is silently expanded to whole 16 KiB chunk boundaries so that every - // chunk touched is authenticated by its AEAD tag; only after that check - // passes is the requested sub-range sliced out and returned. Unauthenticated - // bytes are never returned. + // The requested range is silently widened to whole chunk boundaries so that + // every chunk it touches is authenticated by its AEAD tag; only then is the + // requested sub-range sliced out. Unauthenticated bytes are never returned. // // Note: a range read authenticates the bytes it returns, but not the // message as a whole. It cannot detect truncation of chunks beyond the // requested range, so the total plaintext length must come from a trusted - // source, not be inferred from the (possibly truncated) ciphertext. + // source, not be inferred from the ciphertext. fn decrypt_range<'p>( &mut self, py: pyo3::Python<'p>, - source: &pyo3::Bound<'p, pyo3::PyAny>, + reader: &pyo3::Bound<'p, pyo3::PyAny>, offset: u64, length: usize, ) -> CryptographyResult> { - if self.streaming_used { - return Err(mixing_error()); - } - self.seeking_used = true; - if length == 0 { return Ok(pyo3::types::PyBytes::new(py, &[])); } @@ -700,30 +694,18 @@ impl ChunkedDecryptor { let last_chunk = last_byte / CHUNK_SIZE as u64; let n_chunks = (last_chunk - first_chunk + 1) as usize; - // A buffer-protocol source (bytes, mmap, ...) is indexed directly; any - // other object is treated as a seek()/read() file-like. - let as_buffer = source.extract::>().ok(); - - // Derive the seek cipher (and verify the commitment) once, from the - // 56-byte header at the front of the ciphertext. - if self.seek_cipher.is_none() { - let header = read_at(py, source, as_buffer.as_ref(), 0, HEADER_LEN)?; - if header.len() < HEADER_LEN { - return Err(truncated_error()); - } - let (salt, commitment) = header.split_at(SALT_LEN); - let keys = derive_keys(py, params, &self.key, salt, &self.context)?; - if !cryptography_crypto::constant_time::bytes_eq(&keys.commitment, commitment) { - return Err(CryptographyError::from(exceptions::InvalidTag::new_err(()))); - } - self.seek_cipher = Some(ChunkCipher::new(py, params, &keys)?); + // The header is re-read on every call: it is what binds the returned + // plaintext to this particular message. + let header = range_read(py, reader, 0, HEADER_LEN)?; + if header.len() < HEADER_LEN { + return Err(truncated_error()); } - let cipher = self.seek_cipher.as_ref().unwrap(); + let cipher = self.ensure_cipher(py, &header)?; - // Read the contiguous ciphertext window covering the requested chunks in - // a single read, so a multi-chunk range is one round trip to `source`. + // Read the contiguous ciphertext window covering the requested chunks + // in one request, so a multi-chunk range is a single round trip. let ct_start = HEADER_LEN as u64 + first_chunk * wire as u64; - let data = read_at(py, source, as_buffer.as_ref(), ct_start, n_chunks * wire)?; + let data = range_read(py, reader, ct_start, n_chunks * wire)?; // Decrypt whole chunks, releasing plaintext only for those that // authenticate. Any trailing partial wire chunk is the message's short @@ -756,7 +738,7 @@ impl ChunkedDecryptor { // Slice out the requested range. If the authenticated plaintext does // not reach `offset + length`, the message is shorter than the request - // (truncated, or the caller read past the end); either way we have no + // (truncated, or the caller read past the end); either way there are no // authenticated bytes to return there. let intra = (offset - first_chunk * CHUNK_SIZE as u64) as usize; let end = intra + length; @@ -856,16 +838,6 @@ impl Cobblestone128Decryptor { ) -> CryptographyResult> { self.inner.finalize(py) } - - fn decrypt_range<'p>( - &mut self, - py: pyo3::Python<'p>, - source: pyo3::Bound<'p, pyo3::PyAny>, - offset: u64, - length: usize, - ) -> CryptographyResult> { - self.inner.decrypt_range(py, &source, offset, length) - } } #[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] @@ -957,15 +929,55 @@ impl Cobblestone256Decryptor { ) -> CryptographyResult> { self.inner.finalize(py) } +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] +pub(crate) struct Cobblestone128RangeDecryptor { + inner: RangeDecryptor, +} + +#[pyo3::pymethods] +impl Cobblestone128RangeDecryptor { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + Ok(Cobblestone128RangeDecryptor { + inner: RangeDecryptor::new(&AES_128_GCM, key.as_bytes(), context.as_bytes())?, + }) + } + + fn decrypt_range<'p>( + &mut self, + py: pyo3::Python<'p>, + reader: pyo3::Bound<'p, pyo3::PyAny>, + offset: u64, + length: usize, + ) -> CryptographyResult> { + self.inner.decrypt_range(py, &reader, offset, length) + } +} + +#[pyo3::pyclass(module = "cryptography.hazmat.bindings._rust.cobblestone")] +pub(crate) struct Cobblestone256RangeDecryptor { + inner: RangeDecryptor, +} + +#[pyo3::pymethods] +impl Cobblestone256RangeDecryptor { + #[new] + fn new(key: CffiBuf<'_>, context: CffiBuf<'_>) -> CryptographyResult { + Ok(Cobblestone256RangeDecryptor { + inner: RangeDecryptor::new(&AES_256_GCM, key.as_bytes(), context.as_bytes())?, + }) + } fn decrypt_range<'p>( &mut self, py: pyo3::Python<'p>, - source: pyo3::Bound<'p, pyo3::PyAny>, + reader: pyo3::Bound<'p, pyo3::PyAny>, offset: u64, length: usize, ) -> CryptographyResult> { - self.inner.decrypt_range(py, &source, offset, length) + self.inner.decrypt_range(py, &reader, offset, length) } } @@ -974,8 +986,8 @@ impl Cobblestone256Decryptor { pub(crate) mod cobblestone_mod { #[pymodule_export] use super::{ - Cobblestone128Decryptor, Cobblestone128Encryptor, Cobblestone256Decryptor, - Cobblestone256Encryptor, + Cobblestone128Decryptor, Cobblestone128Encryptor, Cobblestone128RangeDecryptor, + Cobblestone256Decryptor, Cobblestone256Encryptor, Cobblestone256RangeDecryptor, }; } diff --git a/tests/test_cobblestone.py b/tests/test_cobblestone.py index c19b9c4fe6e8..fc1a17c6cf83 100644 --- a/tests/test_cobblestone.py +++ b/tests/test_cobblestone.py @@ -3,17 +3,21 @@ # for complete details. -import io import mmap import os import pytest +from cryptography import cobblestone as cobblestone_module from cryptography.cobblestone import ( + BufferReader, Cobblestone128Decryptor, Cobblestone128Encryptor, + Cobblestone128RangeDecryptor, Cobblestone256Decryptor, Cobblestone256Encryptor, + Cobblestone256RangeDecryptor, + FileReader, ) from cryptography.exceptions import AlreadyFinalized, InvalidTag @@ -397,42 +401,38 @@ def test_accepts_buffers(self, variant): MAX_CHUNK_COUNT = 1 << 38 +RANGE_VARIANTS = [ + pytest.param( + (Cobblestone128Encryptor, Cobblestone128RangeDecryptor), + id="cobblestone128", + ), + pytest.param( + (Cobblestone256Encryptor, Cobblestone256RangeDecryptor), + id="cobblestone256", + ), +] + + +class _CountingReader: + """A minimal RangeReader, standing in for remote range fetches. -class _RangeFetcher: - """A minimal seek()/read() source, standing in for remote range fetches.""" + ``max_read`` caps how much a single ``read_at`` returns, so that callers + which need more must issue further reads. + """ - def __init__(self, data: bytes, none_at_eof: bool = False): + def __init__(self, data: bytes, max_read: int | None = None): self._data = bytes(data) - self._pos = 0 + self._max_read = max_read self.reads = 0 - # Some streams return None (rather than b"") to signal no data. - self._none_at_eof = none_at_eof - def seek(self, offset, whence=0): - assert whence == 0 - self._pos = offset - return self._pos - - def read(self, size): + def read_at(self, offset, length): self.reads += 1 - chunk = self._data[self._pos : self._pos + size] - self._pos += len(chunk) - if not chunk and self._none_at_eof: - return None - return chunk - + if self._max_read is not None: + length = min(length, self._max_read) + return self._data[offset : offset + length] -def _buffer_and_filelike_sources(ciphertext: bytes): - # Buffer-protocol sources take the direct-indexing path; io.BytesIO and the - # range fetcher exercise the seek()/read() path. - yield "bytes", bytes(ciphertext) - yield "bytearray", bytearray(ciphertext) - yield "memoryview", memoryview(bytes(ciphertext)) - yield "bytesio", io.BytesIO(ciphertext) - yield "range_fetcher", _RangeFetcher(ciphertext) - -SEEK_RANGES = [ +RANGES = [ (0, 10), # start of the first chunk (100, 50), # within the first chunk (CHUNK_SIZE - 10, 20), # spanning the 0/1 chunk boundary @@ -444,106 +444,167 @@ def _buffer_and_filelike_sources(ciphertext: bytes): ] -@pytest.mark.parametrize("variant", VARIANTS) -class TestCobblestoneSeekable: +@pytest.mark.parametrize("variant", RANGE_VARIANTS) +class TestCobblestoneRangeDecryptor: def _encrypt(self, variant, context, plaintext): - encryptor_cls, _, _ = variant + encryptor_cls, _ = variant key = encryptor_cls.generate_key() return key, _encrypt_all(encryptor_cls, key, context, plaintext) - @pytest.mark.parametrize("offset,length", SEEK_RANGES) + @pytest.mark.parametrize("offset,length", RANGES) def test_decrypt_range_matches_plaintext(self, variant, offset, length): - _, decryptor_cls, _ = variant - context = b"seekable context" + _, range_cls = variant + context = b"range context" plaintext = os.urandom(3 * CHUNK_SIZE + 5000) key, ciphertext = self._encrypt(variant, context, plaintext) expected = plaintext[offset : offset + length] - for source_id, source in _buffer_and_filelike_sources(ciphertext): - dec = decryptor_cls(key, context) - assert dec.decrypt_range(source, offset, length) == expected, ( - source_id - ) + readers = [ + BufferReader(ciphertext), + BufferReader(bytearray(ciphertext)), + BufferReader(memoryview(ciphertext)), + _CountingReader(ciphertext), + ] + for reader in readers: + dec = range_cls(key, context) + assert dec.decrypt_range(reader, offset, length) == expected def test_decrypt_range_whole_message(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(2 * CHUNK_SIZE + 1234) key, ciphertext = self._encrypt(variant, b"", plaintext) - dec = decryptor_cls(key, b"") - assert dec.decrypt_range(ciphertext, 0, len(plaintext)) == plaintext + dec = range_cls(key, b"") + result = dec.decrypt_range(BufferReader(ciphertext), 0, len(plaintext)) + assert result == plaintext def test_decrypt_range_on_exact_chunk_boundary_message(self, variant): # A message whose length is a multiple of CHUNK_SIZE has an empty # final chunk; ranges not touching it still decrypt. - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(2 * CHUNK_SIZE) key, ciphertext = self._encrypt(variant, b"", plaintext) - dec = decryptor_cls(key, b"") - assert dec.decrypt_range(ciphertext, 0, 2 * CHUNK_SIZE) == plaintext + dec = range_cls(key, b"") + reader = BufferReader(ciphertext) + assert dec.decrypt_range(reader, 0, 2 * CHUNK_SIZE) == plaintext assert ( - dec.decrypt_range(ciphertext, 2 * CHUNK_SIZE - 5, 5) - == plaintext[-5:] + dec.decrypt_range(reader, 2 * CHUNK_SIZE - 5, 5) == plaintext[-5:] ) - def test_decrypt_range_via_mmap(self, variant): - _, decryptor_cls, _ = variant + def test_buffer_reader_over_mmap(self, variant): + _, range_cls = variant plaintext = os.urandom(2 * CHUNK_SIZE + 777) key, ciphertext = self._encrypt(variant, b"ctx", plaintext) mm = mmap.mmap(-1, len(ciphertext)) try: mm[:] = ciphertext - dec = decryptor_cls(key, b"ctx") - assert ( - dec.decrypt_range(mm, CHUNK_SIZE - 3, 10) - == plaintext[CHUNK_SIZE - 3 : CHUNK_SIZE + 7] - ) + dec = range_cls(key, b"ctx") + result = dec.decrypt_range(BufferReader(mm), CHUNK_SIZE - 3, 10) + assert result == plaintext[CHUNK_SIZE - 3 : CHUNK_SIZE + 7] finally: mm.close() + def test_file_reader(self, variant, tmp_path): + _, range_cls = variant + plaintext = os.urandom(2 * CHUNK_SIZE + 999) + key, ciphertext = self._encrypt(variant, b"", plaintext) + path = tmp_path / "message.bin" + path.write_bytes(ciphertext) + + with path.open("rb") as f: + dec = range_cls(key, b"") + reader = FileReader(f) + result = dec.decrypt_range(reader, CHUNK_SIZE - 4, 12) + assert result == plaintext[CHUNK_SIZE - 4 : CHUNK_SIZE + 8] + assert dec.decrypt_range(reader, 0, 20) == plaintext[:20] + + def test_file_reader_without_pread(self, variant, tmp_path, monkeypatch): + # Platforms without os.pread fall back to a locked seek/read. + monkeypatch.setattr(cobblestone_module, "_HAS_PREAD", False) + _, range_cls = variant + plaintext = os.urandom(CHUNK_SIZE + 50) + key, ciphertext = self._encrypt(variant, b"", plaintext) + path = tmp_path / "message.bin" + path.write_bytes(ciphertext) + + with path.open("rb") as f: + dec = range_cls(key, b"") + result = dec.decrypt_range(FileReader(f), CHUNK_SIZE, 50) + assert result == plaintext[CHUNK_SIZE:] + + def test_reader_returning_short_reads(self, variant): + # A reader that returns less than asked is read from repeatedly. + _, range_cls = variant + plaintext = os.urandom(2 * CHUNK_SIZE) + key, ciphertext = self._encrypt(variant, b"", plaintext) + reader = _CountingReader(ciphertext, max_read=1000) + dec = range_cls(key, b"") + assert ( + dec.decrypt_range(reader, 0, CHUNK_SIZE) == plaintext[:CHUNK_SIZE] + ) + def test_zero_length_returns_empty(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant key, ciphertext = self._encrypt(variant, b"", b"some data") - dec = decryptor_cls(key, b"") - # No source access is required for a zero-length range. - assert dec.decrypt_range(b"", 0, 0) == b"" - assert dec.decrypt_range(ciphertext, 3, 0) == b"" + dec = range_cls(key, b"") + reader = _CountingReader(ciphertext) + assert dec.decrypt_range(reader, 0, 0) == b"" + assert dec.decrypt_range(reader, 3, 0) == b"" + # A zero-length range needs no ciphertext at all. + assert reader.reads == 0 def test_instance_is_reusable_for_many_ranges(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(3 * CHUNK_SIZE) key, ciphertext = self._encrypt(variant, b"", plaintext) - dec = decryptor_cls(key, b"") - assert dec.decrypt_range(ciphertext, 10, 20) == plaintext[10:30] + dec = range_cls(key, b"") + reader = BufferReader(ciphertext) + assert dec.decrypt_range(reader, 10, 20) == plaintext[10:30] assert ( - dec.decrypt_range(ciphertext, 2 * CHUNK_SIZE, 100) + dec.decrypt_range(reader, 2 * CHUNK_SIZE, 100) == plaintext[2 * CHUNK_SIZE : 2 * CHUNK_SIZE + 100] ) + def test_instance_reused_across_different_messages(self, variant): + # Each call re-reads the header, so one decryptor may serve readers + # for different messages (which have different salts). + _, range_cls = variant + first = os.urandom(CHUNK_SIZE + 10) + second = os.urandom(CHUNK_SIZE + 10) + encryptor_cls, _ = variant + key = encryptor_cls.generate_key() + ct1 = _encrypt_all(encryptor_cls, key, b"ctx", first) + ct2 = _encrypt_all(encryptor_cls, key, b"ctx", second) + + dec = range_cls(key, b"ctx") + assert dec.decrypt_range(BufferReader(ct1), 0, 100) == first[:100] + assert dec.decrypt_range(BufferReader(ct2), 0, 100) == second[:100] + assert dec.decrypt_range(BufferReader(ct1), 0, 100) == first[:100] + def test_multi_chunk_range_is_a_single_body_read(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(4 * CHUNK_SIZE) key, ciphertext = self._encrypt(variant, b"", plaintext) - fetcher = _RangeFetcher(ciphertext) - dec = decryptor_cls(key, b"") - dec.decrypt_range(fetcher, 100, 2 * CHUNK_SIZE) + reader = _CountingReader(ciphertext) + dec = range_cls(key, b"") + dec.decrypt_range(reader, 100, 2 * CHUNK_SIZE) # One read for the header, one contiguous read for the covering chunks. - assert fetcher.reads == 2 + assert reader.reads == 2 def test_wrong_key_rejected(self, variant): - encryptor_cls, decryptor_cls, _ = variant + encryptor_cls, range_cls = variant _, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE + 1)) - dec = decryptor_cls(encryptor_cls.generate_key(), b"") + dec = range_cls(encryptor_cls.generate_key(), b"") with pytest.raises(InvalidTag): - dec.decrypt_range(ciphertext, 0, 10) + dec.decrypt_range(BufferReader(ciphertext), 0, 10) def test_wrong_context_rejected(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant key, ciphertext = self._encrypt( variant, b"context a", os.urandom(CHUNK_SIZE + 1) ) - dec = decryptor_cls(key, b"context b") + dec = range_cls(key, b"context b") with pytest.raises(InvalidTag): - dec.decrypt_range(ciphertext, 0, 10) + dec.decrypt_range(BufferReader(ciphertext), 0, 10) @pytest.mark.parametrize( "position", @@ -555,126 +616,159 @@ def test_wrong_context_rejected(self, variant): ], ) def test_tampering_in_range_detected(self, variant, position): - _, decryptor_cls, _ = variant + _, range_cls = variant key, ciphertext = self._encrypt( variant, b"", os.urandom(CHUNK_SIZE + 100) ) ciphertext = bytearray(ciphertext) ciphertext[position] ^= 1 - dec = decryptor_cls(key, b"") + dec = range_cls(key, b"") with pytest.raises(InvalidTag): - dec.decrypt_range(bytes(ciphertext), 0, 100) + dec.decrypt_range(BufferReader(bytes(ciphertext)), 0, 100) def test_reading_past_end_rejected(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(CHUNK_SIZE + 500) key, ciphertext = self._encrypt(variant, b"", plaintext) - dec = decryptor_cls(key, b"") + dec = range_cls(key, b"") + reader = BufferReader(ciphertext) # Starting exactly at the end, and overshooting the end, both fail # rather than returning short: past the authenticated end there are no # bytes to return. with pytest.raises(InvalidTag): - dec.decrypt_range(ciphertext, len(plaintext), 1) + dec.decrypt_range(reader, len(plaintext), 1) with pytest.raises(InvalidTag): - dec.decrypt_range(ciphertext, len(plaintext) - 2, 5) + dec.decrypt_range(reader, len(plaintext) - 2, 5) def test_truncated_source_in_range_rejected(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant plaintext = os.urandom(2 * CHUNK_SIZE + 10) key, ciphertext = self._encrypt(variant, b"", plaintext) # Drop everything after the first chunk. - truncated = ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE] + reader = BufferReader(ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE]) - dec = decryptor_cls(key, b"") + dec = range_cls(key, b"") # A range served entirely by the surviving first chunk still succeeds: - # decrypt_range cannot detect truncation beyond the requested range. - assert dec.decrypt_range(truncated, 0, 100) == plaintext[:100] + # a range read cannot detect truncation beyond the requested range. + assert dec.decrypt_range(reader, 0, 100) == plaintext[:100] # A range needing a dropped chunk is rejected. with pytest.raises(InvalidTag): - dec.decrypt_range(truncated, CHUNK_SIZE, 10) + dec.decrypt_range(reader, CHUNK_SIZE, 10) + + def test_short_header_rejected(self, variant): + # A source too short to even hold the 56-byte header is truncated. + _, range_cls = variant + key, _ = self._encrypt(variant, b"", b"data") + dec = range_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(BufferReader(b"\x00" * (HEADER_LEN - 1)), 0, 1) + + def test_final_chunk_shorter_than_tag_rejected(self, variant): + # A final wire chunk shorter than the tag cannot be authenticated. + _, range_cls = variant + key, ciphertext = self._encrypt(variant, b"", os.urandom(100)) + # Keep the full header, but leave only a few (< TAG_LEN) body bytes. + reader = BufferReader(ciphertext[: HEADER_LEN + TAG_LEN - 1]) + dec = range_cls(key, b"") + with pytest.raises(InvalidTag): + dec.decrypt_range(reader, 0, 1) def test_too_large_offset_rejected(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant key, ciphertext = self._encrypt(variant, b"", b"data") - dec = decryptor_cls(key, b"") + dec = range_cls(key, b"") with pytest.raises(ValueError): - dec.decrypt_range(ciphertext, MAX_CHUNK_COUNT * CHUNK_SIZE, 1) + dec.decrypt_range( + BufferReader(ciphertext), MAX_CHUNK_COUNT * CHUNK_SIZE, 1 + ) def test_negative_arguments_rejected(self, variant): - _, decryptor_cls, _ = variant + _, range_cls = variant key, ciphertext = self._encrypt(variant, b"", b"data") - dec = decryptor_cls(key, b"") + dec = range_cls(key, b"") + reader = BufferReader(ciphertext) with pytest.raises((ValueError, OverflowError)): - dec.decrypt_range(ciphertext, -1, 1) + dec.decrypt_range(reader, -1, 1) with pytest.raises((ValueError, OverflowError)): - dec.decrypt_range(ciphertext, 0, -1) - - def test_invalid_source_type_rejected(self, variant): - _, decryptor_cls, _ = variant - key, _ = self._encrypt(variant, b"", b"data") - dec = decryptor_cls(key, b"") - with pytest.raises((TypeError, AttributeError)): - dec.decrypt_range(12345, 0, 10) + dec.decrypt_range(reader, 0, -1) - def test_cannot_seek_after_streaming(self, variant): - _, decryptor_cls, _ = variant - key, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE)) - dec = decryptor_cls(key, b"") - dec.update(ciphertext[:HEADER_LEN]) - with pytest.raises(ValueError, match="cannot be used for both"): + def test_reader_without_read_at_rejected(self, variant): + _, range_cls = variant + key, ciphertext = self._encrypt(variant, b"", b"data") + dec = range_cls(key, b"") + # A bytes-like is not a reader: it must be wrapped in a BufferReader. + with pytest.raises(AttributeError): dec.decrypt_range(ciphertext, 0, 10) - def test_cannot_stream_after_seeking(self, variant): - _, decryptor_cls, _ = variant - key, ciphertext = self._encrypt(variant, b"", os.urandom(CHUNK_SIZE)) - dec = decryptor_cls(key, b"") - dec.decrypt_range(ciphertext, 0, 10) - with pytest.raises(ValueError, match="cannot be used for both"): - dec.update(ciphertext) - with pytest.raises(ValueError, match="cannot be used for both"): - dec.update_into(ciphertext, bytearray(2 * CHUNK_SIZE)) - with pytest.raises(ValueError, match="cannot be used for both"): - dec.finalize() - - def test_final_chunk_read_via_none_returning_source(self, variant): - # A file-like source that signals end-of-input with None (rather than - # b"") is handled: reading the short final chunk asks past its end. - _, decryptor_cls, _ = variant - plaintext = os.urandom(CHUNK_SIZE + 1234) - key, ciphertext = self._encrypt(variant, b"", plaintext) - source = _RangeFetcher(ciphertext, none_at_eof=True) - dec = decryptor_cls(key, b"") - result = dec.decrypt_range(source, CHUNK_SIZE, 1234) - assert result == plaintext[CHUNK_SIZE:] - - def test_short_header_rejected(self, variant): - # A source too short to even hold the 56-byte header is truncated. - _, decryptor_cls, _ = variant + def test_reader_returning_non_buffer_rejected(self, variant): + _, range_cls = variant key, _ = self._encrypt(variant, b"", b"data") - dec = decryptor_cls(key, b"") - with pytest.raises(InvalidTag): - dec.decrypt_range(b"\x00" * (HEADER_LEN - 1), 0, 1) - def test_final_chunk_shorter_than_tag_rejected(self, variant): - # A final wire chunk shorter than the tag cannot be authenticated. - _, decryptor_cls, _ = variant - key, ciphertext = self._encrypt(variant, b"", os.urandom(100)) - # Keep the full header, but leave only a few (< TAG_LEN) body bytes. - truncated = ciphertext[: HEADER_LEN + TAG_LEN - 1] - dec = decryptor_cls(key, b"") - with pytest.raises(InvalidTag): - dec.decrypt_range(truncated, 0, 1) + class BadReader: + def read_at(self, offset, length): + return "not bytes" - def test_seeking_instance_survives_out_of_range_error(self, variant): - # Unlike the streaming decryptor, a random-access failure (here, a - # read past the end) does not poison the instance. - _, decryptor_cls, _ = variant - plaintext = os.urandom(CHUNK_SIZE + 200) - key, ciphertext = self._encrypt(variant, b"", plaintext) - dec = decryptor_cls(key, b"") - with pytest.raises(InvalidTag): - dec.decrypt_range(ciphertext, len(plaintext), 1) - assert dec.decrypt_range(ciphertext, 0, 50) == plaintext[:50] + dec = range_cls(key, b"") + with pytest.raises(TypeError): + dec.decrypt_range(BadReader(), 0, 10) + + def test_invalid_key_size(self, variant): + encryptor_cls, range_cls = variant + key_len = len(encryptor_cls.generate_key()) + for length in [0, key_len - 1, key_len + 1, 64]: + with pytest.raises(ValueError): + range_cls(b"\x00" * length, b"") + + def test_streaming_decryptor_has_no_decrypt_range(self, variant): + # Random access lives on its own class: the streaming decryptor is + # unchanged, so the two modes cannot be interleaved on one object. + _, range_cls = variant + assert not hasattr(Cobblestone128Decryptor, "decrypt_range") + assert not hasattr(Cobblestone256Decryptor, "decrypt_range") + assert not hasattr(range_cls, "update") + assert not hasattr(range_cls, "finalize") + + +class TestRangeReaders: + def test_buffer_reader_read_at(self): + reader = BufferReader(b"0123456789") + assert bytes(reader.read_at(0, 4)) == b"0123" + assert bytes(reader.read_at(4, 3)) == b"456" + # Reads past the end are short rather than an error. + assert bytes(reader.read_at(8, 10)) == b"89" + assert bytes(reader.read_at(20, 4)) == b"" + + def test_file_reader_read_at(self, tmp_path): + path = tmp_path / "data.bin" + path.write_bytes(b"0123456789") + with path.open("rb") as f: + reader = FileReader(f) + assert bytes(reader.read_at(0, 4)) == b"0123" + assert bytes(reader.read_at(4, 3)) == b"456" + assert bytes(reader.read_at(20, 4)) == b"" + + def test_file_reader_read_at_without_pread(self, tmp_path, monkeypatch): + monkeypatch.setattr(cobblestone_module, "_HAS_PREAD", False) + path = tmp_path / "data.bin" + path.write_bytes(b"0123456789") + with path.open("rb") as f: + reader = FileReader(f) + assert bytes(reader.read_at(0, 4)) == b"0123" + assert bytes(reader.read_at(4, 3)) == b"456" + + @pytest.mark.skipif( + not cobblestone_module._HAS_PREAD, reason="platform has no os.pread" + ) + def test_file_reader_does_not_disturb_cursor(self, tmp_path): + # With pread the file position is untouched, which is what allows one + # reader to serve concurrent ranges. + path = tmp_path / "data.bin" + path.write_bytes(b"0123456789") + with path.open("rb") as f: + f.seek(2) + reader = FileReader(f) + assert bytes(reader.read_at(6, 2)) == b"67" + assert f.tell() == 2 class TestVariantsAreDistinct: From 160c6a8569424fc0d622a450c1a54ace4d112054 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 21:00:26 +0000 Subject: [PATCH 6/6] Fix Python 3.9 compatibility in the range decryption code Two problems, both caught only on 3.9 and older targets: - tests/test_cobblestone.py used PEP 604 union syntax (int | None) in an annotation without `from __future__ import annotations`. On 3.9, annotations are evaluated at runtime, so importing the module raised TypeError and the whole file failed to collect. Add the future import. - BufferReader's parameter was annotated as Buffer, which names only bytes, bytearray and memoryview. mmap.mmap supports the buffer protocol and is documented as usable here, but is not one of those types, so passing one was a type error. Widen the annotation to include it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6 --- src/cryptography/cobblestone.py | 5 ++++- tests/test_cobblestone.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cryptography/cobblestone.py b/src/cryptography/cobblestone.py index 113f7edb8ff1..14b9ba04d637 100644 --- a/src/cryptography/cobblestone.py +++ b/src/cryptography/cobblestone.py @@ -4,6 +4,7 @@ from __future__ import annotations +import mmap import os import threading import typing @@ -50,7 +51,9 @@ class BufferReader: into memory. """ - def __init__(self, data: Buffer) -> None: + # mmap.mmap supports the buffer protocol but is not one of the concrete + # types Buffer names, so it is spelled out here. + def __init__(self, data: Buffer | mmap.mmap) -> None: self._data = memoryview(data).cast("B") def read_at(self, offset: int, length: int) -> Buffer: diff --git a/tests/test_cobblestone.py b/tests/test_cobblestone.py index fc1a17c6cf83..38fd6ad218c7 100644 --- a/tests/test_cobblestone.py +++ b/tests/test_cobblestone.py @@ -3,6 +3,8 @@ # for complete details. +from __future__ import annotations + import mmap import os