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:
+13
-760
@@ -523,273 +523,25 @@ pub fn decrypt_sectors_in_content(
|
||||
decrypt_sectors_impl(buf, keys, unit_key_idx, Some((base_lba, content_ranges)))
|
||||
}
|
||||
|
||||
/// True if `lba` falls inside one of the sorted, merged, disjoint
|
||||
/// `(start, count)` ranges (same representation as [`crate::udf::merge_ranges`]
|
||||
/// and `Extent`). O(log n) binary search — cheap enough to run per unit.
|
||||
pub(crate) fn lba_in_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool {
|
||||
match ranges.binary_search_by(|&(start, _)| start.cmp(&lba)) {
|
||||
Ok(_) => true, // lba is exactly a range start
|
||||
Err(0) => false, // before the first range
|
||||
Err(i) => {
|
||||
let (start, count) = ranges[i - 1];
|
||||
lba < start.saturating_add(count) // inside the range that starts before lba?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt_sectors_impl(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
content: Option<(u32, &[(u32, u32)])>,
|
||||
// Unused now that AACS decrypts via the key map only; the CSS arm self-gates on
|
||||
// its per-sector scramble flag and `None` is a no-op. Kept so the wrapper
|
||||
// signatures (decrypt_sectors / _in_content) stay stable for CSS/None callers.
|
||||
_unit_key_idx: usize,
|
||||
_content: Option<(u32, &[(u32, u32)])>,
|
||||
) -> Result<usize, crate::error::Error> {
|
||||
let dropped: usize = match keys {
|
||||
DecryptKeys::None => 0,
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
format,
|
||||
} => {
|
||||
// Validate that unit_key_idx is in-range before doing anything else.
|
||||
// This preserves the existing contract: an out-of-range explicit index
|
||||
// is always an error (tested by `aacs_out_of_range_unit_key_idx_errors`).
|
||||
if unit_keys.get(unit_key_idx).is_none() {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
|
||||
// Container of this disc's content — the key SELECTOR (`is_clean`)
|
||||
// checks the decrypted plaintext against the right structure (TS vs PS).
|
||||
let format = *format;
|
||||
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
|
||||
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
|
||||
// closures only ever need `len()` / `[idx].1`, so collecting one would
|
||||
// just be a heap alloc/free on every batch of the mux hot path.
|
||||
let rdk: Option<[u8; 16]> = *read_data_key;
|
||||
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
||||
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
|
||||
// extent tail, so a buffer is commonly NOT a multiple of the unit
|
||||
// length. We process the whole leading units exactly as a fully
|
||||
// aligned buffer would be, then make a deliberate decision about any
|
||||
// trailing partial unit.
|
||||
//
|
||||
// Trailing-partial contract:
|
||||
// * A clear partial (incomplete final unit / clear nav-TS tail) is
|
||||
// what AACS legitimately leaves in the clear on disc, so we leave
|
||||
// it untouched and return Ok. This is the proven, shipped
|
||||
// behavior every production UHD MKV was made with — no regression
|
||||
// on conformant discs.
|
||||
// * A *scrambled* partial can only arise from a structurally
|
||||
// malformed UDF layout that splits an encrypted unit across an
|
||||
// extent boundary. Those bytes are encrypted content that cannot
|
||||
// be decrypted standalone; passing them through as clear would be
|
||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||
//
|
||||
// Detection: `is_clean` cannot judge a partial (it reports a
|
||||
// shorter-than-a-full-unit buffer as clean, having no full encrypted
|
||||
// packet to check), so we apply the same TS-sync-intactness test it
|
||||
// uses internally (ts_sync_count vs ts_packet_total) directly to the
|
||||
// available partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
|
||||
// stride (> half the packets) → intact → not scrambled → tolerate. An
|
||||
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
|
||||
// reject. If the partial is too short to hold even one TS packet
|
||||
// (< 192 bytes, ts_packet_total == 0) we cannot judge confidently and
|
||||
// tolerate rather than risk a false positive on conformant tails.
|
||||
let partial_len = buf.len() % unit_len;
|
||||
if partial_len != 0 {
|
||||
// Gate the trailing partial on content too: a scrambled partial
|
||||
// OUTSIDE the encrypted m2ts extents is just clear non-TS bytes
|
||||
// (filesystem tail), not a malformed encrypted unit, so it must
|
||||
// not hard-fail. `nfull * 3` is the partial's absolute LBA.
|
||||
let nfull = (buf.len() / unit_len) as u32;
|
||||
let partial_in_content = match content {
|
||||
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
||||
None => true,
|
||||
};
|
||||
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
|
||||
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
|
||||
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
|
||||
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
|
||||
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
|
||||
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
|
||||
let partial = &buf[buf.len() - partial_len..];
|
||||
if !aacs::content::is_clean(partial, format) {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nthreads = decrypt_threads();
|
||||
let nunits = buf.len() / unit_len;
|
||||
|
||||
// Cache the last successfully-validated key index so that runs of
|
||||
// units under the same CPS unit hit on the first try. Initialised to
|
||||
// unit_key_idx (the caller's hint — 0 for almost all discs). An
|
||||
// AtomicUsize lets the parallel path share it cheaply; relaxed
|
||||
// ordering is fine because a stale read just causes one extra try,
|
||||
// never a wrong result (TS-sync verify gates correctness).
|
||||
let last_key_idx = AtomicUsize::new(unit_key_idx);
|
||||
|
||||
// Count bytes of scrambled units that NO key could decrypt. Shared
|
||||
// across the rayon workers (relaxed is fine — it's a pure tally, not
|
||||
// a synchronisation point). A non-zero total is silent decrypt loss:
|
||||
// the bytes pass downstream still encrypted and the TS assembler
|
||||
// drops them without a sync. The caller folds this into mux loss
|
||||
// accounting so a partial key failure isn't reported as a clean rip.
|
||||
let dropped_bytes = AtomicUsize::new(0);
|
||||
|
||||
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
|
||||
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||
// common case where a disc run uses one CPS unit throughout).
|
||||
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
||||
// 3. Select the first key whose output passes the TS-sync verify.
|
||||
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
|
||||
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
|
||||
// tally the unit as unverified. Never restore ciphertext / null.
|
||||
// Nav protection is the caller's content gate, not a restore here.
|
||||
//
|
||||
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
||||
// must happen first — it's a shared layer on top that is key-independent
|
||||
// across all CPS units on the disc.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
|
||||
// not yet restored): the flag alone isn't enough because it lives in
|
||||
// the plaintext header and survives decryption, so an already-decrypted
|
||||
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
|
||||
// this pass. The structure-restored half makes it idempotent. This is
|
||||
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
|
||||
// decrypting a clear unit.
|
||||
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
|
||||
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
|
||||
if let Some(ref rdk_key) = rdk {
|
||||
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||
}
|
||||
|
||||
// Reorder the key iterator: try the cached hint first, then fall
|
||||
// back to the full list skipping the hint.
|
||||
let hint = last_key_idx.load(Ordering::Relaxed);
|
||||
let try_order =
|
||||
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
|
||||
|
||||
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
|
||||
// is the decrypt (apply the key, leave the plaintext). `is_clean`
|
||||
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
|
||||
// key SELECTOR — the first key whose output is clean for the disc's
|
||||
// container (`format`: TS or PS) is the match. "Did a key produce
|
||||
// clean structure?" is NOT "did we decrypt?": a correct key can
|
||||
// decrypt content whose encoding is broken (a muxer concern). When
|
||||
// NO key yields clean structure we STILL decrypted (the cached-hint
|
||||
// key is applied): keep those bytes and report the unit UNVERIFIED.
|
||||
// This function applies no policy; the caller decides what unverified
|
||||
// means (mux passes it to the muxer; sweep/patch recover or fail).
|
||||
|
||||
// Single-key fast path (the vast majority of titles): with no
|
||||
// alternate key to fall back on there is nothing to try/rollback,
|
||||
// so decrypt in place — no per-unit scratch alloc or copy-back.
|
||||
// Clean → cache the hint; unclean → keep the applied bytes and
|
||||
// tally unverified, exactly as the loop below would with one key.
|
||||
if unit_keys.len() == 1 {
|
||||
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
|
||||
if aacs::content::is_clean(chunk, format) {
|
||||
last_key_idx.store(0, Ordering::Relaxed);
|
||||
} else {
|
||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Trial each key against a STACK scratch (unit_len is always
|
||||
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
|
||||
// so a failing attempt doesn't clobber the bus-decrypted base in
|
||||
// `chunk` that the next key retries on — with no per-key heap Vec.
|
||||
// `chunk` is NOT mutated in this loop, so on total miss we simply
|
||||
// re-apply the first key in place (decrypt_unit is pure), which
|
||||
// reproduces the first attempt without stashing its bytes.
|
||||
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let scratch = &mut scratch[..chunk.len()];
|
||||
let mut first_idx: Option<usize> = None;
|
||||
for idx in try_order {
|
||||
if let Some((_, key)) = unit_keys.get(idx) {
|
||||
scratch.copy_from_slice(chunk);
|
||||
aacs::content::decrypt_unit(scratch, key);
|
||||
if aacs::content::is_clean(scratch, format) {
|
||||
chunk.copy_from_slice(scratch);
|
||||
last_key_idx.store(idx, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if first_idx.is_none() {
|
||||
first_idx = Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No key yielded clean structure. Keep the first-tried key's
|
||||
// plaintext (the pool is non-empty past the guard, so `first_idx` is
|
||||
// always `Some`) and tally the unit as unverified. Never restore
|
||||
// ciphertext; that is a caller concern, threaded through the recovery
|
||||
// ciphertext, not this seam.
|
||||
if let Some(idx) = first_idx {
|
||||
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
|
||||
}
|
||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
};
|
||||
|
||||
// Content gate wrapper: when a gate is supplied, skip any unit whose
|
||||
// absolute LBA lies OUTSIDE the encrypted-content extents — it is
|
||||
// clear non-TS data (filesystem / nav) and must never be decrypted,
|
||||
// verified, or counted as loss. Each aligned unit is 3 sectors.
|
||||
let unit_sectors = (unit_len / 2048) as u32;
|
||||
let process = |idx: usize, chunk: &mut [u8]| {
|
||||
if let Some((base, ranges)) = content {
|
||||
let unit_lba = base.saturating_add((idx as u32) * unit_sectors);
|
||||
if !lba_in_ranges(unit_lba, ranges) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
decrypt_one(chunk);
|
||||
};
|
||||
|
||||
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||
// Serial path: avoids thread-pool overhead for tiny
|
||||
// buffers; also the only path when caller pinned
|
||||
// single-threaded via FREEMKV_THREADS=1. Iterate the
|
||||
// chunks directly — no Vec of slice pointers needed.
|
||||
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||
process(idx, chunk);
|
||||
}
|
||||
} else {
|
||||
// Parallel path via rayon's persistent thread pool.
|
||||
// The pool is built once on first use and reused across
|
||||
// every decrypt_sectors call — no per-call OS thread
|
||||
// spawn. Each unit decrypts independently (own key
|
||||
// derivation), so par_iter is sound. On a pool-build
|
||||
// failure (e.g. thread/pid-limit exhaustion) we fall
|
||||
// back to the serial path rather than panic.
|
||||
match decrypt_pool() {
|
||||
Some(pool) => {
|
||||
// `par_chunks_mut` iterates the units in place — no
|
||||
// intermediate `Vec<&mut [u8]>` allocation per batch.
|
||||
pool.install(|| {
|
||||
buf.par_chunks_mut(unit_len)
|
||||
.enumerate()
|
||||
.for_each(|(idx, chunk)| {
|
||||
process(idx, chunk);
|
||||
});
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||
process(idx, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dropped_bytes.into_inner()
|
||||
DecryptKeys::Aacs { .. } => {
|
||||
// AACS decrypts EXCLUSIVELY through the resolved key map
|
||||
// (`decrypt_sectors_mapped`): the map keys every content unit up front,
|
||||
// and a missing key fails at RESOLVE time. The old trial-decrypt path
|
||||
// (try each held key, keep the first-tried plaintext on a miss) is gone
|
||||
// — reaching it means an AACS reader was built without installing its
|
||||
// key map, which would silently apply a wrong key. Fail loud instead.
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
// CSS SELF-recovers: the title key changes per VOB region and is
|
||||
@@ -809,39 +561,6 @@ fn decrypt_sectors_impl(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
|
||||
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
|
||||
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
|
||||
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
|
||||
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
|
||||
/// content-gated, and every whole-disc caller passes the encrypted-content
|
||||
/// extents so nav LBAs are skipped entirely and left untouched.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_when_gated_out_of_content() {
|
||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
unit[0] = b'M';
|
||||
unit[1] = b'P';
|
||||
unit[2] = b'L';
|
||||
unit[3] = b'S';
|
||||
for (i, b) in unit.iter_mut().enumerate().skip(4) {
|
||||
*b = (i as u8).wrapping_mul(31);
|
||||
}
|
||||
let snapshot = unit.clone();
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
|
||||
// so this nav unit is OUTSIDE content and the gate skips it untouched.
|
||||
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
unit, snapshot,
|
||||
"a nav unit outside the content extents must be left untouched by the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a clear-TS region: a 0x47 sync byte at offset 4 of every 192-byte
|
||||
/// BD-TS packet (matching `ts_sync_count`'s probe stride), filler elsewhere.
|
||||
/// Reads as NOT scrambled.
|
||||
@@ -878,163 +597,6 @@ mod tests {
|
||||
|
||||
// ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ──
|
||||
|
||||
#[test]
|
||||
fn lba_in_ranges_membership() {
|
||||
// (start, count) ⇒ [10,15) and [100,110).
|
||||
let r = &[(10u32, 5u32), (100, 10)];
|
||||
assert!(!lba_in_ranges(0, r), "before first range");
|
||||
assert!(!lba_in_ranges(9, r), "just before first range");
|
||||
assert!(lba_in_ranges(10, r), "at first range start");
|
||||
assert!(lba_in_ranges(14, r), "inside first range");
|
||||
assert!(!lba_in_ranges(15, r), "first range end is exclusive");
|
||||
assert!(!lba_in_ranges(50, r), "in the gap between ranges");
|
||||
assert!(lba_in_ranges(100, r), "at second range start");
|
||||
assert!(lba_in_ranges(109, r), "inside second range");
|
||||
assert!(!lba_in_ranges(110, r), "second range end is exclusive");
|
||||
assert!(!lba_in_ranges(5, &[]), "empty set has no members");
|
||||
}
|
||||
|
||||
/// The content gate at the decrypt primitive: a scrambled-LOOKING unit
|
||||
/// OUTSIDE the content extents (e.g. UDF filesystem) must be SKIPPED — never
|
||||
/// decrypted, never counted as loss. The SAME bytes INSIDE content are
|
||||
/// checked and counted. This is the first-2 GB false-positive fix.
|
||||
#[test]
|
||||
fn content_gate_skips_non_content_units() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
|
||||
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped, 0,
|
||||
"a non-content unit must not count as decrypt loss"
|
||||
);
|
||||
assert_eq!(
|
||||
buf, original,
|
||||
"a non-content unit must be left byte-for-byte untouched"
|
||||
);
|
||||
|
||||
// Same bytes INSIDE content (base_lba 100, range covers LBA 100..103).
|
||||
let mut buf2 = original.clone();
|
||||
let dropped2 =
|
||||
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped2,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"an undecryptable CONTENT unit IS counted as loss"
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-unit gating across a content boundary: in a 2-unit buffer where only
|
||||
/// the second unit (LBA 3..6) is content, only the second is decrypt-checked.
|
||||
#[test]
|
||||
fn content_gate_is_per_unit_across_a_boundary() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A content range covering the whole buffer must behave EXACTLY like the
|
||||
/// ungated `decrypt_sectors` — the gate adds nothing when everything is content.
|
||||
#[test]
|
||||
fn content_gate_covering_whole_buffer_matches_ungated() {
|
||||
let mut keys_g = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut keys_u = keys_g.clone();
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut g = original.clone();
|
||||
let mut u = original.clone();
|
||||
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
|
||||
let ungated = decrypt_sectors(&mut u, &mut keys_u, 0).unwrap();
|
||||
assert_eq!(
|
||||
gated, ungated,
|
||||
"gated-covering-all == ungated dropped count"
|
||||
);
|
||||
assert_eq!(g, u, "gated-covering-all == ungated bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_in_ranges_more_edges() {
|
||||
// Single range [5,8).
|
||||
assert!(!lba_in_ranges(4, &[(5, 3)]), "just before single range");
|
||||
assert!(lba_in_ranges(5, &[(5, 3)]), "at single range start");
|
||||
assert!(lba_in_ranges(7, &[(5, 3)]), "inside single range");
|
||||
assert!(
|
||||
!lba_in_ranges(8, &[(5, 3)]),
|
||||
"single range end is exclusive"
|
||||
);
|
||||
// After the last range.
|
||||
assert!(
|
||||
!lba_in_ranges(200, &[(10, 5), (100, 10)]),
|
||||
"past the last range"
|
||||
);
|
||||
// Saturating: a range whose start+count overflows u32 must not panic. The
|
||||
// end saturates to u32::MAX, so the very top LBA is excluded — a harmless
|
||||
// edge (real disc LBAs never reach u32::MAX). The range start is still in.
|
||||
assert!(
|
||||
lba_in_ranges(u32::MAX - 1, &[(u32::MAX - 1, 5)]),
|
||||
"saturating range start is in"
|
||||
);
|
||||
assert!(
|
||||
!lba_in_ranges(u32::MAX, &[(u32::MAX - 1, 5)]),
|
||||
"saturated end excludes the top"
|
||||
);
|
||||
}
|
||||
|
||||
/// An EMPTY content map gates EVERYTHING out — even a scrambled unit is
|
||||
/// skipped (treated as non-content). This is the no-titles fallback at the
|
||||
/// primitive level.
|
||||
#[test]
|
||||
fn content_gate_empty_ranges_skips_everything() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
|
||||
assert_eq!(
|
||||
dropped, 0,
|
||||
"empty content map ⇒ nothing is content ⇒ no loss"
|
||||
);
|
||||
assert_eq!(buf, original, "empty content map ⇒ buffer untouched");
|
||||
}
|
||||
|
||||
/// A CLEAR (sync-intact) unit INSIDE content is not ciphertext, so even though
|
||||
/// it is in-content it is skipped by the ts-sync check and never counted.
|
||||
#[test]
|
||||
fn content_gate_clear_unit_in_content_not_counted() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
|
||||
assert_eq!(buf, original, "a clear in-content unit is left untouched");
|
||||
}
|
||||
|
||||
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
|
||||
#[test]
|
||||
fn content_gate_none_keys_is_noop() {
|
||||
@@ -1138,91 +700,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
||||
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
||||
#[test]
|
||||
fn content_gate_mixed_three_units() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let u = aacs::content::ALIGNED_UNIT_LEN;
|
||||
let mut buf = vec![0u8; 3 * u];
|
||||
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
|
||||
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
|
||||
buf[2 * u..].copy_from_slice(&scrambled_region(u)); // unit2 @ LBA6 scrambled
|
||||
// Content = LBA 0..6 (units 0 and 1); unit2 (LBA6) is out of content.
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 6)]).unwrap();
|
||||
assert_eq!(dropped, u, "only unit0 (in-content + scrambled) counts");
|
||||
}
|
||||
|
||||
/// Mirror of the boundary test: content covers the FIRST unit only.
|
||||
#[test]
|
||||
fn content_gate_covers_first_unit_only() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"only unit0 counts"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
|
||||
/// OUTSIDE content is clear filesystem tail, not a malformed encrypted unit,
|
||||
/// so it must NOT hard-fail.
|
||||
#[test]
|
||||
fn content_gate_scrambled_partial_outside_content_is_tolerated() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
||||
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&scrambled_region(2048));
|
||||
// content far away → both the full unit and the partial are non-content.
|
||||
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"a scrambled partial outside content must not hard-fail"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whole leading units plus a CLEAR trailing partial (the benign,
|
||||
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS
|
||||
/// tail in the clear on disc. We must return `Ok` and leave the partial
|
||||
/// bytes byte-for-byte unchanged — no regression on real discs.
|
||||
#[test]
|
||||
fn aacs_clear_trailing_partial_is_tolerated_unchanged() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
||||
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let tail = clear_ts_region(2048);
|
||||
let mut buf = unit;
|
||||
buf.extend_from_slice(&tail);
|
||||
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
|
||||
|
||||
assert_eq!(
|
||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||
&tail[..],
|
||||
"clear trailing partial unit must be left unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1249,39 +726,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty buffer is a valid no-op (zero units), not an error.
|
||||
#[test]
|
||||
fn aacs_empty_buffer_is_ok() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
||||
}
|
||||
|
||||
/// An exact multiple of the unit length has no trailing partial: behavior
|
||||
/// is unchanged — clear units stay clear, scrambled units are decrypt-
|
||||
/// attempted. Two clear units must round-trip untouched and return `Ok`.
|
||||
#[test]
|
||||
fn aacs_exact_multiple_unchanged() {
|
||||
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 * 2);
|
||||
let snapshot = buf.clone();
|
||||
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
|
||||
|
||||
assert_eq!(
|
||||
buf, snapshot,
|
||||
"clear exact-multiple buffer must be left unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
// ── DecryptKeys::None and is_encrypted ─────────────────────────────────
|
||||
|
||||
/// DecryptKeys::None is a pure no-op: the buffer must be returned
|
||||
@@ -1806,197 +1250,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit encrypted under unit_keys[1] (the second CPS unit) on a
|
||||
/// two-key disc must be correctly decrypted — not left as garbage —
|
||||
/// when `decrypt_sectors` is called with unit_key_idx=0 (the default).
|
||||
///
|
||||
/// Before the fix, `decrypt_one` used only `unit_keys[unit_key_idx]`
|
||||
/// (i.e. always key 0). On a multi-CPS-unit disc this produced silent
|
||||
/// garbage for content under key ≥ 1. The fix tries every key and
|
||||
/// accepts the one whose output passes the TS-sync verify.
|
||||
///
|
||||
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
|
||||
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
||||
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
||||
/// garbled bytes that still look scrambled, failing the `is_clean`
|
||||
/// assert.
|
||||
#[test]
|
||||
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
||||
let key0 = [0x11u8; 16]; // CPS unit 0 key — NOT the correct key for this unit
|
||||
let key1 = [0x22u8; 16]; // CPS unit 1 key — the correct key
|
||||
|
||||
// Build and encrypt a clear unit under key1 (the non-default CPS unit).
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||
assert!(
|
||||
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||
"encrypted unit must look scrambled before decrypt"
|
||||
);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
|
||||
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||
|
||||
assert!(
|
||||
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||
);
|
||||
// Every sync position must carry 0x47.
|
||||
assert_eq!(
|
||||
aacs::content::ts_sync_count(&buf),
|
||||
aacs::content::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored after decrypting under key1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Single-key disc: the common case is unaffected — the single key is
|
||||
/// tried first (via the hint) and validates, so no second-pass overhead.
|
||||
///
|
||||
/// Grounding: the `hint = last_key_idx.load(…)` path returns on the first
|
||||
/// `try_order` iteration. A regression that always tried all keys (instead
|
||||
/// of accepting the first hit) would still pass this test — correctness is
|
||||
/// the invariant here, not the performance shortcut.
|
||||
#[test]
|
||||
fn aacs_single_key_disc_still_decrypts_correctly() {
|
||||
let key = [0x55u8; 16];
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||
assert!(
|
||||
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||
"single-key disc: TS syncs must be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
aacs::content::ts_sync_count(&buf),
|
||||
aacs::content::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored for single-key disc"
|
||||
);
|
||||
}
|
||||
|
||||
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
|
||||
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
|
||||
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
|
||||
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
|
||||
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
|
||||
/// muxer). This is the single decrypt authority applying no policy.
|
||||
///
|
||||
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
|
||||
/// the removal of the `copy_from_slice(&original)` restore.
|
||||
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
|
||||
#[test]
|
||||
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
||||
|
||||
// Encrypt a clear unit under real_key, then offer ONLY the wrong key.
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||
let ciphertext = unit.clone();
|
||||
assert!(
|
||||
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||
"encrypted unit must look scrambled going in"
|
||||
);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, wrong_key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf = unit;
|
||||
let unverified =
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
|
||||
|
||||
assert_eq!(
|
||||
unverified,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"a unit that did not reach clean TS is reported unverified"
|
||||
);
|
||||
assert_ne!(
|
||||
buf, ciphertext,
|
||||
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dropped-byte tally accumulates across a multi-unit buffer where some
|
||||
/// units decrypt and others don't: a 2-unit buffer with one good and one
|
||||
/// bad unit reports exactly one unit's worth of loss, and the good unit is
|
||||
/// fully decrypted. Confirms the count is per-unit, not all-or-nothing.
|
||||
///
|
||||
/// Grounding: the per-chunk `decrypt_one` closure tallies only the units
|
||||
/// that fail; the good unit takes the `return` before the tally.
|
||||
#[test]
|
||||
fn aacs_mixed_buffer_tallies_only_failed_units() {
|
||||
let key = [0x55u8; 16];
|
||||
let wrong = [0x66u8; 16];
|
||||
|
||||
// Unit A: encrypted under `key` (decryptable). Unit B: encrypted under
|
||||
// `wrong` (NOT in the key list → undecryptable).
|
||||
let mut unit_a = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit_a, &key);
|
||||
let mut unit_b = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
|
||||
let unit_b_ciphertext = unit_b.clone();
|
||||
|
||||
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&unit_a);
|
||||
buf.extend_from_slice(&unit_b);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"exactly one unit's worth of bytes must be reported unverified"
|
||||
);
|
||||
assert!(
|
||||
crate::aacs::content::is_clean(
|
||||
&buf[..aacs::content::ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs
|
||||
),
|
||||
"the decryptable unit must come out clear"
|
||||
);
|
||||
assert_ne!(
|
||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||
&unit_b_ciphertext[..],
|
||||
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fully-decryptable single-key buffer reports zero dropped bytes — the
|
||||
/// loss tally must not fire on the clean path.
|
||||
#[test]
|
||||
fn aacs_all_units_decrypt_reports_zero_dropped() {
|
||||
let key = [0x77u8; 16];
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut buf = unit;
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
||||
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
|
||||
}
|
||||
|
||||
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
|
||||
|
||||
/// The default (auto) decrypt thread count is always a usable pool size:
|
||||
|
||||
@@ -192,6 +192,16 @@ impl Disc {
|
||||
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
||||
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
||||
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
||||
// AACS decrypts via the key map. Extract reads arbitrary files (not resolved
|
||||
// title extents), so key every unit with the disc's base Unit Key: the mapped
|
||||
// decrypt applies it to encrypted units and passes clear filesystem/nav
|
||||
// through (its encrypted-flag gate). Single-CPS is exact; a multi-CPS disc's
|
||||
// secondary units are not separately keyed here (extract is not the mux path).
|
||||
if matches!(base_keys, DecryptKeys::Aacs { .. }) {
|
||||
dec = dec.with_key_map(std::sync::Arc::new(
|
||||
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
|
||||
));
|
||||
}
|
||||
|
||||
let mut result = ExtractResult::default();
|
||||
let total_bytes = required;
|
||||
|
||||
+50
-9
@@ -2357,6 +2357,37 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a WHOLE-DISC AACS key map for a decrypting sweep (`disc:// → iso://`):
|
||||
/// the union of every title's proactive key map ([`crate::mux::resolve_mux_key_map`]),
|
||||
/// so a sequential read of the entire disc decrypts each content unit with its
|
||||
/// mapped key and passes clear filesystem/nav sectors (in no range) through.
|
||||
/// Fails loud (via the per-title resolve) if any content unit's key is missing.
|
||||
/// `keys` is mutated as fetched keys are banked; the merged ranges are disjoint
|
||||
/// (titles that share a clip resolve the same span — the duplicate is dropped).
|
||||
pub(crate) fn resolve_content_key_map(
|
||||
&self,
|
||||
reader: &mut dyn SectorSource,
|
||||
keys: &mut crate::decrypt::DecryptKeys,
|
||||
fetch: Option<&crate::sector::KeyFetch>,
|
||||
) -> Result<crate::decrypt::AacsKeyMap> {
|
||||
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
|
||||
for title in &self.titles {
|
||||
let map =
|
||||
crate::mux::resolve_mux_key_map(reader, title, keys, fetch, self.content_format)?;
|
||||
ranges.extend_from_slice(map.ranges());
|
||||
}
|
||||
ranges.sort_by_key(|&(s, _, _, _)| s);
|
||||
let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
|
||||
for r in ranges {
|
||||
// Drop a range that overlaps one already kept (a clip shared by two
|
||||
// titles resolves the same span twice) — entry_for needs disjoint ranges.
|
||||
if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) {
|
||||
merged.push(r);
|
||||
}
|
||||
}
|
||||
Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(merged))
|
||||
}
|
||||
|
||||
/// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
|
||||
/// `(start_lba, sector_count)` ranges — the union of every title's m2ts
|
||||
/// stream extents.
|
||||
@@ -3138,27 +3169,37 @@ impl Disc {
|
||||
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
|
||||
// verify and no post-sweep clip-anchored pass; decryptability is proven at
|
||||
// mux time, not at capture time.)
|
||||
let keys = if opts.decrypt {
|
||||
let mut keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
// Content extent map — only the in-place decrypt path (`opts.decrypt`) gates
|
||||
// on it so clear filesystem / nav sectors pass through untouched.
|
||||
// AACS decrypting sweep: resolve a WHOLE-DISC key map up front (the fetch
|
||||
// secures any missing CPS-unit key, fail-loud) and decrypt via the map —
|
||||
// a clear nav/filesystem sector is in no range and passes through, so no
|
||||
// separate content gate is needed. CSS keeps the content-gated
|
||||
// self-descramble path (the map path is AACS-only).
|
||||
let key_map = if opts.decrypt && decrypt_is_aacs {
|
||||
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
||||
reader,
|
||||
&mut keys,
|
||||
opts.key_fetch.as_ref(),
|
||||
)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
if let Some(map) = key_map {
|
||||
dec = dec.with_key_map(map);
|
||||
} else if opts.decrypt && can_gate {
|
||||
// CSS / clear decrypt: content-gate the self-descramble path.
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
+16
-7
@@ -1329,24 +1329,33 @@ impl Disc {
|
||||
// PHYSICAL read success, not by decrypt structure: a re-read that returns
|
||||
// good bytes recovers the range; a read that errors leaves it NonTrimmed
|
||||
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
|
||||
let keys = if opts.decrypt {
|
||||
let mut keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
// AACS decrypting patch: resolve the whole-disc key map up front and decrypt
|
||||
// via the map (identical to `Disc::sweep`). CSS keeps the content-gated
|
||||
// self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.)
|
||||
let key_map = if opts.decrypt && decrypt_is_aacs {
|
||||
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
||||
reader,
|
||||
&mut keys,
|
||||
opts.key_fetch.as_ref(),
|
||||
)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
if let Some(map) = key_map {
|
||||
dec = dec.with_key_map(map);
|
||||
} else if opts.decrypt && can_gate {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,63 +6,6 @@
|
||||
|
||||
use libfreemkv::{aacs, decrypt::DecryptKeys};
|
||||
|
||||
/// Test: decrypt_sectors with AACS keys actually decrypts units.
|
||||
#[test]
|
||||
fn decrypt_sectors_with_aacs_keys_works() {
|
||||
// Build an encrypted aligned unit
|
||||
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
unit[0] |= 0xC0;
|
||||
|
||||
// Fill with recognizable pattern
|
||||
for (i, byte) in unit
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take(aacs::content::ALIGNED_UNIT_LEN)
|
||||
.skip(1)
|
||||
{
|
||||
*byte = ((i * 3 + 7) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||
|
||||
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
|
||||
// call below. (decrypt_unit is now PURE — it applies the key unconditionally,
|
||||
// so it is NOT idempotent; never call it twice on the same unit.)
|
||||
aacs::content::decrypt_unit(&mut unit, &unit_key);
|
||||
// (byte 0 keeps its CPI bits set from above, so `decrypt_sectors` recognises
|
||||
// this as encrypted content and actually applies the key.)
|
||||
|
||||
let mut aacs_keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0u32, unit_key)],
|
||||
read_data_key: None,
|
||||
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut none_keys = DecryptKeys::None;
|
||||
|
||||
// The regression this guards is passing `DecryptKeys::None` where AACS keys
|
||||
// were meant. Prove the two DIVERGE: AACS applies the key (bytes change), None
|
||||
// leaves the unit byte-for-byte untouched. is_ok alone can't catch that —
|
||||
// both variants return Ok.
|
||||
let mut with_aacs = unit.clone();
|
||||
let mut with_none = unit.clone();
|
||||
libfreemkv::decrypt::decrypt_sectors(&mut with_aacs, &mut aacs_keys, 0)
|
||||
.expect("AACS decrypt must not error");
|
||||
libfreemkv::decrypt::decrypt_sectors(&mut with_none, &mut none_keys, 0)
|
||||
.expect("None decrypt must not error");
|
||||
|
||||
assert_ne!(
|
||||
with_aacs, unit,
|
||||
"AACS keys must actually transform the unit"
|
||||
);
|
||||
assert_eq!(with_none, unit, "None keys must leave the unit untouched");
|
||||
assert_ne!(
|
||||
with_aacs, with_none,
|
||||
"AACS decrypt must differ from the None no-op (the None-vs-Aacs regression)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
|
||||
#[test]
|
||||
fn decrypt_sectors_with_none_keys_is_noop() {
|
||||
|
||||
Reference in New Issue
Block a user