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:
|
||||
|
||||
Reference in New Issue
Block a user