Round 4: fix 26 defects across crypto, resource use and codec paths

Twenty-six confirmed findings from the fourth audit round, landed as one
cluster because they were found by agents working over disjoint file sets.

The one worth calling out is a pair of AACS tests that could not fail.
Both asserted CBC behaviour against a hand-rolled expectation that
happened to be IV-independent, so replacing AACS_IV with sixteen zero
bytes left them passing — they were pinning the code's own arithmetic,
not the published constant. Replaced with a literal witness of the
published IV plus the NIST SP 800-38A F.2.2 CBC-AES128 vector, and
verified the other way round: zeroing AACS_IV now fails three tests.

The rest are allocation and correctness work on hot paths: the Annex-B
writer in demux_sink allocated and freed a whole-frame Vec per frame,
which for a UHD title is ~200,000 allocations over the mmap threshold
plus the page faults to first-touch each one; it now reuses a buffer on
the writer, and still takes the NAL prefix width from the configuration
record rather than assuming four.

Six findings whose real fix lives in a consumer crate are recorded for
re-filing rather than patched here.
This commit is contained in:
Matthew Jackson
2026-07-29 22:09:52 -07:00
parent 4fcd28b487
commit 0bbceed985
19 changed files with 1342 additions and 126 deletions
+56
View File
@@ -1240,6 +1240,62 @@ mod tests {
}
}
/// The mapped descramble indexes the committed key pool POSITIONALLY
/// (`unit_keys[key_idx].1`), so the ORDER of the `Vec<UnitKey>` a
/// `KeySource` returns is load-bearing — it is NOT "cosmetic, the decrypt path
/// strips it and tries every key", as `keysource::resolve_and_apply_traced`'s
/// doc used to claim. Trial-decrypt was deliberately deleted; nothing here
/// searches the pool. Reordering the same two keys therefore sends each range
/// to the WRONG key: the range that decrypted clean now fails the correct-phase
/// `is_clean` net loudly (or, off a forensic phase, would decrypt a whole span
/// under a neighbour's key). Pins the corrected doc.
#[test]
fn mapped_key_selection_is_positional_so_pool_order_matters() {
use crate::disc::ContentFormat;
let key_a = [0xAAu8; 16];
let key_b = [0xBBu8; 16];
let ul = aacs::content::ALIGNED_UNIT_LEN;
let usz = (ul / 2048) as u32;
// Unit 0 encrypted under key_a, unit 1 under key_b.
let build = || {
let mut buf = vec![0u8; 2 * ul];
for (i, k) in [key_a, key_b].iter().enumerate() {
let mut u = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut u, k);
buf[i * ul..(i + 1) * ul].copy_from_slice(&u);
}
buf
};
// Map: unit 0 → pool position 0, unit 1 → pool position 1.
let map = AacsKeyMap::from_ranges_phased(vec![
(0, usz, 0, Phase::Even),
(usz, 2 * usz, 1, Phase::Even),
]);
// Pool in CPS-unit order: each range gets its own key, both come clean.
let mut buf = build();
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
decrypt_sectors_mapped(&mut buf, &keys, 0, &map)
.expect("pool in CPS-unit order decrypts clean");
// SAME keys, SAME CPS-unit numbers, swapped POSITIONS. If the number were
// what mattered (or if the path searched the pool) this would be
// equivalent; positional indexing makes it decrypt both units wrong.
let mut buf = build();
let swapped = DecryptKeys::Aacs {
unit_keys: vec![(1, key_b), (0, key_a)],
read_data_key: None,
format: ContentFormat::BdTs,
};
assert!(
decrypt_sectors_mapped(&mut buf, &swapped, 0, &map).is_err(),
"a reordered pool must fail loud — key selection is positional, so the \
ORDER a KeySource returns its keys in is part of the contract"
);
}
/// The correct-phase safety `is_clean` fires loud: an even unit whose mapped
/// key is wrong does NOT come clean → `DecryptFailed` (not silent corruption).
#[test]