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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user