crypto: feed long messages to the AEAD ciphers in chunks - #10409
Conversation
EVP_EncryptUpdate() / EVP_DecryptUpdate() take the input length as a C int, and the AEAD ciphers passed the whole message in one call, with all lengths held in C ints: a message of 2 GiB or more failed with SystemError (negative size for the result allocation), and above 4 GiB the payload was silently truncated to its length modulo 2^32. Feed the input (data as well as AAD) to OpenSSL in chunks of at most 1 GiB and keep every byte count in a Py_ssize_t. Both AEAD modes (OCB, chacha20-poly1305) stream, so chunked calls give the same result as a single one; the chunk size is a module variable so a test can shrink it and cover chunk boundaries that fall inside a cipher block. A second test round-trips a 2 GiB message, skipped unless BORG_TESTS_BIG_MEMORY is set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mypy checks the tests against low_level.pyi, so the test hook the chunked AEAD update introduced has to be declared there, too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #10409 +/- ##
==========================================
+ Coverage 88.28% 88.29% +0.01%
==========================================
Files 103 103
Lines 18904 18904
Branches 2937 2937
==========================================
+ Hits 16690 16692 +2
+ Misses 1540 1538 -2
Partials 674 674 ☔ View full report in Codecov by Harness. |
|
Ran the Sizes: Each run: encrypt, check envelope length == 16 + size, decrypt, check plaintext length and that it is all zero.
20 passed in 65 s total, peak memory ~8 GiB for the 4 GiB cases. Too heavy for CI, which is why the in-tree test stays behind scratch test usedimport pytest
from borg.crypto.low_level import AES256_OCB, CHACHA20_POLY1305
SIZES = [0, 2**31 - 1, 2**31, 2**31 + 1, 2**32 - 1, 2**32, 2**32 + 1, 2**30 - 1, 2**30, 2**30 + 1]
@pytest.mark.parametrize("size", SIZES, ids=[str(s) for s in SIZES])
@pytest.mark.parametrize("cipher_cls", [AES256_OCB, CHACHA20_POLY1305])
def test_aead_message_sizes(cipher_cls, size):
data = bytes(size)
cipher = cipher_cls(b"k" * 32, iv=0)
envelope = cipher.encrypt(data)
assert len(envelope) == 16 + size
del data
plaintext = cipher.decrypt(envelope)
assert len(plaintext) == size and plaintext.count(b"\0") == size |
The AEAD ciphers (AES-OCB, ChaCha20-Poly1305) in
crypto/low_level.pyxpassed the whole message toEVP_EncryptUpdate()/EVP_DecryptUpdate()in one call and kept every length in a Cint, although the OpenSSL update calls take anintinput length:SystemError: Negative size passed to PyBytes_FromStringAndSize,Pack objects never get there (they are bounded by
MAX_DATA_SIZE), but with #10406 the per-archive reference caches also go through the key's envelope, and an archive referencing ~60 million objects produces a cache of that size, which would crashborg compactandborg analyzeon every run.This feeds the input (data and AAD) to OpenSSL in chunks of at most 1 GiB and keeps every byte count in a
Py_ssize_t. Both AEAD modes stream, so chunked calls give the same envelope as a single call. The chunk size is a module variable so a test can shrink it to 7 bytes and cover chunk boundaries that fall inside a cipher block (OCB buffers partial blocks across calls); a second test round-trips a 2 GiB message and is skipped unlessBORG_TESTS_BIG_MEMORYis set (it needs ~4 GiB of RAM, ~3-4 s here).The legacy AES-CTR classes used for reading borg 1.x repositories keep their
intlengths: their messages are bounded by borg 1.x's object size limit.🤖 Generated with Claude Code