Skip to content

Add random-access decryption to Cobblestone#15322

Open
reaperhulk wants to merge 4 commits into
mainfrom
claude/cobblestone-seekable-decryption-g5vzcp
Open

Add random-access decryption to Cobblestone#15322
reaperhulk wants to merge 4 commits into
mainfrom
claude/cobblestone-seekable-decryption-g5vzcp

Conversation

@reaperhulk

Copy link
Copy Markdown
Member

Implements decrypt_range() method for Cobblestone128Decryptor and Cobblestone256Decryptor, enabling efficient decryption of arbitrary byte ranges from large messages without processing the entire ciphertext.

Summary

This change adds random-access decryption capabilities to the Cobblestone chunked encryption implementation. The new decrypt_range() method allows decrypting a specific range of plaintext bytes by reading and authenticating only the 16 KiB chunks that cover that range, making it practical to work with large encrypted files without loading them entirely into memory.

Key Changes

  • New decrypt_range() method: Added to both Cobblestone128Decryptor and Cobblestone256Decryptor classes. Takes a source (buffer or file-like object), offset, and length; returns authenticated plaintext for the requested range.

  • Helper functions and utilities:

    • read_at(): Reads bytes from either buffer-protocol objects (direct indexing) or file-like objects (via seek/read)
    • mixing_error() and truncated_error(): Error helpers for invalid usage patterns and truncation detection
    • ChunkCipher::decrypt_chunk_at(): Decrypts a single chunk at an explicit index without advancing the sequential counter
    • ChunkNonces::nonce_for(): Stateless nonce derivation for arbitrary chunk indices
  • State tracking: Added fields to ChunkedDecryptor to:

    • Retain key and context for deriving the seek cipher on first decrypt_range() call
    • Cache the seek cipher for reuse across multiple range reads
    • Track whether streaming (update/finalize) or seeking (decrypt_range) has been used to enforce mutual exclusivity
  • Mutual exclusivity enforcement: Streaming and random-access decryption cannot be mixed on a single instance; attempting to do so raises a ValueError.

  • Comprehensive test coverage: Added 20+ test cases covering:

    • Various range positions and sizes (chunk boundaries, interior ranges, final chunks)
    • Multiple source types (bytes, bytearray, memoryview, BytesIO, custom file-like objects, mmap)
    • Error conditions (wrong key/context, tampering, truncation, out-of-range reads)
    • Instance reusability and mutual exclusivity constraints
  • Documentation: Added detailed docstrings, type hints in .pyi file, and comprehensive user documentation with examples showing both buffer and file-like usage patterns, including a remote range-fetch adapter example.

Implementation Details

  • The seek cipher is derived from a 56-byte header (salt + commitment) read directly from the ciphertext source on the first decrypt_range() call and cached for subsequent calls.
  • Requested ranges are silently expanded to whole chunk boundaries for authentication, then the requested sub-range is sliced from the authenticated plaintext.
  • The implementation supports both buffer-protocol objects (bytes, mmap, etc.) for direct indexing and binary file-like objects implementing seek() and read() for remote or streaming sources.
  • Multi-chunk ranges are read in a single contiguous operation for efficiency.
  • Range reads authenticate the bytes they return but cannot detect truncation beyond the requested range; total message length must come from a trusted source.

https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6

claude added 4 commits July 21, 2026 19:11
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6
Fixes an E501 (line too long) flagged by ruff in a test added in the
previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rPrpx6h454zpXTGdzez6
Comment thread docs/cobblestone.rst
>>> 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this be an instance method along-side regular decrypt seems super fraught, what happens when you interleave calls?

Comment thread docs/cobblestone.rst
Comment on lines +199 to +205
: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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a) We should figure out if it really makes sense to do both bytes-like AND file-like. Certainly doing both of them in one-API isn't our usual style. I think you can always turn a bytes in a file-like with io.BytesIO (though maybe that doesn't work for arbitrary buffers?)

b) Gah, is there no python API for pread so we can avoid a reliance on seek? Seek is annoying because it blocks any sort of concurrency.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants