sector: generic recovery seam; FMTS forensic segments as decrypt loss
Replace the AACS-specific inline key-fetch in the decrypt decorator with
a scheme-neutral recovery seam: the input stream (L3) installs a Recover
closure (none / AACS key-fetch) and the decorator (L2) runs it at the
single decrypt-miss point. FMTS (AACS 2.1) forensic-segment units that no
key opens are just undecryptable units, concealed and counted as ordinary
decrypt loss with no FMTS-specific branch ("a loss is a loss"), so the
separate bytes_undecryptable bucket collapses into one loss count.
- sector/recovery.rs: the seam (MissOutcome, none/key_fetch factories),
naming no encryption scheme in its type.
- FMTS: segment routing primitives + BYPASS_FMTS_KEY, and an upfront
ensure_forensic_segments_decryptable gate (Error::FmtsKeyMissing) in
the mux input path, parallel to the unit-key gate.
- CSS descramble/rekey moves from decrypt_sectors into
css::descramble_region: CSS self-recovers from the data itself, so it
stays OFF the seam (which is only for external inputs).
- disc/mod.rs also: main-title selection aligned to largest physical
size; is_regular read from the open file handle, not metadata(path),
fixing a swallowed sync_all on a fresh-rip ISO. decrypt_threads()
resolved once via OnceLock off the per-buffer hot path.
This commit is contained in:
+52
-121
@@ -38,16 +38,6 @@ use super::SectorSource;
|
||||
/// so it can ride the mux highway's producer thread.
|
||||
pub type KeyFetch = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
|
||||
|
||||
/// Cap on how many times one decorator will call the fetch closure over its
|
||||
/// lifetime — bounds key-server traffic to roughly O(distinct CPS units) even
|
||||
/// if scrambled units keep arriving. A disc has only a handful of unit keys.
|
||||
const MAX_FETCH_CALLS: usize = 16;
|
||||
|
||||
/// Cap on how many still-scrambled sample units are handed to the fetch
|
||||
/// closure per call — a few samples are plenty for a key service to identify
|
||||
/// and validate the key, and it bounds the request size.
|
||||
const MAX_FETCH_SAMPLES: usize = 8;
|
||||
|
||||
/// Cap on how many per-unit decrypt-verify-failure diagnostics one read emits.
|
||||
/// The diagnostic runs only on the failure (cold) path and bounds log volume so
|
||||
/// a large undecryptable range can't flood the device log; the first few units
|
||||
@@ -109,21 +99,12 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
///
|
||||
/// [`decrypt_loss`]: Self::decrypt_loss
|
||||
decrypt_dropped: Arc<AtomicU64>,
|
||||
/// Optional "fetch a fresh key for THIS data" callback (see [`KeyFetch`]).
|
||||
/// `None` for the common case (keys fully resolved up front); set via
|
||||
/// [`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>,
|
||||
/// 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,
|
||||
/// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic,
|
||||
/// scheme-neutral recovery the input stream (L3) installs and this decorator
|
||||
/// (L2) executes at the one seam when a content unit will not decrypt. `None`
|
||||
/// = no recovery (a miss is loss). Installed via
|
||||
/// [`with_key_fetch`](Self::with_key_fetch).
|
||||
recovery: Option<crate::sector::recovery::Recover>,
|
||||
/// Verify-only mode: a read decrypt-CHECKS a scratch copy of the bytes (to
|
||||
/// detect undecryptable units) but NEVER mutates `buf` — the inner
|
||||
/// ciphertext is returned unchanged. This is what makes a multipass sweep
|
||||
@@ -173,9 +154,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
unit_key_idx: 0,
|
||||
unit_base: 0,
|
||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||
fetch: None,
|
||||
fetch_dry: std::collections::HashSet::new(),
|
||||
fetch_calls: 0,
|
||||
// No recovery by default. CSS self-decrypts in `decrypt_sectors`
|
||||
// (needs no external input); AACS installs a key-fetch via
|
||||
// `with_key_fetch`.
|
||||
recovery: None,
|
||||
verify_only: false,
|
||||
content_ranges: None,
|
||||
scratch: Vec::new(),
|
||||
@@ -238,7 +220,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
|
||||
/// call — `cb` is the application's seam to its key source.
|
||||
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
|
||||
self.fetch = Some(cb);
|
||||
self.recovery = Some(crate::sector::recovery::key_fetch(cb));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -286,86 +268,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the still-scrambled aligned units in `buf`, hand them to the
|
||||
/// fetch callback, add any returned keys not already held to the AACS
|
||||
/// pool (the CACHE — every later unit this pass, and any later read, reuses
|
||||
/// them), and re-decrypt `buf`. Returns the post-retry dropped-byte count
|
||||
/// (equal to `prev_dropped` when the callback could not help). The re-decrypt
|
||||
/// is content-gated identically to the first read so a non-content unit is
|
||||
/// never re-attempted. Caller guarantees the keys are `DecryptKeys::Aacs`, a
|
||||
/// callback is installed, and the call budget is not yet spent.
|
||||
fn fetch_failed_units(
|
||||
&mut self,
|
||||
buf: &mut [u8],
|
||||
lba: u32,
|
||||
content: Option<&[(u32, u32)]>,
|
||||
prev_dropped: usize,
|
||||
) -> usize {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the
|
||||
// exact on-disc ciphertext no held key could open. A trailing partial
|
||||
// unit (chunks_exact remainder) can't be a whole scrambled unit, so
|
||||
// skipping it is correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
Some(cb) => cb(&samples),
|
||||
None => return prev_dropped,
|
||||
};
|
||||
// Add only keys we don't already hold (dedup by value).
|
||||
let mut added = 0usize;
|
||||
if let DecryptKeys::Aacs { unit_keys, .. } = &mut self.keys {
|
||||
for k in fresh {
|
||||
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
||||
let idx = unit_keys.len() as u32;
|
||||
unit_keys.push((idx, k));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
// 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
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
Self::decrypt_buf(buf, &mut self.keys, self.unit_key_idx, lba, content)
|
||||
.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
|
||||
@@ -521,8 +423,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// recover a unit no held key opened.
|
||||
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.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
||||
// Copy out the small Copy fields the seam needs, so the `&mut self.recovery`
|
||||
// borrow below does not collide with reads of other `self` fields. The
|
||||
// recovery closure self-limits (its budget lives in its captures), so the
|
||||
// decorator simply calls it whenever there is a miss.
|
||||
let unit_key_idx = self.unit_key_idx;
|
||||
// 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`
|
||||
@@ -537,11 +442,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry.
|
||||
// The fetch re-decrypt targets the post-decrypt buffer (scratch / buf),
|
||||
// whose still-scrambled units ARE the failures.
|
||||
let dropped = if self.verify_only {
|
||||
let outcome = if self.verify_only {
|
||||
let mut scratch = std::mem::take(&mut self.scratch);
|
||||
scratch.clear();
|
||||
scratch.extend_from_slice(&buf[..n]);
|
||||
let mut d = match Self::decrypt_buf(
|
||||
let d = match Self::decrypt_buf(
|
||||
&mut scratch,
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
@@ -554,24 +459,45 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut scratch, lba, content_ref, d);
|
||||
}
|
||||
let o = match (d, self.recovery.as_mut()) {
|
||||
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
|
||||
(d, Some(r)) => {
|
||||
let rctx = crate::sector::recovery::RecoverCtx {
|
||||
unit_key_idx,
|
||||
lba,
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut scratch, &mut self.keys, &rctx)
|
||||
}
|
||||
};
|
||||
self.scratch = scratch;
|
||||
d
|
||||
o
|
||||
} else {
|
||||
let mut d = Self::decrypt_buf(
|
||||
let d = Self::decrypt_buf(
|
||||
&mut buf[..n],
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
lba,
|
||||
content_ref,
|
||||
)?;
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut buf[..n], lba, content_ref, d);
|
||||
match (d, self.recovery.as_mut()) {
|
||||
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
|
||||
(d, Some(r)) => {
|
||||
let rctx = crate::sector::recovery::RecoverCtx {
|
||||
unit_key_idx,
|
||||
lba,
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut buf[..n], &mut self.keys, &rctx)
|
||||
}
|
||||
}
|
||||
d
|
||||
};
|
||||
// A loss is a loss: whatever recovery could not decrypt (a missing unit
|
||||
// key, or an AACS 2.1 forensic-segment unit with no variant key — same
|
||||
// thing to the read path) is concealed and counted the same way.
|
||||
let dropped = outcome.dropped;
|
||||
if dropped > 0 {
|
||||
self.decrypt_dropped
|
||||
.fetch_add(dropped as u64, Ordering::Relaxed);
|
||||
@@ -656,6 +582,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// fails (no clean data to mux). Scheme-agnostic (only AACS reaches a
|
||||
// non-zero count); clear filesystem (gated out) and zero-fill (not
|
||||
// scrambled) never get here.
|
||||
//
|
||||
// An undecryptable unit is an undecryptable unit whatever the scheme —
|
||||
// a missing unit key or an AACS 2.1 forensic-segment unit with no
|
||||
// variant key both land here and fail the verify read the same way.
|
||||
// (`dropped > 0` already holds inside the enclosing block.)
|
||||
if DECRYPT_VERIFY_READ {
|
||||
// FACT-FINDING: on a fresh rip these bytes came straight off the
|
||||
// drive, so each failing unit's signature (all-zero? entropy?
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
pub mod decrypting;
|
||||
pub mod file;
|
||||
pub mod prefetched;
|
||||
pub mod recovery;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The recovery seam: what a read does when a content unit will not decrypt.
|
||||
//!
|
||||
//! Per-format miss policy does NOT belong in the generic decrypt decorator
|
||||
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
|
||||
//! it is reading and installs a [`Recover`] at construction; the decorator
|
||||
//! executes it at the one seam and honours the returned outcome. This keeps
|
||||
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
|
||||
//! it would otherwise smear across `if`-branches.
|
||||
//!
|
||||
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
|
||||
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
|
||||
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
|
||||
//! so a scheme is never baked into the type — only into the factory that builds
|
||||
//! a recovery:
|
||||
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
|
||||
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
|
||||
//! application's key source and add any returned keys to the pool. An AACS
|
||||
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
|
||||
//! like any other — a loss is a loss, with no FMTS-specific branch here.
|
||||
//!
|
||||
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
|
||||
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
|
||||
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
|
||||
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
|
||||
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
|
||||
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
|
||||
//! recovery, but CSS has no reason to use it.
|
||||
|
||||
use crate::decrypt::DecryptKeys;
|
||||
use crate::sector::KeyFetch;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The result of running a recovery on a read's still-scrambled units: how many
|
||||
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
|
||||
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
|
||||
/// forensic-segment unit with no variant key is just another undecryptable
|
||||
/// unit).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct MissOutcome {
|
||||
/// Bytes that remain loss after recovery.
|
||||
pub dropped: usize,
|
||||
}
|
||||
|
||||
impl MissOutcome {
|
||||
/// All `n` bytes are loss.
|
||||
fn loss(n: usize) -> Self {
|
||||
Self { dropped: n }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on how many times one recovery will call its fetch closure over its
|
||||
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
|
||||
/// scrambled units keep arriving. A disc has only a handful of unit keys.
|
||||
const MAX_FETCH_CALLS: usize = 16;
|
||||
|
||||
/// Cap on how many still-scrambled sample units are handed to the fetch closure
|
||||
/// per call — a few samples suffice for a key service to identify and validate
|
||||
/// the key, and it bounds the request size.
|
||||
const MAX_FETCH_SAMPLES: usize = 8;
|
||||
|
||||
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup 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()
|
||||
}
|
||||
|
||||
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
|
||||
/// first read so a non-content unit is never re-attempted. Mirrors the
|
||||
/// decorator's `decrypt_buf` dispatch.
|
||||
fn redecrypt(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
lba: u32,
|
||||
content: Option<&[(u32, u32)]>,
|
||||
prev_dropped: usize,
|
||||
) -> usize {
|
||||
match content {
|
||||
Some(ranges) => {
|
||||
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
|
||||
}
|
||||
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
|
||||
}
|
||||
.unwrap_or(prev_dropped)
|
||||
}
|
||||
|
||||
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
|
||||
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
|
||||
/// only the generic [`DecryptKeys`] and these fields.
|
||||
pub struct RecoverCtx {
|
||||
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
|
||||
pub unit_key_idx: usize,
|
||||
/// Base LBA of the read.
|
||||
pub lba: u32,
|
||||
/// The encrypted-content extent map, when the read is content-gated.
|
||||
pub content: Option<Arc<[(u32, u32)]>>,
|
||||
/// Bytes the held keys could not decrypt before recovery ran.
|
||||
pub prev_dropped: usize,
|
||||
}
|
||||
|
||||
/// A recovery: given a read's still-scrambled `buf` and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
|
||||
/// — the installed recovery decides what to do with the generic keys, so any
|
||||
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
|
||||
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
|
||||
/// state (the AACS dedup set / call budget) lives in the closure's captures with
|
||||
/// no lock; `Send` so it can ride the mux highway's producer thread.
|
||||
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||
|
||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the
|
||||
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
|
||||
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
|
||||
/// Returns the post-retry dropped-byte count.
|
||||
fn aacs_fetch_step(
|
||||
dry: &mut HashSet<u64>,
|
||||
calls: &mut usize,
|
||||
fetch: &KeyFetch,
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
ctx: &RecoverCtx,
|
||||
) -> usize {
|
||||
let prev_dropped = ctx.prev_dropped;
|
||||
if *calls >= MAX_FETCH_CALLS {
|
||||
return prev_dropped;
|
||||
}
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
|
||||
// on-disc ciphertext no held key could open. A trailing partial unit
|
||||
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
|
||||
// correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 identical ciphertext only burns a request.
|
||||
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
|
||||
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
|
||||
if fps.iter().all(|fp| dry.contains(fp)) {
|
||||
return prev_dropped;
|
||||
}
|
||||
*calls += 1;
|
||||
let fresh = (fetch)(&samples);
|
||||
// Add only keys we don't already hold (dedup by value).
|
||||
let mut added = 0usize;
|
||||
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||
for k in fresh {
|
||||
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
||||
let idx = unit_keys.len() as u32;
|
||||
unit_keys.push((idx, k));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
// Nothing new for THESE units — remember them so we don't re-ask the same
|
||||
// ciphertext, but leave the door open for other units.
|
||||
dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
redecrypt(
|
||||
buf,
|
||||
keys,
|
||||
ctx.unit_key_idx,
|
||||
ctx.lba,
|
||||
ctx.content.as_deref(),
|
||||
prev_dropped,
|
||||
)
|
||||
}
|
||||
|
||||
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
||||
/// caller that wants an explicit "give up" recovery has one.
|
||||
pub fn none() -> Recover {
|
||||
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||
}
|
||||
|
||||
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
||||
/// source for a key that opens the failing ciphertext and add it to the pool.
|
||||
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||
let mut dry: HashSet<u64> = HashSet::new();
|
||||
let mut calls: usize = 0;
|
||||
Box::new(move |buf, keys, ctx| {
|
||||
MissOutcome::loss(aacs_fetch_step(
|
||||
&mut dry, &mut calls, &fetch, buf, keys, ctx,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
|
||||
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
|
||||
/// probe position forced off 0x47. `tag` varies the whole body so distinct
|
||||
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
|
||||
fn scrambled_unit(tag: u8) -> Vec<u8> {
|
||||
let len = ALIGNED_UNIT_LEN;
|
||||
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
|
||||
let mut off = 4;
|
||||
while off < len {
|
||||
v[off] = 0xA5; // never a 0x47 sync
|
||||
off += 192;
|
||||
}
|
||||
v[0] |= 0xC0; // CPI: reads as encrypted content
|
||||
v
|
||||
}
|
||||
|
||||
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
|
||||
/// held keys could not decrypt.
|
||||
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
|
||||
RecoverCtx {
|
||||
unit_key_idx: 0,
|
||||
lba,
|
||||
content: None,
|
||||
prev_dropped: prev,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_recovers_nothing() {
|
||||
let mut r = none();
|
||||
let mut buf = scrambled_unit(0x33);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
|
||||
assert_eq!(out.dropped, 6144);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fetch_adds_returned_keys_to_the_pool() {
|
||||
// The fetch returns one key; it must be appended to the (empty) pool. We
|
||||
// assert the pool grew (the decrypt itself is exercised end-to-end by the
|
||||
// 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<u8>]| {
|
||||
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 {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
|
||||
assert_eq!(unit_keys[0].1, [0xAB; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fetch_does_not_re_ask_dry_ciphertext() {
|
||||
// A fetch that returns nothing marks the ciphertext dry; a second miss on
|
||||
// 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<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new() // never helps
|
||||
});
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_unit(0x44);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
||||
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"identical dry ciphertext is not re-asked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
});
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
// Distinct ciphertext each time so the dry-set never short-circuits; only
|
||||
// the internal call budget should stop the fetch. The closure self-limits,
|
||||
// so the decorator can call it unconditionally.
|
||||
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
||||
let mut buf = scrambled_unit(i);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
}
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
MAX_FETCH_CALLS,
|
||||
"fetch is capped at MAX_FETCH_CALLS"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user