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

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

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

Removes the obsolete non-mapped-AACS trial/gate/recovery tests (the mapped
path and resolve fail-loud are tested directly).
This commit is contained in:
Matthew Jackson
2026-07-23 13:24:36 -07:00
parent 279ba0dd7c
commit 88e58bfc95
6 changed files with 89 additions and 1545 deletions
+13 -760
View File
@@ -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: