Bind pack object header into AEAD authentication - #9946
Conversation
Authenticate magic, version and chunk_id as AAD so a tampered header byte fails decryption instead of passing through silently. Adds a version gate so existing objects stay readable.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9946 +/- ##
==========================================
+ Coverage 85.49% 85.74% +0.25%
==========================================
Files 93 95 +2
Lines 16122 16828 +706
Branches 2466 2577 +111
==========================================
+ Hits 13783 14430 +647
- Misses 1632 1664 +32
- Partials 707 734 +27 ☔ View full report in Codecov by Harness. |
|
Guess the exception class fix should be moved to a separate PR (and this PR could then be rebased on that PR). |
|
Meta and data are encrypted with the same key and same AAD, so an attacker controlling the repo can swap encrypted_meta and encrypted_data (fixing up the two size fields) and both slots still authenticate — in v1 and in the new v2. This is pre-existing (master has it too), but if we're minting an object format v2 for AAD reasons, slot domain separation (e.g. header_aad + b"M" / + b"D") belongs in it — otherwise we'll need a v3 for it later. |
added a TODO , will raise a separate PR for after this |
added slot tags (b"M"/b"D") into the AAD for meta and data, added a test with that swap case |
|
review by claude fable5 Thanks — with the second commit this is in good shape. I reviewed both commits, ran the test suite locally (repoobj/key/repository/packs units plus the archiver end-to-end subset: check, compact, transfer, extract, debug — all green, matching CI), and verified the crypto claims experimentally. Summary below. What I verified
On compatibilityThe version bump means older borg2 betas fail on new objects with "unsupported object version: 2". That is accepted — we don't care for older betas, no repo-level gating needed. Requested changes
Nits (take or leave)
Possible follow-up (separate issue, pre-existing): for v1 objects and plaintext/authenticated modes, corruption that reaches |
Avoids a name collision with the low-level cipher's own header= param and drops repetitive/negative-space phrasing in the packs.rst AAD section.
| envelope_header = self.TYPE_STR + reserved + iv_48bit + self.sessionid | ||
| return self.cipher.encrypt(data, header=envelope_header, iv=iv, aad=aad + id) |
There was a problem hiding this comment.
as you now have renamed the param to aad, there is no need to rename header.
| # meta_encrypted/data_encrypted are AEAD-authenticated with aad=chunk_id. | ||
| OBJ_VERSION_NO_HEADER_AAD = 0x01 |
There was a problem hiding this comment.
Please file a ticket, that we drop this later, before rc1. Strictly taken, there is no need to support old betas.
But if we do it (and it works, so nothing else must break compatibility), we at least must remember to remove the b22-legacy-support again later, before we go rc.
| encryption modes (AES-256-OCB, ChaCha20-Poly1305). ``meta_size`` and ``data_size`` are excluded from | ||
| the AAD, since they are only known after encryption; tampering with either still fails authentication, |
There was a problem hiding this comment.
sizes only known after encryption - is that true?
Pack object headers (magic, version, chunk_id) are stored unencrypted ahead of the encrypted meta and data. Only chunk_id was included in AEAD authentication as additional authenticated data (AAD); magic and version were not. A tampered magic byte was still rejected by the existing structural check (
hdr.magic != OBJ_MAGIC) before decrypt ever ran; a tampered version byte between the two accepted values was not caught by any check.Format version 0x02 (
OBJ_VERSION_HEADER_AAD) binds the header's first 41 bytes (magic, version, chunk_id) into the AEAD authentication ofencrypted_metaandencrypted_data.format()writes version 0x02;parse()/parse_meta()still accept version 0x01 objects, so nothing needs to be rewritten on disk.encrypted_metaandencrypted_dataeach add a one-byte slot tag on top of the shared AAD, so swapping the two ciphertexts (with matchingmeta_size/data_sizeadjustments) fails authentication instead of decrypting under the wrong slot.Renamed the
header=parameter onKeyBase.encrypt/decrypttoaad=, since the low-level cipher already has its ownheader=meaning a different thing (the stored envelope prefix).Also fixed
AEADKeyBase.decryptcatchinghelpers.IntegrityError, a subclass of thelow_level.IntegrityErroractually raised by the cipher, so the except clause never matched. Existing callers catch the common base class, so this did not let failures through unnoticed; it only meant the chunk-id-annotated error message was never attached.Added tests for a tampered chunk_id, a tampered magic byte, AAD tampering at the key layer, a meta/data slot swap, and reading a version 0x01 object.