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