diff --git a/src/aacs/content.rs b/src/aacs/content.rs index e1d1d9b..f2abf8f 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -106,6 +106,23 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b } } +/// The AACS encrypted flag from an aligned unit's CLEAR seed, readable even on a +/// trailing PARTIAL unit (unlike [`aacs_unit_encrypted`], which requires a whole +/// 6144-byte unit). The flag lives at a fixed low offset in the clear header, so a +/// fragment that still contains that byte can be classified. Used to catch an +/// encrypted unit truncated across a buffer/extent boundary — a fragment we cannot +/// CBC-decrypt and must not emit as clear. `false` for a slice too short to hold +/// the flag byte. Same clip-anchored-read caveat as [`aacs_unit_encrypted`]. +pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool { + use crate::disc::ContentFormat; + match format { + ContentFormat::BdTs => unit.first().is_some_and(|b| b & 0xC0 != 0), + ContentFormat::MpegPs => unit + .get(PS_SCRAMBLE_OFF) + .is_some_and(|b| b & PS_SCRAMBLE_MASK != 0), + } +} + /// True when an aligned unit is flagged encrypted AND still looks scrambled /// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted. /// diff --git a/src/decrypt.rs b/src/decrypt.rs index 816d8ac..ee4417e 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -408,7 +408,20 @@ pub fn decrypt_sectors_mapped( 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 + // Trailing partial unit (buffer/region tail shorter than a whole unit). + // Normally a genuinely-clear content tail (source-zero padding or a + // short final fragment) — leave as-is. But a partial that is BOTH inside + // a mapped (encrypted) range AND flagged encrypted in its clear seed is + // an encrypted unit split across a boundary: a CBC fragment we cannot + // decrypt, so emitting it verbatim would ship ciphertext as clear. Fail + // loud instead (restores the guard the removed `decrypt_sectors` had). + let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); + if map.entry_for(unit_lba).is_some() + && aacs::content::aacs_unit_seed_encrypted(chunk, format) + { + verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + } + return; } let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); // No range covers this LBA → the map keys no content here, so pass the @@ -470,30 +483,21 @@ pub fn decrypt_sectors_mapped( Ok(()) } -/// Decrypt a buffer of sectors in-place. +/// Decrypt a buffer of sectors in-place — the CSS / clear path only. /// -/// For AACS: processes in 6144-byte aligned units (3 sectors). -/// For CSS: processes per 2048-byte sector. -/// For None: no-op. +/// For CSS: descrambles per 2048-byte sector, self-cracking the title key from the +/// data (no external input). For `None`: a no-op. For AACS: **always** returns +/// `Err(DecryptFailed)` — AACS decrypts exclusively through the resolved key map +/// ([`decrypt_sectors_mapped`]), which keys every content unit up front and fails +/// at RESOLVE time when a key is missing. Reaching this arm with AACS keys means a +/// reader was built without installing its map (a bug), so it fails loud rather +/// than apply a guessed key. /// -/// `unit_key_idx` is the initial AACS unit-key hint (0 for most discs). On a -/// multi-CPS-unit disc every key is tried per unit until the TS-sync verify -/// passes; `unit_key_idx` is tried first so single-CPS-unit discs pay zero -/// overhead. An out-of-range `unit_key_idx` is always an error. -/// -/// Returns `Err` if decryption was expected but keys are missing or invalid. -/// Never produces silently corrupted output. -/// -/// Pure decrypt: every encrypted unit has a key APPLIED in place and the -/// plaintext is left as-is — this function applies NO policy (it never restores -/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes -/// belonging to units a key was applied to but that did NOT reassemble to clean -/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION / -/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can -/// decrypt content whose encoding is broken. The caller decides what an -/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch -/// verify path recovers a key and retries, or fails the read. `0` for `None` / -/// `Css` and for any AACS buffer where every unit reached clean TS. +/// `unit_key_idx` and `content` are legacy parameters kept so the CSS / `None` +/// wrapper signatures stay stable; they are ignored (the CSS arm self-gates on its +/// per-sector scramble flag). Returns `Err` if decryption was expected but +/// impossible; never produces silently corrupted output. The `usize` return is a +/// legacy unverified-byte count that is always `0` for the CSS / `None` arms. pub fn decrypt_sectors( buf: &mut [u8], keys: &mut DecryptKeys, diff --git a/src/disc/extract.rs b/src/disc/extract.rs index b525ccd..f981ef4 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -185,22 +185,43 @@ impl Disc { // Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily // when a scrambled VOB group needs it. AACS / None discs keep the // disc-wide keys for every file. - let base_keys = self.decrypt_keys(); + let mut base_keys = self.decrypt_keys(); + + // AACS key map for the extract, chosen by CPS-unit count: + // + // * SINGLE CPS (the overwhelming majority, incl. every single-key UHD): one + // Unit Key opens EVERY encrypted unit on the disc — content in a parsed + // title AND an orphan clip that no playlist references. A blanket key-0 + // map over the whole LBA space is exact and covers orphans; clear + // filesystem/nav (encrypted-flag off) passes through untouched. + // + // * MULTI-CPS: each clip is protected by a different Unit Key, so a blanket + // key-0 map would mis-decrypt every secondary-CPS file into garbage + // (silently, since Phase::All is trust-only). Build the EXACT per-CPS + // content map instead (each title's extents → the CPS key that opens a + // real sample from it), up front before the decorator takes the reader. A + // content unit whose key the pool lacks fails loud at resolve (extract has + // no CPS/forensic fetch source), never emits a wrong-key garble. + let key_map = + match &base_keys { + DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => { + Some(std::sync::Arc::new( + crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]), + )) + } + DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new( + self.resolve_content_key_map(reader, &mut base_keys, None)?, + )), + _ => None, + }; // ── Phase 2: stream each file through the decrypting decorator ──── // The decorator owns its inner source for its lifetime. We hand it a // 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)]), - )); + if let Some(map) = key_map { + dec = dec.with_key_map(map); } let mut result = ExtractResult::default(); diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 57f6258..50341f0 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -786,11 +786,27 @@ fn resolve_fmts_key_map( 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.) + // The segment SPNs are in the FORENSIC FEATURE clip's byte space. A title + // whose extents do not cover any segment's clip bytes carries no forensic + // content (a menu/extras playlist, or simply a different clip): its base Unit + // Key/CPS map applies and there is nothing forensic to resolve. Filter to the + // segments addressable within THIS title; if none, fall back (`Ok(None)`) + // rather than hard-failing. Without this, `resolve_content_key_map` — which + // resolves EVERY title for the whole-disc sweep — aborts the entire decrypt on + // the first non-forensic title (a menu playlist), and `build_iso_pipeline` + // aborts muxing any non-main title. + let segments: Vec = segments + .into_iter() + .filter(|s| clip_byte_to_lba(&title.extents, s.start_spn as u64 * 192).is_some()) + .collect(); + if segments.is_empty() { + return Ok(None); + } + // This title HAS forensic content, 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()); }; @@ -918,12 +934,22 @@ fn resolve_fmts_key_map( let phase = match even.cmp(&odd) { std::cmp::Ordering::Greater => crate::decrypt::Phase::Even, std::cmp::Ordering::Less => crate::decrypt::Phase::Odd, - std::cmp::Ordering::Equal => { - // Neither half decrypts clean under this index's key: the map would - // be wrong. Fail loud rather than emit a broken segment map. + std::cmp::Ordering::Equal if even == 0 => { + // NEITHER half decrypts clean under this index's key: the key is + // wrong or the sampled units aren't this index's real content. The + // map would be wrong — fail loud rather than emit a broken segment. tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map"); return Err(crate::error::Error::FmtsKeyMissing.into()); } + std::cmp::Ordering::Equal => { + // BOTH halves decrypt clean: the sampled units are source-zero + // padding (`is_clean_ts` is true for all-zero content under ANY + // key), so the key is valid and the parity is immaterial here — + // default Even (we decrypt one parity; padding in the dropped parity + // is harmless). A padding-heavy sample must NOT abort the rip. + tracing::debug!(target: "freemkv::keysource", index = tag, even, odd, "fmts: padding tie — defaulting Even"); + crate::decrypt::Phase::Even + } }; phase_of_index.insert(tag, phase); }