From dc7dfc6041996c12dafb573a4e20905dc2f30eb3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:45:17 -0700 Subject: [PATCH] Test hygiene: real AACS decrypt coverage, drop dead recovery fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repoint three tautological AACS tests (which passed only via the unconditional-Err AACS arm of decrypt_sectors) at the real shipping path decrypt_sectors_mapped: out-of-range mapped key index, empty pool vs a non-empty map, and a scrambled encrypted trailing partial (the new guard) — plus a clear-trailing-partial pass-through and an explicit fail-loud safety-net test for AACS reaching the unmapped wrapper. - Add the missing end-to-end AACS round-trip through the decorator + key map (aacs_decorator_decrypts_encrypted_unit_via_map) and a mapless-AACS-fails-loud decorator test — the exact class of bug the TrueHD probe shipped. Fills the // TODO: AACS round-trip test gap. - Delete the now-dead recovery test fixtures (encrypt_aacs_unit_bad, AnyLbaUnit) left after the reactive key-fetch path was removed; keep encrypt_aacs_unit + FixedUnit, now used by the round-trip test. --- src/decrypt.rs | 120 +++++++++++++++++++++++++-------------- src/sector/decrypting.rs | 106 ++++++++++++++++------------------ 2 files changed, 127 insertions(+), 99 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index ee4417e..487febe 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -704,25 +704,29 @@ mod tests { ); } - /// Whole leading units plus a SCRAMBLED trailing partial (the malformed - /// danger case): an encrypted unit split across an extent boundary cannot be - /// decrypted standalone. Passing it through as clear would be silent - /// corruption, so we must fail loud with `DecryptFailed`. + /// Whole leading unit plus a SCRAMBLED trailing partial that is FLAGGED + /// encrypted in its clear seed (the malformed danger case): an encrypted unit + /// split across an extent boundary cannot be CBC-decrypted standalone. The + /// mapped decrypt must fail loud with `DecryptFailed` rather than emit the + /// ciphertext partial as clear. Exercises the real shipping path + /// (`decrypt_sectors_mapped`) and its trailing-partial guard. #[test] fn aacs_scrambled_trailing_partial_is_rejected() { - let mut keys = DecryptKeys::Aacs { + let keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, format: crate::disc::ContentFormat::BdTs, }; - // One full unit + a 4096-byte (two-sector) SCRAMBLED tail. - let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); - let tail = scrambled_region(4096); - let mut buf = unit; + // One CLEAR leading unit (passes through) + a 4096-byte (two-sector) tail + // whose seed byte flags it encrypted, inside the mapped range. + let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); + let mut tail = scrambled_region(4096); + tail[0] |= 0xC0; // CPI bits → flagged encrypted on the partial buf.extend_from_slice(&tail); - let err = decrypt_sectors(&mut buf, &mut keys, 0) - .expect_err("scrambled trailing partial must be rejected"); + let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]); + let err = decrypt_sectors_mapped(&mut buf, &keys, 0, &map) + .expect_err("scrambled encrypted trailing partial must be rejected"); assert_eq!( err.code(), crate::error::Error::DecryptFailed.code(), @@ -730,6 +734,23 @@ mod tests { ); } + /// A CLEAR trailing partial (encrypted flag NOT set) is a legitimate content + /// tail and must pass through, never trip the guard above. + #[test] + fn aacs_clear_trailing_partial_passes_through() { + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); + let mut tail = clear_ts_region(4096); + tail[0] &= 0x3F; // ensure the CPI bits are clear + buf.extend_from_slice(&tail); + let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]); + assert!(decrypt_sectors_mapped(&mut buf, &keys, 0, &map).is_ok()); + } + // ── DecryptKeys::None and is_encrypted ───────────────────────────────── /// DecryptKeys::None is a pure no-op: the buffer must be returned @@ -960,45 +981,60 @@ mod tests { // ── AACS unit-key index selection ────────────────────────────────────── - /// AACS decrypt with an out-of-range unit_key_idx must fail loud with - /// DecryptFailed — never silently fall back to a wrong key or pass - /// encrypted data through as clear. + /// A map that selects a key index OUTSIDE the held pool must fail loud with + /// DecryptFailed — never silently apply a wrong key or pass ciphertext through. + /// This validates `decrypt_sectors_mapped`'s up-front `key_indices()` bounds + /// check (the real shipping AACS decrypt path). /// - /// Grounding: `let uk = match unit_keys.get(unit_key_idx) { Some => ..., - /// None => return Err(DecryptFailed) }`. - /// Mutation: change `unit_keys.get(unit_key_idx)` to `unit_keys.get(0)` or - /// `.unwrap_or` a default -> the out-of-range index would not error; this - /// fails. + /// Mutation: drop the `unit_keys.get(idx).is_none()` guard → the out-of-range + /// index would not error; this fails. #[test] - fn aacs_out_of_range_unit_key_idx_errors() { + fn aacs_mapped_out_of_range_key_idx_errors() { + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); + let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 5)]); // idx 5, pool holds 1 key + let err = decrypt_sectors_mapped(&mut buf, &keys, 0, &map) + .expect_err("map index 5 is out of range for a 1-key pool"); + assert_eq!( + err.code(), + crate::error::Error::DecryptFailed.code(), + "out-of-range mapped key index must be DecryptFailed" + ); + } + + /// A non-empty map over an EMPTY unit_keys pool has no key to satisfy its + /// selected index → DecryptFailed (via the same bounds check). + #[test] + fn aacs_mapped_empty_unit_keys_errors() { + let keys = DecryptKeys::Aacs { + unit_keys: vec![], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); + let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]); + let err = decrypt_sectors_mapped(&mut buf, &keys, 0, &map) + .expect_err("empty unit_keys cannot satisfy map idx 0"); + assert_eq!(err.code(), crate::error::Error::DecryptFailed.code()); + } + + /// SAFETY NET: reaching the CSS/`None` wrapper (`decrypt_sectors`) with AACS + /// keys means a reader was built with no map — a bug. It must fail loud, never + /// apply a guessed key. (AACS decrypts exclusively via `decrypt_sectors_mapped`.) + #[test] + fn aacs_via_unmapped_decrypt_sectors_fails_loud() { let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, format: crate::disc::ContentFormat::BdTs, }; let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); - let err = decrypt_sectors(&mut buf, &mut keys, 5) - .expect_err("unit_key_idx 5 is out of range for a 1-key list"); - assert_eq!( - err.code(), - crate::error::Error::DecryptFailed.code(), - "out-of-range unit key index must be DecryptFailed" - ); - } - - /// AACS with an empty unit_keys list and any index errors (no key to use). - /// - /// Grounding: `unit_keys.get(0)` on an empty Vec is None -> DecryptFailed. - /// Mutation: defaulting to [0u8;16] on None would proceed; this fails. - #[test] - fn aacs_empty_unit_keys_errors() { - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![], - read_data_key: None, - format: crate::disc::ContentFormat::BdTs, - }; - let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); - let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error"); + let err = decrypt_sectors(&mut buf, &mut keys, 0) + .expect_err("AACS through the unmapped path must fail loud"); assert_eq!(err.code(), crate::error::Error::DecryptFailed.code()); } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index b91ac6b..b40be8f 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -792,47 +792,6 @@ mod tests { unit } - /// Like [`encrypt_aacs_unit`] but knocks out the TS sync on `bad_pkts` (kept as - /// NON-zero content, so they read as authored-bad packets, not padding) BEFORE - /// encryption — a unit the correct key still OPENS on its remaining good - /// packets, but that carries bad-encoded content the muxer must drop. - fn encrypt_aacs_unit_bad(unit_key: &[u8; 16], bad_pkts: &[usize]) -> Vec { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; - let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; - let mut off = 4; - while off < unit.len() { - unit[off] = 0x47; - off += 192; - } - for &p in bad_pkts { - let o = p * 192; - unit[o + 4] = 0x00; // no TS sync after decrypt - unit[o + 5] = 0xAB; // non-zero payload => real content, not padding - } - unit[0] |= 0xC0; - let header: [u8; 16] = unit[..16].try_into().unwrap(); - let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header); - let mut k = [0u8; 16]; - for i in 0..16 { - k[i] = derived[i] ^ header[i]; - } - let cipher = Aes128::new(GenericArray::from_slice(&k)); - let mut prev = crate::aacs::crypto::AACS_IV; - let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16; - for i in 0..blocks { - let o = 16 + i * 16; - for j in 0..16 { - unit[o + j] ^= prev[j]; - } - let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]); - cipher.encrypt_block(&mut blk); - unit[o..o + 16].copy_from_slice(&blk); - prev.copy_from_slice(&unit[o..o + 16]); - } - unit - } - /// `into_inner` / `inner` / `inner_mut` must hand back the original /// source unchanged. Grounding: the accessor methods. #[test] @@ -863,22 +822,55 @@ mod tests { } } - /// A source that returns a fixed encrypted unit for ANY read — used to drive - /// the verify-only fetch + cache tests below. - struct AnyLbaUnit { - unit: Vec, - } - impl SectorSource for AnyLbaUnit { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _r: bool, - ) -> Result { - let b = count as usize * 2048; - buf[..b].copy_from_slice(&self.unit); - Ok(b) + /// END-TO-END AACS: an encrypted unit read through the decorator with a matching + /// [`AacsKeyMap`] comes back as the known plaintext. This is the round-trip that + /// was previously only asserted through the (deleted) reactive path — the actual + /// shipping mapped-decrypt path had no decorator-level coverage. + #[test] + fn aacs_decorator_decrypts_encrypted_unit_via_map() { + let key = [0x5Au8; 16]; + let unit = encrypt_aacs_unit(&key); + let src = FixedUnit { unit }; + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let map = std::sync::Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![( + 0, + u32::MAX, + 0, + )])); + let mut dec = DecryptingSectorSource::new(src, keys).with_key_map(map); + let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + let n = dec.read_sectors(0, 3, &mut buf, false).unwrap(); + assert_eq!(n, crate::aacs::content::ALIGNED_UNIT_LEN); + // Decrypted: the TS sync 0x47 reappears at the BD-TS stride (offset 4, then + // every 192 bytes). If the map/keys were wrong the bytes would stay + // ciphertext and these syncs would be absent. + for off in (4..crate::aacs::content::ALIGNED_UNIT_LEN).step_by(192) { + assert_eq!(buf[off], 0x47, "TS sync recovered at offset {off}"); } } + + /// An AACS decorator built WITHOUT a key map must fail loud on the first unit — + /// the map is mandatory for AACS (it decrypts only via the mapped path). Guards + /// the class of bug the TrueHD probe shipped (a mapless AACS `DecryptingSectorSource`). + #[test] + fn aacs_decorator_without_map_fails_loud() { + let key = [0x5Au8; 16]; + let unit = encrypt_aacs_unit(&key); + let src = FixedUnit { unit }; + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let mut dec = DecryptingSectorSource::new(src, keys); // no with_key_map + let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + let err = dec + .read_sectors(0, 3, &mut buf, false) + .expect_err("AACS decorator with no key map must fail loud"); + assert_eq!(err.code(), crate::error::Error::DecryptFailed.code()); + } }