Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ Changelog
Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
chunked-encryption specification
<https://c2sp.org/chunked-encryption>`_ for streaming authenticated
encryption of large messages.
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.
Expand Down
191 changes: 191 additions & 0 deletions docs/cobblestone.rst
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,195 @@ that mandate 256-bit keys).
messages produced by :class:`Cobblestone256Encryptor` with a
32-byte key.

.. class:: Cobblestone128RangeDecryptor(key, context)

.. 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 <cobblestone-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``.

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.

.. 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 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 the ciphertext is truncated
within the requested range (which includes reading past the
end of the message).
:raises ValueError: If ``offset`` or ``length`` is negative, or
the range exceeds the maximum message length.

.. class:: Cobblestone256RangeDecryptor(key, context)

.. versionadded:: 50.0.0

Exactly like :class:`Cobblestone128RangeDecryptor`, but decrypts
ranges of messages produced by :class:`Cobblestone256Encryptor` with
a 32-byte key.

.. _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. A
:class:`Cobblestone128RangeDecryptor` reads only the chunks covering the
requested range, authenticates each of them, and returns just the bytes
asked for.

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::

>>> from cryptography.cobblestone import (
... 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 = Cobblestone128RangeDecryptor(key, context=b"ranged")
>>> decryptor.decrypt_range(BufferReader(ciphertext), 40005, 9)
b'brown fox'
>>> _ == plaintext[40005:40014]
True

.. 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 S3Reader:
def __init__(self, client, bucket, key):
self._client = client
self._bucket = bucket
self._key = key

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::

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
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Decapsulate
Decapsulation
declaratively
decrypt
decryptor
Decryptor
decrypts
Decrypts
Expand Down
74 changes: 74 additions & 0 deletions src/cryptography/cobblestone.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,92 @@

from __future__ import annotations

import mmap
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.
"""

# 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:
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",
]
19 changes: 19 additions & 0 deletions src/cryptography/hazmat/bindings/_rust/cobblestone.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
# 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 _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: ...
@staticmethod
Expand All @@ -18,6 +25,12 @@ class Cobblestone128Decryptor:
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, reader: _RangeReader, offset: int, length: int
) -> bytes: ...

class Cobblestone256Encryptor:
def __init__(self, key: Buffer, context: Buffer) -> None: ...
@staticmethod
Expand All @@ -31,3 +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, reader: _RangeReader, offset: int, length: int
) -> bytes: ...
Loading