From 63ed05bd63710700ff4093e5695eebe383b2c301 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:40:50 -0700 Subject: [PATCH] libfreemkv: clip-anchored AACS unit gate + consolidate key mechanism The AACS unit-alignment gate measured `lba % 3` against absolute disc LBA 0, but aligned units are anchored at each clip's encrypted-region start. A clip whose start_lba is not 3-aligned had its readable units wrongly rejected with "Decryption failed" (the big-title-only failure on some Blu-rays). One canonical clip-anchored helper (`aacs::is_unit_aligned`) is now the single source of truth for the decrypt-on-read gate; both mux read paths set the per-extent `unit_base = start_lba` via a new `SectorSource::set_unit_base`. Also moves key *mechanism* into the library: the encrypted sample reader (`read_encrypted_units`) and the candidate-key resolution loop (`resolve_and_apply`) now live here, so a key source is purely a lookup. Regression test covers a clip based at a non-3-aligned LBA. --- src/aacs/decrypt.rs | 20 +++++++++ src/aacs/mod.rs | 5 ++- src/keysource.rs | 94 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- src/mux/disc.rs | 11 +++-- src/sector/decrypting.rs | 92 ++++++++++++++++++++++++++++++++++----- src/sector/mod.rs | 11 +++++ src/sector/prefetched.rs | 5 +++ 8 files changed, 224 insertions(+), 16 deletions(-) diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 8c01d8f..f5eb86b 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -13,6 +13,26 @@ pub(crate) const AACS_IV: [u8; 16] = [ /// Size of an AACS aligned unit (3 × 2048-byte sectors). pub const ALIGNED_UNIT_LEN: usize = 6144; +/// An AACS aligned unit spans this many 2048-byte sectors (3). +pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_LEN) as u32; + +/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to +/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`, +/// NOT absolute disc LBA 0). +/// +/// AACS aligned units (6144 B / 3 sectors) are anchored at the start of each +/// clip's encrypted region, so a read must begin a whole number of units past +/// that base for `decrypt_sectors` (which anchors units at buffer offset 0) to +/// align the CBC correctly. This is the SINGLE source of truth for the test — +/// the decrypt-on-read gate, the inline and highway mux read paths, and the +/// key-validation sample reader all key off this, never absolute `lba % 3`. A +/// disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate +/// (reject readable units, then report "Decryption failed") on exactly the +/// titles whose clips land off a 3-boundary. +pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool { + lba.wrapping_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0 +} + /// Size of one sector. const SECTOR_LEN: usize = 2048; diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 178c0c6..c21b3aa 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -24,8 +24,9 @@ pub mod variants; // Explicit re-exports — only items needed by external consumers and sibling crate modules. // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs. pub use decrypt::{ - ALIGNED_UNIT_LEN, UnitKeyResult, decrypt_bus, decrypt_unit, decrypt_unit_full, - decrypt_unit_try_keys, is_aacs_scrambled, ts_packet_total, ts_sync_count, unit_key_validates, + ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit, + decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total, + ts_sync_count, unit_key_validates, }; pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; pub use keys::probe; diff --git a/src/keysource.rs b/src/keysource.rs index 03078d7..f55d913 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -122,6 +122,100 @@ pub trait KeySource { } } +/// Drive `sources` until one key decrypts `disc`. Loops [`KeySource::next_key`] +/// and hands each candidate to [`crate::Disc::decrypt_with`] (which validates it +/// against `inputs.samples` and only mutates the disc on success), returning +/// `true` at the first key that decrypts and `false` once every source is +/// exhausted — the genuine "no key for this disc". THE shared key-resolution +/// loop: every application uses it instead of re-rolling the candidate/retry +/// logic, so the "no key" verdict is identical everywhere. +/// +/// Lives in the library, not a key-source crate: resolution is decryption +/// *mechanism* — it validates candidates against disc content via +/// `Disc::decrypt_with` — and the library owns all mechanism. A key source only +/// hands out candidate keys; what's done with them is not its concern. +pub fn resolve_and_apply( + sources: &mut dyn KeySource, + inputs: &DiscInputs, + disc: &mut crate::Disc, +) -> bool { + while let Some(key) = sources.next_key(inputs) { + if disc.decrypt_with(key, &inputs.samples).is_ok() { + return true; + } + } + false +} + +/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no +/// decrypt) — the content samples that populate [`DiscInputs::samples`] for a +/// key server to validate a candidate against, and that [`resolve_and_apply`] +/// hands to [`crate::Disc::decrypt_with`]. +/// +/// Lives in the library, not a key-source crate: reading the disc and carving +/// AACS units is decryption *mechanism* (unit geometry anchored at each extent's +/// `start_lba`), which the library owns. A key source is *handed* these bytes +/// via `DiscInputs.samples`; it never reads the disc itself. +/// +/// "Encrypted" is decided by [`crate::aacs::is_aacs_scrambled`] — the SAME +/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear +/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a +/// clear unit proves nothing, so this collects only scrambled ones, sampling the +/// largest extent at its midpoint forward. +pub fn read_encrypted_units( + reader: &mut dyn crate::sector::SectorSource, + title: &crate::disc::DiscTitle, + n: usize, +) -> Vec> { + use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, is_aacs_scrambled}; + const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap + const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint + + let mut out: Vec> = Vec::new(); + for ext in &title.extents { + let total_units = ext.sector_count / ALIGNED_UNIT_SECTORS; + if total_units == 0 { + continue; + } + let mut unit = total_units / 2; // midpoint (past the clear nav at the head) + for _ in 0..MAX_CHUNKS_PER_EXTENT { + if unit >= total_units { + break; + } + let units_this = CHUNK_UNITS.min(total_units - unit); + // Saturate: start_lba comes from attacker-controlled UDF/MPLS + // extents; a malformed extent near u32::MAX would otherwise panic + // (debug) or wrap to a wrong LBA (release). An over-capacity LBA then + // fails cleanly via the read_sectors().is_err() break below. + let lba = ext + .start_lba + .saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); + let count = (units_this * ALIGNED_UNIT_SECTORS) as u16; + let mut buf = vec![0u8; count as usize * 2048]; + // `false` = no recovery retries; the reader is the raw drive/file + // (no decrypt decorator), so these are the on-disc encrypted bytes. + if reader.read_sectors(lba, count, &mut buf, false).is_err() { + break; + } + for i in 0..units_this as usize { + let o = i * ALIGNED_UNIT_LEN; + if o + ALIGNED_UNIT_LEN > buf.len() { + break; + } + let u = &buf[o..o + ALIGNED_UNIT_LEN]; + if is_aacs_scrambled(u) { + out.push(u.to_vec()); + if out.len() >= n { + return out; + } + } + } + unit += units_this; + } + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 55ba4fb..c5e4824 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -194,7 +194,7 @@ pub use disc::{ KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage, }; -pub use keysource::{DiscInputs, KeySource}; +pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply}; // ─── Streams ──────────────────────────────────────────────────────────────── // diff --git a/src/mux/disc.rs b/src/mux/disc.rs index fe31fca..7672e99 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -359,6 +359,11 @@ impl DiscStream { // Saturate for consistency with the rest of the file's arithmetic. let lba = ext_start.saturating_add(self.current_offset); + // AACS aligned units are anchored at this extent's start LBA — gate the + // decrypt-on-read source relative to it (clip-anchored), not absolute + // disc LBA 0. No-op for CSS / None. + self.reader.set_unit_base(ext_start); + // Adaptive sizer: start at current (preferred until a failure), shrink // on failure, advance on success. One 5s read attempt per try — no // retry loops, no sleeps. On size-1 failure, skip or error. @@ -465,9 +470,9 @@ impl DiscStream { lba, sectors ); - let rec = - self.reader - .read_sectors(lba, sectors, &mut self.read_buf[..bytes], true); + let rec = self + .reader + .read_sectors(lba, sectors, &mut self.read_buf[..bytes], true); if let Ok(&got) = rec.as_ref() { debug_assert!(got <= bytes, "recovery read over-reported byte count"); if let Some(ev) = self.adaptive.on_success(sectors) { diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index ed42899..723aeda 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -32,6 +32,15 @@ pub struct DecryptingSectorSource { inner: S, keys: DecryptKeys, unit_key_idx: usize, + /// Base LBA of the encrypted region currently being read — the clip / + /// extent `start_lba` that AACS aligned units are anchored at. The unit- + /// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0, + /// so a clip whose `start_lba` is not 3-aligned still gates correctly. Set + /// per-extent by the mux read paths via [`set_unit_base`]; defaults to 0 + /// (absolute alignment) for callers that read from a 3-aligned base. + /// + /// [`set_unit_base`]: Self::set_unit_base + unit_base: u32, /// Cumulative bytes of scrambled AACS units that no key could decrypt. /// `decrypt_sectors` restores those bytes to their original ciphertext (so a /// clear nav-file is never corrupted), but for genuine encrypted content the @@ -56,6 +65,7 @@ impl DecryptingSectorSource { inner, keys, unit_key_idx: 0, + unit_base: 0, decrypt_dropped: Arc::new(AtomicU64::new(0)), } } @@ -118,16 +128,20 @@ impl SectorSource for DecryptingSectorSource { recovery: bool, ) -> Result { // Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and - // `decrypt_sectors` anchors units at buffer offset 0. A read whose START - // LBA is not unit-aligned (lba % 3 != 0) would decrypt every unit under - // the wrong CBC/unit alignment and silently mis-decrypt. Reject loud - // (DecryptFailed) BEFORE reading rather than ever mis-decrypting — callers - // (e.g. the multipass sweep) must issue unit-aligned reads. - if matches!(self.keys, DecryptKeys::Aacs { .. }) { - const UNIT_SECTORS: u32 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u32; // 3 - if lba % UNIT_SECTORS != 0 { - return Err(crate::error::Error::DecryptFailed); - } + // `decrypt_sectors` anchors units at buffer offset 0. A read that does + // not begin a whole number of units past the encrypted region's base + // (`unit_base`, the clip/extent start_lba) would decrypt every unit + // under the wrong CBC/unit alignment and silently mis-decrypt. Reject + // loud (DecryptFailed) BEFORE reading rather than ever mis-decrypting. + // The gate is measured RELATIVE to `unit_base` (set per-extent by the + // mux read paths via `set_unit_base`), never absolute `lba % 3` — a clip + // whose start_lba is not itself 3-aligned must still gate on its own + // units (else its readable units are wrongly rejected → "Decryption + // failed" on exactly those titles). + if matches!(self.keys, DecryptKeys::Aacs { .. }) + && !crate::aacs::is_unit_aligned(lba, self.unit_base) + { + return Err(crate::error::Error::DecryptFailed); } let n = self.inner.read_sectors(lba, count, buf, recovery)?; // Apply the crate-wide AACS/CSS/None decrypt entry point in-place @@ -146,6 +160,10 @@ impl SectorSource for DecryptingSectorSource { fn set_speed(&mut self, kbs: u16) { self.inner.set_speed(kbs) } + + fn set_unit_base(&mut self, lba: u32) { + self.unit_base = lba; + } } #[cfg(test)] @@ -638,6 +656,60 @@ mod tests { } } + /// Clip-anchored gate (the Watership Down "Decryption failed" regression): + /// AACS aligned units are anchored at the clip's encrypted-region start + /// (`unit_base`), NOT absolute disc LBA 0. A clip whose `start_lba` is not + /// itself 3-aligned must gate on ITS OWN units, so the clip's base LBA + /// (which the old `lba % 3` gate wrongly rejected) now passes, and only + /// reads off the clip-relative unit grid reject. + #[test] + fn aacs_gate_is_clip_anchored_not_absolute() { + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0u32, [0u8; 16])], + read_data_key: None, + }; + // base = 64 (abs % 3 == 1): the non-3-aligned clip start that triggered + // the bug. The old absolute gate rejected every read here; the clip- + // anchored gate must accept the clip's own unit grid. + let base = 64u32; + + // Clip-relative aligned starts (base + {0,3,6,30}) pass. + for off in [0u32, 3, 6, 30] { + let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone()); + w.set_unit_base(base); + let mut buf = vec![0u8; 3 * 2048]; + let n = w + .read_sectors(base + off, 3, &mut buf, false) + .unwrap_or_else(|_| panic!("clip-relative aligned lba {} must pass", base + off)); + assert_eq!(n, 3 * 2048); + } + + // The clip's base LBA itself (abs % 3 == 1) — the exact read the old gate + // wrongly rejected — must now decrypt. + let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone()); + w.set_unit_base(base); + let mut buf = vec![0u8; 3 * 2048]; + assert!( + w.read_sectors(base, 3, &mut buf, false).is_ok(), + "a clip starting at a non-3-aligned LBA must decrypt from its own base" + ); + + // Clip-relative MISaligned starts (base + {1,2,4,5}) still reject. + for off in [1u32, 2, 4, 5] { + let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone()); + w.set_unit_base(base); + let mut buf = vec![0u8; 3 * 2048]; + let err = w + .read_sectors(base + off, 3, &mut buf, false) + .expect_err("clip-relative unaligned start must reject"); + assert_eq!( + err.code(), + crate::error::Error::DecryptFailed.code(), + "base+{off} is off the clip-relative unit grid" + ); + } + } + /// The unit-alignment guard is AACS-only. A CSS decrypting read (per-sector, /// stateless — DVDs) must NOT be gated on a 3-sector boundary: a single /// sector at lba 1 must read fine. Grounding: the guard is inside diff --git a/src/sector/mod.rs b/src/sector/mod.rs index daa1ba9..2201895 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -56,6 +56,17 @@ pub trait SectorSource: Send { /// Optional speed control for sources that map to a physical /// drive. No-op for everything else. fn set_speed(&mut self, _kbs: u16) {} + + /// Set the base LBA an AACS unit-alignment gate measures against — the + /// `start_lba` of the extent/clip about to be read. Aligned AACS units + /// (6144 B / 3 sectors) are anchored at each clip's encrypted-region start, + /// so a decrypt-on-read source gates `lba` relative to this base, not + /// absolute disc LBA 0. Mux read paths call this when they advance to a new + /// extent. No-op for everything except [`DecryptingSectorSource`], the only + /// source that applies the unit-alignment gate. + /// + /// [`DecryptingSectorSource`]: crate::sector::DecryptingSectorSource + fn set_unit_base(&mut self, _lba: u32) {} } // Forwarding impls so `Box` and `&mut dyn SectorSource` diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index ea94551..ff22686 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -176,6 +176,11 @@ impl PrefetchedSectorSource { return; } let extent = &extents[ext_idx]; + // AACS aligned units are anchored at THIS extent's start + // LBA, so tell the decrypt-on-read source to gate relative + // to it (clip-anchored), not absolute disc LBA 0. A no-op + // for non-decrypting / CSS / None sources. + reader.set_unit_base(extent.start_lba); let remaining = extent.sector_count.saturating_sub(offset); if remaining == 0 { ext_idx += 1;