Decrypt is keymap-only: sweep/patch/extract, no AACS trial-decrypt

Every AACS decrypt now goes through the resolved key map (decrypt_sectors_
mapped): the map keys each content unit up front and a missing key fails at
resolve time. The old trial-decrypt path — try each held key per unit, keep
the first-tried plaintext on a miss — is gone; decrypt_sectors_impl's AACS
arm now fails loud (reaching it means a reader was built without its map,
which would silently apply a wrong key). CSS (self-descramble) and the clear
no-op path are unchanged.

Disc::sweep and Disc::patch resolve a whole-disc key map up front for a
decrypting pass (the fetch secures any missing CPS-unit key, fail-loud) and
decrypt via the map — clear nav/filesystem sectors are in no range and pass
through, so the separate content-range gate and the reactive per-unit
key-fetch recovery are no longer needed. extract_tree keys every unit with
the base Unit Key through the map (its encrypted-flag gate skips clear
files). Multipass sweeps stay --raw.

Removes the obsolete non-mapped-AACS trial/gate/recovery tests (the mapped
path and resolve fail-loud are tested directly).
This commit is contained in:
Matthew Jackson
2026-07-23 13:24:36 -07:00
parent 279ba0dd7c
commit 88e58bfc95
6 changed files with 89 additions and 1545 deletions
-712
View File
@@ -730,43 +730,6 @@ mod tests {
}
}
/// `with_unit_key_idx` selects which unit key the AACS path uses.
/// idx=2 against a single populated key is out of range → the
/// `unit_keys.get(idx)` lookup returns None → DecryptFailed. idx=0
/// is in range → the lookup succeeds, and on a clear (TS-sync
/// intact) full unit the cipher is a no-op, so the read returns Ok
/// with the bytes unchanged. Grounding: `decrypt_sectors`'
/// `unit_keys.get(unit_key_idx)`.
#[test]
fn with_unit_key_idx_selects_key() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// 3 sectors = one 6144-byte aligned unit (so partial_len == 0).
let mut buf = vec![0u8; 3 * 2048];
// idx=2 out of range → lookup fails.
let mut bad =
DecryptingSectorSource::new(ClearUnitSource, keys.clone()).with_unit_key_idx(2);
assert!(
bad.read_sectors(0, 3, &mut buf, false).is_err(),
"out-of-range unit_key_idx must fail the lookup"
);
// idx=0 in range → lookup ok, clear unit left untouched.
let mut good = DecryptingSectorSource::new(ClearUnitSource, keys).with_unit_key_idx(0);
let mut buf2 = vec![0u8; 3 * 2048];
let n = good.read_sectors(0, 3, &mut buf2, false).unwrap();
assert_eq!(n, 3 * 2048);
// Clear unit: sync byte preserved at offset 4.
assert_eq!(
buf2[4], 0x47,
"clear unit must be left intact under valid idx"
);
}
/// `set_keys` must replace the active keys mid-life. We use a
/// CSS-SCRAMBLED-flagged sector (byte 0x14 scramble bits set) so the
/// effect of the active key is observable: under a CSS key the
@@ -839,100 +802,6 @@ mod tests {
);
}
/// Defense-in-depth: an AACS decrypting read whose START LBA is not
/// unit-aligned (lba % 3 != 0) must be rejected with DecryptFailed BEFORE
/// touching the cipher — a mid-unit start would decrypt every unit under the
/// wrong CBC/unit alignment and silently mis-decrypt. A unit-aligned start
/// (lba % 3 == 0) must pass the guard and proceed normally.
///
/// Grounding: the `lba % UNIT_SECTORS != 0` guard in `read_sectors`.
#[test]
fn aacs_unaligned_start_lba_rejected() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Unaligned starts (1, 2, 4, 5, 32 — note 32 % 3 == 2) must all reject.
for lba in [1u32, 2, 4, 5, 32, 64] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let r = wrapped.read_sectors(lba, 3, &mut buf, false);
let err = r.expect_err("unaligned AACS start LBA must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"lba {lba} (% 3 = {}) must reject with DecryptFailed",
lba % 3
);
}
// Unit-aligned starts (0, 3, 33, 66) must pass the guard. ClearUnitSource
// yields TS-clear units, so decrypt is a no-op and the read succeeds.
for lba in [0u32, 3, 33, 66] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let n = wrapped
.read_sectors(lba, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("aligned lba {lba} must pass the guard"));
assert_eq!(n, 3 * 2048);
}
}
/// Clip-anchored gate (the Watership Down "Decryption failed" regression):
/// AACS aligned units are anchored at the clip's encrypted-region start
/// (`unit_base`), NOT absolute disc LBA 0. A clip whose `start_lba` is not
/// itself 3-aligned must gate on ITS OWN units, so the clip's base LBA
/// (which the old `lba % 3` gate wrongly rejected) now passes, and only
/// reads off the clip-relative unit grid reject.
#[test]
fn aacs_gate_is_clip_anchored_not_absolute() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// base = 64 (abs % 3 == 1): the non-3-aligned clip start that triggered
// the bug. The old absolute gate rejected every read here; the clip-
// anchored gate must accept the clip's own unit grid.
let base = 64u32;
// Clip-relative aligned starts (base + {0,3,6,30}) pass.
for off in [0u32, 3, 6, 30] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let n = w
.read_sectors(base + off, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("clip-relative aligned lba {} must pass", base + off));
assert_eq!(n, 3 * 2048);
}
// The clip's base LBA itself (abs % 3 == 1) — the exact read the old gate
// wrongly rejected — must now decrypt.
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
assert!(
w.read_sectors(base, 3, &mut buf, false).is_ok(),
"a clip starting at a non-3-aligned LBA must decrypt from its own base"
);
// Clip-relative MISaligned starts (base + {1,2,4,5}) still reject.
for off in [1u32, 2, 4, 5] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let err = w
.read_sectors(base + off, 3, &mut buf, false)
.expect_err("clip-relative unaligned start must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"base+{off} is off the clip-relative unit grid"
);
}
}
/// The unit-alignment guard is AACS-only. A CSS decrypting read (per-sector,
/// stateless — DVDs) must NOT be gated on a 3-sector boundary: a single
/// sector at lba 1 must read fine. Grounding: the guard is inside
@@ -1028,424 +897,6 @@ mod tests {
unit
}
/// MUX (read > decrypt > mux): an undecryptable AACS content unit must NOT
/// fail the read and must NOT be nulled. The best key is applied and the (bad)
/// bytes pass through to the muxer; broken TS is a muxer concern. The read only
/// hard-fails on a genuine can't-decrypt (no key at all / misaligned unit).
#[test]
fn mux_passes_undecryptable_unit_through_without_nulling() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
o += 192;
}
let mut two_units = enc;
two_units.extend_from_slice(&clear);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// Unit 0 is passed through DECRYPTED (the wrong key was applied), NOT
// null-TS concealed: it is not the all-0x47/PID-0x1FFF null pattern.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// MUX pass-through, mixed buffer: a unit the pool CAN decrypt (a
/// content-fragment TAIL — a few real packets + source-zero padding, the 1.2.0
/// shape, <16 TS syncs) comes out byte-for-byte correct, and a unit it CANNOT
/// (encrypted under an absent key) is passed through best-effort — never
/// null-TS filled, never counted as loss. The old path nulled the good tail
/// (silent data loss) whenever it shared a buffer with an undecryptable unit.
#[test]
fn mux_passes_both_decryptable_and_undecryptable_units_through() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// Unit A (absent key) → passed through best-effort, NOT null-TS concealed.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decryptable padding-tail unit comes out byte-for-byte correct"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
/// that returns that key, the decorator must hand the still-scrambled unit to
/// the callback, add the returned key, re-decrypt, and register ZERO loss.
/// Without the callback the same read accumulates loss (the baseline).
///
/// Grounding: `read_sectors` invokes `fetch_failed_units` when
/// `decrypt_sectors` leaves a scrambled unit and a callback is installed.
#[test]
fn key_fetch_recovers_unit_with_a_fresh_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
struct EncUnitSource {
unit: Vec<u8>,
}
impl SectorSource for EncUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.unit);
Ok(bytes)
}
}
let unit = encrypt_aacs_unit(&real_key);
// Capture what the callback was handed, and how many times it fired.
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
vec![real_key]
}));
let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
// The recovered key decrypts the unit: it is now clean TS in `buf`.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
assert!(
crate::aacs::content::is_clean(unit0, crate::disc::ContentFormat::BdTs),
"fetch supplied the key → the unit decrypts to clean TS"
);
let got = seen.lock().unwrap();
assert_eq!(
got.len(),
1,
"callback must be invoked once with the failing unit"
);
assert!(
!crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
got[0], unit,
"the exact on-disc unit is forwarded for fetch"
);
}
/// THE MUX-STORM REGRESSION. A unit the held key OPENS (>= the 4-packet proof
/// floor) but that carries many authored-bad packets (< half synced) must
/// NEVER be handed to the key-fetch closure — its key is already in hand. Only
/// a GENUINE miss (no held key opens it) is sampled. Before the min(E,4)
/// unification, the bad-encoded unit tripped the old >50% majority in
/// `aacs_unit_needs_decrypt`, so every batch re-sampled it to the key service
/// (the Jason Bourne / Stand By Me stall). This drives the REAL
/// `DecryptingSectorSource` recovery path, not a synthetic check.
#[test]
fn bad_encoded_opened_unit_is_never_sampled_to_the_key_service() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
let held = [0x5au8; 16]; // opens the bad-encoded unit
let orphan = [0x77u8; 16]; // opens the genuine-miss unit (NOT held)
// Knock out packets 1..27 (26 authored-bad) → ~5 synced: >= the 4-packet
// floor (OPENED) yet < half (what the old >50% majority false-flagged).
let bad_pkts: Vec<usize> = (1..27).collect();
let bad_encoded = encrypt_aacs_unit_bad(&held, &bad_pkts);
let genuine_miss = encrypt_aacs_unit(&orphan);
// One 6-sector read spans both units: bad-encoded at [0,3), miss at [3,6).
struct TwoUnits {
a: Vec<u8>,
b: Vec<u8>,
}
impl SectorSource for TwoUnits {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let n = crate::aacs::content::ALIGNED_UNIT_LEN;
buf[..n].copy_from_slice(&self.a);
buf[n..2 * n].copy_from_slice(&self.b);
Ok(2 * n)
}
}
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
Vec::new() // service has nothing for the orphan — forces the sampling path
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]);
let mut dec = DecryptingSectorSource::new(
TwoUnits {
a: bad_encoded.clone(),
b: genuine_miss.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, held)], // opens bad_encoded, NOT genuine_miss
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 6 * 2048];
let _ = dec.read_sectors(0, 6, &mut buf, false);
let got = seen.lock().unwrap();
assert!(
!got.is_empty(),
"the genuine orphan-key miss must trigger a fetch"
);
for s in got.iter() {
assert_ne!(
&s[..ALIGNED_UNIT_LEN.min(s.len())],
&bad_encoded[..],
"a bad-encoded unit the key OPENED must NEVER be sampled (the storm)"
);
}
assert!(
got.iter().any(|s| s.as_slice() == genuine_miss.as_slice()),
"only the genuine miss is sampled to the key service"
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
// LBA-addressable (like a real File/drive): unit A at LBA 0..3, unit B
// at LBA 3..6. Re-reading the same LBA returns the same ciphertext — the
// key-fetch recovery re-reads on a miss, so a call-order-stateful mock
// would hand it the wrong unit.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = if lba < 3 {
&self.units[0]
} else {
&self.units[1]
};
buf[..bytes].copy_from_slice(u);
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
}));
let mut wrapped = DecryptingSectorSource::new(
AltSource {
units: vec![unit_a, unit_b],
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]
@@ -1476,43 +927,6 @@ mod tests {
}
}
/// In-place decrypt + content map: a NON-content read passes through unchanged
/// (ciphertext, not decrypted); an in-content read is decrypted IN PLACE.
#[test]
fn inplace_decrypt_content_gate_passes_clear_decrypts_content() {
let key = [0x5a; 16];
let cipher_unit = encrypt_aacs_unit(&key);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); // content @ 1002..
let mut dec = DecryptingSectorSource::new(
FixedUnit {
unit: cipher_unit.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges); // decrypt in place, content-gated
// Non-content read (LBA 0): not decrypted → buf stays ciphertext.
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(
buf, cipher_unit,
"a non-content read is passed through, not decrypted"
);
// In-content read (LBA 1002): decrypted in place → TS sync restored.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(1002, 3, &mut buf2, false).unwrap();
assert_ne!(
buf2, cipher_unit,
"an in-content read is decrypted in place"
);
assert_eq!(buf2[4], 0x47, "decrypted content carries the TS sync byte");
}
/// A source that returns a fixed encrypted unit for ANY read — used to drive
/// the verify-only fetch + cache tests below.
struct AnyLbaUnit {
@@ -1531,130 +945,4 @@ mod tests {
Ok(b)
}
}
/// CPS-2 key recovery at the read level: a content unit no HELD key opens hands
/// its on-disc ciphertext to the fetch closure, the returned key is added to the
/// pool (the CACHE) and the read is re-decrypted IN PLACE. The cached key then
/// serves the NEXT unit WITHOUT another callback (≈one fetch per CPS unit) —
/// what stops an orphan CPS unit from producing garbage.
#[test]
fn fetch_recovers_and_caches_the_cps_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
// The closure is handed the still-scrambled on-disc ciphertext.
assert!(!samples.is_empty(), "fetch receives the failing units");
assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN);
vec![real_key]
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
// First read (LBA 0): wrong key fails → fetch supplies real_key → the read
// is re-decrypted IN PLACE, so buf comes out clean TS (not the ciphertext).
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("fetch recovers the orphan unit's key");
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
assert!(
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the recovered read is clean TS"
);
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
// Second read (LBA 3): real_key now CACHED → decrypts with no new callback.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(3, 3, &mut buf2, false)
.expect("cached key serves the next unit");
assert_eq!(
*calls.lock().unwrap(),
1,
"cache hit — the fetch callback must NOT fire again"
);
}
/// Bad-encoding pass-through: a unit the held key OPENS (the proof floor is >=4
/// good packets) but that carries many authored-bad packets reads Ok and is
/// DECRYPTED in place — never fails loud, never grinds on a physically fine
/// read. The old 75% proportion false-failed this exact unit.
#[test]
fn bad_encoded_unit_the_key_opened_passes_through_decrypted() {
let key = [0x5au8; 16];
// 20 authored-bad packets (1..21); packets 0 + 21..31 stay clean → 11 good
// encrypted packets ≥ the 4-packet proof floor, so the key OPENED the unit.
let bad: Vec<usize> = (1..21).collect();
let unit = encrypt_aacs_unit_bad(&key, &bad);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]);
let mut dec = DecryptingSectorSource::new(
FixedUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("a bad-encoded unit the key OPENED reads Ok, never fail-loud");
assert_ne!(
buf, unit,
"the unit is decrypted in place, not left ciphertext"
);
// The 11 good packets recovered their TS sync (the muxer drops the bad ones).
assert_eq!(buf[21 * 192 + 4], 0x47, "a good packet restored its sync");
}
/// The fetch is content-gated: a scrambled unit OUTSIDE the content extents
/// is clear filesystem, not ciphertext, so the read succeeds and the fetch
/// callback is never consulted (no wasted key-server traffic on nav/UDF).
#[test]
fn fetch_not_called_outside_content() {
let real_key = [0x5au8; 16];
let wrong = [0x11u8; 16];
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |_: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
vec![real_key]
}));
// Content lives far away; LBA 0 is "filesystem".
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]);
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("non-content scrambled-looking bytes read OK (gated out)");
assert_eq!(
*calls.lock().unwrap(),
0,
"fetch must NOT fire for a non-content unit"
);
}
}