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
+15 -7
View File
@@ -274,15 +274,23 @@ mod tests {
// sectors 937..=946 → LBA 1937..1947, key index 7. // sectors 937..=946 → LBA 1937..1947, key index 7.
assert_eq!(ranges[1], (1937, 1947, 7)); assert_eq!(ranges[1], (1937, 1947, 7));
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default. // The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0); let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key"); assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5"); assert_eq!(
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7"); map.key_idx_for(1012),
Some(5),
"inside index-5 segment → key 5"
);
assert_eq!(
map.key_idx_for(1940),
Some(7),
"inside index-7 segment → key 7"
);
assert_eq!( assert_eq!(
map.key_idx_for(1019), map.key_idx_for(1019),
0, None,
"segment end is exclusive → Unit Key" "segment end is exclusive → no key"
); );
} }
+56 -76
View File
@@ -206,97 +206,67 @@ pub enum Phase {
/// common disc pays zero lookup cost and needs no structural walk. /// common disc pays zero lookup cost and needs no structural walk.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AacsKeyMap { pub struct AacsKeyMap {
// (start_lba, end_lba, key_idx, phase) // (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through
// untouched — the map is a positive list of "this key here", nothing more.
ranges: Vec<(u32, u32, usize, Phase)>, ranges: Vec<(u32, u32, usize, Phase)>,
default_idx: usize,
} }
impl AacsKeyMap { 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 `[start_lba, end_lba) → key_idx` ranges that decrypt EVERY unit /// Build from `[start_lba, end_lba) → key_idx` ranges that decrypt EVERY unit
/// (multi-CPS): each range is [`Phase::All`]. `default_idx` answers any /// (single- or multi-CPS): each range is [`Phase::All`]. An LBA in no range is
/// uncovered LBA. Byte-for-byte identical behaviour to before phases existed. /// passed through untouched.
pub fn from_ranges(ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self { pub fn from_ranges(ranges: Vec<(u32, u32, usize)>) -> Self {
let phased = ranges let phased = ranges
.into_iter() .into_iter()
.map(|(s, e, i)| (s, e, i, Phase::All)) .map(|(s, e, i)| (s, e, i, Phase::All))
.collect(); .collect();
Self::from_ranges_phased(phased, default_idx) Self::from_ranges_phased(phased)
} }
/// Build a PHASE-AWARE map (FMTS): each range carries which unit-parity its key /// Build a PHASE-AWARE map (FMTS): each range carries which unit-parity its key
/// opens ([`Phase::Even`]/[`Phase::Odd`] for a forensic segment, [`Phase::All`] /// opens ([`Phase::Even`]/[`Phase::Odd`] for a forensic segment, [`Phase::All`]
/// for base/CPS). Ranges are sorted; `default_idx` answers any uncovered LBA. /// for base/CPS). Ranges are sorted; an LBA in no range is passed through.
pub fn from_ranges_phased( pub fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self {
mut ranges: Vec<(u32, u32, usize, Phase)>,
default_idx: usize,
) -> Self {
ranges.sort_by_key(|&(start, _, _, _)| start); ranges.sort_by_key(|&(start, _, _, _)| start);
Self { Self { ranges }
ranges,
default_idx,
}
} }
/// The `(key_idx, phase, range_start_lba)` for the aligned unit at `lba`. /// The `(key_idx, phase, range_start_lba)` for the aligned unit at `lba`, or
/// O(log n) — the last range whose start is `<= lba` and whose end is `> lba`, /// `None` when no range covers it (not encrypted content this map keys — pass
/// else `(default_idx, All, 0)`. `range_start_lba` lets the mapped decrypt /// the unit through untouched). O(log n). `range_start_lba` lets the mapped
/// compute a unit's parity WITHIN a forensic segment (for `Even`/`Odd`). /// decrypt compute a unit's parity WITHIN a forensic segment (`Even`/`Odd`).
pub fn entry_for(&self, lba: u32) -> (usize, Phase, u32) { pub fn entry_for(&self, lba: u32) -> Option<(usize, Phase, u32)> {
if self.ranges.is_empty() {
return (self.default_idx, Phase::All, 0);
}
match self match self
.ranges .ranges
.binary_search_by(|&(start, _, _, _)| start.cmp(&lba)) .binary_search_by(|&(start, _, _, _)| start.cmp(&lba))
{ {
Ok(i) => { Ok(i) => {
let (start, _, idx, ph) = self.ranges[i]; let (start, _, idx, ph) = self.ranges[i];
(idx, ph, start) Some((idx, ph, start))
} }
Err(0) => (self.default_idx, Phase::All, 0), Err(0) => None,
Err(i) => { Err(i) => {
let (start, end, idx, ph) = self.ranges[i - 1]; let (start, end, idx, ph) = self.ranges[i - 1];
if lba >= start && lba < end { (lba >= start && lba < end).then_some((idx, ph, start))
(idx, ph, start)
} else {
(self.default_idx, Phase::All, 0)
}
} }
} }
} }
/// The unit-key index for the aligned unit at `lba` (phase-agnostic; see /// The unit-key index for the aligned unit at `lba`, or `None` when no range
/// [`entry_for`](Self::entry_for) for the phase). Cheap per-unit hot-path call. /// covers it (pass through). See [`entry_for`](Self::entry_for) for the phase.
pub fn key_idx_for(&self, lba: u32) -> usize { pub fn key_idx_for(&self, lba: u32) -> Option<usize> {
self.entry_for(lba).0 self.entry_for(lba).map(|(idx, _, _)| idx)
} }
/// The `[start_lba, end_lba) → (key_idx, phase)` ranges (sorted, disjoint). /// The `[start_lba, end_lba) → (key_idx, phase)` ranges (sorted, disjoint).
/// Empty for a single-CPS map (everything uses [`default_idx`](Self::default_idx)).
pub fn ranges(&self) -> &[(u32, u32, usize, Phase)] { pub fn ranges(&self) -> &[(u32, u32, usize, Phase)] {
&self.ranges &self.ranges
} }
/// The key index for any LBA no explicit range claims (the single-CPS key). /// The distinct key indices this map selects — the CPS units / segments the
pub fn default_idx(&self) -> usize { /// title actually reaches. The resolver secures exactly these up front.
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<usize> { pub fn key_indices(&self) -> Vec<usize> {
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i, _)| i).collect(); let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i, _)| i).collect();
v.push(self.default_idx);
v.sort_unstable(); v.sort_unstable();
v.dedup(); v.dedup();
v v
@@ -367,10 +337,11 @@ impl AacsKeyMap {
push(lba, remaining); push(lba, remaining);
break; break;
} }
let (_, phase, range_start) = self.entry_for(lba); // A unit in NO range is pass-through content (base/default) — read
let keep = match phase { // it. Only an alternate-phase forensic unit is dropped from the plan.
Phase::All => true, let keep = match self.entry_for(lba) {
Phase::Even | Phase::Odd => { None | Some((_, Phase::All, _)) => true,
Some((_, phase, range_start)) => {
let unit_ix = (lba - range_start) / us; let unit_ix = (lba - range_start) / us;
let is_odd = unit_ix % 2 == 1; let is_odd = unit_ix % 2 == 1;
is_odd == matches!(phase, Phase::Odd) is_odd == matches!(phase, Phase::Odd)
@@ -440,7 +411,11 @@ pub fn decrypt_sectors_mapped(
return; // trailing partial unit: clear tail on disc, leave as-is return; // trailing partial unit: clear tail on disc, leave as-is
} }
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);
let (key_idx, phase, range_start) = map.entry_for(unit_lba); // No range covers this LBA → the map keys no content here, so pass the
// unit through untouched (clear filesystem / nav on a whole-disc read).
let Some((key_idx, phase, range_start)) = map.entry_for(unit_lba) else {
return;
};
// PHASE GATE (FMTS forensic segment): the segment interleaves two variants // PHASE GATE (FMTS forensic segment): the segment interleaves two variants
// at the unit level. Decrypt ONLY our parity; leave the alternate half as // at the unit level. Decrypt ONLY our parity; leave the alternate half as
// ciphertext (the muxer drops untouched ciphertext cleanly — no garble). // ciphertext (the muxer drops untouched ciphertext cleanly — no garble).
@@ -1626,18 +1601,19 @@ mod tests {
// ── FMTS phase-aware map ────────────────────────────────────────────────── // ── FMTS phase-aware map ──────────────────────────────────────────────────
/// `entry_for` returns (idx, phase, range_start); `from_ranges` is All, /// `entry_for` returns Some((idx, phase, range_start)) inside a range;
/// `from_ranges_phased` carries the phase; uncovered → (default, All, 0). /// `from_ranges` is All, `from_ranges_phased` carries the phase; an uncovered
/// LBA is `None` (pass through).
#[test] #[test]
fn aacskeymap_phase_entry_for() { fn aacskeymap_phase_entry_for() {
let all = AacsKeyMap::from_ranges(vec![(100, 200, 3)], 0); let all = AacsKeyMap::from_ranges(vec![(100, 200, 3)]);
assert_eq!(all.entry_for(150), (3, Phase::All, 100)); assert_eq!(all.entry_for(150), Some((3, Phase::All, 100)));
assert_eq!(all.entry_for(50), (0, Phase::All, 0)); assert_eq!(all.entry_for(50), None);
let phased = AacsKeyMap::from_ranges_phased(vec![(100, 200, 3, Phase::Odd)], 7); let phased = AacsKeyMap::from_ranges_phased(vec![(100, 200, 3, Phase::Odd)]);
assert_eq!(phased.entry_for(150), (3, Phase::Odd, 100)); assert_eq!(phased.entry_for(150), Some((3, Phase::Odd, 100)));
assert_eq!(phased.entry_for(250), (7, Phase::All, 0)); assert_eq!(phased.entry_for(250), None);
assert_eq!(phased.key_idx_for(150), 3); assert_eq!(phased.key_idx_for(150), Some(3));
} }
/// A map with no forensic (Even/Odd) range is the common disc: `read_plan` /// A map with no forensic (Even/Odd) range is the common disc: `read_plan`
@@ -1656,9 +1632,9 @@ mod tests {
sector_count: 60, sector_count: 60,
}, },
]; ];
// Single-CPS and multi-CPS (All) maps both leave the plan untouched. // A non-forensic map (empty, or multi-CPS All) leaves the plan untouched.
assert_eq!(AacsKeyMap::single(0).read_plan(&ext, us), ext); assert_eq!(AacsKeyMap::from_ranges(vec![]).read_plan(&ext, us), ext);
let multi = AacsKeyMap::from_ranges(vec![(1000, 1150, 2)], 0); let multi = AacsKeyMap::from_ranges(vec![(1000, 1150, 2)]);
assert_eq!(multi.read_plan(&ext, us), ext); assert_eq!(multi.read_plan(&ext, us), ext);
} }
@@ -1677,7 +1653,7 @@ mod tests {
start_lba: 1000, start_lba: 1000,
sector_count: 300, sector_count: 300,
}]; }];
let map = AacsKeyMap::from_ranges_phased(vec![(1030, 1060, 5, Phase::Even)], 0); let map = AacsKeyMap::from_ranges_phased(vec![(1030, 1060, 5, Phase::Even)]);
let plan = map.read_plan(&ext, us); let plan = map.read_plan(&ext, us);
let expected = vec![ let expected = vec![
Extent { Extent {
@@ -1719,8 +1695,7 @@ mod tests {
let mut off = 0; let mut off = 0;
while off < e.sector_count { while off < e.sector_count {
let lba = e.start_lba + off; let lba = e.start_lba + off;
let (_, phase, rs) = map.entry_for(lba); if let Some((_, phase @ (Phase::Even | Phase::Odd), rs)) = map.entry_for(lba) {
if let Phase::Even | Phase::Odd = phase {
let is_odd = ((lba - rs) / us) % 2 == 1; let is_odd = ((lba - rs) / us) % 2 == 1;
assert!( assert!(
is_odd == matches!(phase, Phase::Odd), is_odd == matches!(phase, Phase::Odd),
@@ -1756,7 +1731,7 @@ mod tests {
read_data_key: None, read_data_key: None,
format: ContentFormat::BdTs, format: ContentFormat::BdTs,
}; };
let map = AacsKeyMap::from_ranges_phased(vec![(0, 8 * usz, 0, Phase::Even)], 0); let map = AacsKeyMap::from_ranges_phased(vec![(0, 8 * usz, 0, Phase::Even)]);
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("even phase decrypts clean"); decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("even phase decrypts clean");
for i in 0..8 { for i in 0..8 {
let u = &buf[i * ul..(i + 1) * ul]; let u = &buf[i * ul..(i + 1) * ul];
@@ -1791,7 +1766,7 @@ mod tests {
read_data_key: None, read_data_key: None,
format: ContentFormat::BdTs, format: ContentFormat::BdTs,
}; };
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)], 0); let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)]);
assert!(matches!( assert!(matches!(
decrypt_sectors_mapped(&mut buf, &keys, 0, &map), decrypt_sectors_mapped(&mut buf, &keys, 0, &map),
Err(crate::error::Error::DecryptFailed) Err(crate::error::Error::DecryptFailed)
@@ -1816,7 +1791,12 @@ mod tests {
read_data_key: None, read_data_key: None,
format: ContentFormat::BdTs, format: ContentFormat::BdTs,
}; };
decrypt_sectors_mapped(&mut buf, &keys, 0, &AacsKeyMap::single(0)) decrypt_sectors_mapped(
&mut buf,
&keys,
0,
&AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
)
.expect("all-phase decrypts"); .expect("all-phase decrypts");
for i in 0..4 { for i in 0..4 {
assert!( assert!(
+1 -1
View File
@@ -1180,7 +1180,7 @@ mod tests {
}; };
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60): // 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
// even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped. // even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped.
let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)], 0); let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)]);
let stream = DiscStream::new( let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 300 }), Box::new(ZeroReader { capacity: 300 }),
synthetic_title(300), synthetic_title(300),
+49 -13
View File
@@ -951,9 +951,33 @@ fn resolve_fmts_key_map(
return Err(crate::error::Error::FmtsKeyMissing.into()); return Err(crate::error::Error::FmtsKeyMissing.into());
} }
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased( // Cover the NON-segment content with the base Unit Key: the forensic segments
ranges, base_idx, // (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 /// 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 /// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
/// decrypts each unit with its mapped key and trusts it. /// decrypts each unit with its mapped key and trusts it.
/// ///
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the /// Single-CPS (the overwhelming majority, incl. every single-key UHD) keys every
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to /// content extent with one index; multi-CPS keys each extent with the key that
/// the key that opens a real sample from it; a bad-content extent no sample can /// opens a real sample from it; FMTS layers per-segment index keys on top. Any LBA
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping /// outside the title's content (nav/filesystem) is in no range and passes through.
/// layers onto the same structure. ///
/// 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( pub fn resolve_mux_key_map(
reader: &mut dyn SectorSource, reader: &mut dyn SectorSource,
title: &DiscTitle, title: &DiscTitle,
@@ -994,8 +1029,9 @@ pub fn resolve_mux_key_map(
// always >= 1 for the AACS map paths below. // always >= 1 for the AACS map paths below.
let pool_len = match keys { let pool_len = match keys {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(), crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
// CSS / clear: no AACS map (the decorator's map path is AACS-only). // CSS / clear: the AACS map keys nothing here — an empty map passes every
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)), // 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 // 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 // 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); return Ok(map);
} }
if pool_len == 1 { if pool_len == 1 {
// One CPS unit → one key everywhere. No structural walk, no sampling. // One CPS unit → key 0 over every content extent; nav passes through.
return Ok(crate::decrypt::AacsKeyMap::single(0)); return Ok(content_map(title, 0));
} }
// Multi-CPS: read a spread of real encrypted units from each extent and pick // 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, 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 /// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for