diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 1cb027a..3906dab 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -53,31 +53,6 @@ const TS_SYNC: u8 = 0x47; // ── Content decryption ────────────────────────────────────────────────────── -/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it -/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196, -/// 388, … (one per 192-byte source packet). -/// -/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A -/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR -/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are -/// byte-indistinguishable. So this answers only *"does this unit look like valid -/// clear TS, or not"*, nothing about encryption. -/// -/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by -/// the caller, because it needs context this function lacks: -/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts` -/// (see [`crate::decrypt::decrypt_sectors_in_content`] and -/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this -/// primitive separates an encrypted/garbled unit (destroyed) from a clear -/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem -/// bytes is what produced the first-2 GB false-positive this split fixes. -/// -/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or -/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably. -pub fn ts_sync_destroyed(unit: &[u8]) -> bool { - unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit) -} - /// HD-DVD `.evo` (MPEG-2 Program Stream) AACS-encrypted-unit flag offset & mask. /// /// BD/UHD/FMTS flag encryption with the Copy Permission Indicator in the top 2 @@ -144,12 +119,13 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b /// /// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary. pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool { - use crate::disc::ContentFormat; - aacs_unit_encrypted(unit, format) - && match format { - ContentFormat::BdTs => ts_sync_destroyed(unit), - ContentFormat::MpegPs => !is_clean_ps(unit), - } + // "Still needs the key applied" = flagged encrypted AND not yet structurally + // clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof + // floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second + // threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED + // unit as still-scrambled, so the mux re-sampled it to the key service every + // batch (the storm) and could re-apply the key over already-clear bytes. + aacs_unit_encrypted(unit, format) && !is_clean(unit, format) } /// Minimum synced content packets that PROVE a key opened a unit. Four `0x47` @@ -241,10 +217,6 @@ pub fn ts_packet_total(unit: &[u8]) -> usize { unit.len() / BD_SOURCE_PACKET_BYTES } -fn ts_syncs_intact(unit: &[u8]) -> bool { - ts_sync_count(unit) > ts_packet_total(unit) / 2 -} - /// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): a pure structural /// check that a unit is valid MPEG-2 PS — every 2048-byte pack begins with the /// pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans three packs. @@ -421,7 +393,10 @@ mod tests { unit[off] = TS_SYNC; off += BD_SOURCE_PACKET_BYTES; } - assert!(!ts_sync_destroyed(&unit)); + assert!(crate::aacs::content::is_clean( + &unit, + crate::disc::ContentFormat::BdTs + )); assert!( !aacs_unit_encrypted(&unit, ts), "byte-0 CPI clear ⇒ not flagged encrypted" @@ -448,54 +423,70 @@ mod tests { } #[test] - fn scramble_detection_at_16_32_boundary() { - // With 32 stride positions the majority threshold is - // total/2 = 16. A unit with EXACTLY half its syncs intact (16) must - // NOT be over-counted into the "scrambled" bucket by an inflated - // total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear. - // The fix is that `total` is 32 (not 33), so the boundary sits cleanly - // at the real midpoint. - let set_syncs = |n: usize| { - let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; - let mut off = 4; + fn is_clean_min4_proof_floor() { + // ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED + // (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build + // NON-ZERO payloads (real content, not padding) so every packet counts + // toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped). + let unit_with = |synced: usize| { + let mut unit: Vec = (0..ALIGNED_UNIT_LEN) + .map(|i| ((i * 7 + 1) as u8) | 1) + .collect(); + // Scrub any accidental 0x47 at a sync position, then place exactly + // `synced` real syncs in packets 1.. (skip packet 0). + let mut off = BD_SOURCE_PACKET_BYTES + 4; let mut placed = 0; - while off < ALIGNED_UNIT_LEN && placed < n { - unit[off] = TS_SYNC; - off += BD_SOURCE_PACKET_BYTES; + while off < ALIGNED_UNIT_LEN { + unit[off] = if placed < synced { TS_SYNC } else { 0x46 }; placed += 1; + off += BD_SOURCE_PACKET_BYTES; } unit }; - - assert_eq!(ts_sync_count(&set_syncs(16)), 16); - assert_eq!(ts_sync_count(&set_syncs(17)), 17); - - // Exactly half intact → classified scrambled (16 > 16 is false). - assert!(ts_sync_destroyed(&set_syncs(16))); - // One past half → classified clear. - assert!(!ts_sync_destroyed(&set_syncs(17))); + // E = 31 content packets (all non-zero) → threshold min(31,4) = 4. + assert!( + !crate::aacs::content::is_clean(&unit_with(3), crate::disc::ContentFormat::BdTs), + "3 synced of a well-populated unit is below the proof floor → not clean" + ); + assert!( + crate::aacs::content::is_clean(&unit_with(4), crate::disc::ContentFormat::BdTs), + "4 synced proves the key opened it, even with many bad-encoded packets" + ); + // The old >50% majority would have called `unit_with(4)` scrambled (4/31 + // < half) — that false-flag was the mux key-server storm. min(E,4) fixes it. } #[test] fn scramble_detection_extremes() { - // Detection semantics for the clear-cut cases must be preserved: - // a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no - // syncs (fully scrambled body) IS scrambled. + // A fully-clear unit (every packet synced) is clean; a fully-scrambled + // unit (non-zero ciphertext, NO syncs) is not. (An all-zero buffer is + // empty padding — E==0 — which `is_clean` treats as clean, NOT scrambled.) let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; let mut off = 4; while off < ALIGNED_UNIT_LEN { clear[off] = TS_SYNC; off += BD_SOURCE_PACKET_BYTES; } - assert_eq!(ts_sync_count(&clear), 32); assert!( - !ts_sync_destroyed(&clear), - "fully-clear unit → not scrambled" + crate::aacs::content::is_clean(&clear, crate::disc::ContentFormat::BdTs), + "fully-clear unit → clean" ); - let scrambled = vec![0u8; ALIGNED_UNIT_LEN]; - assert_eq!(ts_sync_count(&scrambled), 0); - assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled"); + // Real scrambled ciphertext: non-zero everywhere, no 0x47 at any sync slot. + let mut scrambled: Vec = (0..ALIGNED_UNIT_LEN) + .map(|i| ((i * 13 + 3) as u8) | 1) + .collect(); + let mut off = 4; + while off < ALIGNED_UNIT_LEN { + if scrambled[off] == TS_SYNC { + scrambled[off] = 0x46; + } + off += BD_SOURCE_PACKET_BYTES; + } + assert!( + !crate::aacs::content::is_clean(&scrambled, crate::disc::ContentFormat::BdTs), + "non-zero body with no syncs → scrambled" + ); } #[test] @@ -574,9 +565,15 @@ mod tests { // Now plain contains encrypted data. Decrypt it. let mut unit = plain; - assert!(ts_sync_destroyed(&unit)); + assert!(!crate::aacs::content::is_clean( + &unit, + crate::disc::ContentFormat::BdTs + )); decrypt_unit(&mut unit, &unit_key); - assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored + assert!(crate::aacs::content::is_clean( + &unit, + crate::disc::ContentFormat::BdTs + )); // decrypted: TS syncs restored // Verify TS sync bytes let mut count = 0; @@ -1151,14 +1148,17 @@ mod tests { let mut unit = clear_unit(); aacs_encrypt_unit(&mut unit, &unit_key); assert!( - ts_sync_destroyed(&unit), + !crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs), "encrypted unit must look scrambled" ); decrypt_unit(&mut unit, &unit_key); // All 32 stride positions carry sync after decrypt. assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); - assert!(!ts_sync_destroyed(&unit)); + assert!(crate::aacs::content::is_clean( + &unit, + crate::disc::ContentFormat::BdTs + )); } #[test] @@ -1291,13 +1291,22 @@ mod tests { // The function guards on `len >= ALIGNED_UNIT_LEN` first; anything // shorter is reported NOT scrambled (so the decrypt gate skips it) // rather than indexing past the end. - assert!(!ts_sync_destroyed(&[])); - assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1])); + assert!(crate::aacs::content::is_clean( + &[], + crate::disc::ContentFormat::BdTs + )); + assert!(crate::aacs::content::is_clean( + &vec![0u8; ALIGNED_UNIT_LEN - 1], + crate::disc::ContentFormat::BdTs + )); // A scrambled-looking buffer that is one byte short is still "not // scrambled" by the length guard. let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1]; almost[4] = 0x00; // no syncs - assert!(!ts_sync_destroyed(&almost)); + assert!(crate::aacs::content::is_clean( + &almost, + crate::disc::ContentFormat::BdTs + )); } #[test] diff --git a/src/aacs/variant_select.rs b/src/aacs/index_select.rs similarity index 57% rename from src/aacs/variant_select.rs rename to src/aacs/index_select.rs index 3ea512d..b26655d 100644 --- a/src/aacs/variant_select.rs +++ b/src/aacs/index_select.rs @@ -1,73 +1,73 @@ -//! FMTS variant selection — the pure decode-time decision for a 2.1 disc. +//! FMTS index selection — the pure decode-time decision for a 2.1 disc. //! -//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given -//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see -//! [`super::segment`]); the decode keeps the segments matching our variant, +//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given +//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see +//! [`super::segment`]); the decode keeps the segments matching our index, //! drops the other 31, and treats everything outside a segment as ordinary -//! (variant-0) content. This module owns that classification and nothing else — +//! (index-0) content. This module owns that classification and nothing else — //! no I/O, no keys, no cipher — so it is fully testable in isolation. The //! decrypt pipeline consumes the [`UnitDisposition`] it returns. //! -//! Where the resolved variant comes from is a separate concern -//! ([`resolve_disc_variant`]): today it is read off the variant keys the key +//! Where the resolved index comes from is a separate concern +//! ([`resolve_disc_index`]): today it is read off the index keys the key //! source handed us; when Processing Keys are available it will come from the //! VK derivation instead. Either way the disposition logic below is identical. -use super::segment::{Segment, variant_segment_for_unit}; +use super::segment::{Segment, segment_for_unit}; use super::types::UnitKey; /// What the decode should do with one AACS aligned unit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnitDisposition { /// Outside every forensic segment: ordinary content, decrypt with the - /// default (variant-0) unit key. + /// default (index-0) unit key. Default, - /// Inside a forensic segment tagged with OUR resolved variant: decrypt with - /// that variant's key. - Variant(u8), - /// Inside a forensic segment tagged with a DIFFERENT variant: not our + /// Inside a forensic segment tagged with OUR resolved index: decrypt with + /// that index's key. + Index(u8), + /// Inside a forensic segment tagged with a DIFFERENT index: not our /// watermark, so it is not part of our output — drop it. - DropForeignVariant(u8), - /// Inside a forensic segment but no variant key is held (the disc's variant + DropForeignIndex(u8), + /// Inside a forensic segment but no index key is held (the disc's index /// was never resolved): the segment cannot be decoded, so it is concealed - /// as loss. Carries the segment's variant for diagnostics. + /// as loss. Carries the segment's index for diagnostics. ForensicNoKey(u8), } -/// Resolve the disc's single forensic variant from the keys we hold. +/// Resolve the disc's single forensic index from the keys we hold. /// -/// Scans for a variant key (`variant_number` in `1..=32`) and returns its -/// variant. `None` when only default (variant-0) keys are held — i.e. no -/// variant source answered, so forensic segments are not decodable. A disc has -/// exactly one variant, so the first non-zero key decides; if several distinct -/// variant keys were somehow supplied the lowest wins (deterministic), which is +/// Scans for an index key (`index_number` in `1..=32`) and returns its +/// index. `None` when only default (index-0) keys are held — i.e. no +/// index source answered, so forensic segments are not decodable. A disc has +/// exactly one index, so the first non-zero key decides; if several distinct +/// index keys were somehow supplied the lowest wins (deterministic), which is /// only a defensive tiebreak — the probe/derivation yields one. -pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option { +pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option { unit_keys .iter() - .map(|k| k.variant_number) + .map(|k| k.index_number) .filter(|&v| v != 0) .min() } /// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given -/// the forensic segment map and the disc's resolved variant (`None` if no -/// variant key is held). +/// the forensic segment map and the disc's resolved index (`None` if no +/// index key is held). pub fn unit_disposition( unit_offset: u64, segments: &[Segment], - disc_variant: Option, + disc_index: Option, ) -> UnitDisposition { - match variant_segment_for_unit(segments, unit_offset) { + match segment_for_unit(segments, unit_offset) { // Not in any forensic segment → ordinary content. None => UnitDisposition::Default, - // In a forensic segment → decide by whether it is our variant. + // In a forensic segment → decide by whether it is our index. Some(seg) => { - let seg_variant = seg.variant as u8; - match disc_variant { - Some(v) if v == seg_variant => UnitDisposition::Variant(v), - Some(_) => UnitDisposition::DropForeignVariant(seg_variant), - None => UnitDisposition::ForensicNoKey(seg_variant), + let seg_index = seg.index as u8; + match disc_index { + Some(v) if v == seg_index => UnitDisposition::Index(v), + Some(_) => UnitDisposition::DropForeignIndex(seg_index), + None => UnitDisposition::ForensicNoKey(seg_index), } } } @@ -79,7 +79,7 @@ mod tests { use crate::aacs::content::ALIGNED_UNIT_LEN; use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments}; - /// Build a one-record segment table (variant, start_spn, end_spn). + /// Build a one-record segment table (index, start_spn, end_spn). fn tbl(recs: &[(u16, u32, u32)]) -> Vec { let mut v = Vec::new(); v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); @@ -95,23 +95,23 @@ mod tests { parse_individual_segments(&v).expect("parse") } - fn uk(idx: u32, variant: u8) -> UnitKey { - if variant == 0 { + fn uk(idx: u32, index: u8) -> UnitKey { + if index == 0 { UnitKey::new(idx, [0u8; 16]) } else { - UnitKey::variant(idx, [variant; 16], variant) + UnitKey::forensic(idx, [index; 16], index) } } #[test] - fn resolve_picks_the_single_variant_key() { - // Default keys only → no variant resolved. - assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None); - assert_eq!(resolve_disc_variant(&[]), None); - // One variant key among defaults → that variant. - assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7)); - // Defensive: lowest of several distinct variants (deterministic). - assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3)); + fn resolve_picks_the_single_index_key() { + // Default keys only → no index resolved. + assert_eq!(resolve_disc_index(&[uk(0, 0)]), None); + assert_eq!(resolve_disc_index(&[]), None); + // One index key among defaults → that index. + assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7)); + // Defensive: lowest of several distinct indexes (deterministic). + assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3)); } #[test] @@ -130,29 +130,29 @@ mod tests { } #[test] - fn unit_in_our_variant_decrypts() { + fn unit_in_our_index_decrypts() { let segs = tbl(&[(7, 100, 200)]); let off = 120u64 * SOURCE_PACKET_LEN; assert_eq!( unit_disposition(off, &segs, Some(7)), - UnitDisposition::Variant(7) + UnitDisposition::Index(7) ); } #[test] - fn unit_in_foreign_variant_drops() { - // Segment tagged variant 7, but our disc variant is 3 → drop it. + fn unit_in_foreign_index_drops() { + // Segment tagged index 7, but our disc index is 3 → drop it. let segs = tbl(&[(7, 100, 200)]); let off = 120u64 * SOURCE_PACKET_LEN; assert_eq!( unit_disposition(off, &segs, Some(3)), - UnitDisposition::DropForeignVariant(7) + UnitDisposition::DropForeignIndex(7) ); } #[test] fn forensic_unit_with_no_key_is_concealed() { - // A forensic segment but we never resolved a variant → conceal as loss. + // A forensic segment but we never resolved an index → conceal as loss. let segs = tbl(&[(7, 100, 200)]); let off = 120u64 * SOURCE_PACKET_LEN; assert_eq!( @@ -164,7 +164,7 @@ mod tests { #[test] fn straddling_unit_still_classified_as_its_segment() { // A unit whose 32-packet span only tails into the segment still routes - // to the segment (matches variant_segment_for_unit's span test). + // to the segment (matches segment_for_unit's span test). let segs = tbl(&[(5, 100, 200)]); let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32 // Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100. @@ -172,7 +172,7 @@ mod tests { assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg"); assert_eq!( unit_disposition(off, &segs, Some(5)), - UnitDisposition::Variant(5) + UnitDisposition::Index(5) ); } } diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index dfe11b9..6c022ce 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -29,6 +29,7 @@ pub mod content; pub mod crypto; pub mod derive; pub mod host_certs; +pub mod index_select; pub mod inf; pub mod mkb; pub mod provider; @@ -38,7 +39,6 @@ pub mod segment_key; pub mod trace; pub mod types; pub mod variant; -pub mod variant_select; /// On-disc UDF paths to the AACS key-input files. /// @@ -119,7 +119,7 @@ mod tests { //! Touching one representative item per module keeps these as a //! compile-time contract that the module paths stay stable. - use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed}; + use super::content::ALIGNED_UNIT_LEN; use super::inf::{disc_hash, disc_hash_hex}; use super::mkb::{AacsVersion, mkb_content_len, walk_mkb}; use super::variant::is_variant_mkb; @@ -147,7 +147,10 @@ mod tests { fn public_helpers_are_callable_by_module_path() { // Touch a representative function from each module so a dropped/renamed // item fails to compile. Smoke calls, not behavioural assertions. - let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]); + let _ = !crate::aacs::content::is_clean( + &[0u8; ALIGNED_UNIT_LEN], + crate::disc::ContentFormat::BdTs, + ); let _ = mkb_content_len(&[]); let _ = is_variant_mkb(&walk_mkb(&[])); let _ = disc_hash_hex(&disc_hash(b"x")); diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index 59b2621..69a5eab 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -1,40 +1,49 @@ //! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`. //! -//! An FMTS main feature interleaves N "variant" segments — the sequence-key / -//! forensic-watermark mechanism. The same frames are authored as several -//! slightly different variants; each variant is encrypted under its own SEGMENT -//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the -//! right device keys can decrypt exactly one variant per segment, and which one -//! silently identifies the player (traitor tracing). Decrypting a variant -//! segment with the Unit Key yields garbage — broken HEVC reference frames -//! (empirically: `Could not find ref with POC …` on a plain unit-key rip). +//! An FMTS main feature interleaves short forensic **segments** — the sequence-key +//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a +//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index +//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key. //! -//! This table says WHERE the variant segments live so a decoder can decrypt -//! them with segment keys and select one coherent variant instead of muxing +//! Terminology (see the project AACS reference): the **index** here is NOT the +//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the +//! MKB that decides *which set* of index keys a device receives, a layer this +//! module does not deal with. All the index keys belong to one variant, whose +//! number is unknown and irrelevant to the segment map. Decrypting a segment with +//! the Unit Key yields garbage — broken HEVC reference frames (empirically: +//! `Could not find ref with POC …` on a plain unit-key rip). +//! +//! This table says WHERE the segments live and which index each carries, so a +//! decoder can decrypt them with the matching index key instead of muxing //! unit-key garbage. //! //! Format (validated against a retail AACS 2.1 disc): //! ```text //! header (8 bytes): u32 type | u16 count | u16 record_size (= 16) //! record[count] (16 bytes each): -//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1) +//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1) //! u32 start_spn | u32 end_spn (source-packet numbers, inclusive) //! ``` -//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id: -//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across -//! records in file order — 24 full cycles of 32 plus a final partial cycle of -//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index: -//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread -//! across the entire 54 GB feature (one roughly every 67 MB). +//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured +//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in +//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792 +//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset +//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units, +//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a +//! segment the 80 units interleave in two stride-2 halves: applying the segment's +//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second +//! interleaved half, unidentified), which the demux then drops — leaving one +//! coherent stream. Confirmed by decoding a retail disc with a full set of 32 +//! index keys. /// Fixed size of one `IndividualSegment.tbl` record. pub const SEGMENT_RECORD_LEN: usize = 16; /// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header). pub const SOURCE_PACKET_LEN: u64 = 192; -/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys. +/// Whether a 2.1 (FMTS) disc may rip WITHOUT the forensic index keys. /// -/// `true` (today): the forensic variant segments are skipped as expected loss +/// `true` (today): the forensic segments are skipped as expected loss /// and the bulk of the title decodes with the unit key, so a 2.1 disc rips /// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS /// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure @@ -45,17 +54,17 @@ pub const SOURCE_PACKET_LEN: u64 = 192; /// refused. Hardcoded on purpose — not a user setting. /// /// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing -pub const BYPASS_FMTS_KEY: bool = true; +pub const BYPASS_FMTS_KEY: bool = false; -/// One forensic variant segment: the inclusive source-packet range it occupies -/// in the FMTS clip. +/// One forensic segment: the inclusive source-packet range it occupies in the +/// FMTS clip. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Segment { - /// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the - /// table rather than counting up — it selects WHICH variant this range is, - /// which is what a variant-keyed decode routes on. (`0` is not used here; - /// the default/non-forensic content carries no segment record at all.) - pub variant: u16, + /// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the + /// table rather than counting up — it selects WHICH of the 32 index keys + /// decrypts this range. (`0` is not used here; the default/non-forensic + /// content carries no segment record at all.) + pub index: u16, /// First source packet of the segment (inclusive). pub start_spn: u32, /// Last source packet of the segment (inclusive). @@ -109,8 +118,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 { /// unit's clip-relative byte offset. /// /// This is the routing decision behind a 2.1 decrypt-miss: a unit that -/// overlaps a forensic segment must be opened with that segment's **variant -/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with +/// overlaps a forensic segment must be opened with that segment's **index key** +/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with /// the Unit Key is exactly what yields the broken-reference-frame garbage a /// plain unit-key rip produces. A unit outside every segment is ordinary /// content and a miss on it is a Unit-Key miss, so this returns `None` and the @@ -118,16 +127,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 { /// /// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a /// unit that only partly overlaps a segment edge is still classified as -/// variant; on the observed disc segments are unit-aligned, but the span test +/// forensic; on the observed disc segments are unit-aligned, but the span test /// does not rely on that. -pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> { +pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> { let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64; let first = (unit_offset / SOURCE_PACKET_LEN) as u32; let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32; segments.iter().find(|s| s.overlaps_spn(first, last)) } -/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table +/// Parse `IndividualSegment.tbl` into its forensic segments, in table /// order. Returns `None` when the header is malformed, the record size is not /// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer — /// so a truncated / foreign table degrades to "no segment map" rather than @@ -147,12 +156,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option> { let mut segments = Vec::with_capacity(count); for i in 0..count { let o = 8 + i * record_size; - // o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN. - let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]); + // o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN. + let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]); let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]); let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]); segments.push(Segment { - variant, + index, start_spn, end_spn, }); @@ -160,12 +169,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option> { Some(segments) } +/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking +/// the title's extents (the `.fmts` clip's sectors in file order). Segment +/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how +/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the +/// clip. +pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option { + let mut cum = 0u64; + for e in extents { + let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64; + if clip_byte < cum + len { + let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32; + return Some(e.start_lba.saturating_add(sector_in_ext)); + } + cum += len; + } + None +} + +/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map. +/// +/// Each forensic segment's clip-relative source-packet span becomes an absolute +/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`, +/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that +/// one key across the whole segment decodes the ~40 units of its interleave half +/// to clean TS and garbles the other ~40 (the second interleaved half), which the +/// demux then drops — yielding one coherent stream. Ranges outside every segment +/// are left for the map's default (the ordinary Unit Key). A segment that straddles +/// a UDF extent boundary is emitted as one range per whole-sector slice it covers. +/// +/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges) +/// with the Unit-Key index as the default — the same structure the CPS map uses, +/// only finer-grained. +pub fn fmts_key_ranges( + segments: &[Segment], + extents: &[crate::disc::Extent], + index_to_key_idx: &dyn Fn(u16) -> usize, +) -> Vec<(u32, u32, usize)> { + let mut ranges = Vec::new(); + for s in segments { + let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN; + let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive + // A segment is unit-aligned and contiguous in clip bytes; map its first + // and last sector to LBAs. Segments are ~480 KB and extents are GB-sized, + // so a segment almost never crosses an extent boundary — but if the two + // ends land in different extents (non-contiguous LBAs), skip rather than + // emit a wrong span; the units there fall to the Unit Key (garble+drop), + // never a mis-decrypt. + let (Some(a), Some(b)) = ( + clip_byte_to_lba(extents, start_byte), + clip_byte_to_lba(extents, end_byte - 1), + ) else { + continue; + }; + if b >= a + && (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64 + { + ranges.push((a, b + 1, index_to_key_idx(s.index))); + } + } + ranges +} + #[cfg(test)] mod tests { use super::*; /// Build a table with the real on-disc layout: 8-byte header + N 16-byte - /// records. `recs` are `(variant, start_spn, end_spn)`. + /// records. `recs` are `(index, start_spn, end_spn)`. fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec { let mut v = Vec::new(); v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type @@ -181,11 +252,75 @@ mod tests { v } + #[test] + fn fmts_key_ranges_maps_segments_to_lba_by_index() { + use crate::disc::Extent; + // One big clip extent starting at LBA 1000. Clip byte B lives at + // LBA 1000 + B/2048. + let extents = vec![Extent { + start_lba: 1000, + sector_count: 1_000_000, + }]; + // Two segments, indexes 5 and 7 (spn ranges as on a real disc). + let segs = vec![ + Segment { + index: 5, + start_spn: 100, + end_spn: 199, + }, + Segment { + index: 7, + start_spn: 10_000, + end_spn: 10_099, + }, + ]; + // Pool layout [base, idx1, idx2, …] → index N uses key slot N. + let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize); + assert_eq!(ranges.len(), 2, "one LBA range per segment"); + // Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18 + // → LBA 1009..1019, key index 5. + assert_eq!(ranges[0], (1009, 1019, 5)); + // Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) → + // sectors 937..=946 → LBA 1937..1947, key index 7. + assert_eq!(ranges[1], (1937, 1947, 7)); + + // The ranges drive an AacsKeyMap with the Unit Key (index 0) as default. + let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0); + assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key"); + assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5"); + assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7"); + assert_eq!( + map.key_idx_for(1019), + 0, + "segment end is exclusive → Unit Key" + ); + } + + #[test] + fn clip_byte_to_lba_walks_extents() { + use crate::disc::Extent; + let extents = vec![ + Extent { + start_lba: 100, + sector_count: 10, + }, // clip bytes [0, 20480) + Extent { + start_lba: 500, + sector_count: 10, + }, // clip bytes [20480, 40960) + ]; + assert_eq!(clip_byte_to_lba(&extents, 0), Some(100)); + assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101)); + assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent + assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501)); + assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip + } + #[test] fn parses_real_disc_layout() { // First three records observed on retail 2.1 (Zombieland): the variant // field counts 1,2,3,… (it wraps at 32 further into the table — see - // `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets. + // `index_field_cycles_one_to_thirty_two`), segments are 2560 packets. let tbl = build_tbl(&[ (1, 343680, 346239), (2, 695616, 698175), @@ -193,9 +328,9 @@ mod tests { ]); let segs = parse_individual_segments(&tbl).expect("parse"); assert_eq!(segs.len(), 3); - assert_eq!(segs[0].variant, 1); - assert_eq!(segs[1].variant, 2); - assert_eq!(segs[2].variant, 3); + assert_eq!(segs[0].index, 1); + assert_eq!(segs[1].index, 2); + assert_eq!(segs[2].index, 3); assert_eq!(segs[0].start_spn, 343680); assert_eq!(segs[0].end_spn, 346239); assert_eq!(segs[0].packet_count(), 2560); @@ -234,18 +369,18 @@ mod tests { } #[test] - fn unit_inside_segment_routes_to_variant() { + fn unit_inside_segment_routes_to_index() { // A real first-record segment: packets [343680, 346239]. let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap(); // A unit sitting squarely inside: start at packet 344000 → byte 344000*192. let off = 344000u64 * SOURCE_PACKET_LEN; - let hit = variant_segment_for_unit(&segs, off).expect("inside the segment"); - assert_eq!(hit.variant, 1); + let hit = segment_for_unit(&segs, off).expect("inside the segment"); + assert_eq!(hit.index, 1); } #[test] - fn variant_field_cycles_one_to_thirty_two() { - // Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file + fn index_field_cycles_one_to_thirty_two() { + // Reality on Zombieland: field@4 is the index, cycling 1..=32 in file // order (NOT a sequential segment id). Reproduce one-and-a-bit cycles. let mut recs = Vec::new(); let mut spn = 1000u32; @@ -258,9 +393,9 @@ mod tests { } let segs = parse_individual_segments(&build_tbl(&recs)).unwrap(); assert_eq!(segs.len(), 64); - assert_eq!(segs[31].variant, 32); // end of first cycle - assert_eq!(segs[32].variant, 1); // wraps, does not become 33 - assert!(segs.iter().all(|s| (1..=32).contains(&s.variant))); + assert_eq!(segs[31].index, 32); // end of first cycle + assert_eq!(segs[32].index, 1); // wraps, does not become 33 + assert!(segs.iter().all(|s| (1..=32).contains(&s.index))); } #[test] @@ -268,29 +403,29 @@ mod tests { let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap(); // A unit well before the segment is ordinary content → None (unit-key path). let off = 1000u64 * SOURCE_PACKET_LEN; - assert!(variant_segment_for_unit(&segs, off).is_none()); + assert!(segment_for_unit(&segs, off).is_none()); } #[test] - fn unit_straddling_a_segment_edge_counts_as_variant() { + fn unit_straddling_a_segment_edge_counts_as_forensic() { // Segment starts at packet 100. A unit that ENDS just inside it (its 32 - // packets straddle the boundary) must still route to the variant key, - // because part of its ciphertext is variant-encrypted. + // packets straddle the boundary) must still route to the index key, + // because part of its ciphertext is forensic-encrypted. let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap(); // Unit covering packets [80, 111]: overlaps [100,200] at the tail. let off = 80u64 * SOURCE_PACKET_LEN; - let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge"); - assert_eq!(hit.variant, 7); + let hit = segment_for_unit(&segs, off).expect("straddles the start edge"); + assert_eq!(hit.index, 7); // A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap. let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99] - assert!(variant_segment_for_unit(&segs, before).is_none()); + assert!(segment_for_unit(&segs, before).is_none()); } #[test] - fn no_segments_never_routes_to_variant() { + fn no_segments_never_routes_to_index() { // The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss. - assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none()); - assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none()); + assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none()); + assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none()); } #[test] diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 90cb875..19400a2 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -57,41 +57,40 @@ pub struct ProcessingKey(pub [u8; 16]); pub struct UnitKey { pub idx: u32, pub key: [u8; 16], - /// AACS 2.1 (FMTS) forensic-variant tag. + /// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]). /// /// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0 - /// key and for the bulk of a 2.1 title. `1..=32` = a variant key that - /// decrypts the forensic segments tagged with that same variant in - /// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at - /// most one non-zero value is ever in play for a given rip; the decode - /// selects the segments matching it and drops the other variants. - pub variant_number: u8, + /// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that + /// decrypts the `IndividualSegment.tbl` segments tagged with that same index. + /// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant + /// (the 65536-value device selector), which is a separate MKB-layer concern. + pub index_number: u8, } impl UnitKey { - /// An ordinary (non-forensic) unit key: `variant_number == 0`. The value + /// An ordinary (non-forensic) unit key: `index_number == 0`. The value /// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title. pub const fn new(idx: u32, key: [u8; 16]) -> Self { Self { idx, key, - variant_number: 0, + index_number: 0, } } - /// A forensic-variant key: `variant_number` in `1..=32`, decrypting the - /// `IndividualSegment.tbl` segments tagged with that variant. - pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self { + /// A forensic index key: `index_number` in `1..=32`, decrypting the + /// `IndividualSegment.tbl` segments tagged with that index. + pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self { Self { idx, key, - variant_number, + index_number, } } - /// Whether this key decrypts ordinary (non-forensic) content. - pub const fn is_default_variant(&self) -> bool { - self.variant_number == 0 + /// Whether this key decrypts ordinary (non-forensic) content (index 0). + pub const fn is_default_index(&self) -> bool { + self.index_number == 0 } } diff --git a/src/decrypt.rs b/src/decrypt.rs index bd50731..241801b 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -171,6 +171,183 @@ impl DecryptKeys { } } +/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a +/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit +/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per +/// unit at mux time. +/// +/// This is the pivot that ends the mux "key-server storm": the old path decrypts +/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because +/// authored-bad content never reaches that bar — concludes "wrong key, fetch a +/// fresh one" and re-asks the key service for units it already holds the correct +/// key for. There is NO per-unit byte pattern that separates "correctly decrypted +/// but authored-bad" from "still encrypted", so that check is unanswerable. The +/// map removes the question: we resolve one key per CPS unit / segment up front +/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux +/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's +/// concern, exactly as for a physically-read clear disc. +/// +/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool, +/// sorted and disjoint. `default_idx` covers any LBA no range claims — the +/// single-CPS case is just an empty range list with `default_idx = 0`, so the +/// common disc pays zero lookup cost and needs no structural walk. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AacsKeyMap { + ranges: Vec<(u32, u32, usize)>, + default_idx: usize, +} + +impl AacsKeyMap { + /// The whole title is one CPS unit → one key (`idx`) everywhere. This is the + /// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk. + pub fn single(idx: usize) -> Self { + Self { + ranges: Vec::new(), + default_idx: idx, + } + } + + /// Build from explicit `[start_lba, end_lba) → key_idx` ranges (multi-CPS / + /// FMTS). Ranges are sorted; `default_idx` answers any uncovered LBA. + pub fn from_ranges(mut ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self { + ranges.sort_by_key(|&(start, _, _)| start); + Self { + ranges, + default_idx, + } + } + + /// The unit-key index to decrypt the aligned unit at `lba` with. O(log n) — + /// the last range whose start is `<= lba` and whose end is `> lba`, else the + /// default. Cheap enough to call per aligned unit on the mux hot path. + pub fn key_idx_for(&self, lba: u32) -> usize { + if self.ranges.is_empty() { + return self.default_idx; + } + match self + .ranges + .binary_search_by(|&(start, _, _)| start.cmp(&lba)) + { + Ok(i) => self.ranges[i].2, + Err(0) => self.default_idx, + Err(i) => { + let (start, end, idx) = self.ranges[i - 1]; + if lba >= start && lba < end { + idx + } else { + self.default_idx + } + } + } + } + + /// The `[start_lba, end_lba) → key_idx` ranges (sorted, disjoint). Empty for a + /// single-CPS map (everything uses [`default_idx`](Self::default_idx)). + pub fn ranges(&self) -> &[(u32, u32, usize)] { + &self.ranges + } + + /// The key index for any LBA no explicit range claims (the single-CPS key). + pub fn default_idx(&self) -> usize { + self.default_idx + } + + /// The distinct key indices this map can select — the CPS units / segments a + /// title actually reaches. Used by the resolver to know which keys to secure + /// up front. + pub fn key_indices(&self) -> Vec { + let mut v: Vec = self.ranges.iter().map(|&(_, _, i)| i).collect(); + v.push(self.default_idx); + v.sort_unstable(); + v.dedup(); + v + } +} + +/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the +/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector; +/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its +/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided +/// the key from disc structure, so we apply it and move on — a unit that decrypts +/// to authored-bad TS passes through for the muxer to drop, never re-fetched. +/// +/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside +/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the +/// decorator can dispatch uniformly. A map index outside the held pool is a +/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every +/// selectable index is present, so a gap here is a resolver bug, not silent loss. +pub fn decrypt_sectors_mapped( + buf: &mut [u8], + keys: &DecryptKeys, + base_lba: u32, + map: &AacsKeyMap, +) -> Result<(), crate::error::Error> { + let (unit_keys, rdk, format) = match keys { + DecryptKeys::Aacs { + unit_keys, + read_data_key, + format, + } => (unit_keys, *read_data_key, *format), + // Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched; + // CSS descrambles via `decrypt_sectors` and `None` is already clear. + _ => return Ok(()), + }; + + let unit_len = aacs::content::ALIGNED_UNIT_LEN; + let unit_sectors = (unit_len / 2048) as u32; + + // Validate every selectable index up front (fail loud) so the per-unit hot + // loop can index without bounds churn and a resolver gap never silently + // passes ciphertext through as "decrypted". + for idx in map.key_indices() { + if unit_keys.get(idx).is_none() { + return Err(crate::error::Error::DecryptFailed); + } + } + + let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| { + if chunk.len() != unit_len { + return; // trailing partial unit: clear tail on disc, leave as-is + } + // Gate on the authoritative encrypted flag ONLY (the CPI bits in the clear + // seed) — no `is_clean`. A clear unit (flag unset) is left untouched; an + // encrypted unit is decrypted with its MAPPED key and trusted. + if !aacs::content::aacs_unit_encrypted(chunk, format) { + return; + } + let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); + let key_idx = map.key_idx_for(unit_lba); + // Bounds already proven above; index directly. + let key = &unit_keys[key_idx].1; + if let Some(ref rdk_key) = rdk { + aacs::content::decrypt_bus(chunk, rdk_key); + } + aacs::content::decrypt_unit(chunk, key); + }; + + let nthreads = decrypt_threads(); + let nunits = buf.len() / unit_len; + if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS { + for (i, chunk) in buf.chunks_mut(unit_len).enumerate() { + decrypt_one(i, chunk); + } + } else { + match decrypt_pool() { + Some(pool) => pool.install(|| { + buf.par_chunks_mut(unit_len) + .enumerate() + .for_each(|(i, chunk)| decrypt_one(i, chunk)); + }), + None => { + for (i, chunk) in buf.chunks_mut(unit_len).enumerate() { + decrypt_one(i, chunk); + } + } + } + } + Ok(()) +} + /// Decrypt a buffer of sectors in-place. /// /// For AACS: processes in 6144-byte aligned units (3 sectors). @@ -286,7 +463,7 @@ fn decrypt_sectors_impl( // silent corruption. We fail loud (Error::DecryptFailed), matching // the highway path's Error::ExtentNotUnitAligned policy. // - // Detection: ts_sync_destroyed() short-circuits to false for any + // Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any // buffer shorter than a full unit, so it cannot judge a partial. We // instead apply the same TS-sync-intactness test it uses internally // (ts_sync_count vs ts_packet_total) directly to the available @@ -308,15 +485,13 @@ fn decrypt_sectors_impl( None => true, }; // TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte - // unit) can't be unit-decrypted, so fail loud. The heuristic is - // MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely — - // running it on PS would false-trip `DecryptFailed`. HD-DVD partial- - // scramble detection is not yet wired (consistent with the UNVERIFIED - // PS path in `aacs_unit_encrypted`). + // 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..]; - let packets = aacs::content::ts_packet_total(partial); - if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 { + if !aacs::content::is_clean(partial, format) { return Err(crate::error::Error::DecryptFailed); } } @@ -1324,7 +1499,7 @@ mod tests { let mut unit = clear_ts_unit(); aacs_encrypt_unit_for_test(&mut unit, &key1); assert!( - aacs::content::ts_sync_destroyed(&unit), + !crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs), "encrypted unit must look scrambled before decrypt" ); @@ -1339,7 +1514,7 @@ mod tests { decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed"); assert!( - !aacs::content::ts_sync_destroyed(&buf), + 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. @@ -1371,7 +1546,7 @@ mod tests { let mut buf = unit; decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt"); assert!( - !aacs::content::ts_sync_destroyed(&buf), + crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs), "single-key disc: TS syncs must be restored" ); assert_eq!( @@ -1401,7 +1576,7 @@ mod tests { aacs_encrypt_unit_for_test(&mut unit, &real_key); let ciphertext = unit.clone(); assert!( - aacs::content::ts_sync_destroyed(&unit), + !crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs), "encrypted unit must look scrambled going in" ); @@ -1462,7 +1637,10 @@ mod tests { "exactly one unit's worth of bytes must be reported unverified" ); assert!( - !aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]), + crate::aacs::content::is_clean( + &buf[..aacs::content::ALIGNED_UNIT_LEN], + crate::disc::ContentFormat::BdTs + ), "the decryptable unit must come out clear" ); assert_ne!( diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8f23042..d208623 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2655,28 +2655,6 @@ impl Disc { self.ensure_decryptable_keys(raw, keys) } - /// Upfront FMTS (AACS 2.1) key gate, parallel to - /// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc - /// carries forensic variant segments that need segment (variant) keys the - /// unit-key path cannot provide. When - /// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`, - /// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the - /// same policy as a missing unit key, so a forensic-holed rip is refused, not - /// produced. When `true` (the default today) the segments are skipped as - /// expected loss and this passes. `raw` mode and non-FMTS discs always pass. - pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> { - if raw || crate::aacs::segment::BYPASS_FMTS_KEY { - return Ok(()); - } - // A 2.1 (FMTS) disc carries forensic variant segments with no segment-key - // source (none exists yet), so its variant segments cannot be opened. - // Refuse upfront rather than emit a forensic-holed rip. - if self.format == DiscFormat::Fmts { - return Err(Error::FmtsKeyMissing); - } - Ok(()) - } - /// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux /// / resume path. The keys come from the mapfile's `# freemkv-uk:` header /// (persisted at sweep time when the disc was keyed), so the mux decrypts @@ -5326,7 +5304,7 @@ mod tests { #[test] fn unit_key_validation_gates_on_real_ciphertext() { - use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed}; + use crate::aacs::content::ALIGNED_UNIT_LEN; // No samples -> nothing to disprove against -> accept (sample-less paths // like resume / mapfile must be unaffected). @@ -5345,7 +5323,10 @@ mod tests { clear[off] = 0x47; off += 192; } - assert!(!ts_sync_destroyed(&clear)); + assert!(crate::aacs::content::is_clean( + &clear, + crate::disc::ContentFormat::BdTs + )); assert!(super::aligned_unit_keys_validate( &[(0, [0x11u8; 16])], None, @@ -5357,7 +5338,7 @@ mod tests { let uk = [0x5au8; 16]; let enc = encrypt_unit_for_test(&clear, &uk); assert!( - ts_sync_destroyed(&enc), + !crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs), "encrypted unit must read scrambled" ); @@ -5393,7 +5374,7 @@ mod tests { // CPS-unit-1 sectors then passed through as raw encrypted bytes into the // ISO/MKV with no error surfaced. The gate must now reject a key set // that leaves any scrambled sample uncovered. - use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed}; + use crate::aacs::content::ALIGNED_UNIT_LEN; let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; let mut off = 4; @@ -5406,8 +5387,14 @@ mod tests { let uk1 = [0x22u8; 16]; let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body - assert!(ts_sync_destroyed(&sample0)); - assert!(ts_sync_destroyed(&sample1)); + assert!(!crate::aacs::content::is_clean( + &sample0, + crate::disc::ContentFormat::BdTs + )); + assert!(!crate::aacs::content::is_clean( + &sample1, + crate::disc::ContentFormat::BdTs + )); let samples = vec![sample0.clone(), sample1.clone()]; diff --git a/src/keysource.rs b/src/keysource.rs index dd09636..4993b81 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -318,7 +318,28 @@ pub fn key_fetch( inputs: DiscInputs, make_sources: std::sync::Arc Vec> + Send + Sync>, ) -> crate::sector::KeyFetch { + // Memoize by the fingerprint of the sample batch. The resolved keys are + // disc-level (the same clip's index / CPS keys are identical for every title + // that references it), and this one closure is shared across every title's mux + // — so the first title resolves a given batch over the network and every later + // title (or repeated batch) is answered from the cache with no request. Empty + // replies are cached too: a key the service does not have for a batch will not + // appear on a re-ask, so re-hitting the network buys nothing. + let cache: std::sync::Arc>>> = + std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); std::sync::Arc::new(move |samples: &[Vec]| -> Vec<[u8; 16]> { + let fp = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + samples.len().hash(&mut h); + for s in samples { + s.hash(&mut h); + } + h.finish() + }; + if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) { + return hit.clone(); + } let sources = make_sources(); let mut di = inputs.clone(); di.samples = samples.to_vec(); @@ -327,10 +348,15 @@ pub fn key_fetch( // derives unit keys from `enc_title_keys`, which a V10 disc parses at the // 48-byte stride — hardcoding the V20 stride here corrupted them. let ctx = DiscInputsCtx::new(&di); - fetch_unit_keys(&sources, &ctx) + let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx) .into_iter() .map(|u| u.key) - .collect() + .collect(); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fp, keys.clone()); + keys }) } diff --git a/src/lib.rs b/src/lib.rs index 11f3bc2..d6ddc79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -187,7 +187,10 @@ pub use identity::DriveId; // don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)` // accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is // for callers that operate on raw sector buffers (e.g. ISO patching). -pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads}; +pub use decrypt::{ + AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads, + set_decrypt_threads, +}; // ─── Disc structure ───────────────────────────────────────────────────────── // @@ -248,6 +251,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; // single decrypt-on-read decorator (AACS / CSS / none) — wrap any // `SectorSource` to get plaintext sectors out. pub use mux::build_iso_pipeline; +pub use mux::resolve_mux_key_map; pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives}; pub use sector::{ DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource, diff --git a/src/mux/mod.rs b/src/mux/mod.rs index a3ed8be..92af736 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -117,6 +117,7 @@ pub use network::NetworkStream; pub use null::NullStream; pub use pipelined_stream::PipelinedPesStream; pub use resolve::build_iso_pipeline; +pub use resolve::resolve_mux_key_map; pub use resolve::{InputOptions, StreamUrl, input, output, parse_url}; pub use stdio::StdioStream; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 8d36ef7..16572e7 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -358,12 +358,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; - // Upfront FMTS gate, parallel to the unit-key gate above. With - // BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are - // concealed as ordinary decrypt loss below; without it, a 2.1 disc - // lacking segment keys fails here rather than emitting a holed mux. - disc.ensure_forensic_segments_decryptable(opts.raw) - .map_err(|e| -> io::Error { e.into() })?; + // FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked + // downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold + // the key-fetch closure and can actually attempt resolution. (An older + // upfront blanket-reject gate lived here; it predated the resolver and + // rejected every 2.1 disc before a source could be tried.) // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) // by probing the first DECRYPTED access units of the chosen title. // A fresh reader avoids disturbing the mux reader below. Skipped in @@ -600,6 +599,330 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { (parsers, pid_to_track, ts, ps) } +/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title +/// before muxing. It decides which held unit key decrypts each of the title's +/// LBA ranges and secures any key the pool is missing through the app's +/// configured source (`fetch`) up front, never reactively per unit at mux time. +/// +/// This is what ends the key-server storm. The old mux decrypted a unit, checked +/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad +/// content never reaches that bar — re-asked the key service for a key it already +/// held. There is no per-unit byte pattern that separates "correctly decrypted +/// but authored-bad" from "still encrypted", so that check is unanswerable. Here +/// we answer the answerable question instead: which CPS unit does each LBA range +/// belong to, decided by the disc's key structure (validated once against real +/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just +/// decrypts each unit with its mapped key and trusts it. +/// +/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the +/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to +/// the key that opens a real sample from it; a bad-content extent no sample can +/// classify inherits its predecessor's key (contiguity). FMTS segment mapping +/// layers onto the same structure. +/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the +/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None` +/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then +/// applies, and the forensic units garble and are dropped by the demux). +/// +/// The forensic segments each carry an **index** tag (1..32) selecting one of 32 +/// **index keys** the base Unit Key cannot open (see [`crate::aacs::segment`]). +/// This resolves those keys up front from the configured source — sending, per +/// index, a batch of same-index units the service maps to that index's key — adds +/// them to the pool, and builds a per-segment LBA→key map. Applying a segment's +/// key over its whole range decodes the ~40 units of that index's interleave half +/// to clean TS and garbles the other ~40 (the alternate half), which the demux +/// then drops, yielding one coherent stream. The base Unit Key covers everything +/// outside a segment. +fn resolve_fmts_key_map( + reader: &mut dyn SectorSource, + title: &DiscTitle, + keys: &mut crate::decrypt::DecryptKeys, + fetch: Option<&crate::sector::KeyFetch>, + _format: ContentFormat, +) -> io::Result> { + use crate::aacs::content::ALIGNED_UNIT_LEN; + use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; + + // Off by default: while `BYPASS_FMTS_KEY` is set, forensic decode is disabled — + // no segment table read, no key-service traffic — and the caller's base-Unit-Key + // path applies (the forensic units garble and the demux drops them, the shipped + // behaviour). Flip `BYPASS_FMTS_KEY` to false to activate forensic decode once + // the index-key resolution is validated end to end. + if crate::aacs::segment::BYPASS_FMTS_KEY { + return Ok(None); + } + + // Load the segment map; absent → not an FMTS disc. + let Ok(udf) = crate::udf::read_filesystem(reader) else { + return Ok(None); + }; + let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else { + return Ok(None); + }; + let Some(segments) = parse_individual_segments(&tbl) else { + return Ok(None); + }; + if segments.is_empty() { + return Ok(None); + } + // This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like + // a Unit Key. Without a configured key source we cannot obtain them, so we + // cannot produce a complete rip: fail loud rather than silently drop the + // forensic segments. (The caller may still choose `--raw`, which never reaches + // this path.) + let Some(fetch) = fetch else { + return Err(crate::error::Error::FmtsKeyMissing.into()); + }; + tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution"); + + // Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`. + let read_unit = + |reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| { + let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64; + let lba = clip_byte_to_lba(&title.extents, clip_byte)?; + let mut c = vec![0u8; ALIGNED_UNIT_LEN]; + reader.read_sectors(lba, 3, &mut c, false).ok()?; + Some(c) + }; + // ── ONE forensic query. The key service returns ALL forensic index keys for the + // disc in a single response, ORDERED by index (array element i = index i+1). + // So send one clean single-variant batch (a segment's even-phase units) and + // read the whole set back — no per-index probing, no phase measurement, no + // decrypt-and-check: the array position IS the index. The first readable + // segment whose batch yields the full set wins; a short (e.g. 1-key, + // base-UK-shaped) response means that batch wasn't forensic (a wrong + // feature-title mapping), so try the next segment. ───────────────────────── + const N_INDEX: usize = 32; + let mut index_keys: Vec<[u8; 16]> = Vec::new(); + for seg in segments.iter().take(16) { + let mut batch: Vec> = Vec::new(); + for p in 0..8usize { + if let Some(c) = read_unit(reader, seg, p * 2) { + batch.push(c); + } + } + if batch.len() < 8 { + continue; // read fault / short tail + } + let fresh = fetch(&batch); + if fresh.len() >= N_INDEX { + index_keys = fresh; + break; + } + } + tracing::info!(target: "freemkv::keysource", held = index_keys.len(), need = N_INDEX, "fmts: collection done"); + // The full set is required. Anything short holes the rip — fail loud like a + // missing Unit Key rather than emit forensic-holed output. + if index_keys.len() < N_INDEX { + return Err(crate::error::Error::FmtsKeyMissing.into()); + } + + // Map array position → forensic index (element i = index i+1); add each key to + // the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0). + let base_idx = 0usize; + let mut tag_slot: std::collections::HashMap = std::collections::HashMap::new(); + if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { + for (i, k) in index_keys.iter().take(N_INDEX).enumerate() { + let tag = (i + 1) as u16; + let slot = match unit_keys.iter().position(|(_, h)| h == k) { + Some(s) => s, + None => { + let s = unit_keys.len(); + // CPS-unit id is cosmetic for the mapped decrypt (it indexes by + // slot); use a high, distinct number for the forensic keys. + unit_keys.push((1000 + s as u32, *k)); + s + } + }; + tag_slot.insert(tag, slot); + } + } + + // ── Build the per-segment LBA ranges directly from the tag. Each segment is + // decoded from its TAG half: the map routes the segment's whole span to its + // tag's key; the tag key opens the tag half wherever it interleaves, and the + // un-served version-B half — decrypted with that (for it, wrong) key — + // garbles and the demux drops it, leaving one clean variant per span. No + // re-read and no phase needed here: byte-5 `seg.index` selects the key. A + // segment whose tag is somehow absent (cannot happen with all 32 held) or + // that straddles an extent boundary is left unmapped and tallied. ───────── + let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(segments.len()); + let mut unresolved = 0usize; + for seg in &segments { + let Some(&slot) = tag_slot.get(&seg.index) else { + unresolved += 1; + continue; + }; + let start_byte = seg.start_spn as u64 * 192; + let end_byte = (seg.end_spn as u64 + 1) * 192; + let (Some(a), Some(b)) = ( + clip_byte_to_lba(&title.extents, start_byte), + clip_byte_to_lba(&title.extents, end_byte - 1), + ) else { + unresolved += 1; + continue; + }; + // Only emit a contiguous within-extent range (segments are ~480 KB; a rare + // extent-straddle is left unresolved rather than given a wrong span). + if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 { + ranges.push((a, b + 1, slot)); + } else { + unresolved += 1; + } + } + // Every forensic segment must map to an index key. Any that did not is a hole + // in the rip — with the full 32-key set in hand this should never happen, so + // treat it as a hard failure (a read fault or an unexpected on-disc layout) + // rather than silently emitting a segment the base Unit Key only garbles. + if unresolved != 0 { + return Err(crate::error::Error::FmtsKeyMissing.into()); + } + + Ok(Some(crate::decrypt::AacsKeyMap::from_ranges( + ranges, base_idx, + ))) +} + +pub fn resolve_mux_key_map( + reader: &mut dyn SectorSource, + title: &DiscTitle, + keys: &mut crate::decrypt::DecryptKeys, + fetch: Option<&crate::sector::KeyFetch>, + format: ContentFormat, +) -> io::Result { + use crate::aacs::content::{ + ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean, + }; + + let pool_len = match keys { + crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(), + // CSS / clear: no AACS map (the decorator's map path is AACS-only). + _ => return Ok(crate::decrypt::AacsKeyMap::single(0)), + }; + + // Secure the disc's key up front from the configured source when the pool is + // empty (a genuine "no key yet" — e.g. keydb miss, online-only disc). + if pool_len == 0 { + if let Some(f) = fetch { + let samples = crate::keysource::read_encrypted_units(reader, title, 8); + if !samples.is_empty() { + let fresh = f(&samples); + if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { + for k in fresh { + if !unit_keys.iter().any(|(_, h)| *h == k) { + let i = unit_keys.len() as u32; + unit_keys.push((i, k)); + } + } + } + } + } + // If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we + // could not obtain from any source. That is the same situation as any known + // key we don't hold — fail loud at resolve time rather than deferring an + // opaque decrypt error (or, worse, emitting ciphertext) at mux time. + let empty = matches!( + keys, + crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty() + ); + if empty { + return Err(crate::error::Error::DecryptFailed.into()); + } + return Ok(crate::decrypt::AacsKeyMap::single(0)); + } + // FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic + // segments need per-index keys the base Unit Key can't open. Resolve them up + // front from the configured source and build a per-segment map. Returns `None` + // when the disc is not FMTS, or no key source is configured (then the base UK + // path below applies and the forensic units garble → demux drops them). + if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? { + return Ok(map); + } + if pool_len == 1 { + // One CPS unit → one key everywhere. No structural walk, no sampling. + return Ok(crate::decrypt::AacsKeyMap::single(0)); + } + + // Multi-CPS: read a spread of real encrypted units from each extent and pick + // the held key that opens one (the `is_clean` proof is sound HERE — samples + // are guaranteed real content, not the authored-bad units that trip the mux). + let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec> { + let total_units = sectors / ALIGNED_UNIT_SECTORS; + let mut out = Vec::new(); + if total_units == 0 { + return out; + } + const PROBES: u32 = 8; + for p in 1..=PROBES { + let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32; + if unit >= total_units { + continue; + } + let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); + let mut buf = vec![0u8; ALIGNED_UNIT_LEN]; + if reader + .read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false) + .is_ok() + && aacs_unit_encrypted(&buf, format) + { + out.push(buf); + } + } + out + }; + let pick = |samples: &[Vec], pool: &[(u32, [u8; 16])]| -> Option { + for (i, (_, k)) in pool.iter().enumerate() { + if samples.iter().any(|s| { + let mut u = s.clone(); + decrypt_unit(&mut u, k); + is_clean(&u, format) + }) { + return Some(i); + } + } + None + }; + + let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len()); + let mut last_idx = 0usize; + for ext in &title.extents { + let samples = sample_units(reader, ext.start_lba, ext.sector_count); + // Snapshot the current pool for the pure `pick` closure. + let pool: Vec<(u32, [u8; 16])> = match keys { + crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), + _ => Vec::new(), + }; + let mut idx = pick(&samples, &pool); + if idx.is_none() { + if let Some(f) = fetch { + if !samples.is_empty() { + let fresh = f(&samples); + if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { + for k in fresh { + if !unit_keys.iter().any(|(_, h)| *h == k) { + let i = unit_keys.len() as u32; + unit_keys.push((i, k)); + } + } + idx = pick(&samples, unit_keys); + } + } + } + } + // A bad-content extent no sample can classify inherits its predecessor's + // key (CPS boundaries are contiguous, so the neighbour is almost always + // right); this never storms and never mis-fails a decryptable disc. + let idx = idx.unwrap_or(last_idx); + last_idx = idx; + ranges.push(( + ext.start_lba, + ext.start_lba.saturating_add(ext.sector_count), + idx, + )); + } + Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0)) +} + /// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for /// a `FileSectorSource`-backed reader. Returns the resulting /// `PipelinedPesStream`. @@ -618,18 +941,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { /// - `halt`: cooperative cancel token (not a timeout); when cancelled the /// pipeline stops at the next boundary. `None` disables cancellation. /// - `event_fn`: optional progress/event callback invoked by the prefetcher. -/// - `fetch`: optional fresh-key-on-failure callback (see -/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the -/// decrypt decorator hands that ciphertext to `fetch` and adds any key it -/// returns, then re-decrypts. `None` means no mid-stream key recovery — the -/// unit's best-effort bytes pass through to the muxer as-is. +/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to +/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time +/// callback: the map decides the key for every LBA before the read loop starts. // Eight reader/title/keys/tuning/callback params is inherent to the mux entry // point; grouping them into a struct would only move the same fields around. #[allow(clippy::too_many_arguments)] pub fn build_iso_pipeline( - reader: S, + mut reader: S, title: DiscTitle, - keys: crate::decrypt::DecryptKeys, + mut keys: crate::decrypt::DecryptKeys, batch_sectors: u16, format: ContentFormat, halt: Option, @@ -646,20 +967,23 @@ pub fn build_iso_pipeline( crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 1, }; - // MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and - // passes the bytes to the muxer; a unit that decrypts to broken TS is the - // muxer's problem, not a decrypt failure, so the mux never conceals a unit or - // counts it as loss. + // MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT + // — one key per CPS unit / segment, secured from the configured source and + // recorded against the LBA ranges it covers. The mux then decrypts each unit + // with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive + // key-fetch, no key-server storm. A unit that decrypts to broken TS is the + // muxer's problem, exactly as before. AACS-only; CSS self-cracks per region. + let key_map = + match &keys { + crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new( + resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?, + )), + _ => None, + }; let mut decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); - // Install the fresh-key-on-failure callback (if the app supplied one). This is - // how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a - // unit no held key opens — "get the key when we need it." It fires only on a - // genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that - // decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer - // storms the key source the way the old TS supermajority gate did. - if let Some(cb) = fetch { - decrypting = decrypting.with_key_fetch(cb); + if let Some(map) = key_map { + decrypting = decrypting.with_key_map(map); } // Loss-counter handle. The mux does NOT tally decrypt-quality misses: a // broken-TS unit is the muxer's concern, and a missing key is an up-front diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index d0d73c1..54d0af9 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -79,6 +79,14 @@ pub struct DecryptingSectorSource { /// batches at highway speed, now that the mux installs a key-fetch for /// multi-CPS — reuses one allocation instead of alloc/free-ing every read. cipher_scratch: Vec, + /// Proactive AACS key map (see [`crate::decrypt::AacsKeyMap`]). When set, the + /// mux resolved one key per CPS unit / segment UP FRONT, so this read decrypts + /// each aligned unit with its MAPPED key and TRUSTS it — no per-unit + /// `is_clean` verdict, no reactive key-fetch, no key-server storm. `None` + /// keeps the legacy trial-and-recover path (sweep / patch, or a mux that did + /// not build a map). Mutually exclusive with `recovery` in practice: the mux + /// installs one or the other. + key_map: Option>, } impl DecryptingSectorSource { @@ -99,9 +107,21 @@ impl DecryptingSectorSource { recovery: None, content_ranges: None, cipher_scratch: Vec::new(), + key_map: None, } } + /// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the mux + /// resolved one key per CPS unit / segment up front, so every aligned unit is + /// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check, + /// no reactive key-fetch. This is the storm-free mux path; it supersedes + /// [`with_key_fetch`](Self::with_key_fetch) (do not set both). AACS-only; a + /// CSS / clear disc ignores it. + pub fn with_key_map(mut self, map: Arc) -> Self { + self.key_map = Some(map); + self + } + /// Restrict decrypt to the disc's encrypted-content extents /// (sorted/merged `(start_lba, sector_count)` — see /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). @@ -221,6 +241,18 @@ impl SectorSource for DecryptingSectorSource { let n = self .inner .read_sectors_fua(lba, count, buf, recovery, fua)?; + + // PROACTIVE MAP PATH (the storm-free mux): when a key map is installed the + // mux resolved one key per CPS unit / segment up front, so decrypt each + // aligned unit with its MAPPED key and trust it — no per-unit `is_clean` + // verdict, no reactive key-fetch. A resolver gap surfaces loud from + // `decrypt_sectors_mapped` (DecryptFailed); authored-bad TS just passes + // through for the muxer to drop. + if let Some(map) = self.key_map.clone() { + crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?; + return Ok(n); + } + // Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS). // With a content map installed the `*_in_content` entry skips units OUTSIDE // the encrypted extents (clear filesystem / nav pass through untouched); the @@ -1156,7 +1188,7 @@ mod tests { // 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::ts_sync_destroyed(unit0), + 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(); @@ -1166,7 +1198,7 @@ mod tests { "callback must be invoked once with the failing unit" ); assert!( - crate::aacs::content::ts_sync_destroyed(&got[0]), + !crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs), "the sample handed to the callback is the still-scrambled ciphertext" ); assert_eq!( @@ -1175,6 +1207,91 @@ mod tests { ); } + /// 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 = 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 @@ -1257,7 +1374,7 @@ mod tests { "fetch fired for BOTH units — the dry result for A did not latch off B" ); assert!( - !crate::aacs::content::ts_sync_destroyed(&buf2), + crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs), "unit B is decrypted after its on-demand fetch" ); } @@ -1388,7 +1505,7 @@ mod tests { .expect("fetch recovers the orphan unit's key"); assert_ne!(buf, unit, "the fetched key decrypts the unit in place"); assert!( - !crate::aacs::content::ts_sync_destroyed(&buf), + 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"); diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index eedcb92..956edf7 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -131,12 +131,15 @@ fn aacs_decrypt_unit_roundtrip() { } // Verify it looks encrypted (body TS syncs scrambled) - assert!(aacs::content::ts_sync_destroyed(&plain)); + assert!(!aacs::content::is_clean( + &plain, + libfreemkv::disc::ContentFormat::BdTs + )); // Now decrypt aacs::content::decrypt_unit(&mut plain, &unit_key); assert!( - !aacs::content::ts_sync_destroyed(&plain), + aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs), "decrypted unit should read as clear (TS syncs restored)" ); @@ -254,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() { assert_eq!(vuk, vuk2, "derive_vuk not deterministic"); } -/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs. +/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor. #[test] -fn aacs_ts_sync_destroyed_detection() { +fn aacs_is_clean_detection() { // A clear unit: TS sync (0x47) intact at every 192-byte packet → not // scrambled. (Flag bits play no role.) let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN]; @@ -266,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() { off += 192; } assert!( - !aacs::content::ts_sync_destroyed(&clear), + aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs), "clear unit (syncs intact) must not be scrambled" ); @@ -275,21 +278,21 @@ fn aacs_ts_sync_destroyed_detection() { flagged[0] = 0xC0; // copy-control bits flagged[7] = 0xC0; // TSC bits assert!( - !aacs::content::ts_sync_destroyed(&flagged), + aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs), "flag bits must not be read as encryption" ); // A scrambled body (syncs destroyed) → scrambled. let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN]; assert!( - aacs::content::ts_sync_destroyed(&scrambled), + !aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs), "unit with no intact TS syncs must read as scrambled" ); // Too short let short = vec![0xFFu8; 100]; assert!( - !aacs::content::ts_sync_destroyed(&short), + aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs), "short buffer should not be detected" ); } @@ -312,7 +315,10 @@ fn aacs_clear_unit_reports_not_encrypted() { // CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext. unit[0] &= 0x3F; - assert!(!aacs::content::ts_sync_destroyed(&unit)); + assert!(aacs::content::is_clean( + &unit, + libfreemkv::disc::ContentFormat::BdTs + )); assert!( !aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs), "CPI-clear unit reports not-encrypted; the caller never decrypts it" diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 766b0ef..ddb342b 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -108,16 +108,25 @@ fn aacs_encryption_flag_detection() { off += 192; } // Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit. - assert!(!aacs::content::ts_sync_destroyed(&unit)); + assert!(aacs::content::is_clean( + &unit, + libfreemkv::disc::ContentFormat::BdTs + )); // Flag bits on a synced unit do not make it look encrypted. unit[0] = 0xC0; unit[7] = 0xC0; - assert!(!aacs::content::ts_sync_destroyed(&unit)); + assert!(aacs::content::is_clean( + &unit, + libfreemkv::disc::ContentFormat::BdTs + )); // Scrambled body (syncs gone) → encrypted. let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN]; - assert!(aacs::content::ts_sync_destroyed(&scrambled)); + assert!(!aacs::content::is_clean( + &scrambled, + libfreemkv::disc::ContentFormat::BdTs + )); } /// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.