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.
This commit is contained in:
Matthew Jackson
2026-06-24 15:40:50 -07:00
parent 987e26e44d
commit 63ed05bd63
8 changed files with 224 additions and 16 deletions
+82 -10
View File
@@ -32,6 +32,15 @@ pub struct DecryptingSectorSource<S: SectorSource> {
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<S: SectorSource> DecryptingSectorSource<S> {
inner,
keys,
unit_key_idx: 0,
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
}
}
@@ -118,16 +128,20 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
recovery: bool,
) -> Result<usize> {
// 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<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
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
+11
View File
@@ -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<dyn SectorSource>` and `&mut dyn SectorSource`
+5
View File
@@ -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;