1.1.1: AACS decrypt + key-resolution hardening

- decrypt_unit: padding-aware acceptance — recover real video at content-
  fragment tails (the phantom mux-loss class) without weakening wrong-key
  rejection (a full content unit still needs all 32 TS syncs).
- scan: read the MKB via the bounded read_mkb_content so Disc::inputs()
  carries it. Online key resolution was shipping mkb=0 (a full read of the
  ~128 MiB MKB_RO allocation fails) → the decode service 404'd.
- resolve_vid_only: surface an MKB read error instead of silently emptying.
- fetch: a per-sample dry-set replaces the global fetch_spent latch, so a
  second CPS unit's key can still be fetched after the first came back empty.
- verify::push_ranges: saturating arithmetic (corrupt-disc panic guard).
- Tests for all of the above.
This commit is contained in:
Matthew Jackson
2026-06-28 21:12:57 -07:00
parent eba34f4c20
commit 59681dfd4b
5 changed files with 319 additions and 27 deletions
+112 -9
View File
@@ -113,10 +113,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
/// to ask its key source for a key when a unit fails to decrypt.
fetch: Option<KeyFetch>,
/// Latched once a fetch call returns no NEW key — further failures on this
/// decorator then skip the callback (the source has nothing more to offer, so
/// re-asking would only burn key-server requests).
fetch_spent: bool,
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
/// already returned NO new key for. A later failure re-asks the source only
/// for units NOT in this set — so on a multi-CPS disc the source is still
/// asked for the *second* CPS unit's key even after the first came back dry
/// (the old global latch blocked that), while the *same* failing unit is
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
fetch_dry: std::collections::HashSet<u64>,
/// How many times the fetch closure has been invoked, capped at
/// [`MAX_FETCH_CALLS`].
fetch_calls: usize,
@@ -156,7 +159,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
fetch: None,
fetch_spent: false,
fetch_dry: std::collections::HashSet::new(),
fetch_calls: 0,
verify_only: false,
content_ranges: None,
@@ -289,6 +292,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
if samples.is_empty() {
return prev_dropped;
}
// Skip the call when EVERY failing unit here is one a prior fetch already
// came back empty for — re-asking the identical ciphertext only burns a
// key-server request. A unit we have NOT asked about yet (e.g. a second
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
// global `fetch_spent` latch wrongly blocked it.
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
return prev_dropped;
}
// Ask the application's key source for keys that open this ciphertext.
self.fetch_calls += 1;
let fresh = match self.fetch.as_ref() {
@@ -307,8 +319,9 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
}
if added == 0 {
// Nothing new — stop asking for the rest of this decorator's life.
self.fetch_spent = true;
// Nothing new for THESE units — remember them so we don't re-ask the
// same ciphertext, but leave the door open for other units.
self.fetch_dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
@@ -317,6 +330,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
.unwrap_or(prev_dropped)
}
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
/// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to
/// equal fingerprints within a process — all the dedup needs.
fn sample_fp(sample: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
sample.hash(&mut h);
h.finish()
}
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
/// failed verify read. Called only on the failure (cold) path. On a fresh
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
@@ -457,8 +480,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
// Whether a fresh-key fetch is still worth attempting on this decorator.
let fetch_viable =
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
@@ -1338,6 +1360,87 @@ mod tests {
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
idx: usize,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = &self.units[self.idx.min(self.units.len() - 1)];
buf[..bytes].copy_from_slice(u);
self.idx += 1;
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
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<u8>]| {
*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 {
units: vec![unit_a, unit_b],
idx: 0,
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::ts_sync_destroyed(&buf2),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]