3. Truncate to the recorded size; never strip padding
- Status: Accepted
- Date: 2026-08-21
Context
An encrypted content blob is AES-CBC ciphertext padded out to the 16-byte block
boundary. Recovering the plaintext length can be done two ways: strip the
padding, or truncate to the length Manifest.db records for that file.
Several published iOS-backup decryptors strip PKCS#7.
Decision
crypto::decrypt_aes_cbc removes no padding at all. Backup::read truncates to
BackupFile::size.
Rationale
The manifest already states the length. It is recorded evidence. Stripping padding derives a value that is sitting right there — and a derivation that replaces a correct recorded value with a computed one is a regression wearing the costume of an improvement.
Padding-stripping corrupts real files. A file whose final plaintext byte
happens to equal its own pad length is indistinguishable from a padded one. A
32-byte file ending in 0x10 would be read as 16 bytes, and reported as a
success. tests/data/*/AppDomain-com.example.app/Documents/exact.bin is exactly
two blocks and exists to hold this behaviour in place.
A blob shorter than its recorded size is left alone. The shortfall is evidence of a truncated backup; padding it out would manufacture bytes that were never captured.
Amended 2026-08-21, after comparison with a reference implementation
datatags/mount-ios-backup (which vendors iphone-dataprotection, the canonical
reverse-engineering reference) takes the other route: it calls removePadding
and never consults Size for content length.
Reviewing against it confirmed the decision and exposed a defect in how it was implemented here.
Confirmed: the reference's removePadding raises Invalid CBC padding on a
malformed trailer, losing the whole file. Truncating to a recorded length has no
such failure, and it is immune to the padding scheme entirely — our fixtures were
switched from zero padding to PKCS#7 (what iOS actually writes) and every test
stayed green without a code change, which is the property in action.
The defect: Size was read with unwrap_or_default(), so a row whose metadata
records no Size became 0, and the read truncated a real file to nothing
and returned Ok. Refusal counted as zero — and adversarially reachable, since a
crafted backup omitting Size makes a file disappear from an examiner's view
while every operation reports success. The reference implementation is immune
precisely because it never consults Size.
BackupFile::size is now Option<u64>; None is distinct from Some(0), and
when it is None the reader falls back to stripping the PKCS#7 trailer — the
reference's only strategy becomes our fallback. Where the trailer is also
malformed the bytes are returned intact rather than erroring: with no recorded
length there is nothing to check against, and handing back slightly too many real
bytes beats discarding a file an examiner needs.
Consequences
- Byte-exact reads for every file, including block-multiple lengths.
- Padding-scheme-agnostic: PKCS#7, zero padding, or none, the recorded length decides.
- A manifest that disagrees with the blob on disk stays visible as a
disagreement, and
ios-backup-forensicreports it. - An absent size is reported as absent, all the way to the exhibit: a
BlobMissingfinding prints(not recorded)rather than a0the manifest never claimed.