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:
+119
-16
@@ -147,8 +147,14 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat)
|
||||
}
|
||||
|
||||
/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47`
|
||||
/// syncs ≈ 32 bits of MPEG-TS structure ≈ 1-in-4-billion that a wrong key (uniform
|
||||
/// AES noise, `0x47` at 1/256 per packet) fakes it. It is an ABSOLUTE proof floor,
|
||||
/// syncs are 32 bits of MPEG-TS structure, but the per-UNIT false-pass risk is
|
||||
/// NOT 2^-32: `is_clean_ts` accepts ANY four of the ~31 encrypted packets in a
|
||||
/// 6144-byte aligned unit, so for a wrong key (uniform AES noise, `0x47` at 1/256
|
||||
/// per packet) it is ≈ C(31,4)·256^-4 ≈ 7e-6, i.e. ~1e-5 — the figure
|
||||
/// [`is_clean_ts`]'s own doc below states. 1-in-4-billion is the probability for
|
||||
/// four SPECIFIC packets and overstates the margin by ~4000x; at
|
||||
/// `KEY_PROOF_PACKETS = 3` the per-unit rate is ≈ C(31,3)·256^-3 ≈ 2.6e-4, so do
|
||||
/// NOT lower it on the strength of slack that is not there. It is an ABSOLUTE proof floor,
|
||||
/// NOT a proportion — a unit the key opened but whose content is bad-encoded
|
||||
/// (many non-conforming packets) is proven by ANY four good packets, not rejected
|
||||
/// for the bad ones.
|
||||
@@ -172,8 +178,16 @@ pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
/// Structural "does this unit carry enough valid MPEG-TS to prove a key opened
|
||||
/// it?" — the Transport-Stream arm of [`is_clean`]. It is
|
||||
/// NOT a decryption verdict: [`decrypt_unit`] applies a key (that is
|
||||
/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question. The
|
||||
/// mux never calls this — TS validity is a muxer concern, never a decrypt result.
|
||||
/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question.
|
||||
///
|
||||
/// The mux is its PRINCIPAL consumer for `BdTs` discs, reaching it through
|
||||
/// [`is_clean`]: `mux::resolve`'s multi-CPS `pick` closure selects a unit key by
|
||||
/// it, `probe_index_phase` reports each FMTS index's interleave parity by it, and
|
||||
/// `decrypt::decrypt_sectors_mapped` uses it as the forensic-range verify net.
|
||||
/// (The doc used to say "the mux never calls this", which invited a maintainer to
|
||||
/// tighten or loosen the proof rule below believing only whole-disc read
|
||||
/// verification was affected — while it in fact changes which unit key a
|
||||
/// multi-CPS disc muxes with and which phase an FMTS index is muxed at.)
|
||||
///
|
||||
/// Rule — evidence is ABSOLUTE, scaled to the packets that exist. Over the
|
||||
/// ENCRYPTED packets (skip packet 0: its `0x47` sits in the clear 16-byte seed, so
|
||||
@@ -381,13 +395,20 @@ pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
|
||||
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
// Expand the key schedule ONCE for the whole unit. `read_data_key` is
|
||||
// loop-invariant here (and constant for the entire disc), but calling
|
||||
// `aes_cbc_decrypt` per sector rebuilt the AES-128 schedule per sector — three
|
||||
// expansions per 6144-byte aligned unit, i.e. ~29 million redundant expansions
|
||||
// over a 90 GB read on a stock drive, on the per-unit decrypt hot path.
|
||||
// Measured by `decrypt_bus_expands_the_read_data_key_once_per_unit`.
|
||||
let cipher = crate::aacs::crypto::new_cipher_for(read_data_key);
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
if sector_start + SECTOR_BYTES > unit.len() {
|
||||
break;
|
||||
}
|
||||
// First 16 bytes of each sector are plaintext
|
||||
aes_cbc_decrypt(
|
||||
read_data_key,
|
||||
crate::aacs::crypto::cbc_decrypt_blocks(
|
||||
&cipher,
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
||||
);
|
||||
}
|
||||
@@ -1182,21 +1203,100 @@ mod tests {
|
||||
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
|
||||
}
|
||||
|
||||
// ── decrypt_bus: one key schedule per unit, not one per sector ─────────
|
||||
|
||||
/// MEASURED, not reasoned: `decrypt_bus` called `aes_cbc_decrypt` once per
|
||||
/// 2048-byte sector, and each call built its own AES-128 key schedule, so a
|
||||
/// 6144-byte aligned unit performed THREE key expansions under the same
|
||||
/// loop-invariant `read_data_key`. On a 90 GB UHD read on a stock (non-
|
||||
/// LibreDrive) drive — ~14.6 million aligned units — that is ~29 million
|
||||
/// redundant expansions on the per-unit decrypt hot path, for a key that is
|
||||
/// constant for the whole disc. The counter is incremented inside
|
||||
/// `crypto::new_cipher`, the single construction site.
|
||||
#[test]
|
||||
fn decrypt_bus_expands_the_read_data_key_once_per_unit() {
|
||||
use crate::aacs::crypto::KEY_EXPANSIONS;
|
||||
let mut unit = clear_unit();
|
||||
let rdk = [0x4Eu8; 16];
|
||||
KEY_EXPANSIONS.with(|c| c.set(0));
|
||||
decrypt_bus(&mut unit, &rdk);
|
||||
let n = KEY_EXPANSIONS.with(|c| c.get());
|
||||
assert_eq!(
|
||||
n, 1,
|
||||
"one aligned unit under one read_data_key must expand the schedule \
|
||||
exactly once, not once per 2048-byte sector"
|
||||
);
|
||||
}
|
||||
|
||||
/// The single-expansion refactor must be byte-identical: bus encryption
|
||||
/// ([C] §4.2) covers bytes 16..2048 of every 2048-byte sector, so a
|
||||
/// three-sector aligned unit round-trips through the forward direction
|
||||
/// sector by sector and `decrypt_bus` must recover it exactly.
|
||||
#[test]
|
||||
fn decrypt_bus_roundtrips_every_sector_region() {
|
||||
let rdk = [0x91u8; 16];
|
||||
let original = clear_unit();
|
||||
let mut unit = original.clone();
|
||||
// Forward direction, region by region — the inverse of decrypt_bus.
|
||||
for start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
crate::aacs::crypto::aes_cbc_encrypt(&rdk, &mut unit[start + 16..start + SECTOR_BYTES]);
|
||||
}
|
||||
assert_ne!(
|
||||
&unit[16..64],
|
||||
&original[16..64],
|
||||
"the forward direction must have changed the bytes"
|
||||
);
|
||||
decrypt_bus(&mut unit, &rdk);
|
||||
assert_eq!(
|
||||
unit.as_slice(),
|
||||
original.as_slice(),
|
||||
"decrypt_bus must invert the per-sector bus encryption exactly"
|
||||
);
|
||||
}
|
||||
|
||||
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
|
||||
|
||||
/// The published `iv0` bytes, INDEPENDENT of the production constant.
|
||||
///
|
||||
/// [C] §2.1.2 fixes one default CBC IV for every AACS AES-CBC operation.
|
||||
/// Both IV tests below used to compute their expected value from
|
||||
/// `crypto::AACS_IV` itself, so the constant was asserted against itself and
|
||||
/// NOTHING in the suite pinned its bytes: swapping `AACS_IV` for `[0u8; 16]`
|
||||
/// left both tests passing (one builds its ciphertext with the same value and
|
||||
/// the other cancels the change in a triple XOR) while every real AACS disc
|
||||
/// decrypted to noise — block 0 of every 6128-byte aligned unit and of every
|
||||
/// bus-encrypted sector XORed with the wrong IV. This literal is the
|
||||
/// independent witness the tests assert against.
|
||||
const IV0_PUBLISHED: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F,
|
||||
0x78,
|
||||
];
|
||||
|
||||
/// Pins the fixed AACS CBC IV ([C] §2.1.2 `iv0`) against a literal, so a
|
||||
/// change to `crypto::AACS_IV` fails HERE rather than silently shipping.
|
||||
#[test]
|
||||
fn aacs_iv_matches_published_iv0() {
|
||||
assert_eq!(
|
||||
AACS_IV, IV0_PUBLISHED,
|
||||
"the fixed AACS CBC IV must be the published iv0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_decrypt_first_block_xors_aacs_iv() {
|
||||
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
|
||||
// constant (not zero). Encrypt a single block forward with IV, then
|
||||
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
|
||||
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
|
||||
// makes the recovered block wrong.
|
||||
// constant (not zero). Encrypt a single block forward with the PUBLISHED
|
||||
// iv0 literal, then confirm aes_cbc_decrypt recovers it — proving the IV
|
||||
// the production code uses on block 0 is exactly that value. Building the
|
||||
// fixture from `IV0_PUBLISHED` rather than from `AACS_IV` is what makes
|
||||
// the claim real: a mutation that swaps AACS_IV for [0u8;16] now makes the
|
||||
// recovered block wrong.
|
||||
let key = [0x24u8; 16];
|
||||
let plain = [0x5Au8; 16];
|
||||
// Forward CBC for one block: C = AES-E(K, P XOR IV).
|
||||
let mut x = plain;
|
||||
for j in 0..16 {
|
||||
x[j] ^= AACS_IV[j];
|
||||
x[j] ^= IV0_PUBLISHED[j];
|
||||
}
|
||||
let ct = aes_ecb_encrypt(&key, &x);
|
||||
let mut buf = ct;
|
||||
@@ -1225,10 +1325,13 @@ mod tests {
|
||||
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
|
||||
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
|
||||
// pins the real reverse-order CBC chaining against a published KAT.
|
||||
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
|
||||
// XOR AACS_IV — the documented IV substitution. Asserting this exact
|
||||
// relation pins both the AES decrypt of C[0] AND that block 0 uses
|
||||
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
|
||||
// * Block 0 = AES-D(K, C[0]) XOR iv0 = NIST_PT[0] XOR NIST_IV XOR iv0 —
|
||||
// the documented IV substitution. Asserting this exact relation pins
|
||||
// both the AES decrypt of C[0] AND that block 0 uses iv0. The expected
|
||||
// value is built from the `IV0_PUBLISHED` literal, NOT from
|
||||
// `crypto::AACS_IV`: computing it from the production constant made
|
||||
// the change cancel out of the triple XOR, so a swap to [0u8;16] still
|
||||
// passed. It now fails.
|
||||
let key = [
|
||||
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
|
||||
0x4F, 0x3C,
|
||||
@@ -1268,7 +1371,7 @@ mod tests {
|
||||
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
|
||||
let mut expected_block0 = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
|
||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ IV0_PUBLISHED[i];
|
||||
}
|
||||
assert_eq!(
|
||||
&buf[0..16],
|
||||
|
||||
+62
-8
@@ -15,6 +15,33 @@ pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
// Per-thread count of AES-128 key schedules built through `new_cipher`.
|
||||
// Test-only instrumentation: an AES-128 key expansion is 10 round-key
|
||||
// derivations, and the CBC helpers here run on the per-aligned-unit decrypt hot
|
||||
// path of a whole disc read, so "how many times was the schedule built for one
|
||||
// loop-invariant key" is a property worth asserting rather than reasoning about.
|
||||
// THREAD-LOCAL, not a global atomic: `cargo test` runs tests concurrently, so a
|
||||
// shared counter would see every other test's expansions. See
|
||||
// `content::tests::decrypt_bus_expands_the_read_data_key_once_per_unit`.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static KEY_EXPANSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule for a caller that will drive
|
||||
/// [`cbc_decrypt_blocks`] over several regions under one key.
|
||||
pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
|
||||
new_cipher(key)
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule. The single construction site for the CBC
|
||||
/// helpers, so [`KEY_EXPANSIONS`] can count them under test.
|
||||
fn new_cipher(key: &[u8; 16]) -> Aes128 {
|
||||
#[cfg(test)]
|
||||
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
|
||||
Aes128::new(GenericArray::from_slice(key))
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
@@ -35,13 +62,12 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
|
||||
/// AES-128-CBC ENCRYPT in place under the fixed [`AACS_IV`] — the forward
|
||||
/// direction of [`aes_cbc_decrypt`], and its exact inverse. [C] §2.1.2
|
||||
/// (`AES-128CBCE`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
/// AES-128-CBC encrypt in place under the fixed [`AACS_IV`] — the forward
|
||||
/// direction of [`aes_cbc_decrypt`], and its exact inverse.
|
||||
/// Precondition: `data.len()` is a multiple of 16; the assert
|
||||
/// documents/enforces that contract.
|
||||
///
|
||||
/// Constructs the cipher ONCE for the whole slice. Driving this from the
|
||||
/// single-block [`aes_ecb_encrypt`] instead rebuilds the AES key schedule per
|
||||
@@ -52,7 +78,7 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_encrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let cipher = new_cipher(key);
|
||||
let num_blocks = data.len() / 16;
|
||||
let mut prev = AACS_IV;
|
||||
// Forward order: each block is XORed with the PRECEDING ciphertext block.
|
||||
@@ -69,12 +95,40 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-CBC DECRYPT in-place with the fixed AACS IV. [C] §2.1.2
|
||||
/// (`AES-128CBCD`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
///
|
||||
/// (This doc block was orphaned onto `aes_cbc_encrypt` above when that function
|
||||
/// was inserted directly after it with no separating blank line, so rustdoc
|
||||
/// rendered the crate's only forward-direction AACS primitive as "decrypt" and
|
||||
/// cited the spec's DECRYPT clause for it, while this function had no doc at
|
||||
/// all. `encrypt_unit_is_the_exact_inverse_of_decrypt_unit` in `content.rs` pins
|
||||
/// the directions behaviourally so a maintainer 'fixing' the contradiction by
|
||||
/// swapping the two bodies fails the suite instead of shipping a second
|
||||
/// decryptor behind an already-set encrypted flag.)
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
cbc_decrypt_blocks(&new_cipher(key), data);
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in place under the fixed [`AACS_IV`] with an ALREADY
|
||||
/// EXPANDED key schedule.
|
||||
///
|
||||
/// Split out of [`aes_cbc_decrypt`] so a caller that decrypts several regions
|
||||
/// under one loop-invariant key expands the schedule once. `decrypt_bus`
|
||||
/// ([`super::content::decrypt_bus`]) is that caller: bus encryption
|
||||
/// ([C] §4.2 / the AACS 2.0 Read Data Key) covers bytes 16..2048 of EVERY
|
||||
/// 2048-byte sector, so a 6144-byte aligned unit is three regions under one
|
||||
/// `read_data_key` — three key schedules where one suffices, on the per-unit
|
||||
/// decrypt hot path of a whole 90 GB read.
|
||||
pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) {
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
for i in (0..num_blocks).rev() {
|
||||
|
||||
Reference in New Issue
Block a user