Fix FMTS per-title resolve, extract multi-CPS keying, trailing-partial guard

- resolve_fmts_key_map: filter segments to those addressable within THIS
  title's extents; a title with no forensic content (menu/extras playlist,
  or a different clip) returns Ok(None) and takes the base Unit-Key/CPS
  path instead of hard-failing FmtsKeyMissing. Previously the first
  non-forensic title aborted the entire whole-disc sweep
  (resolve_content_key_map iterates every title) and blocked muxing any
  non-main title.
- FMTS phase probe: an even/odd is_clean tie now only fails loud when
  BOTH halves are 0 (no clean decrypt). A both-clean tie is source-zero
  padding (is_clean is true for any key on all-zero content) — the key is
  valid, default Even, never abort the rip on a padding-heavy sample.
- extract_tree: multi-CPS discs now build the exact per-CPS content map
  (resolve_content_key_map) instead of a blanket key-0 map that silently
  mis-decrypted every secondary-CPS file into garbage. Single-CPS keeps
  the blanket key-0 map (one key opens every unit, incl. orphan clips).
- decrypt_sectors_mapped: a trailing partial unit that is inside a mapped
  range AND flagged encrypted in its clear seed now fails loud (a CBC
  fragment split across a boundary can't be decrypted) instead of being
  emitted as clear. New aacs_unit_seed_encrypted reads the flag on a
  partial.
- Correct the stale decrypt_sectors doc (AACS arm now always errors;
  AACS decrypts only via decrypt_sectors_mapped).
This commit is contained in:
Matthew Jackson
2026-07-23 15:36:15 -07:00
parent e632874665
commit 0acb326079
4 changed files with 109 additions and 41 deletions
+17
View File
@@ -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 /// True when an aligned unit is flagged encrypted AND still looks scrambled
/// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted. /// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted.
/// ///
+27 -23
View File
@@ -408,7 +408,20 @@ pub fn decrypt_sectors_mapped(
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| { let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
if chunk.len() != unit_len { 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); 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 // No range covers this LBA → the map keys no content here, so pass the
@@ -470,30 +483,21 @@ pub fn decrypt_sectors_mapped(
Ok(()) 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: descrambles per 2048-byte sector, self-cracking the title key from the
/// For CSS: processes per 2048-byte sector. /// data (no external input). For `None`: a no-op. For AACS: **always** returns
/// For None: no-op. /// `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 /// `unit_key_idx` and `content` are legacy parameters kept so the CSS / `None`
/// multi-CPS-unit disc every key is tried per unit until the TS-sync verify /// wrapper signatures stay stable; they are ignored (the CSS arm self-gates on its
/// passes; `unit_key_idx` is tried first so single-CPS-unit discs pay zero /// per-sector scramble flag). Returns `Err` if decryption was expected but
/// overhead. An out-of-range `unit_key_idx` is always an error. /// impossible; never produces silently corrupted output. The `usize` return is a
/// /// legacy unverified-byte count that is always `0` for the CSS / `None` arms.
/// 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.
pub fn decrypt_sectors( pub fn decrypt_sectors(
buf: &mut [u8], buf: &mut [u8],
keys: &mut DecryptKeys, keys: &mut DecryptKeys,
+31 -10
View File
@@ -185,22 +185,43 @@ impl Disc {
// Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily // Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily
// when a scrambled VOB group needs it. AACS / None discs keep the // when a scrambled VOB group needs it. AACS / None discs keep the
// disc-wide keys for every file. // 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 ──── // ── Phase 2: stream each file through the decrypting decorator ────
// The decorator owns its inner source for its lifetime. We hand it a // The decorator owns its inner source for its lifetime. We hand it a
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS // borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout. // VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone()); let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
// AACS decrypts via the key map. Extract reads arbitrary files (not resolved if let Some(map) = key_map {
// title extents), so key every unit with the disc's base Unit Key: the mapped dec = dec.with_key_map(map);
// decrypt applies it to encrypted units and passes clear filesystem/nav
// through (its encrypted-flag gate). Single-CPS is exact; a multi-CPS disc's
// secondary units are not separately keyed here (extract is not the mux path).
if matches!(base_keys, DecryptKeys::Aacs { .. }) {
dec = dec.with_key_map(std::sync::Arc::new(
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
));
} }
let mut result = ExtractResult::default(); let mut result = ExtractResult::default();
+34 -8
View File
@@ -786,11 +786,27 @@ fn resolve_fmts_key_map(
if segments.is_empty() { if segments.is_empty() {
return Ok(None); return Ok(None);
} }
// This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like // The segment SPNs are in the FORENSIC FEATURE clip's byte space. A title
// a Unit Key. Without a configured key source we cannot obtain them, so we // whose extents do not cover any segment's clip bytes carries no forensic
// cannot produce a complete rip: fail loud rather than silently drop the // content (a menu/extras playlist, or simply a different clip): its base Unit
// forensic segments. (The caller may still choose `--raw`, which never reaches // Key/CPS map applies and there is nothing forensic to resolve. Filter to the
// this path.) // 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<crate::aacs::segment::Segment> = 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 { let Some(fetch) = fetch else {
return Err(crate::error::Error::FmtsKeyMissing.into()); return Err(crate::error::Error::FmtsKeyMissing.into());
}; };
@@ -918,12 +934,22 @@ fn resolve_fmts_key_map(
let phase = match even.cmp(&odd) { let phase = match even.cmp(&odd) {
std::cmp::Ordering::Greater => crate::decrypt::Phase::Even, std::cmp::Ordering::Greater => crate::decrypt::Phase::Even,
std::cmp::Ordering::Less => crate::decrypt::Phase::Odd, std::cmp::Ordering::Less => crate::decrypt::Phase::Odd,
std::cmp::Ordering::Equal => { std::cmp::Ordering::Equal if even == 0 => {
// Neither half decrypts clean under this index's key: the map would // NEITHER half decrypts clean under this index's key: the key is
// be wrong. Fail loud rather than emit a broken segment map. // 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"); 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()); 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); phase_of_index.insert(tag, phase);
} }