From ee9be986e172bef81d9bc35e7b292dcc16f8107c Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 22:46:41 +0200 Subject: [PATCH 1/7] crypto: limit the data encrypted with one aes256-ocb session key, #6501 AES-OCB has a birthday-type security bound in the amount of data encrypted using one key: the attacker's advantage is about 6 * sigma^2 / 2^128, sigma being the number of 128bit cipher blocks (Krovetz/Rogaway OCB3, Theorem 1). RFC 7253 derives its "at most 2^48 blocks (4PiB) per key" rule of thumb from that bound, which corresponds to an advantage of 2^-32. We now aim higher and start a new session after 2^37 blocks (2TiB), giving an advantage of about 2^-51, in line with the target probability used in the examples of draft-irtf-cfrg-aead-limits. Starting a new session is cheap (one sha256 for the new session key) and fully transparent, because the session id is part of every chunk header - so old chunks stay decryptable and nothing changes for reading existing repositories. Rekeying that often also is what helps in the multi-key setting: the advantages of the individual session keys just add up, so the quantity that matters over the lifetime of a borg key is sum(sigma_i^2) and not (sum sigma_i)^2. chacha20-poly1305 does not need such a limit: its confidentiality bound does not depend on the amount of data encrypted at all and its integrity bound only limits the number of forgery attempts, counted over all keys. --- docs/changes.rst | 1 + docs/internals/data-structures.rst | 23 +++++++++++++++ src/borg/crypto/key.py | 42 ++++++++++++++++++++++++++- src/borg/testsuite/crypto/key_test.py | 31 ++++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index d2dd33fde9..41e507f7ca 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -191,6 +191,7 @@ Fixes: Other changes: +- crypto: start a new session after encrypting 2TiB with one aes256-ocb session key, #6501 - removed some global options (they were difficult to use and spammed the help output): - --remote-path -> BORG_REMOTE_PATH diff --git a/docs/internals/data-structures.rst b/docs/internals/data-structures.rst index 03fe44d71b..dc57efbb9a 100644 --- a/docs/internals/data-structures.rst +++ b/docs/internals/data-structures.rst @@ -785,6 +785,29 @@ meaning you would run against other limitations (RAM, storage, time) way before In practice, chunks are usually bigger, for big files even much bigger, giving an even higher limit. +AEAD usage limits +~~~~~~~~~~~~~~~~~ + +The IV range is not what limits how much data may be encrypted with one session key, +the security bounds of the ciphers are (see issue #6501 for the details): + +- AES-OCB: the attacker's advantage grows with the square of the amount of data + encrypted using one key (about ``6 * sigma^2 / 2^128``, ``sigma`` being the number + of 128bit cipher blocks, including the authenticated header). RFC 7253 derives from + this that one key should encrypt at most 2^48 blocks (4PiB), which corresponds to an + advantage of 2^-32. borg aims higher and starts a new session after 2^37 blocks + (2TiB), which corresponds to an advantage of about 2^-51. +- CHACHA20-POLY1305: no such limit exists, its confidentiality bound does not depend + on the amount of data encrypted. Its integrity bound only limits the number of + **forgery attempts** (failed decryptions of tampered data, which borg refuses and + which are counted over all keys), not the amount of data encrypted. + +Starting a new session just means computing a new random session id and deriving a new +session key from it (and counting the IV from 0 again). That is cheap and it does not +need any special handling when reading, because the session id is part of every chunk +header. Because the advantages of the individual session keys just add up, frequent +session key changes also keep the total advantage low over the lifetime of a borg key. + Legacy modes ~~~~~~~~~~~~ diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 293fc256f9..302df009be 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -3,8 +3,9 @@ import os import textwrap from hashlib import sha256 +from math import ceil from pathlib import Path -from typing import Literal, ClassVar +from typing import Literal, ClassVar, Optional from collections.abc import Callable from ..logger import create_logger @@ -1092,6 +1093,14 @@ class AEADKeyBase(KeyBase): MAX_IV = 2**48 - 1 + # Maximum amount of data (in 128bit cipher blocks, counting plaintext **and** AAD) that we + # encrypt using one session key. When this is exceeded, we just start a new session (see + # new_session): that is cheap (one sha256) and fully transparent, because the sessionID + # travels in every chunk header, so old chunks stay decryptable. + # + # None means "no limit needed for this ciphersuite", see the subclasses for the reasoning. + MAX_SESSION_BLOCKS: Optional[int] = None + # default storage; an individual key's actual storage is tracked per-instance in self.storage. STORAGE = KeyBlobStorage.REPO # an AEAD key may be stored as a keyfile or inside the repository (see borg key change-location). @@ -1121,6 +1130,14 @@ def encrypt(self, id, data, aad=b""): # aad is additional authenticated data: authenticated together with data, but not encrypted # or returned. reserved = b"\0" + if self.MAX_SESSION_BLOCKS is not None: + # blocks the cipher will process for this message: payload and AAD are separate strings + # and thus rounded up separately, +2 covers the per-message cipher setup. + header_len = 1 + 1 + 6 + 24 # see Layout + blocks = ceil(len(data) / 16) + ceil((header_len + len(aad) + len(id)) / 16) + 2 + if self.session_blocks + blocks > self.MAX_SESSION_BLOCKS: + self.new_session() # do not encrypt more than that with the same session key + self.session_blocks += blocks iv = self.cipher.next_iv() if iv > self.MAX_IV: # see the data-structures docs about why the IV range is enough raise IntegrityError("IV overflow, should never happen.") @@ -1180,8 +1197,13 @@ def _get_cipher(self, sessionid, iv): def init_ciphers(self, manifest_data=None, iv=0): # in every new session we start with a fresh sessionid and at iv == 0, manifest_data and iv params are ignored + self.new_session() + + def new_session(self): + """start a new session: fresh random sessionid, fresh session key, iv counting from 0 again""" self.sessionid = os.urandom(24) self.cipher = self._get_cipher(self.sessionid, iv=0) + self.session_blocks = 0 # cipher blocks encrypted using the current session key # Each of these is one unified key class per crypto suite. A key of this class may be stored either as @@ -1189,12 +1211,29 @@ def init_ciphers(self, manifest_data=None, iv=0): # a class distinction. The class is selected from the manifest's key-type byte (see identify_key), which # only encodes the crypto suite (there is exactly one type byte per suite now). +# AES-OCB has a birthday-type bound: an attacker's advantage in distinguishing the ciphertexts from +# random is about 6 * sigma^2 / 2^128, sigma being the number of 128bit cipher blocks encrypted using +# one key (Krovetz/Rogaway OCB3, Theorem 1; RFC 7253 states this as s^2 / 2^128 and derives its "use +# a key for at most 2^48 blocks (4PiB)" rule of thumb from it - that is an advantage of 2^-32). +# We aim higher and use 2^37 blocks (2TiB) per session key, giving an advantage of about 2^-51, which +# is in line with the target probability used in the examples of draft-irtf-cfrg-aead-limits. +# Rolling the session key this often is practically free and it is also what helps in the multi-key +# setting: the advantages of the individual session keys just add up, so the quantity that matters +# over the lifetime of the borg key is sum(sigma_i^2), not (sum sigma_i)^2, see #6501. +AES_OCB_MAX_SESSION_BLOCKS = 2**37 # 2TiB + +# chacha20-poly1305 does not need such a limit: its confidentiality bound does not depend on the +# amount of data encrypted at all (draft-irtf-cfrg-aead-limits 6.3.1: CA <= 0) and its integrity bound +# only limits the number of **forgery attempts** (failed decryptions), which is counted over all keys +# (7.2.1) and thus can not be improved by using more session keys anyway. + class AESOCBKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): TYPE = KeyType.AESOCB TYPES_ACCEPTABLE = {TYPE} ENC_NAME = "aes256-ocb" # IDHASH_NAME = "sha256" via ID_HMAC_SHA_256 mix-in CIPHERSUITE = AES256_OCB + MAX_SESSION_BLOCKS = AES_OCB_MAX_SESSION_BLOCKS class CHPOKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): @@ -1209,6 +1248,7 @@ class Blake3AESOCBKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): TYPES_ACCEPTABLE = {TYPE} ENC_NAME = "aes256-ocb" # IDHASH_NAME = "blake3" via ID_BLAKE3_256 mix-in CIPHERSUITE = AES256_OCB + MAX_SESSION_BLOCKS = AES_OCB_MAX_SESSION_BLOCKS class Blake3CHPOKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): diff --git a/src/borg/testsuite/crypto/key_test.py b/src/borg/testsuite/crypto/key_test.py index 3d1da278d9..f6afd67347 100644 --- a/src/borg/testsuite/crypto/key_test.py +++ b/src/borg/testsuite/crypto/key_test.py @@ -10,6 +10,7 @@ from ...crypto.key import AESCTRKey, Blake2AESCTRKey from ...crypto.key import AEADKeyBase from ...crypto.key import AESOCBKey, CHPOKey, Blake3AESOCBKey, Blake3CHPOKey +from ...crypto.key import AES_OCB_MAX_SESSION_BLOCKS from ...crypto.key import Blake3AuthenticatedKey from ...crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256, ID_BLAKE3_256 from ...crypto.key import UnsupportedManifestError, UnsupportedKeyFormatError @@ -253,6 +254,36 @@ def test_assert_id(self, key): with pytest.raises(IntegrityError): key.assert_id(id, plaintext_changed) + def test_ocb_session_key_rollover(self, monkeypatch): + # aes256-ocb must not encrypt too much data with one session key, see #6501. + monkeypatch.setenv("BORG_PASSPHRASE", "test") + key = AESOCBKey.create(self.MockRepository(), self.MockArgs()) + assert key.MAX_SESSION_BLOCKS == AES_OCB_MAX_SESSION_BLOCKS + monkeypatch.setattr(key, "MAX_SESSION_BLOCKS", 32) # 512B, way below the real limit + plaintext = b"1234567890123456" # one cipher block + id = key.id_hash(plaintext) + chunks = [key.encrypt(id, plaintext) for _ in range(20)] + sessionids = [chunk[8:32] for chunk in chunks] # see Layout + ivs = [int.from_bytes(chunk[2:8], "big") for chunk in chunks] + assert len(set(sessionids)) > 1 # borg started new sessions + for previous, current, iv in zip(sessionids, sessionids[1:], ivs[1:]): + if current != previous: + assert iv == 1 # a new session counts the IV from the beginning again + else: + assert iv > 1 + for chunk in chunks: # no matter which session key was used, we can read it all + assert key.decrypt(id, chunk) == plaintext + + def test_chpo_no_session_key_rollover(self, monkeypatch): + # chacha20-poly1305 has no limit on the amount of data encrypted with one key, see #6501. + monkeypatch.setenv("BORG_PASSPHRASE", "test") + key = CHPOKey.create(self.MockRepository(), self.MockArgs()) + assert key.MAX_SESSION_BLOCKS is None + plaintext = b"1234567890123456" + id = key.id_hash(plaintext) + chunks = [key.encrypt(id, plaintext) for _ in range(20)] + assert len({chunk[8:32] for chunk in chunks}) == 1 + def test_authenticated_encrypt(self, monkeypatch): monkeypatch.setenv("BORG_PASSPHRASE", "test") key = AuthenticatedKey.create(self.MockRepository(), self.MockArgs()) From 85219317fbc6240b6dd09181bdfb57224f15ada9 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:00:07 +0200 Subject: [PATCH 2/7] docs: document the AEAD usage limits we aim for, #6501 The "48 bit IV is way more than needed" paragraph justified the IV size with the amount of data encrypted in one session, but the IV only limits the number of **messages** (2^48, borg refuses to encrypt more). How much **data** we may encrypt with one session key is determined by the security bounds of the ciphers. So now we state all 3 relevant quantities and the target probability we aim for: - number of messages (q): 2^48 per session key, never the binding limit. - data volume: AES-OCB only, 2^37 blocks (2TiB) per session key == p 2^-51 (RFC 7253's 4PiB rule of thumb corresponds to p 2^-32). chacha20-poly1305 does not have such a limit. - forgery attempts (v): chacha20-poly1305, about 2^33 at p 2^-50 for our biggest messages, counted over all session keys (so more sessions do not help there). Also mention the session key change in the in-depth crypto docs (security.rst). --- docs/internals/data-structures.rst | 50 +++++++++++++++++++----------- docs/internals/security.rst | 3 ++ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/internals/data-structures.rst b/docs/internals/data-structures.rst index dc57efbb9a..beab113fcd 100644 --- a/docs/internals/data-structures.rst +++ b/docs/internals/data-structures.rst @@ -779,28 +779,42 @@ The encryption layout is best seen at the bottom of this diagram: No special IV/counter management is needed here due to the use of session keys. -A 48 bit IV is way more than needed: If you only backed up 4kiB chunks (2^12B), -the IV would "limit" the data encrypted in one session to 2^(12+48)B == 2.3 exabytes, -meaning you would run against other limitations (RAM, storage, time) way before that. -In practice, chunks are usually bigger, for big files even much bigger, giving an -even higher limit. +The 48 bit IV limits the number of **messages** (chunks and metadata objects) that we +encrypt with one session key to 2^48 - borg refuses to encrypt more rather than reusing +an IV. That is way more than needed: even if you only backed up 4kiB chunks (2^12B), +2^48 messages would be 2^(12+48)B == 1.2 exabytes of input data, meaning you would run +against other limitations (RAM, storage, time) way before that. + +How much **data** we may encrypt with one session key is a different question, which is +not answered by the IV size, but by the security bounds of the ciphers, see below. + +.. _aead_usage_limits: AEAD usage limits ~~~~~~~~~~~~~~~~~ -The IV range is not what limits how much data may be encrypted with one session key, -the security bounds of the ciphers are (see issue #6501 for the details): - -- AES-OCB: the attacker's advantage grows with the square of the amount of data - encrypted using one key (about ``6 * sigma^2 / 2^128``, ``sigma`` being the number - of 128bit cipher blocks, including the authenticated header). RFC 7253 derives from - this that one key should encrypt at most 2^48 blocks (4PiB), which corresponds to an - advantage of 2^-32. borg aims higher and starts a new session after 2^37 blocks - (2TiB), which corresponds to an advantage of about 2^-51. -- CHACHA20-POLY1305: no such limit exists, its confidentiality bound does not depend - on the amount of data encrypted. Its integrity bound only limits the number of - **forgery attempts** (failed decryptions of tampered data, which borg refuses and - which are counted over all keys), not the amount of data encrypted. +The relevant quantities are the number of encrypted messages (q), the amount of data +encrypted with one key and the number of forgery attempts (v, decryptions of tampered +data that borg refuses). ``p`` is the attacker's success probability we still consider +acceptable. See issue #6501 for the details and for the computations. + +- **Number of messages** (both ciphers): limited to 2^48 per session key by the IV size, + see above. This is never the binding limit for either cipher. +- **Data volume** (AES-OCB): the attacker's advantage grows with the square of the amount + of data encrypted using one key: about ``6 * sigma^2 / 2^128``, ``sigma`` being the + number of 128bit cipher blocks, including the authenticated header. RFC 7253 derives + from this bound that one key should encrypt at most 2^48 blocks (4PiB), which + corresponds to p == 2^-32. borg aims higher and starts a new session after 2^37 blocks + (2TiB), which corresponds to p == 2^-51 per session key. + + CHACHA20-POLY1305 does not have such a limit at all: its confidentiality bound does not + depend on the amount of data encrypted. +- **Forgery attempts** (CHACHA20-POLY1305): ``v <= p * 2^103 / (L' + 1)``, ``L'`` being + the message length (payload plus authenticated header) in 128bit blocks. For borg's + biggest messages, that is about 2^33 forgery attempts at p == 2^-50, so an attacker + would have to make borg read more than 100PiB of tampered data. Note that this is + counted over **all** session keys, so - unlike the data volume limit - it can not be + improved by starting more sessions. Starting a new session just means computing a new random session id and deriving a new session key from it (and counting the IV from 0 again). That is cheap and it does not diff --git a/docs/internals/security.rst b/docs/internals/security.rst index 1534988aea..d53adda521 100644 --- a/docs/internals/security.rst +++ b/docs/internals/security.rst @@ -184,6 +184,9 @@ Notable: - More modern and often faster AEAD ciphers instead of self-assembled stuff. - Due to the usage of session keys, which just start at 0 per session, IVs (nonces) do not need long-term special care here as they did for the legacy encryption modes. +- The session key is also changed within a borg invocation if we encrypted a lot of data + with it (AES-OCB only, see :ref:`aead_usage_limits`). Reading is not affected by this, + because the session id is part of every message. - The id is now also input into the authentication tag computation. This strongly associates the id with the written data (== associates the key with the value). When later reading the data for some id, authentication will only From 929f6a101821d3494ca153fa0a8240fee4ae4b93 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:02:51 +0200 Subject: [PATCH 3/7] docs: we do not count forgery attempts, and why, #6501 The forgery attempts (v) limit is the only AEAD limit borg does not enforce (chacha20-poly1305: about 2^33 for our biggest messages at p 2^-50, AES-OCB with its 128bit auth tag is far less restrictive). Document that this is intentional: - a failed decryption means tampered or corrupted data, borg refuses it and usually aborts the whole command (borg check and archive listing keep going, but only to report the damage). - reaching the limit would need way more tampered data than a real repository will ever hold. Also note it at the place where the failed decryptions actually happen. --- docs/internals/data-structures.rst | 10 ++++++++++ src/borg/crypto/key.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/docs/internals/data-structures.rst b/docs/internals/data-structures.rst index beab113fcd..e3f183663c 100644 --- a/docs/internals/data-structures.rst +++ b/docs/internals/data-structures.rst @@ -816,6 +816,16 @@ acceptable. See issue #6501 for the details and for the computations. counted over **all** session keys, so - unlike the data volume limit - it can not be improved by starting more sessions. + For AES-OCB, the corresponding limit is much higher (its 128bit authentication tag + gives a term in the order of ``v * L / 2^128``), so the CHACHA20-POLY1305 limit is the + one to look at. + +We do **not** count or enforce the forgery attempts limit, we just document it here: +a failed decryption means we got tampered or corrupted data and borg refuses it, usually +aborting the whole command (``borg check`` and archive listing keep going, but only to +report the damage). Getting anywhere near the limit computed above would require feeding +borg a lot more tampered data than any real repository will ever hold. + Starting a new session just means computing a new random session id and deriving a new session key from it (and counting the IV from 0 again). That is cheap and it does not need any special handling when reading, because the session id is part of every chunk diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 302df009be..4a2e27f85e 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -1147,6 +1147,8 @@ def encrypt(self, id, data, aad=b""): def decrypt(self, id, data, aad=b""): # to decrypt existing data, we need to get a cipher configured for the sessionid and iv from header + # note: we deliberately do not count the failed decryptions (forgery attempts) here, although + # there is a limit for them also - see the "AEAD usage limits" docs and #6501 about why. self.assert_type(data[0], id) iv_48bit = data[2:8] sessionid = bytes(data[8:32]) From 4fce3062dd962a4ddf409e10d4fd2d8635bcc2de Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:06:18 +0200 Subject: [PATCH 4/7] crypto: fix the "internal 32bit counter" claim, #6501 "AES-OCB, CHACHA20 ciphers all add a internal 32bit counter to the 96bit IV we provide" is only true for chacha20-poly1305: the ChaCha20 block function has a 32bit block counter besides the 96bit nonce, limiting a message to 2^32 blocks (256GiB) per (key, nonce). AES-OCB has no such counter, it derives the per-block offsets from the nonce (RFC 7253). The conclusions drawn from that claim were fine for both ciphers, so this is a comment/docs fix only: - the 2^32 blocks per message check stays as it is - it is required for chacha and just conservative for OCB (it can not trigger anyway, our messages are limited to MAX_DATA_SIZE == 20MiB). - incrementing the IV by 1 per message stays correct for both ciphers, because the cipher blocks of a message do not consume IVs. Also reworded the ValueError message, which claimed a counter overflow. --- docs/internals/data-structures.rst | 5 +++-- src/borg/crypto/low_level.pyx | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/internals/data-structures.rst b/docs/internals/data-structures.rst index e3f183663c..56b8e09f25 100644 --- a/docs/internals/data-structures.rst +++ b/docs/internals/data-structures.rst @@ -768,8 +768,9 @@ AEAD modes For new repositories, borg only uses modern AEAD ciphers: AES-OCB or CHACHA20-POLY1305. For each borg invocation, a new sessionkey is derived from the borg key material -and the 48bit IV starts from 0 again (both ciphers internally add a 32bit counter -to our IV, so we'll just count up by 1 per chunk). +and the 48bit IV starts from 0 again. The cipher blocks of a chunk do not consume +IVs here (CHACHA20-POLY1305 counts them in its internal 32bit block counter, AES-OCB +derives the per-block offsets from the IV), so we just count up by 1 per chunk. The encryption layout is best seen at the bottom of this diagram: diff --git a/src/borg/crypto/low_level.pyx b/src/borg/crypto/low_level.pyx index 7c0e659f0e..5d487d6e5e 100644 --- a/src/borg/crypto/low_level.pyx +++ b/src/borg/crypto/low_level.pyx @@ -481,11 +481,14 @@ cdef class _AEAD_BASE: if iv is not None: self.set_iv(iv) assert self.blocks == 0, 'iv needs to be set before encrypt is called' - # AES-OCB, CHACHA20 ciphers all add a internal 32bit counter to the 96bit (12Byte) - # IV we provide, thus we must not encrypt more than 2^32 cipher blocks with same IV). + # CHACHA20 has an internal 32bit block counter (besides the 96bit (12Byte) IV we give it), + # thus we must not encrypt more than 2^32 cipher blocks with the same (key, IV) pair. + # AES-OCB has no such counter (it derives the per-block offsets from the IV), but we apply + # the same limit to both ciphers: the check is cheap and can not trigger for borg messages + # anyway, as these are limited to MAX_DATA_SIZE. block_count = self.block_count(len(data)) if block_count > 2**32: - raise ValueError('too much data, would overflow internal 32bit counter') + raise ValueError('too much data for one message (max 2^32 cipher blocks)') cdef int ilen = len(data) cdef int hlen = len(header) assert hlen == self.header_len_expected @@ -554,11 +557,11 @@ cdef class _AEAD_BASE: authenticate aad + header + cdata (from envelope), ignore header bytes up to aad_offset., return decrypted cdata. """ - # AES-OCB, CHACHA20 ciphers all add a internal 32bit counter to the 96bit (12Byte) - # IV we provide, thus we must not decrypt more than 2^32 cipher blocks with same IV): + # same limit as for encryption, see there: we must not decrypt more than 2^32 cipher + # blocks with the same (key, IV) pair. approx_block_count = self.block_count(len(envelope)) # sloppy, but good enough for borg if approx_block_count > 2**32: - raise ValueError('too much data, would overflow internal 32bit counter') + raise ValueError('too much data for one message (max 2^32 cipher blocks)') cdef int ilen = len(envelope) cdef int hlen = self.header_len_expected cdef int aoffset = self.aad_offset @@ -627,8 +630,9 @@ cdef class _AEAD_BASE: def next_iv(self): # call this after encrypt() to get the next iv (int) for the next encrypt() call - # AES-OCB, CHACHA20 ciphers all add a internal 32bit counter to the 96bit - # (12 byte) IV we provide, thus we only need to increment the IV by 1. + # the cipher blocks of a message do not consume IVs here (CHACHA20 counts them in its + # internal 32bit block counter, AES-OCB derives the per-block offsets from the 96bit + # (12 byte) IV we give it), thus we only need to increment the IV by 1 per message. iv = int.from_bytes(self.iv[:self.iv_len], byteorder='big') return iv + 1 From 298ce79ddf720cf6a38adabe6f2e29415ad80cc7 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:11:56 +0200 Subject: [PATCH 5/7] crypto: fix the stale API sketch in the low_level docstring None of the ciphersuite classes in this module has next_iv(len(data)) - both the AEAD and the legacy AES-CTR classes compute the next iv without arguments. Also, header_len and aad_offset are constructor arguments, not encrypt/decrypt arguments, and encrypt takes iv and aad. --- src/borg/crypto/low_level.pyx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/borg/crypto/low_level.pyx b/src/borg/crypto/low_level.pyx index 5d487d6e5e..dacbcf6aad 100644 --- a/src/borg/crypto/low_level.pyx +++ b/src/borg/crypto/low_level.pyx @@ -2,8 +2,10 @@ API: - encrypt(data, header=b'', aad_offset=0) -> envelope - decrypt(envelope, header_len=0, aad_offset=0) -> data + encrypt(data, header=b'', iv=None, aad=b'') -> envelope + decrypt(envelope, aad=b'') -> data + +header_len and aad_offset are given to the ciphersuite class when creating it, see below. Envelope layout: @@ -25,12 +27,13 @@ garbage. Newly designed envelope layouts can just authenticate the whole header. -IV handling: +IV handling (CS is one of the ciphersuite classes below - the AEAD ones take a single key, +the legacy AES-CTR ones a mac_key and an enc_key): iv = ... # just never repeat! - cs = CS(hmac_key, enc_key, iv=iv) - envelope = cs.encrypt(data, header, aad_offset) - iv = cs.next_iv(len(data)) + cs = CS(..., iv=iv, header_len=header_len, aad_offset=aad_offset) + envelope = cs.encrypt(data, header=header) + iv = cs.next_iv() (repeat) """ From de208c917d17ed32ed73f4de530017e5d49e0bde Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:18:21 +0200 Subject: [PATCH 6/7] crypto: raise IntegrityError for truncated AEAD/AE envelopes A truncated envelope (shorter than header + auth tag, for the legacy AES-CTR modes: header + mac + iv) previously ended up calling EVP_DecryptUpdate with a negative length, which OpenSSL luckily rejects, resulting in CryptoError('EVP_DecryptUpdate failed'). But CryptoError means "malfunction in the crypto module" and no caller catches it, so a truncated chunk (e.g. 32..47 bytes for the AEAD envelope layout) gave an ugly crash instead of being handled like what it is: corrupted or tampered data. Now we check the envelope length first and raise IntegrityError, which the upper layers already handle properly. Also: requirements_check() raised the NotImplemented constant (a TypeError at runtime) instead of NotImplementedError, and the _AEAD_BASE key param docstring called the AEAD key an "encrypt-then-mac key" - AEAD modes are exactly what replaced borg 1.x's encrypt-then-MAC construction, they take a single AEAD key. CryptoTestCase is part of the startup self test, so SELFTEST_COUNT needs to be updated for the 2 test methods added here (otherwise every borg process aborts with "Self-test count mismatch"). --- src/borg/crypto/low_level.pyx | 17 +++++++++------ src/borg/selftest.py | 2 +- src/borg/testsuite/crypto/crypto_test.py | 27 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/borg/crypto/low_level.pyx b/src/borg/crypto/low_level.pyx index dacbcf6aad..d926453cc3 100644 --- a/src/borg/crypto/low_level.pyx +++ b/src/borg/crypto/low_level.pyx @@ -44,7 +44,6 @@ from math import ceil from cpython cimport PyMem_Malloc, PyMem_Free from cpython.buffer cimport PyBUF_SIMPLE, PyObject_GetBuffer, PyBuffer_Release from cpython.bytes cimport PyBytes_FromStringAndSize, PyBytes_AsString -from libc.stdlib cimport malloc, free from libc.stdint cimport uint8_t, uint32_t, uint64_t from libc.string cimport memset, memcpy @@ -72,8 +71,6 @@ cdef extern from "openssl/evp.h": EVP_CIPHER_CTX *EVP_CIPHER_CTX_new() void EVP_CIPHER_CTX_free(EVP_CIPHER_CTX *a) - void EVP_CIPHER_CTX_init(EVP_CIPHER_CTX *a) - void EVP_CIPHER_CTX_cleanup(EVP_CIPHER_CTX *a) int EVP_EncryptInit_ex(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *cipher, ENGINE *impl, const unsigned char *key, const unsigned char *iv) @@ -288,6 +285,10 @@ cdef class AES256_CTR_BASE: """ authenticate aad + iv + cdata, decrypt cdata, ignore header bytes up to aad_offset. """ + if len(envelope) < self.header_len + self.mac_len + self.iv_len_short: + # truncated data - handle it like any other corruption or tampering, instead of + # computing the MAC over negative lengths below. + raise IntegrityError('MAC Authentication failed: envelope too short') cdef int ilen = len(envelope) cdef int hlen = self.header_len cdef int aoffset = self.aad_offset @@ -444,13 +445,13 @@ cdef class _AEAD_BASE: @classmethod def requirements_check(cls): """check whether library requirements for this ciphersuite are satisfied""" - raise NotImplemented # override / implement in child class + raise NotImplementedError # override / implement in child class def __init__(self, key, iv=None, header_len=0, aad_offset=0): """ init AEAD crypto - :param key: 256bit encrypt-then-mac key + :param key: 256bit AEAD key :param iv: 96bit initialisation vector / nonce :param header_len: expected length of header :param aad_offset: where in the header the authenticated data starts @@ -557,7 +558,7 @@ cdef class _AEAD_BASE: def decrypt(self, envelope, aad=b''): """ - authenticate aad + header + cdata (from envelope), ignore header bytes up to aad_offset., + authenticate aad + header + cdata (from envelope), ignore header bytes up to aad_offset, return decrypted cdata. """ # same limit as for encryption, see there: we must not decrypt more than 2^32 cipher @@ -565,6 +566,10 @@ cdef class _AEAD_BASE: approx_block_count = self.block_count(len(envelope)) # sloppy, but good enough for borg if approx_block_count > 2**32: raise ValueError('too much data for one message (max 2^32 cipher blocks)') + if len(envelope) < self.header_len_expected + self.mac_len: + # truncated data - handle it like any other corruption or tampering, instead of + # confusing OpenSSL with negative lengths below. + raise IntegrityError('Authentication failed: envelope too short') cdef int ilen = len(envelope) cdef int hlen = self.header_len_expected cdef int aoffset = self.aad_offset diff --git a/src/borg/selftest.py b/src/borg/selftest.py index 72f3926d25..080ed3a66e 100644 --- a/src/borg/selftest.py +++ b/src/borg/selftest.py @@ -26,7 +26,7 @@ SELFTEST_CASES = [CryptoTestCase, ChunkerTestCase, ChunkerFixedTestCase] -SELFTEST_COUNT = 17 +SELFTEST_COUNT = 19 class SelfTestResult(TestResult): diff --git a/src/borg/testsuite/crypto/crypto_test.py b/src/borg/testsuite/crypto/crypto_test.py index c25a60eda0..14fdfa62fa 100644 --- a/src/borg/testsuite/crypto/crypto_test.py +++ b/src/borg/testsuite/crypto/crypto_test.py @@ -177,6 +177,33 @@ def test_AEAD(self): hdr_mac_iv_cdata_corrupted = hdr_mac_iv_cdata[:1] + b"\0" + hdr_mac_iv_cdata[2:] self.assert_raises(IntegrityError, lambda: cs.decrypt(hdr_mac_iv_cdata_corrupted)) + def test_AEAD_truncated_envelope(self): + # a truncated envelope (shorter than header + auth tag) is corrupted/tampered data + # and must raise IntegrityError (not crash or raise some other exception type). + key = b"X" * 32 + iv_int = 0 + data = b"foo" * 10 + header = b"\x12\x34\x56" + iv_int.to_bytes(12, "big") + for cs_cls in (AES256_OCB, CHACHA20_POLY1305): + cs = cs_cls(key, iv_int, header_len=len(header), aad_offset=1) + hdr_mac_iv_cdata = cs.encrypt(data, header=header) + for length in (0, 1, len(header), len(header) + 16 - 1): # auth tag is 16 bytes + cs = cs_cls(key, iv_int, header_len=len(header), aad_offset=1) + self.assert_raises(IntegrityError, lambda: cs.decrypt(hdr_mac_iv_cdata[:length])) + + def test_AE_truncated_envelope(self): + # same for the legacy AES256-CTR modes: header + mac (32) + iv (8) is the minimum. + mac_key = b"Y" * 32 + enc_key = b"X" * 32 + iv_int = 0 + data = b"foo" * 10 + header = b"\x42" + cs = AES256_CTR_HMAC_SHA256(mac_key, enc_key, iv_int, header_len=len(header), aad_offset=1) + hdr_mac_iv_cdata = cs.encrypt(data, header=header) + for length in (0, 1, len(header) + 32 + 8 - 1): + cs = AES256_CTR_HMAC_SHA256(mac_key, enc_key, iv_int, header_len=len(header), aad_offset=1) + self.assert_raises(IntegrityError, lambda: cs.decrypt(hdr_mac_iv_cdata[:length])) + def test_AEAD_with_more_AAD(self): # test giving extra aad to the .encrypt() and .decrypt() calls key = b"X" * 32 From b890e9c659a132bbc3667e0d7f607dd2cdda6818 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 23:18:39 +0200 Subject: [PATCH 7/7] crypto: move the AES type stub to borg.legacy.crypto borg.crypto.low_level has no AES class - the legacy AES wrapper (only used for legacy key file encryption) lives in borg.legacy.crypto.low_level, which had no type stub at all. So move the stub entry where the class actually is. --- src/borg/crypto/low_level.pyi | 10 ---------- src/borg/legacy/crypto/low_level.pyi | 12 ++++++++++++ 2 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 src/borg/legacy/crypto/low_level.pyi diff --git a/src/borg/crypto/low_level.pyi b/src/borg/crypto/low_level.pyi index 4ac4448aa7..2db4a91757 100644 --- a/src/borg/crypto/low_level.pyi +++ b/src/borg/crypto/low_level.pyi @@ -117,16 +117,6 @@ class CHACHA20_POLY1305(_AEAD_BASE): def requirements_check(cls) -> None: ... def __init__(self, key: bytes, iv: int | bytes | None = None, header_len: int = 0, aad_offset: int = 0) -> None: ... -class AES: - """A thin wrapper around the OpenSSL EVP cipher API - for legacy code, like key file encryption.""" - - def __init__(self, enc_key: bytes, iv: int | bytes | None = None) -> None: ... - def encrypt(self, data: bytes, iv: int | bytes | None = None) -> bytes: ... - def decrypt(self, data: bytes) -> bytes: ... - def block_count(self, length: int) -> int: ... - def set_iv(self, iv: int | bytes) -> None: ... - def next_iv(self) -> int: ... - class CSPRNG: """ Cryptographically Secure Pseudo-Random Number Generator based on AES-CTR mode. diff --git a/src/borg/legacy/crypto/low_level.pyi b/src/borg/legacy/crypto/low_level.pyi new file mode 100644 index 0000000000..778e7f8065 --- /dev/null +++ b/src/borg/legacy/crypto/low_level.pyi @@ -0,0 +1,12 @@ +# Type stubs for borg.legacy.crypto.low_level +# This file provides type hints for the Cython extension module + +class AES: + """A thin wrapper around the OpenSSL EVP cipher API - for legacy key file encryption.""" + + def __init__(self, enc_key: bytes, iv: int | bytes | None = None) -> None: ... + def encrypt(self, data: bytes, iv: int | bytes | None = None) -> bytes: ... + def decrypt(self, data: bytes) -> bytes: ... + def block_count(self, length: int) -> int: ... + def set_iv(self, iv: int | bytes) -> None: ... + def next_iv(self) -> int: ...