From 5e1f880f6e8cdf5b5bf628acf267fe53ff3f8a92 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:51:15 -0700 Subject: [PATCH] libfreemkv: phase-aware FMTS decode + two-operation KeyFetch/KeySource seam FMTS (AACS 2.1) now decodes per (LBA, phase): Phase enum + AacsKeyMap:: from_ranges_phased, decrypt only the variant's parity half. resolve_fmts_key_map does a 2-phase index-1 anchor then per-index phase probe, and sizes the forensic set to whatever the source returns (no hardcoded 32). KeyFetch is now two explicit operations (unit_keys / fmts_indexes) and KeySource splits get_uk into get_unit_keys + get_fmts_indexes. BYPASS_FMTS_KEY gate removed (first-class format). Teed up for 1.4.5. Local WIP baseline. --- CHANGELOG.md | 42 +++++++ src/aacs/segment.rs | 15 --- src/decrypt.rs | 224 +++++++++++++++++++++++++++++---- src/disc/encrypt.rs | 2 +- src/keysource.rs | 258 +++++++++++++++++++++++++++++---------- src/mux/resolve.rs | 173 ++++++++++++++++---------- src/sector/decrypting.rs | 126 +++++++++++++------ src/sector/mod.rs | 2 +- src/sector/recovery.rs | 14 +-- 9 files changed, 645 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4865d15..6841dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.4.4] — 2026-07-17 + +### Fixed + +- **Online key requests are no longer silently dropped on discs that yield few + sample units.** The online key source refuses any request carrying fewer than + `MIN_SAMPLE_UNITS` (8) encrypted-content samples — too few can match an + incidental unit rather than the one asked about (a false positive, most acute on + AACS 2.1 forensic-variant content). autorip gathered only 4, so every online + lookup was skipped before it ever reached the key service and surfaced to the + user as "key service down." autorip's sample count is now tied to + `MIN_SAMPLE_UNITS` with a **compile-time floor**, so it can never regress below + the minimum again. + +### Changed + +- **The online request is assembled from a proven-sufficient sample set.** New + `DecodeSampleSet` (`libfreemkv::keysource`) wraps the content-unit samples and + can only be constructed with at least `MIN_SAMPLE_UNITS` of them — so an online + key request cannot be built from too few samples. The minimum is validated once, + at construction, rather than by a runtime check a caller could forget. + +## [1.4.3] — 2026-07-17 + +### Changed + +- **`MIN_SAMPLE_UNITS` moved to the base crate.** The minimum sample count an + online key request must carry now has a single definition in + `libfreemkv::keysource`; `freemkv-keysources` re-exports it, so the online source + and libfreemkv's own forensic query size their requests from one shared value. +- **The online unit-key reply is parsed as a list.** A response carries either a + single Unit Key (ordinary disc) or the full ordered set (an AACS 2.1 + forensic-variant disc); the client accepts both and maps array position to + forensic index. + +### Added + +- **Forensic-variant online query samples the anchor segment.** On an AACS 2.1 + forensic-variant disc the online key query draws its sample from the first + forensic segment (index 1) — one canonical, deterministic sample — instead of an + arbitrary segment. + ## [1.4.2] — 2026-07-15 ### Fixed diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index 69a5eab..cdd71e7 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -41,21 +41,6 @@ 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 the forensic index keys. -/// -/// `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 -/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a -/// forensic-holed rip is refused rather than produced. No segment-key source -/// exists yet, so `true` is the only value under which a 2.1 disc rips at all; -/// flip to `false` once segment keys can be sourced and a partial rip should be -/// refused. Hardcoded on purpose — not a user setting. -/// -/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing -pub const BYPASS_FMTS_KEY: bool = false; - /// One forensic segment: the inclusive source-packet range it occupies in the /// FMTS clip. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/decrypt.rs b/src/decrypt.rs index 241801b..88828a4 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -191,9 +191,23 @@ impl DecryptKeys { /// 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. +/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments +/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's +/// half (parity of the unit's index within the segment) and the ALTERNATE half is +/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range — +/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the +/// common disc is byte-for-byte unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + All, + Even, + Odd, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct AacsKeyMap { - ranges: Vec<(u32, u32, usize)>, + // (start_lba, end_lba, key_idx, phase) + ranges: Vec<(u32, u32, usize, Phase)>, default_idx: usize, } @@ -207,43 +221,68 @@ impl AacsKeyMap { } } - /// 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); + /// Build from `[start_lba, end_lba) → key_idx` ranges that decrypt EVERY unit + /// (multi-CPS): each range is [`Phase::All`]. `default_idx` answers any + /// uncovered LBA. Byte-for-byte identical behaviour to before phases existed. + pub fn from_ranges(ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self { + let phased = ranges + .into_iter() + .map(|(s, e, i)| (s, e, i, Phase::All)) + .collect(); + Self::from_ranges_phased(phased, default_idx) + } + + /// 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`] + /// for base/CPS). Ranges are sorted; `default_idx` answers any uncovered LBA. + pub fn from_ranges_phased( + mut ranges: Vec<(u32, u32, usize, Phase)>, + 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 { + /// The `(key_idx, phase, range_start_lba)` for the aligned unit at `lba`. + /// O(log n) — the last range whose start is `<= lba` and whose end is `> lba`, + /// else `(default_idx, All, 0)`. `range_start_lba` lets the mapped decrypt + /// compute a unit's parity WITHIN a forensic segment (for `Even`/`Odd`). + pub fn entry_for(&self, lba: u32) -> (usize, Phase, u32) { if self.ranges.is_empty() { - return self.default_idx; + return (self.default_idx, Phase::All, 0); } match self .ranges - .binary_search_by(|&(start, _, _)| start.cmp(&lba)) + .binary_search_by(|&(start, _, _, _)| start.cmp(&lba)) { - Ok(i) => self.ranges[i].2, - Err(0) => self.default_idx, + Ok(i) => { + let (start, _, idx, ph) = self.ranges[i]; + (idx, ph, start) + } + Err(0) => (self.default_idx, Phase::All, 0), Err(i) => { - let (start, end, idx) = self.ranges[i - 1]; + let (start, end, idx, ph) = self.ranges[i - 1]; if lba >= start && lba < end { - idx + (idx, ph, start) } else { - self.default_idx + (self.default_idx, Phase::All, 0) } } } } - /// 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)] { + /// The unit-key index for the aligned unit at `lba` (phase-agnostic; see + /// [`entry_for`](Self::entry_for) for the phase). Cheap per-unit hot-path call. + pub fn key_idx_for(&self, lba: u32) -> usize { + self.entry_for(lba).0 + } + + /// 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)] { &self.ranges } @@ -256,7 +295,7 @@ impl AacsKeyMap { /// 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(); + let mut v: Vec = self.ranges.iter().map(|&(_, _, i, _)| i).collect(); v.push(self.default_idx); v.sort_unstable(); v.dedup(); @@ -305,24 +344,46 @@ pub fn decrypt_sectors_mapped( } } + // Cheap safety net for the "map must be right" model: with a correct + // phase-aware map, every CORRECT-PHASE forensic unit decrypts to clean TS, so + // this never fires in the happy path — but a map bug (wrong phase/key for a + // segment) surfaces as a loud DecryptFailed instead of silent corruption. Only + // forensic (Even/Odd) ranges are verified; base / multi-CPS (All) stays + // trust-only, so the common disc is byte-for-byte unchanged. + let verify_failed = std::sync::atomic::AtomicBool::new(false); + 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. + 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); + // PHASE GATE (FMTS forensic segment): the segment interleaves two variants + // at the unit level. Decrypt ONLY our parity; leave the alternate half as + // ciphertext (the muxer drops untouched ciphertext cleanly — no garble). + if matches!(phase, Phase::Even | Phase::Odd) { + let unit_ix = (unit_lba - range_start) / unit_sectors; + let is_odd = unit_ix % 2 == 1; + if is_odd != matches!(phase, Phase::Odd) { + return; // alternate half — leave as-is + } + } + // Gate on the authoritative encrypted flag ONLY (CPI bits in the clear + // seed): a clear unit 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); + // Correct-phase forensic verify (silent unless the map is wrong). + if matches!(phase, Phase::Even | Phase::Odd) && !aacs::content::is_clean(chunk, format) { + verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + } }; let nthreads = decrypt_threads(); @@ -345,6 +406,9 @@ pub fn decrypt_sectors_mapped( } } } + if verify_failed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(crate::error::Error::DecryptFailed); + } Ok(()) } @@ -1476,6 +1540,116 @@ mod tests { unit } + // ── FMTS phase-aware map ────────────────────────────────────────────────── + + /// `entry_for` returns (idx, phase, range_start); `from_ranges` is All, + /// `from_ranges_phased` carries the phase; uncovered → (default, All, 0). + #[test] + fn aacskeymap_phase_entry_for() { + let all = AacsKeyMap::from_ranges(vec![(100, 200, 3)], 0); + assert_eq!(all.entry_for(150), (3, Phase::All, 100)); + assert_eq!(all.entry_for(50), (0, Phase::All, 0)); + + let phased = AacsKeyMap::from_ranges_phased(vec![(100, 200, 3, Phase::Odd)], 7); + assert_eq!(phased.entry_for(150), (3, Phase::Odd, 100)); + assert_eq!(phased.entry_for(250), (7, Phase::All, 0)); + assert_eq!(phased.key_idx_for(150), 3); + } + + /// Phase::Even → only even-index units in the range are decrypted; the odd + /// (alternate variant) half is left BYTE-FOR-BYTE as ciphertext for the muxer. + #[test] + fn mapped_phase_even_decrypts_even_leaves_odd_ciphertext() { + use crate::disc::ContentFormat; + let key_a = [0xAAu8; 16]; + let key_b = [0xBBu8; 16]; + let ul = aacs::content::ALIGNED_UNIT_LEN; + let usz = (ul / 2048) as u32; + let mut buf = vec![0u8; 8 * ul]; + let mut odd_cipher = Vec::new(); + for i in 0..8 { + let mut u = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u, if i % 2 == 0 { &key_a } else { &key_b }); + if i % 2 == 1 { + odd_cipher.push(u.clone()); + } + buf[i * ul..(i + 1) * ul].copy_from_slice(&u); + } + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key_a)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let map = AacsKeyMap::from_ranges_phased(vec![(0, 8 * usz, 0, Phase::Even)], 0); + decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("even phase decrypts clean"); + for i in 0..8 { + let u = &buf[i * ul..(i + 1) * ul]; + if i % 2 == 0 { + assert!( + aacs::content::is_clean(u, ContentFormat::BdTs), + "even unit {i} decrypted to clean TS" + ); + } else { + assert_eq!( + u, + odd_cipher[i / 2].as_slice(), + "odd unit {i} left as ciphertext" + ); + } + } + } + + /// The correct-phase safety `is_clean` fires loud: an even unit whose mapped + /// key is wrong does NOT come clean → `DecryptFailed` (not silent corruption). + #[test] + fn mapped_phase_verify_fails_loud_on_wrong_key() { + use crate::disc::ContentFormat; + let ul = aacs::content::ALIGNED_UNIT_LEN; + let usz = (ul / 2048) as u32; + let mut buf = vec![0u8; 2 * ul]; + let mut u0 = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u0, &[0xAAu8; 16]); // encrypted under A + buf[..ul].copy_from_slice(&u0); + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xCCu8; 16])], // map slot points at the WRONG key + read_data_key: None, + format: ContentFormat::BdTs, + }; + let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)], 0); + assert!(matches!( + decrypt_sectors_mapped(&mut buf, &keys, 0, &map), + Err(crate::error::Error::DecryptFailed) + )); + } + + /// Phase::All (multi-CPS / base) decrypts EVERY unit and never runs the verify + /// — the common-disc path is byte-for-byte unchanged. + #[test] + fn mapped_all_phase_decrypts_every_unit() { + use crate::disc::ContentFormat; + let key = [0x11u8; 16]; + let ul = aacs::content::ALIGNED_UNIT_LEN; + let mut buf = vec![0u8; 4 * ul]; + for i in 0..4 { + let mut u = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u, &key); + buf[i * ul..(i + 1) * ul].copy_from_slice(&u); + } + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + decrypt_sectors_mapped(&mut buf, &keys, 0, &AacsKeyMap::single(0)) + .expect("all-phase decrypts"); + for i in 0..4 { + assert!( + aacs::content::is_clean(&buf[i * ul..(i + 1) * ul], ContentFormat::BdTs), + "unit {i} decrypted (All)" + ); + } + } + /// A unit encrypted under unit_keys[1] (the second CPS unit) on a /// two-key disc must be correctly decrypted — not left as garbage — /// when `decrypt_sectors` is called with unit_key_idx=0 (the default). diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index a761bb3..633b53c 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -976,7 +976,7 @@ mod tests { /// A minimal in-test KeySource that yields no keys but a fixed cert list. struct CertSource(Vec); impl crate::KeySource for CertSource { - fn get_uk( + fn get_unit_keys( &self, _ctx: &dyn crate::keysource::ResolveCtx, ) -> Result> { diff --git a/src/keysource.rs b/src/keysource.rs index f8947f9..25c06aa 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -115,7 +115,7 @@ pub struct DiscInputs { pub volume_label: Option, } -/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a +/// A lazy view of a disc's AACS material, handed to [`KeySource::get_unit_keys`] so a /// source can drive the derivation chain without holding the disc reader. /// /// "Lazy" by contract: each accessor returns only what the source asks for, so a @@ -233,10 +233,34 @@ impl ResolveCtx for DiscInputsCtx<'_> { /// ([`resolve_and_apply`]) tries each source in order and validates the returned /// keys against real ciphertext before committing them, so a wrong key from one /// source transparently falls through to the next. +/// +/// Two explicit resolve operations, one per key kind — never one overloaded call +/// whose meaning depends on how many keys came back: +/// * [`get_unit_keys`](Self::get_unit_keys) — the disc's base per-CPS-unit Unit +/// Keys (index space = CPS-unit number). The common path for every disc. +/// * [`get_fmts_indexes`](Self::get_fmts_indexes) — the AACS 2.1 forensic index +/// keys (index space = forensic index 1..N). Defaults to empty: a source with +/// no forensic material opts out, and only an FMTS disc ever asks. +/// +/// What each source must do to answer is the source's own business: a keydb keys +/// on `disc_hash` and reads no samples; the online source submits the ctx's +/// content samples (a base batch for `get_unit_keys`, an index-1 anchor batch for +/// `get_fmts_indexes`) to the key service. pub trait KeySource { - /// Resolve this disc's terminal Unit Keys from this source. An empty `Vec` - /// is a genuine "no key here"; `Err` is a source failure. - fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result, Error>; + /// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty + /// `Vec` is a genuine "no key here"; `Err` is a source failure. + fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result, Error>; + + /// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the + /// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by + /// forensic index (element `i` carries `UnitKey.idx == i`, forensic index + /// `i + 1`). The source hands back the COMPLETE set it holds; the caller + /// trusts any non-empty result as all of them and never assumes a fixed count. + /// Defaults to empty: a source with no forensic material (a plain keydb, the + /// mapfile) opts out, and only an FMTS disc's mux ever calls this. + fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(Vec::new()) + } /// The AACS host certificate(s) this source can supply for the live-drive /// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the @@ -273,7 +297,7 @@ pub fn resolve_and_apply( /// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for /// applications to render. ZERO English; the trace is typed enums only. /// -/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly +/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly /// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped /// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`], /// which validates them against `inputs.samples` and only mutates the disc on @@ -301,7 +325,7 @@ pub fn resolve_and_apply_traced( for source in sources { // `who` is the source's own stable identifier — no enum to map back to. let who = source.label().to_string(); - match source.get_uk(&ctx) { + match source.get_unit_keys(&ctx) { Ok(uks) if !uks.is_empty() => { // Positional index → canonical CPS-unit number (position + 1). let unit_keys: Vec<(u32, [u8; 16])> = uks @@ -352,7 +376,7 @@ pub fn resolve_and_apply_traced( /// decorator re-decrypts with the returned keys, which is the validation. pub fn fetch_unit_keys(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec { for source in sources { - if let Ok(uks) = source.get_uk(ctx) { + if let Ok(uks) = source.get_unit_keys(ctx) { if !uks.is_empty() { return uks; } @@ -361,61 +385,92 @@ pub fn fetch_unit_keys(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec::new() } -/// Build the read-time key-fetch closure from the disc's public AACS inputs and -/// a way to (re)build the application's key sources. The decorator calls it with -/// the still-scrambled unit ciphertext when no held key opens that unit; it runs -/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys. +/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and +/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries +/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and +/// injects it as the ctx's samples); a source that needs no samples (a keydb +/// keying on `disc_hash`) ignores them. Whatever the winning source returns — +/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed. +pub fn fetch_fmts_indexes(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec { + for source in sources { + if let Ok(uks) = source.get_fmts_indexes(ctx) { + if !uks.is_empty() { + return uks; + } + } + } + Vec::new() +} + +/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS +/// inputs and a way to (re)build the application's key sources. The returned +/// resolver has the two explicit operations the mux and recovery decorator call: +/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`] +/// (base per-CPS-unit keys), [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes) +/// drives [`fetch_fmts_indexes`] (the AACS 2.1 forensic set). Each is handed the +/// caller's sample batch as the ctx's `samples`, so a source pulls whatever +/// material it needs. /// -/// One builder, used by every read path (sweep / patch / mux) and by every -/// consumer (CLI, autorip) — neither application contains the fetch logic, only -/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`] -/// (`Arc`): build it once, clone it into each read path. `make_sources` is -/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays +/// One builder, used by every read path (sweep / patch / mux) and every consumer +/// (CLI, autorip) — neither application contains the fetch logic, only its +/// key-source config. Cheap to clone; build once, clone into each read path. +/// `make_sources` is invoked per fetch (the cold path) so the resolver stays /// `Send + Sync` without requiring `KeySource: Send`. 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); + // One driver behind both operations: rebuild the sources, inject `samples` + // as the ctx's content samples, run `drive` (the per-kind fetch), map the + // resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample + // batch: the resolved keys are disc-level (a clip's index / CPS keys are + // identical for every title that references it), so the first batch resolves + // over the network and every repeat is answered from the cache with no + // request. Empty replies are cached too — a key the service lacks for a batch + // won't appear on a re-ask, so re-hitting the network buys nothing. Each + // operation gets its OWN cache: a base batch and a forensic anchor never + // collide, and the same bytes could legitimately resolve differently per op. + // The per-kind driver: `fetch_unit_keys` or `fetch_fmts_indexes`. + type FetchDriver = fn(&[Box], &dyn ResolveCtx) -> Vec; + fn make_op( + inputs: DiscInputs, + make_sources: std::sync::Arc Vec> + Send + Sync>, + drive: FetchDriver, + ) -> crate::sector::KeyFetchFn { + 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(); } - 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(); - // Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`): - // an online /decode reply that returns a VUK (not a terminal UK) then - // 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); - let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx) - .into_iter() - .map(|u| u.key) - .collect(); - cache - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(fp, keys.clone()); - keys - }) + let sources = make_sources(); + let mut di = inputs.clone(); + di.samples = samples.to_vec(); + // Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`): + // an online /decode reply that returns a VUK (not a terminal UK) then + // 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); + let keys: Vec<[u8; 16]> = drive(&sources, &ctx).into_iter().map(|u| u.key).collect(); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fp, keys.clone()); + keys + }) + } + let unit = make_op(inputs.clone(), make_sources.clone(), fetch_unit_keys); + let fmts = make_op(inputs, make_sources, fetch_fmts_indexes); + crate::sector::KeyFetch::new(unit, fmts) } /// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no @@ -560,7 +615,7 @@ mod tests { fn key_source_host_certs_defaults_to_empty() { struct MinimalSource; impl KeySource for MinimalSource { - fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { Ok(Vec::new()) } } @@ -617,7 +672,7 @@ mod tests { fn trace_who_is_the_source_label_verbatim() { struct LabeledSource(&'static str); impl KeySource for LabeledSource { - fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { Ok(Vec::new()) } fn label(&self) -> &'static str { @@ -674,19 +729,19 @@ mod tests { struct EmptySource; impl KeySource for EmptySource { - fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { Ok(Vec::new()) } } struct ErroringSource; impl KeySource for ErroringSource { - fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { Err(Error::AacsNoKeys) } } struct HasKey([u8; 16]); impl KeySource for HasKey { - fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { Ok(vec![UnitKey::new(0, self.0)]) } } @@ -729,7 +784,7 @@ mod tests { seen: Arc>>>, } impl KeySource for Probe { - fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result, Error> { + fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result, Error> { if let Ok(s) = ctx.samples(8) { self.seen.lock().unwrap().extend(s); } @@ -749,7 +804,7 @@ mod tests { let cb = key_fetch(empty_inputs(), make); let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]]; - let got = cb(&samples); + let got = cb.unit_keys(&samples); assert_eq!( got, vec![key], @@ -763,6 +818,85 @@ mod tests { assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); } + /// The two `KeyFetch` operations route to the two DISTINCT trait methods: + /// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives + /// `get_fmts_indexes`. A source that returns different keys per method proves + /// the seam no longer collapses "1 base key" and "the forensic set" into one + /// overloaded call — the operation, not the return length, decides which. + #[test] + fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() { + const BASE: [u8; 16] = [0xB0; 16]; + const F1: [u8; 16] = [0xF1; 16]; + const F2: [u8; 16] = [0xF2; 16]; + + struct TwoOp; + impl KeySource for TwoOp { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(vec![UnitKey::new(0, BASE)]) + } + fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)]) + } + } + + let make: Arc Vec> + Send + Sync> = + Arc::new(|| vec![Box::new(TwoOp) as Box]); + let cb = key_fetch(empty_inputs(), make); + let samples = vec![vec![0x01u8; 4]]; + + assert_eq!( + cb.unit_keys(&samples), + vec![BASE], + "unit_keys resolves the base Unit Key via get_unit_keys" + ); + assert_eq!( + cb.fmts_indexes(&samples), + vec![F1, F2], + "fmts_indexes resolves the forensic set (any length) via get_fmts_indexes" + ); + } + + /// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the + /// contract the sweep/patch recovery decorator relies on (it resolves CPS + /// units only). Its `fmts_indexes` is unconditionally empty. + #[test] + fn key_fetch_unit_only_never_serves_forensic() { + let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| vec![[0xAA; 16]])); + assert_eq!(f.unit_keys(&[vec![0u8; 4]]), vec![[0xAA; 16]]); + assert!( + f.fmts_indexes(&[vec![0u8; 4]]).is_empty(), + "unit_only resolver yields no forensic keys" + ); + } + + /// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts + /// out of the forensic path without implementing it. `fetch_fmts_indexes` then + /// falls through to the next source, exactly like the unit-key driver. + #[test] + fn fetch_fmts_indexes_skips_default_optout_source() { + struct BaseOnly; // uses the default (empty) get_fmts_indexes + impl KeySource for BaseOnly { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(vec![UnitKey::new(0, [0x11; 16])]) + } + } + struct Forensic; + impl KeySource for Forensic { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(Vec::new()) + } + fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(vec![UnitKey::new(0, [0x77; 16])]) + } + } + let inputs = empty_inputs(); + let ctx = DiscInputsCtx::new(&inputs); + let sources: Vec> = vec![Box::new(BaseOnly), Box::new(Forensic)]; + let got = fetch_fmts_indexes(&sources, &ctx); + assert_eq!(got.len(), 1); + assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped"); + } + /// #4 regression: encrypted content NOT at the extent midpoint (a late- /// starting feature, or a midpoint landing in clear nav) must still be /// sampled — empty samples make `decrypt_with` skip wrong-key validation. diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 44f3567..64d49fd 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -638,20 +638,11 @@ fn resolve_fmts_key_map( title: &DiscTitle, keys: &mut crate::decrypt::DecryptKeys, fetch: Option<&crate::sector::KeyFetch>, - _format: ContentFormat, + format: ContentFormat, ) -> io::Result> { - use crate::aacs::content::ALIGNED_UNIT_LEN; + use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean}; 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); @@ -684,56 +675,64 @@ fn resolve_fmts_key_map( 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. + // ── ANCHOR — fetch the whole 32-key set from ONE index-1 batch. The key + // service returns ALL forensic index keys ordered (element i = index i+1) + // only for a canonical INDEX-1 sample that decrypts under the index-1 key. + // A forensic segment interleaves TWO variants at the aligned-unit level, so + // index-1's real content is one PHASE (even or odd units) and the alternate + // is a different variant that won't decrypt. We don't know the phase a + // priori, so try PHASE A (even) then PHASE B (odd): whichever is index-1's + // content comes back with the full set. Both phases failing (across the + // read-fault fallback over index-1 segments) ⇒ this disc has no FMTS keys. // - // ANCHOR RULE: the query MUST sample an INDEX-1 segment. The key service only - // returns the full set for the canonical anchor sample (a unit that decrypts - // under the index-1 key); a batch from any other forensic index is rejected - // (a base-UK-shaped miss). The `index == 1` filter guarantees every batch we - // send is an anchor — and the forensic tag cycles 1..32 in file order, so - // ~1-in-32 segments qualify (~25 across the feature), leaving ample read-fault - // fallback within the `MAX_ANCHOR_ATTEMPTS` budget. ───────────────────────── - const N_INDEX: usize = 32; - // Each forensic batch carries the server's minimum-samples count (the same - // disambiguation floor the online source enforces), drawn as even-phase units - // to land one clean variant half. + // The set's SIZE is whatever the source returns (≥ 1) — never assumed. 32 + // is all we have seen, but a disc with a different forensic index count is + // not ruled out, so the map is sized to the returned `len()`, not a const. + // ───────────────────────────────────────────────────────────────────────── + // Batch size = the key service's minimum-samples floor (same as the online + // source), drawn from ONE phase to land a clean single-variant half. const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS; - // Read-fault fallback budget: how many INDEX-1 (anchor) segments to attempt - // before giving up. Only matters when the leading anchor segments are - // unreadable; each attempt is one server round-trip, so it is bounded. + // Read-fault fallback: how many index-1 segments to attempt if the leading one + // is unreadable. The 2 phase requests happen per readable segment. const MAX_ANCHOR_ATTEMPTS: usize = 16; + // Even units = p*2; odd units = p*2 + 1. + let read_phase_batch = |reader: &mut dyn SectorSource, + seg: &crate::aacs::segment::Segment, + phase_off: usize| + -> Option>> { + let mut batch: Vec> = Vec::with_capacity(BATCH_UNITS); + for p in 0..BATCH_UNITS { + batch.push(read_unit(reader, seg, p * 2 + phase_off)?); + } + Some(batch) + }; let mut index_keys: Vec<[u8; 16]> = Vec::new(); - for seg in segments + 'anchor: for seg in segments .iter() .filter(|s| s.index == 1) .take(MAX_ANCHOR_ATTEMPTS) { - let mut batch: Vec> = Vec::new(); - for p in 0..BATCH_UNITS { - if let Some(c) = read_unit(reader, seg, p * 2) { - batch.push(c); + for phase_off in [0usize, 1usize] { + let Some(batch) = read_phase_batch(reader, seg, phase_off) else { + continue; // read fault on this phase — try the other / next segment + }; + let fresh = fetch.fmts_indexes(&batch); + // Any non-empty reply is the source's COMPLETE ordered forensic set; + // trust it and stop. An empty reply = this phase/segment did not anchor. + if !fresh.is_empty() { + index_keys = fresh; + break 'anchor; } } - if batch.len() < BATCH_UNITS { - 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 { + // The count is whatever the source returned — not a fixed 32. Sized here, used + // everywhere below. + let n_index = index_keys.len(); + tracing::info!(target: "freemkv::keysource", held = n_index, "fmts: collection done"); + // At least one forensic index key is required. None ⇒ no FMTS key for this + // disc from any source — fail loud like a missing Unit Key rather than emit + // forensic-holed output. + if index_keys.is_empty() { return Err(crate::error::Error::FmtsKeyMissing.into()); } @@ -742,7 +741,7 @@ fn resolve_fmts_key_map( 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() { + for (i, k) in index_keys.iter().enumerate() { let tag = (i + 1) as u16; let slot = match unit_keys.iter().position(|(_, h)| h == k) { Some(s) => s, @@ -758,21 +757,64 @@ fn resolve_fmts_key_map( } } - // ── 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()); + // ── PROBE each index's phase. A forensic segment interleaves two variants at + // the aligned-unit level; only ONE parity is this index's real content (the + // other is the alternate variant — a different key, garbles under ours). For + // each index, read a representative tagged segment and count clean decrypts + // of its EVEN vs ODD units under that index's key: the clean half is the + // index's phase. This is the ONE place `is_clean` runs — "the map must be + // right", verified here, once — so the mux decrypt can then trust the map. + // Phase is per-index and shared by every segment carrying that index. ────── + let mut phase_of_index: std::collections::HashMap = + std::collections::HashMap::new(); + for (i, k) in index_keys.iter().enumerate() { + let tag = (i + 1) as u16; + let Some(seg) = segments.iter().find(|s| s.index == tag) else { + continue; // no segment carries this index on this feature — skip + }; + let (mut even, mut odd) = (0usize, 0usize); + for p in 0..BATCH_UNITS { + for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] { + if let Some(mut c) = read_unit(reader, seg, p * 2 + phase_off) { + if aacs_unit_encrypted(&c, format) { + decrypt_unit(&mut c, k); + if is_clean(&c, format) { + *counter += 1; + } + } + } + } + } + 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. + 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()); + } + }; + phase_of_index.insert(tag, phase); + } + + // ── Build the per-segment LBA ranges: each forensic segment → its tag's key + // AND its index's phase. The mapped decrypt opens only that half and leaves + // the alternate as ciphertext (the muxer drops untouched ciphertext) — + // clean by construction, no garble. A segment straddling an extent boundary + // is left unmapped and tallied (a hard failure below). ──────────────────── + let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = + 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 phase = phase_of_index + .get(&seg.index) + .copied() + .unwrap_or(crate::decrypt::Phase::All); let start_byte = seg.start_spn as u64 * 192; let end_byte = (seg.end_spn as u64 + 1) * 192; let (Some(a), Some(b)) = ( @@ -785,20 +827,19 @@ fn resolve_fmts_key_map( // 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)); + ranges.push((a, b + 1, slot, phase)); } 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. + // treat it as a hard failure rather than silently emitting a garbled segment. if unresolved != 0 { return Err(crate::error::Error::FmtsKeyMissing.into()); } - Ok(Some(crate::decrypt::AacsKeyMap::from_ranges( + Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased( ranges, base_idx, ))) } @@ -826,7 +867,7 @@ pub fn resolve_mux_key_map( if let Some(f) = fetch { let samples = crate::keysource::read_encrypted_units(reader, title, 8); if !samples.is_empty() { - let fresh = f(&samples); + let fresh = f.unit_keys(&samples); if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { for k in fresh { if !unit_keys.iter().any(|(_, h)| *h == k) { @@ -916,7 +957,7 @@ pub fn resolve_mux_key_map( if idx.is_none() { if let Some(f) = fetch { if !samples.is_empty() { - let fresh = f(&samples); + let fresh = f.unit_keys(&samples); if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { for k in fresh { if !unit_keys.iter().any(|(_, h)| *h == k) { diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 54d0af9..7592a25 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -28,14 +28,67 @@ use super::SectorSource; /// empty if the source can't help. Mirrors the DVD model (try the held key, /// then ask the key source for the failing data) generalised to AACS. /// -/// The library performs NO key lookup or network I/O itself; this closure is -/// the seam an application uses to call its key source (e.g. an online key -/// service) with the exact ciphertext that failed. A **stateless, shared** -/// `Arc` — the decorator owns the only mutable state (its call-count cap and -/// spent flag), so one closure is built once and cloned cheaply into every read -/// path (sweep / patch / mux); no per-decorator factory is needed. `Send + Sync` +/// The library performs NO key lookup or network I/O itself; this is the seam an +/// application uses to call its key source (e.g. an online key service) with the +/// exact ciphertext that failed. +/// +/// TWO explicit operations, so the "one base key vs a whole forensic set" +/// contract lives in the type instead of a caller guessing at the return length: +/// +/// * [`unit_keys`](Self::unit_keys) — the base Unit Key(s) for a CPS unit, from +/// real encrypted samples drawn from it. The non-forensic path: one key per CPS +/// unit (the pool grows by whatever it returns). Used by the mux's base / +/// multi-CPS map resolution AND by the sweep/patch recovery decorator. +/// * [`fmts_indexes`](Self::fmts_indexes) — the disc's AACS 2.1 forensic index +/// keys, from an index-1 single-phase anchor batch. The source hands back the +/// COMPLETE set (ordered index 1..N); the caller sizes the forensic map to +/// `len()` and never assumes a fixed N (32 is all we've seen, but the contract +/// is "whatever the source returns, ≥ 1, is all of them"). +/// +/// A **stateless, shared** pair of `Arc` — the decorator owns the only +/// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built +/// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync` /// so it can ride the mux highway's producer thread. -pub type KeyFetch = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; +/// A closure resolving keys from encrypted-content samples — the shape of both +/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields) +/// read clearly. +pub type KeyFetchFn = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; + +#[derive(Clone)] +pub struct KeyFetch { + unit: KeyFetchFn, + fmts: KeyFetchFn, +} + +impl KeyFetch { + /// Build a resolver from its two operations: `unit` resolves base Unit Keys + /// from a CPS unit's samples; `fmts` resolves the forensic index set from an + /// index-1 anchor batch. + pub fn new(unit: KeyFetchFn, fmts: KeyFetchFn) -> Self { + Self { unit, fmts } + } + + /// A resolver that serves ONLY base Unit Keys; [`fmts_indexes`](Self::fmts_indexes) + /// is always empty. For read paths that never resolve forensic keys — the + /// sweep/patch recovery decorator, which handles CPS units only. + pub fn unit_only(unit: KeyFetchFn) -> Self { + Self::new(unit, std::sync::Arc::new(|_| Vec::new())) + } + + /// Resolve the base Unit Key(s) for a CPS unit from `samples` (real encrypted + /// units drawn from it). Normally one key; the caller adds whatever it returns + /// to the pool. + pub fn unit_keys(&self, samples: &[Vec]) -> Vec<[u8; 16]> { + (self.unit)(samples) + } + + /// Resolve the disc's AACS 2.1 forensic index keys from an index-1 single- + /// phase `anchor` batch. The source returns the COMPLETE ordered set (index i + /// = element i); the caller trusts any non-empty result as all of them. + pub fn fmts_indexes(&self, anchor: &[Vec]) -> Vec<[u8; 16]> { + (self.fmts)(anchor) + } +} /// Decorator: read from `inner`, then run the configured /// AACS / CSS decrypt over the bytes that landed in `buf`. @@ -1167,10 +1220,11 @@ mod tests { // Capture what the callback was handed, and how many times it fired. 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![real_key] - }); + let fetch: super::KeyFetch = + super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec]| { + seen_cb.lock().unwrap().extend_from_slice(samples); + vec![real_key] + })); let mut wrapped = DecryptingSectorSource::new( EncUnitSource { unit: unit.clone() }, @@ -1251,10 +1305,11 @@ mod tests { 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 fetch: super::KeyFetch = + super::KeyFetch::unit_only(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( @@ -1337,14 +1392,15 @@ mod tests { let unit_b_cb = unit_b.clone(); let calls = Arc::new(Mutex::new(0usize)); let calls_cb = Arc::clone(&calls); - let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec]| { - *calls_cb.lock().unwrap() += 1; - if samples.iter().any(|s| *s == unit_b_cb) { - vec![key_b] - } else { - vec![] - } - }); + let fetch: super::KeyFetch = + super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + if samples.iter().any(|s| *s == unit_b_cb) { + vec![key_b] + } else { + vec![] + } + })); let mut wrapped = DecryptingSectorSource::new( AltSource { @@ -1478,13 +1534,14 @@ mod tests { let calls = Arc::new(Mutex::new(0usize)); let calls_cb = Arc::clone(&calls); - let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec]| { - *calls_cb.lock().unwrap() += 1; - // The closure is handed the still-scrambled on-disc ciphertext. - assert!(!samples.is_empty(), "fetch receives the failing units"); - assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN); - vec![real_key] - }); + let fetch: super::KeyFetch = + super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + // The closure is handed the still-scrambled on-disc ciphertext. + assert!(!samples.is_empty(), "fetch receives the failing units"); + assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN); + vec![real_key] + })); let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content let mut dec = DecryptingSectorSource::new( @@ -1563,10 +1620,11 @@ mod tests { let unit = encrypt_aacs_unit(&real_key); let calls = Arc::new(Mutex::new(0usize)); let calls_cb = Arc::clone(&calls); - let fetch: super::KeyFetch = std::sync::Arc::new(move |_: &[Vec]| { - *calls_cb.lock().unwrap() += 1; - vec![real_key] - }); + let fetch: super::KeyFetch = + super::KeyFetch::unit_only(std::sync::Arc::new(move |_: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + vec![real_key] + })); // Content lives far away; LBA 0 is "filesystem". let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); let mut dec = DecryptingSectorSource::new( diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 5acab5a..6db59fc 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -189,7 +189,7 @@ pub trait SectorSink: Send { } pub use crate::io::file_sector_source::FileSectorSource; -pub use decrypting::{DecryptingSectorSource, KeyFetch}; +pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn}; pub use file::FileSectorSink; pub use prefetched::PrefetchedSectorSource; diff --git a/src/sector/recovery.rs b/src/sector/recovery.rs index 03f458a..b0aac98 100644 --- a/src/sector/recovery.rs +++ b/src/sector/recovery.rs @@ -170,7 +170,7 @@ fn aacs_fetch_step( return prev_dropped; } *calls += 1; - let fresh = (fetch)(&samples); + let fresh = fetch.unit_keys(&samples); // Add only keys we don't already hold (dedup by value). let mut added = 0usize; if let DecryptKeys::Aacs { unit_keys, .. } = keys { @@ -276,11 +276,11 @@ mod tests { // decorator's integration tests); here we pin the seam's key-plumbing. let calls = Arc::new(AtomicUsize::new(0)); let c2 = Arc::clone(&calls); - let fetch: KeyFetch = Arc::new(move |samples: &[Vec]| { + let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |samples: &[Vec]| { c2.fetch_add(1, Ordering::SeqCst); assert!(!samples.is_empty(), "failing ciphertext is forwarded"); vec![[0xAB; 16]] - }); + })); let mut r = key_fetch(fetch); let mut buf = scrambled_unit(0x33); let mut keys = DecryptKeys::Aacs { @@ -304,10 +304,10 @@ mod tests { // the SAME ciphertext must not call the fetch again. let calls = Arc::new(AtomicUsize::new(0)); let c2 = Arc::clone(&calls); - let fetch: KeyFetch = Arc::new(move |_: &[Vec]| { + let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec]| { c2.fetch_add(1, Ordering::SeqCst); Vec::new() // never helps - }); + })); let mut r = key_fetch(fetch); let mut keys = DecryptKeys::Aacs { unit_keys: vec![], @@ -331,10 +331,10 @@ mod tests { fn key_fetch_call_budget_bounds_fetches() { let calls = Arc::new(AtomicUsize::new(0)); let c2 = Arc::clone(&calls); - let fetch: KeyFetch = Arc::new(move |_: &[Vec]| { + let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec]| { c2.fetch_add(1, Ordering::SeqCst); Vec::new() - }); + })); let mut r = key_fetch(fetch); let mut keys = DecryptKeys::Aacs { unit_keys: vec![],