From 88e58bfc95f3ec0b3e6234f0a61ba10d013656db Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:24:36 -0700 Subject: [PATCH] Decrypt is keymap-only: sweep/patch/extract, no AACS trial-decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/decrypt.rs | 773 +------------------------------------- src/disc/extract.rs | 10 + src/disc/mod.rs | 59 ++- src/disc/patch.rs | 23 +- src/sector/decrypting.rs | 712 ----------------------------------- tests/pass_n_patch_fix.rs | 57 --- 6 files changed, 89 insertions(+), 1545 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index 7995d1d..816d8ac 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -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 { 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 = 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 = 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: diff --git a/src/disc/extract.rs b/src/disc/extract.rs index d25c27a..b525ccd 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -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; diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8eeb2e7..8c7864d 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -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 { + 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; diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 0ddb644..0207643 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -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; diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index e824093..1c1f1cd 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -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, - } - 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 { - 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, - } - 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 { - 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, - } - impl SectorSource for EncUnitSource { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - 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>>> = 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]| { - 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 = (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, - b: Vec, - } - 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 { - 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>>> = 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]| { - 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>, - } - 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 { - 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]| { - *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]| { - *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 = (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]| { - *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" - ); - } } diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index ddb342b..653640c 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -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() {