AacsKeyMap: a positive map — no key for a sector means pass through

The key map is now purely "these sectors use this key": entry_for returns
Option and an LBA in no range is left untouched (no default-decrypt-
everything fallback). resolve_mux_key_map builds explicit content ranges
for every case — single-CPS keys each content extent, multi-CPS keys each
extent with the key that opens it, and FMTS fills the non-segment content
with the base Unit Key so a whole-disc read decrypts content and passes
nav/filesystem through. Combined with the fail-loud resolve, a map can
never silently apply a wrong key, and clear sectors are never scrambled.

decrypt_sectors_mapped skips a unit with no map entry; read_plan keeps an
unmapped unit (pass-through content) and drops only alternate-phase
forensic units. Tests updated to the Option semantics.
This commit is contained in:
Matthew Jackson
2026-07-23 12:58:35 -07:00
parent 1eb6910bdb
commit 279ba0dd7c
4 changed files with 122 additions and 98 deletions
+49 -13
View File
@@ -951,9 +951,33 @@ fn resolve_fmts_key_map(
return Err(crate::error::Error::FmtsKeyMissing.into());
}
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(
ranges, base_idx,
)))
// Cover the NON-segment content with the base Unit Key: the forensic segments
// (added above with their index keys) carve holes out of the title's content
// extents; every other content unit uses the base UK. Fill the gaps so the map
// is a complete positive list — an LBA in no range is nav and passes through.
let cuts: Vec<(u32, u32)> = {
let mut c: Vec<(u32, u32)> = ranges.iter().map(|&(s, e, _, _)| (s, e)).collect();
c.sort_unstable();
c
};
for ext in &title.extents {
let end = ext.start_lba.saturating_add(ext.sector_count);
let mut cur = ext.start_lba;
for &(cs, ce) in &cuts {
if ce <= cur || cs >= end {
continue; // cut outside this extent
}
if cs > cur {
ranges.push((cur, cs, base_idx, crate::decrypt::Phase::All));
}
cur = cur.max(ce);
}
if cur < end {
ranges.push((cur, end, base_idx, crate::decrypt::Phase::All));
}
}
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges)))
}
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
@@ -971,11 +995,22 @@ fn resolve_fmts_key_map(
/// 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.
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) keys every
/// content extent with one index; multi-CPS keys each extent with the key that
/// opens a real sample from it; FMTS layers per-segment index keys on top. Any LBA
/// outside the title's content (nav/filesystem) is in no range and passes through.
///
/// A single-key content map: every content extent → `idx`; everything else passes
/// through. The positive-map replacement for the old "one key everywhere" default.
fn content_map(title: &DiscTitle, idx: usize) -> crate::decrypt::AacsKeyMap {
let ranges = title
.extents
.iter()
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count), idx))
.collect();
crate::decrypt::AacsKeyMap::from_ranges(ranges)
}
pub fn resolve_mux_key_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
@@ -994,8 +1029,9 @@ pub fn resolve_mux_key_map(
// always >= 1 for the AACS map paths below.
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)),
// CSS / clear: the AACS map keys nothing here — an empty map passes every
// unit through (CSS self-descrambles on its own path).
_ => return Ok(crate::decrypt::AacsKeyMap::from_ranges(Vec::new())),
};
// 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
@@ -1006,8 +1042,8 @@ pub fn resolve_mux_key_map(
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));
// One CPS unit → key 0 over every content extent; nav passes through.
return Ok(content_map(title, 0));
}
// Multi-CPS: read a spread of real encrypted units from each extent and pick
@@ -1095,7 +1131,7 @@ pub fn resolve_mux_key_map(
idx,
));
}
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0))
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges))
}
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for