The keyserver protocol now returns all 32 index keys as an array for a forensic content sample (and a single-element array for plain content). resolve_fmts_key_map sends one forensic batch and maps array element i to segment index i+1, replacing the per-index blind-probe collection loop that repeatedly hit the key service. Segment/index parsing and the aligned-unit content classification are reworked to support this: - rename variant_select -> index_select (per-index, not per-variant) - content classification moves to is_clean(buf, ContentFormat) so the unit selector emits only units the key service accepts - segment.rs: parse IndividualSegment.tbl index tags + SPN ranges, build contiguous LBA key ranges from the resolved 32-key array - decrypt/decorator plumbing for the resolved per-index keys Fail loud (FmtsKeyMissing) when the forensic query returns < 32 keys or any segment index stays unresolved.
1694 lines
77 KiB
Rust
1694 lines
77 KiB
Rust
//! Decrypt-on-read layer.
|
|
//!
|
|
//! Decrypts sectors in-place using resolved keys from disc scanning.
|
|
//! Handles AACS 1.0, AACS 2.0, and CSS transparently.
|
|
//! The caller never sees encrypted data unless explicitly bypassed.
|
|
//!
|
|
//! ## Parallel AACS decrypt
|
|
//!
|
|
//! Each AACS aligned unit (6144 bytes) is decrypted INDEPENDENTLY of
|
|
//! every other unit — per-unit key derivation from the unit_key plus
|
|
//! the unit's own first-16-byte header. There is no cross-unit
|
|
//! dependency, so a buffer of N units can be decrypted on N threads
|
|
//! in parallel via a persistent rayon thread pool.
|
|
//!
|
|
//! Small buffers (< [`PARALLEL_MIN_UNITS`] units) fall through to the
|
|
//! serial path to avoid pool dispatch overhead beating the per-unit
|
|
//! AES work.
|
|
//!
|
|
//! ## Thread-count configuration — three layers
|
|
//!
|
|
//! Resolution order (highest wins):
|
|
//! 1. The most recent [`set_decrypt_threads`] call with `n > 0`.
|
|
//! Calling this *replaces* the live thread pool — useful for a
|
|
//! settings-page slider in a long-running daemon.
|
|
//! 2. `FREEMKV_THREADS` env var, if set and `> 0`. Single knob
|
|
//! covering decrypt today, intended to also drive any future
|
|
//! input-side / output-side worker pools.
|
|
//! 3. Default: all available cores. Algorithm optimisation comes
|
|
//! first — we measure single-thread performance to find serial
|
|
//! bottlenecks before throwing parallelism at it — but once a
|
|
//! pool is engaged we use the whole box. Hard cap at
|
|
//! [`MAX_THREADS`] (rayon stack memory).
|
|
|
|
use crate::aacs;
|
|
use crate::css;
|
|
use rayon::prelude::*;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
/// Minimum units in a buffer before we pay the pool-dispatch cost of
|
|
/// fanning out. Below this, serial is faster.
|
|
const PARALLEL_MIN_UNITS: usize = 8;
|
|
|
|
/// Hard upper bound on configurable thread count. Anything larger is
|
|
/// almost certainly a misconfiguration; rayon would happily allocate
|
|
/// thousands of worker stacks otherwise.
|
|
pub const MAX_THREADS: usize = 64;
|
|
|
|
/// Process-wide decrypt thread count override. `0` means "use env
|
|
/// var, else default" — see [`decrypt_threads`] for the resolution
|
|
/// order.
|
|
static DECRYPT_THREADS: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// Current rayon pool. `RwLock<Option<Arc<...>>>` so that
|
|
/// [`set_decrypt_threads`] can swap the pool out without leaking the
|
|
/// old one and without blocking ongoing decrypt work (in-flight calls
|
|
/// hold an `Arc` clone via [`decrypt_pool`] and finish on the old
|
|
/// pool; new calls pick up the new pool).
|
|
static DECRYPT_POOL: RwLock<Option<Arc<rayon::ThreadPool>>> = RwLock::new(None);
|
|
|
|
/// Configure how many threads to use for AACS unit decryption. A value
|
|
/// of `0` resets to the env / default resolution. `1` forces serial.
|
|
/// `N > 1` builds a new rayon pool of size N (capped at [`MAX_THREADS`])
|
|
/// and atomically replaces the live pool.
|
|
///
|
|
/// Thread-safe. Live decrypt calls keep their previously-acquired
|
|
/// pool reference for the rest of the call — no mid-call pool
|
|
/// switch. Subsequent calls see the new pool.
|
|
///
|
|
/// Pool construction is ~ms-scale; safe to call from a settings POST
|
|
/// handler.
|
|
pub fn set_decrypt_threads(n: usize) {
|
|
let clamped = n.min(MAX_THREADS);
|
|
DECRYPT_THREADS.store(clamped, Ordering::Relaxed);
|
|
// Drop the existing pool. Next decrypt_pool() call rebuilds with
|
|
// the new resolved thread count.
|
|
if let Ok(mut guard) = DECRYPT_POOL.write() {
|
|
*guard = None;
|
|
}
|
|
}
|
|
|
|
/// Get (or lazily build) the active rayon thread pool. Returns an
|
|
/// `Arc` so in-flight work survives a concurrent
|
|
/// [`set_decrypt_threads`] swap.
|
|
///
|
|
/// Returns `None` if the pool cannot be built (e.g. the OS refuses the
|
|
/// worker threads under a pid/thread limit). The caller falls back to
|
|
/// the serial decrypt path — library code never panics here.
|
|
fn decrypt_pool() -> Option<Arc<rayon::ThreadPool>> {
|
|
// Fast path: pool already built. A poisoned read lock still yields a
|
|
// usable guard (the pool Arc is immutable once stored).
|
|
{
|
|
let guard = DECRYPT_POOL.read().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(pool) = guard.as_ref() {
|
|
return Some(Arc::clone(pool));
|
|
}
|
|
}
|
|
// Slow path: build a new one under the write lock. Recover the guard
|
|
// on poisoning (a prior panic) rather than propagating a secondary
|
|
// panic — we simply rebuild. Double-check after acquiring in case
|
|
// another caller built it first.
|
|
let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(pool) = guard.as_ref() {
|
|
return Some(Arc::clone(pool));
|
|
}
|
|
let n = decrypt_threads();
|
|
let pool = rayon::ThreadPoolBuilder::new()
|
|
.num_threads(n)
|
|
.thread_name(|i| format!("freemkv-decrypt-{i}"))
|
|
.build()
|
|
.ok()
|
|
.map(Arc::new)?;
|
|
*guard = Some(Arc::clone(&pool));
|
|
Some(pool)
|
|
}
|
|
|
|
/// Current effective decrypt thread count. Resolution order:
|
|
/// 1. Most recent [`set_decrypt_threads`] value (if > 0)
|
|
/// 2. `FREEMKV_THREADS` env var (if set and > 0)
|
|
/// 3. Default: all available cores, capped at [`MAX_THREADS`].
|
|
pub fn decrypt_threads() -> usize {
|
|
let explicit = DECRYPT_THREADS.load(Ordering::Relaxed);
|
|
if explicit > 0 {
|
|
return explicit;
|
|
}
|
|
// Resolve the `FREEMKV_THREADS` env var + `available_parallelism()` ONCE and
|
|
// cache it — this runs on the per-buffer decrypt hot path, and a getenv +
|
|
// String alloc + parallelism syscall per call is pure overhead. The explicit
|
|
// `set_decrypt_threads` override above still takes effect dynamically.
|
|
static DEFAULT_THREADS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
|
*DEFAULT_THREADS.get_or_init(|| {
|
|
let env = std::env::var("FREEMKV_THREADS")
|
|
.ok()
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
if env > 0 {
|
|
return env.min(MAX_THREADS);
|
|
}
|
|
let cores = std::thread::available_parallelism()
|
|
.map(|n| n.get())
|
|
.unwrap_or(2);
|
|
cores.clamp(1, MAX_THREADS)
|
|
})
|
|
}
|
|
|
|
/// Resolved decryption state from disc scanning.
|
|
/// Passed to `decrypt_sectors()` — the caller doesn't need to know
|
|
/// which encryption scheme is in use.
|
|
#[derive(Clone)]
|
|
pub enum DecryptKeys {
|
|
/// No encryption on this disc.
|
|
None,
|
|
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. The
|
|
/// `format` is the disc's content container (BD/UHD/FMTS = Transport Stream,
|
|
/// HD-DVD `.evo` = Program Stream); it travels with the keys because both are
|
|
/// resolved once per disc, and the key SELECTOR (`is_clean`) needs it to prove
|
|
/// a key structurally against the right container.
|
|
Aacs {
|
|
unit_keys: Vec<(u32, [u8; 16])>,
|
|
read_data_key: Option<[u8; 16]>,
|
|
format: crate::disc::ContentFormat,
|
|
},
|
|
/// CSS (DVD). Title key for sector descrambling.
|
|
Css { title_key: [u8; 5] },
|
|
}
|
|
|
|
impl DecryptKeys {
|
|
/// True if there are keys to decrypt with.
|
|
pub fn is_encrypted(&self) -> bool {
|
|
!matches!(self, DecryptKeys::None)
|
|
}
|
|
}
|
|
|
|
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
|
|
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
|
|
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
|
|
/// unit at mux time.
|
|
///
|
|
/// This is the pivot that ends the mux "key-server storm": the old path decrypts
|
|
/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because
|
|
/// authored-bad content never reaches that bar — concludes "wrong key, fetch a
|
|
/// fresh one" and re-asks the key service for units it already holds the correct
|
|
/// key for. There is NO per-unit byte pattern that separates "correctly decrypted
|
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. The
|
|
/// map removes the question: we resolve one key per CPS unit / segment up front
|
|
/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux
|
|
/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's
|
|
/// concern, exactly as for a physically-read clear disc.
|
|
///
|
|
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool,
|
|
/// 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.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct AacsKeyMap {
|
|
ranges: Vec<(u32, u32, usize)>,
|
|
default_idx: usize,
|
|
}
|
|
|
|
impl AacsKeyMap {
|
|
/// The whole title is one CPS unit → one key (`idx`) everywhere. This is the
|
|
/// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk.
|
|
pub fn single(idx: usize) -> Self {
|
|
Self {
|
|
ranges: Vec::new(),
|
|
default_idx: idx,
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
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 {
|
|
if self.ranges.is_empty() {
|
|
return self.default_idx;
|
|
}
|
|
match self
|
|
.ranges
|
|
.binary_search_by(|&(start, _, _)| start.cmp(&lba))
|
|
{
|
|
Ok(i) => self.ranges[i].2,
|
|
Err(0) => self.default_idx,
|
|
Err(i) => {
|
|
let (start, end, idx) = self.ranges[i - 1];
|
|
if lba >= start && lba < end {
|
|
idx
|
|
} else {
|
|
self.default_idx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)] {
|
|
&self.ranges
|
|
}
|
|
|
|
/// The key index for any LBA no explicit range claims (the single-CPS key).
|
|
pub fn default_idx(&self) -> usize {
|
|
self.default_idx
|
|
}
|
|
|
|
/// The distinct key indices this map can select — the CPS units / segments a
|
|
/// title actually reaches. Used by the resolver to know which keys to secure
|
|
/// up front.
|
|
pub fn key_indices(&self) -> Vec<usize> {
|
|
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i)| i).collect();
|
|
v.push(self.default_idx);
|
|
v.sort_unstable();
|
|
v.dedup();
|
|
v
|
|
}
|
|
}
|
|
|
|
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
|
|
/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector;
|
|
/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its
|
|
/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided
|
|
/// the key from disc structure, so we apply it and move on — a unit that decrypts
|
|
/// to authored-bad TS passes through for the muxer to drop, never re-fetched.
|
|
///
|
|
/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside
|
|
/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the
|
|
/// decorator can dispatch uniformly. A map index outside the held pool is a
|
|
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
|
|
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
|
|
pub fn decrypt_sectors_mapped(
|
|
buf: &mut [u8],
|
|
keys: &DecryptKeys,
|
|
base_lba: u32,
|
|
map: &AacsKeyMap,
|
|
) -> Result<(), crate::error::Error> {
|
|
let (unit_keys, rdk, format) = match keys {
|
|
DecryptKeys::Aacs {
|
|
unit_keys,
|
|
read_data_key,
|
|
format,
|
|
} => (unit_keys, *read_data_key, *format),
|
|
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
|
|
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
|
|
_ => return Ok(()),
|
|
};
|
|
|
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
|
let unit_sectors = (unit_len / 2048) as u32;
|
|
|
|
// Validate every selectable index up front (fail loud) so the per-unit hot
|
|
// loop can index without bounds churn and a resolver gap never silently
|
|
// passes ciphertext through as "decrypted".
|
|
for idx in map.key_indices() {
|
|
if unit_keys.get(idx).is_none() {
|
|
return Err(crate::error::Error::DecryptFailed);
|
|
}
|
|
}
|
|
|
|
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.
|
|
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);
|
|
};
|
|
|
|
let nthreads = decrypt_threads();
|
|
let nunits = buf.len() / unit_len;
|
|
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
|
decrypt_one(i, chunk);
|
|
}
|
|
} else {
|
|
match decrypt_pool() {
|
|
Some(pool) => pool.install(|| {
|
|
buf.par_chunks_mut(unit_len)
|
|
.enumerate()
|
|
.for_each(|(i, chunk)| decrypt_one(i, chunk));
|
|
}),
|
|
None => {
|
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
|
decrypt_one(i, chunk);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Decrypt a buffer of sectors in-place.
|
|
///
|
|
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
|
/// For CSS: processes per 2048-byte sector.
|
|
/// For None: no-op.
|
|
///
|
|
/// `unit_key_idx` is the initial AACS unit-key hint (0 for most discs). On a
|
|
/// multi-CPS-unit disc every key is tried per unit until the TS-sync verify
|
|
/// passes; `unit_key_idx` is tried first so single-CPS-unit discs pay zero
|
|
/// overhead. An out-of-range `unit_key_idx` is always an error.
|
|
///
|
|
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
|
/// Never produces silently corrupted output.
|
|
///
|
|
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
|
|
/// plaintext is left as-is — this function applies NO policy (it never restores
|
|
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
|
|
/// belonging to units a key was applied to but that did NOT reassemble to clean
|
|
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
|
|
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
|
|
/// decrypt content whose encoding is broken. The caller decides what an
|
|
/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch
|
|
/// verify path recovers a key and retries, or fails the read. `0` for `None` /
|
|
/// `Css` and for any AACS buffer where every unit reached clean TS.
|
|
pub fn decrypt_sectors(
|
|
buf: &mut [u8],
|
|
keys: &mut DecryptKeys,
|
|
unit_key_idx: usize,
|
|
) -> Result<usize, crate::error::Error> {
|
|
decrypt_sectors_impl(buf, keys, unit_key_idx, None)
|
|
}
|
|
|
|
/// Like [`decrypt_sectors`], but ONLY decrypts/verifies units whose absolute LBA
|
|
/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts
|
|
/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left
|
|
/// untouched and never counted as decrypt loss: they are clear by definition, so
|
|
/// [`ts_sync_destroyed`] must not be consulted about them (a filesystem unit has
|
|
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
|
|
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
|
|
///
|
|
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
|
|
/// tuples (each covering `[start_lba, start_lba + sector_count)`).
|
|
pub fn decrypt_sectors_in_content(
|
|
buf: &mut [u8],
|
|
keys: &mut DecryptKeys,
|
|
unit_key_idx: usize,
|
|
base_lba: u32,
|
|
content_ranges: &[(u32, u32)],
|
|
) -> Result<usize, crate::error::Error> {
|
|
decrypt_sectors_impl(buf, keys, unit_key_idx, Some((base_lba, content_ranges)))
|
|
}
|
|
|
|
/// True if `lba` falls inside one of the sorted, merged, disjoint
|
|
/// `(start, count)` ranges (same representation as [`crate::udf::merge_ranges`]
|
|
/// and `Extent`). O(log n) binary search — cheap enough to run per unit.
|
|
pub(crate) fn lba_in_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool {
|
|
match ranges.binary_search_by(|&(start, _)| start.cmp(&lba)) {
|
|
Ok(_) => true, // lba is exactly a range start
|
|
Err(0) => false, // before the first range
|
|
Err(i) => {
|
|
let (start, count) = ranges[i - 1];
|
|
lba < start.saturating_add(count) // inside the range that starts before lba?
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decrypt_sectors_impl(
|
|
buf: &mut [u8],
|
|
keys: &mut DecryptKeys,
|
|
unit_key_idx: usize,
|
|
content: Option<(u32, &[(u32, u32)])>,
|
|
) -> Result<usize, crate::error::Error> {
|
|
let dropped: usize = match keys {
|
|
DecryptKeys::None => 0,
|
|
DecryptKeys::Aacs {
|
|
unit_keys,
|
|
read_data_key,
|
|
format,
|
|
} => {
|
|
// Validate that unit_key_idx is in-range before doing anything else.
|
|
// This preserves the existing contract: an out-of-range explicit index
|
|
// is always an error (tested by `aacs_out_of_range_unit_key_idx_errors`).
|
|
if unit_keys.get(unit_key_idx).is_none() {
|
|
return Err(crate::error::Error::DecryptFailed);
|
|
}
|
|
|
|
// Container of this disc's content — the key SELECTOR (`is_clean`)
|
|
// checks the decrypted plaintext against the right structure (TS vs PS).
|
|
let format = *format;
|
|
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
|
|
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
|
|
// closures only ever need `len()` / `[idx].1`, so collecting one would
|
|
// just be a heap alloc/free on every batch of the mux hot path.
|
|
let rdk: Option<[u8; 16]> = *read_data_key;
|
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
|
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
|
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
|
|
// extent tail, so a buffer is commonly NOT a multiple of the unit
|
|
// length. We process the whole leading units exactly as a fully
|
|
// aligned buffer would be, then make a deliberate decision about any
|
|
// trailing partial unit.
|
|
//
|
|
// Trailing-partial contract:
|
|
// * A clear partial (incomplete final unit / clear nav-TS tail) is
|
|
// what AACS legitimately leaves in the clear on disc, so we leave
|
|
// it untouched and return Ok. This is the proven, shipped
|
|
// behavior every production UHD MKV was made with — no regression
|
|
// on conformant discs.
|
|
// * A *scrambled* partial can only arise from a structurally
|
|
// malformed UDF layout that splits an encrypted unit across an
|
|
// extent boundary. Those bytes are encrypted content that cannot
|
|
// be decrypted standalone; passing them through as clear would be
|
|
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
|
// the highway path's Error::ExtentNotUnitAligned policy.
|
|
//
|
|
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
|
|
// buffer shorter than a full unit, so it cannot judge a partial. We
|
|
// instead apply the same TS-sync-intactness test it uses internally
|
|
// (ts_sync_count vs ts_packet_total) directly to the available
|
|
// partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
|
|
// stride (> half the packets) → intact → not scrambled → tolerate. An
|
|
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
|
|
// reject. If the partial is too short to hold even one TS packet
|
|
// (< 192 bytes, ts_packet_total == 0) we cannot judge confidently and
|
|
// tolerate rather than risk a false positive on conformant tails.
|
|
let partial_len = buf.len() % unit_len;
|
|
if partial_len != 0 {
|
|
// Gate the trailing partial on content too: a scrambled partial
|
|
// OUTSIDE the encrypted m2ts extents is just clear non-TS bytes
|
|
// (filesystem tail), not a malformed encrypted unit, so it must
|
|
// not hard-fail. `nfull * 3` is the partial's absolute LBA.
|
|
let nfull = (buf.len() / unit_len) as u32;
|
|
let partial_in_content = match content {
|
|
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
|
None => true,
|
|
};
|
|
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
|
|
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
|
|
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
|
|
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
|
|
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
|
|
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
|
|
let partial = &buf[buf.len() - partial_len..];
|
|
if !aacs::content::is_clean(partial, format) {
|
|
return Err(crate::error::Error::DecryptFailed);
|
|
}
|
|
}
|
|
}
|
|
let nthreads = decrypt_threads();
|
|
let nunits = buf.len() / unit_len;
|
|
|
|
// Cache the last successfully-validated key index so that runs of
|
|
// units under the same CPS unit hit on the first try. Initialised to
|
|
// unit_key_idx (the caller's hint — 0 for almost all discs). An
|
|
// AtomicUsize lets the parallel path share it cheaply; relaxed
|
|
// ordering is fine because a stale read just causes one extra try,
|
|
// never a wrong result (TS-sync verify gates correctness).
|
|
let last_key_idx = AtomicUsize::new(unit_key_idx);
|
|
|
|
// Count bytes of scrambled units that NO key could decrypt. Shared
|
|
// across the rayon workers (relaxed is fine — it's a pure tally, not
|
|
// a synchronisation point). A non-zero total is silent decrypt loss:
|
|
// the bytes pass downstream still encrypted and the TS assembler
|
|
// drops them without a sync. The caller folds this into mux loss
|
|
// accounting so a partial key failure isn't reported as a clean rip.
|
|
let dropped_bytes = AtomicUsize::new(0);
|
|
|
|
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
|
|
// 1. Try the cached key index first (avoids scanning all keys on the
|
|
// common case where a disc run uses one CPS unit throughout).
|
|
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
|
// 3. Select the first key whose output passes the TS-sync verify.
|
|
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
|
|
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
|
|
// tally the unit as unverified. Never restore ciphertext / null.
|
|
// Nav protection is the caller's content gate, not a restore here.
|
|
//
|
|
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
|
// must happen first — it's a shared layer on top that is key-independent
|
|
// across all CPS units on the disc.
|
|
let decrypt_one = |chunk: &mut [u8]| {
|
|
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
|
|
// not yet restored): the flag alone isn't enough because it lives in
|
|
// the plaintext header and survives decryption, so an already-decrypted
|
|
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
|
|
// this pass. The structure-restored half makes it idempotent. This is
|
|
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
|
|
// decrypting a clear unit.
|
|
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
|
|
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
|
|
if let Some(ref rdk_key) = rdk {
|
|
aacs::content::decrypt_bus(chunk, rdk_key);
|
|
}
|
|
|
|
// Reorder the key iterator: try the cached hint first, then fall
|
|
// back to the full list skipping the hint.
|
|
let hint = last_key_idx.load(Ordering::Relaxed);
|
|
let try_order =
|
|
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
|
|
|
|
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
|
|
// is the decrypt (apply the key, leave the plaintext). `is_clean`
|
|
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
|
|
// key SELECTOR — the first key whose output is clean for the disc's
|
|
// container (`format`: TS or PS) is the match. "Did a key produce
|
|
// clean structure?" is NOT "did we decrypt?": a correct key can
|
|
// decrypt content whose encoding is broken (a muxer concern). When
|
|
// NO key yields clean structure we STILL decrypted (the cached-hint
|
|
// key is applied): keep those bytes and report the unit UNVERIFIED.
|
|
// This function applies no policy; the caller decides what unverified
|
|
// means (mux passes it to the muxer; sweep/patch recover or fail).
|
|
|
|
// Single-key fast path (the vast majority of titles): with no
|
|
// alternate key to fall back on there is nothing to try/rollback,
|
|
// so decrypt in place — no per-unit scratch alloc or copy-back.
|
|
// Clean → cache the hint; unclean → keep the applied bytes and
|
|
// tally unverified, exactly as the loop below would with one key.
|
|
if unit_keys.len() == 1 {
|
|
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
|
|
if aacs::content::is_clean(chunk, format) {
|
|
last_key_idx.store(0, Ordering::Relaxed);
|
|
} else {
|
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Trial each key against a STACK scratch (unit_len is always
|
|
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
|
|
// so a failing attempt doesn't clobber the bus-decrypted base in
|
|
// `chunk` that the next key retries on — with no per-key heap Vec.
|
|
// `chunk` is NOT mutated in this loop, so on total miss we simply
|
|
// re-apply the first key in place (decrypt_unit is pure), which
|
|
// reproduces the first attempt without stashing its bytes.
|
|
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
let scratch = &mut scratch[..chunk.len()];
|
|
let mut first_idx: Option<usize> = None;
|
|
for idx in try_order {
|
|
if let Some((_, key)) = unit_keys.get(idx) {
|
|
scratch.copy_from_slice(chunk);
|
|
aacs::content::decrypt_unit(scratch, key);
|
|
if aacs::content::is_clean(scratch, format) {
|
|
chunk.copy_from_slice(scratch);
|
|
last_key_idx.store(idx, Ordering::Relaxed);
|
|
return;
|
|
}
|
|
if first_idx.is_none() {
|
|
first_idx = Some(idx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// No key yielded clean structure. Keep the first-tried key's
|
|
// plaintext (the pool is non-empty past the guard, so `first_idx` is
|
|
// always `Some`) and tally the unit as unverified. Never restore
|
|
// ciphertext; that is a caller concern, threaded through the recovery
|
|
// ciphertext, not this seam.
|
|
if let Some(idx) = first_idx {
|
|
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
|
|
}
|
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
|
};
|
|
|
|
// Content gate wrapper: when a gate is supplied, skip any unit whose
|
|
// absolute LBA lies OUTSIDE the encrypted-content extents — it is
|
|
// clear non-TS data (filesystem / nav) and must never be decrypted,
|
|
// verified, or counted as loss. Each aligned unit is 3 sectors.
|
|
let unit_sectors = (unit_len / 2048) as u32;
|
|
let process = |idx: usize, chunk: &mut [u8]| {
|
|
if let Some((base, ranges)) = content {
|
|
let unit_lba = base.saturating_add((idx as u32) * unit_sectors);
|
|
if !lba_in_ranges(unit_lba, ranges) {
|
|
return;
|
|
}
|
|
}
|
|
decrypt_one(chunk);
|
|
};
|
|
|
|
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
|
// Serial path: avoids thread-pool overhead for tiny
|
|
// buffers; also the only path when caller pinned
|
|
// single-threaded via FREEMKV_THREADS=1. Iterate the
|
|
// chunks directly — no Vec of slice pointers needed.
|
|
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
|
process(idx, chunk);
|
|
}
|
|
} else {
|
|
// Parallel path via rayon's persistent thread pool.
|
|
// The pool is built once on first use and reused across
|
|
// every decrypt_sectors call — no per-call OS thread
|
|
// spawn. Each unit decrypts independently (own key
|
|
// derivation), so par_iter is sound. On a pool-build
|
|
// failure (e.g. thread/pid-limit exhaustion) we fall
|
|
// back to the serial path rather than panic.
|
|
match decrypt_pool() {
|
|
Some(pool) => {
|
|
// `par_chunks_mut` iterates the units in place — no
|
|
// intermediate `Vec<&mut [u8]>` allocation per batch.
|
|
pool.install(|| {
|
|
buf.par_chunks_mut(unit_len)
|
|
.enumerate()
|
|
.for_each(|(idx, chunk)| {
|
|
process(idx, chunk);
|
|
});
|
|
});
|
|
}
|
|
None => {
|
|
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
|
process(idx, chunk);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
dropped_bytes.into_inner()
|
|
}
|
|
DecryptKeys::Css { title_key } => {
|
|
// CSS SELF-recovers: the title key changes per VOB region and is
|
|
// re-cracked constantly, but always FROM THE DATA ITSELF — no external
|
|
// input. So the whole descramble-and-rekey is self-contained here (see
|
|
// `css::descramble_region`), and CSS does not need the post-decrypt
|
|
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
|
|
// consume external inputs a `decrypt_sectors` caller cannot supply).
|
|
css::descramble_region(buf, title_key);
|
|
0
|
|
}
|
|
};
|
|
Ok(dropped)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
|
|
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
|
|
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
|
|
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
|
|
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
|
|
/// content-gated, and every whole-disc caller passes the encrypted-content
|
|
/// extents so nav LBAs are skipped entirely and left untouched.
|
|
#[test]
|
|
fn nav_file_unit_survives_when_gated_out_of_content() {
|
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
unit[0] = b'M';
|
|
unit[1] = b'P';
|
|
unit[2] = b'L';
|
|
unit[3] = b'S';
|
|
for (i, b) in unit.iter_mut().enumerate().skip(4) {
|
|
*b = (i as u8).wrapping_mul(31);
|
|
}
|
|
let snapshot = unit.clone();
|
|
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
|
|
// so this nav unit is OUTSIDE content and the gate skips it untouched.
|
|
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
|
assert_eq!(
|
|
unit, snapshot,
|
|
"a nav unit outside the content extents must be left untouched by the gate"
|
|
);
|
|
}
|
|
|
|
/// Build a clear-TS region: a 0x47 sync byte at offset 4 of every 192-byte
|
|
/// BD-TS packet (matching `ts_sync_count`'s probe stride), filler elsewhere.
|
|
/// Reads as NOT scrambled.
|
|
fn clear_ts_region(len: usize) -> Vec<u8> {
|
|
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
|
|
let mut off = 4;
|
|
while off < len {
|
|
v[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Build a scrambled region: the 192-byte-stride sync positions are NOT
|
|
/// 0x47 (encrypted content destroys them), so it reads as scrambled.
|
|
fn scrambled_region(len: usize) -> Vec<u8> {
|
|
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
|
|
let mut off = 4;
|
|
while off < len {
|
|
// Force a non-sync byte at every probe position.
|
|
v[off] = 0xA5;
|
|
off += 192;
|
|
}
|
|
// Flag every aligned unit's CPI bits (byte 0) so it reads as encrypted
|
|
// under the authoritative `aacs_unit_encrypted`/`aacs_unit_needs_decrypt`
|
|
// gate — real encrypted content always carries these.
|
|
let mut u = 0;
|
|
while u < len {
|
|
v[u] |= 0xC0;
|
|
u += aacs::content::ALIGNED_UNIT_LEN;
|
|
}
|
|
v
|
|
}
|
|
|
|
// ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ──
|
|
|
|
#[test]
|
|
fn lba_in_ranges_membership() {
|
|
// (start, count) ⇒ [10,15) and [100,110).
|
|
let r = &[(10u32, 5u32), (100, 10)];
|
|
assert!(!lba_in_ranges(0, r), "before first range");
|
|
assert!(!lba_in_ranges(9, r), "just before first range");
|
|
assert!(lba_in_ranges(10, r), "at first range start");
|
|
assert!(lba_in_ranges(14, r), "inside first range");
|
|
assert!(!lba_in_ranges(15, r), "first range end is exclusive");
|
|
assert!(!lba_in_ranges(50, r), "in the gap between ranges");
|
|
assert!(lba_in_ranges(100, r), "at second range start");
|
|
assert!(lba_in_ranges(109, r), "inside second range");
|
|
assert!(!lba_in_ranges(110, r), "second range end is exclusive");
|
|
assert!(!lba_in_ranges(5, &[]), "empty set has no members");
|
|
}
|
|
|
|
/// The content gate at the decrypt primitive: a scrambled-LOOKING unit
|
|
/// OUTSIDE the content extents (e.g. UDF filesystem) must be SKIPPED — never
|
|
/// decrypted, never counted as loss. The SAME bytes INSIDE content are
|
|
/// checked and counted. This is the first-2 GB false-positive fix.
|
|
#[test]
|
|
fn content_gate_skips_non_content_units() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
|
|
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
|
|
let mut buf = original.clone();
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
|
assert_eq!(
|
|
dropped, 0,
|
|
"a non-content unit must not count as decrypt loss"
|
|
);
|
|
assert_eq!(
|
|
buf, original,
|
|
"a non-content unit must be left byte-for-byte untouched"
|
|
);
|
|
|
|
// Same bytes INSIDE content (base_lba 100, range covers LBA 100..103).
|
|
let mut buf2 = original.clone();
|
|
let dropped2 =
|
|
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
|
|
assert_eq!(
|
|
dropped2,
|
|
aacs::content::ALIGNED_UNIT_LEN,
|
|
"an undecryptable CONTENT unit IS counted as loss"
|
|
);
|
|
}
|
|
|
|
/// Per-unit gating across a content boundary: in a 2-unit buffer where only
|
|
/// the second unit (LBA 3..6) is content, only the second is decrypt-checked.
|
|
#[test]
|
|
fn content_gate_is_per_unit_across_a_boundary() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
|
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
|
|
assert_eq!(
|
|
dropped,
|
|
aacs::content::ALIGNED_UNIT_LEN,
|
|
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
|
|
);
|
|
}
|
|
|
|
/// A content range covering the whole buffer must behave EXACTLY like the
|
|
/// ungated `decrypt_sectors` — the gate adds nothing when everything is content.
|
|
#[test]
|
|
fn content_gate_covering_whole_buffer_matches_ungated() {
|
|
let mut keys_g = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut keys_u = keys_g.clone();
|
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let mut g = original.clone();
|
|
let mut u = original.clone();
|
|
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
|
|
let ungated = decrypt_sectors(&mut u, &mut keys_u, 0).unwrap();
|
|
assert_eq!(
|
|
gated, ungated,
|
|
"gated-covering-all == ungated dropped count"
|
|
);
|
|
assert_eq!(g, u, "gated-covering-all == ungated bytes");
|
|
}
|
|
|
|
#[test]
|
|
fn lba_in_ranges_more_edges() {
|
|
// Single range [5,8).
|
|
assert!(!lba_in_ranges(4, &[(5, 3)]), "just before single range");
|
|
assert!(lba_in_ranges(5, &[(5, 3)]), "at single range start");
|
|
assert!(lba_in_ranges(7, &[(5, 3)]), "inside single range");
|
|
assert!(
|
|
!lba_in_ranges(8, &[(5, 3)]),
|
|
"single range end is exclusive"
|
|
);
|
|
// After the last range.
|
|
assert!(
|
|
!lba_in_ranges(200, &[(10, 5), (100, 10)]),
|
|
"past the last range"
|
|
);
|
|
// Saturating: a range whose start+count overflows u32 must not panic. The
|
|
// end saturates to u32::MAX, so the very top LBA is excluded — a harmless
|
|
// edge (real disc LBAs never reach u32::MAX). The range start is still in.
|
|
assert!(
|
|
lba_in_ranges(u32::MAX - 1, &[(u32::MAX - 1, 5)]),
|
|
"saturating range start is in"
|
|
);
|
|
assert!(
|
|
!lba_in_ranges(u32::MAX, &[(u32::MAX - 1, 5)]),
|
|
"saturated end excludes the top"
|
|
);
|
|
}
|
|
|
|
/// An EMPTY content map gates EVERYTHING out — even a scrambled unit is
|
|
/// skipped (treated as non-content). This is the no-titles fallback at the
|
|
/// primitive level.
|
|
#[test]
|
|
fn content_gate_empty_ranges_skips_everything() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let mut buf = original.clone();
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
|
|
assert_eq!(
|
|
dropped, 0,
|
|
"empty content map ⇒ nothing is content ⇒ no loss"
|
|
);
|
|
assert_eq!(buf, original, "empty content map ⇒ buffer untouched");
|
|
}
|
|
|
|
/// A CLEAR (sync-intact) unit INSIDE content is not ciphertext, so even though
|
|
/// it is in-content it is skipped by the ts-sync check and never counted.
|
|
#[test]
|
|
fn content_gate_clear_unit_in_content_not_counted() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let mut buf = original.clone();
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
|
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
|
|
assert_eq!(buf, original, "a clear in-content unit is left untouched");
|
|
}
|
|
|
|
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
|
|
#[test]
|
|
fn content_gate_none_keys_is_noop() {
|
|
let mut keys = DecryptKeys::None;
|
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let mut buf = original.clone();
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
|
assert_eq!(dropped, 0);
|
|
assert_eq!(buf, original);
|
|
}
|
|
|
|
/// CSS ignores the content gate (it lives in the AACS arm) and always reports
|
|
/// `0` — confirming the gate is a no-op for CSS and the read stays
|
|
/// scheme-agnostic (the litmus test: adding CSS verify touches only the CSS
|
|
/// arm, never the read).
|
|
#[test]
|
|
fn content_gate_css_keys_is_noop() {
|
|
let mut keys = DecryptKeys::Css { title_key: [0; 5] };
|
|
let mut buf = vec![0u8; 2048];
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
|
assert_eq!(
|
|
dropped, 0,
|
|
"CSS arm returns 0; content gate is a no-op for CSS"
|
|
);
|
|
}
|
|
|
|
/// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors
|
|
/// `crackable_sector` in the css::mod tests): a periodic run in the clear
|
|
/// header continues past 0x80 into the encrypted region, so
|
|
/// `stevenson::crack_title_key` recovers the key. Distinct `seed` values give
|
|
/// two sectors different cribs, standing in for two VOB regions.
|
|
fn crackable_css_sector(title_key: &[u8; 5], seed: &[u8; 5]) -> Vec<u8> {
|
|
const RUN_START: usize = 0x59;
|
|
const SEED_OFFSET: usize = 0x54;
|
|
const PERIOD: usize = 8;
|
|
let mut plaintext = vec![0u8; 2048];
|
|
plaintext[0x00..0x04].copy_from_slice(&css::PACK_START);
|
|
plaintext[0x14] = 0x10; // scramble flag
|
|
let pat: Vec<u8> = (0..PERIOD)
|
|
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
|
.collect();
|
|
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
|
*b = pat[i % PERIOD];
|
|
}
|
|
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
|
css::lfsr::scramble_sector(title_key, &mut plaintext);
|
|
plaintext
|
|
}
|
|
|
|
/// CHARACTERIZATION (recovery refactor safety net): the CSS arm's per-region
|
|
/// re-crack (the `title_key` cache is stale for a new VOB region → restore
|
|
/// ciphertext, `crack_title_key` this sector, re-descramble). Two crackable
|
|
/// sectors scrambled under DIFFERENT keys sit back-to-back; the cache is
|
|
/// primed to the FIRST key. Sector 0 rides the cache (crib matches); sector 1
|
|
/// must trip the crib mismatch and re-crack to its own key. Both must land
|
|
/// correct plaintext, and the cache must end on region 1's key.
|
|
///
|
|
/// This behaviour currently lives inline in `decrypt_sectors` (the `Css`
|
|
/// arm). It is the delicate logic the recovery refactor will move to the
|
|
/// input-stream seam, so it must stay green byte-for-byte across that move.
|
|
#[test]
|
|
fn css_region_change_recracks_the_title_key() {
|
|
let key_a = [0x11, 0x22, 0x33, 0x44, 0x55];
|
|
let key_b = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
|
|
let sector_a = crackable_css_sector(&key_a, &[0x01, 0x02, 0x03, 0x04, 0x05]);
|
|
let sector_b = crackable_css_sector(&key_b, &[0x09, 0x08, 0x07, 0x06, 0x05]);
|
|
|
|
// Expected plaintext bodies: each sector descrambled under its true key.
|
|
let mut plain_a = sector_a.clone();
|
|
css::lfsr::descramble_sector(&key_a, &mut plain_a);
|
|
let mut plain_b = sector_b.clone();
|
|
css::lfsr::descramble_sector(&key_b, &mut plain_b);
|
|
|
|
let mut buf = Vec::with_capacity(4096);
|
|
buf.extend_from_slice(§or_a);
|
|
buf.extend_from_slice(§or_b);
|
|
|
|
// Cache primed to region A's key (as if A was the last crack). CSS
|
|
// descramble-and-rekey lives in `css::descramble_region` (the recovery
|
|
// seam calls it); the region change must re-crack region B's key.
|
|
let mut ended = key_a;
|
|
css::descramble_region(&mut buf, &mut ended);
|
|
|
|
assert_eq!(
|
|
&buf[0x80..2048],
|
|
&plain_a[0x80..2048],
|
|
"sector 0 rides the cached key (crib matches, no re-crack)"
|
|
);
|
|
assert_eq!(
|
|
&buf[2048 + 0x80..4096],
|
|
&plain_b[0x80..2048],
|
|
"sector 1 re-cracks its own region key and descrambles correctly"
|
|
);
|
|
// The cache must have advanced to a key that descrambles region B.
|
|
let mut check_b = sector_b.clone();
|
|
css::lfsr::descramble_sector(&ended, &mut check_b);
|
|
assert_eq!(
|
|
&check_b[0x80..2048],
|
|
&plain_b[0x80..2048],
|
|
"the ended cache key must round-trip region B's body"
|
|
);
|
|
}
|
|
|
|
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
|
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
|
#[test]
|
|
fn content_gate_mixed_three_units() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let u = aacs::content::ALIGNED_UNIT_LEN;
|
|
let mut buf = vec![0u8; 3 * u];
|
|
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
|
|
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
|
|
buf[2 * u..].copy_from_slice(&scrambled_region(u)); // unit2 @ LBA6 scrambled
|
|
// Content = LBA 0..6 (units 0 and 1); unit2 (LBA6) is out of content.
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 6)]).unwrap();
|
|
assert_eq!(dropped, u, "only unit0 (in-content + scrambled) counts");
|
|
}
|
|
|
|
/// Mirror of the boundary test: content covers the FIRST unit only.
|
|
#[test]
|
|
fn content_gate_covers_first_unit_only() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
|
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
|
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
|
assert_eq!(
|
|
dropped,
|
|
aacs::content::ALIGNED_UNIT_LEN,
|
|
"only unit0 counts"
|
|
);
|
|
}
|
|
|
|
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
|
|
/// OUTSIDE content is clear filesystem tail, not a malformed encrypted unit,
|
|
/// so it must NOT hard-fail.
|
|
#[test]
|
|
fn content_gate_scrambled_partial_outside_content_is_tolerated() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
|
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
buf.extend_from_slice(&scrambled_region(2048));
|
|
// content far away → both the full unit and the partial are non-content.
|
|
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
|
|
assert!(
|
|
res.is_ok(),
|
|
"a scrambled partial outside content must not hard-fail"
|
|
);
|
|
}
|
|
|
|
/// Whole leading units plus a CLEAR trailing partial (the benign,
|
|
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS
|
|
/// tail in the clear on disc. We must return `Ok` and leave the partial
|
|
/// bytes byte-for-byte unchanged — no regression on real discs.
|
|
#[test]
|
|
fn aacs_clear_trailing_partial_is_tolerated_unchanged() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
|
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let tail = clear_ts_region(2048);
|
|
let mut buf = unit;
|
|
buf.extend_from_slice(&tail);
|
|
|
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
|
|
|
|
assert_eq!(
|
|
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
|
&tail[..],
|
|
"clear trailing partial unit must be left unchanged"
|
|
);
|
|
}
|
|
|
|
/// Whole leading units plus a SCRAMBLED trailing partial (the malformed
|
|
/// danger case): an encrypted unit split across an extent boundary cannot be
|
|
/// decrypted standalone. Passing it through as clear would be silent
|
|
/// corruption, so we must fail loud with `DecryptFailed`.
|
|
#[test]
|
|
fn aacs_scrambled_trailing_partial_is_rejected() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
|
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let tail = scrambled_region(4096);
|
|
let mut buf = unit;
|
|
buf.extend_from_slice(&tail);
|
|
|
|
let err = decrypt_sectors(&mut buf, &mut keys, 0)
|
|
.expect_err("scrambled trailing partial must be rejected");
|
|
assert_eq!(
|
|
err.code(),
|
|
crate::error::Error::DecryptFailed.code(),
|
|
"scrambled trailing partial must fail with DecryptFailed"
|
|
);
|
|
}
|
|
|
|
/// An empty buffer is a valid no-op (zero units), not an error.
|
|
#[test]
|
|
fn aacs_empty_buffer_is_ok() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
|
}
|
|
|
|
/// An exact multiple of the unit length has no trailing partial: behavior
|
|
/// is unchanged — clear units stay clear, scrambled units are decrypt-
|
|
/// attempted. Two clear units must round-trip untouched and return `Ok`.
|
|
#[test]
|
|
fn aacs_exact_multiple_unchanged() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
|
let snapshot = buf.clone();
|
|
|
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
|
|
|
|
assert_eq!(
|
|
buf, snapshot,
|
|
"clear exact-multiple buffer must be left unchanged"
|
|
);
|
|
}
|
|
|
|
// ── DecryptKeys::None and is_encrypted ─────────────────────────────────
|
|
|
|
/// DecryptKeys::None is a pure no-op: the buffer must be returned
|
|
/// byte-for-byte unchanged with Ok, regardless of content (even content
|
|
/// that looks scrambled).
|
|
///
|
|
/// Grounding: the `DecryptKeys::None => {}` match arm does nothing.
|
|
/// Mutation: replace the empty arm with a call that mutates buf -> the
|
|
/// unchanged assert fails.
|
|
#[test]
|
|
fn none_keys_is_noop() {
|
|
let mut buf: Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect();
|
|
let snapshot = buf.clone();
|
|
decrypt_sectors(&mut buf, &mut DecryptKeys::None, 0).expect("None is always Ok");
|
|
assert_eq!(buf, snapshot, "None must not touch the buffer");
|
|
}
|
|
|
|
/// is_encrypted reflects the variant: None -> false, Css/Aacs -> true.
|
|
///
|
|
/// Grounding: `!matches!(self, DecryptKeys::None)`.
|
|
/// Mutation: invert the `!` -> None reports true, this fails.
|
|
#[test]
|
|
fn is_encrypted_matches_variant() {
|
|
assert!(!DecryptKeys::None.is_encrypted());
|
|
assert!(DecryptKeys::Css { title_key: [0; 5] }.is_encrypted());
|
|
assert!(
|
|
DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
}
|
|
.is_encrypted()
|
|
);
|
|
}
|
|
|
|
// ── CSS dispatch (DecryptKeys::Css) ────────────────────────────────────
|
|
|
|
/// Build a CSS-scrambled 2048-byte sector by scrambling a known plaintext
|
|
/// body with the exact inverse of `descramble_sector`, so decrypt_sectors
|
|
/// will descramble it back to the plaintext. The content cipher applies
|
|
/// TAB1 to the ciphertext (`plain = TAB1[cipher] ^ ks`), so it is NOT a
|
|
/// self-inverse XOR — `scramble_sector` is the true inverse and sets the
|
|
/// scramble flag.
|
|
fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec<u8>, Vec<u8>) {
|
|
let mut sector = vec![body_fill; 2048];
|
|
sector[0x14] = 0x30; // scramble flag (bits 4-5)
|
|
sector[0x54..0x59].copy_from_slice(seed);
|
|
let plaintext = sector.clone();
|
|
css::lfsr::scramble_sector(title_key, &mut sector);
|
|
(sector, plaintext)
|
|
}
|
|
|
|
/// The CSS path descrambles each 2048-byte sector with the title key. A
|
|
/// scrambled sector run through decrypt_sectors must come back to its
|
|
/// plaintext body (keystream XOR is involutive), proving the title key is
|
|
/// actually applied.
|
|
///
|
|
/// Grounding: `DecryptKeys::Css { title_key } => for chunk in
|
|
/// buf.chunks_mut(2048) { descramble_sector(title_key, chunk) }`.
|
|
/// Mutation: change `chunks_mut(2048)` to `chunks_mut(2049)` or pass a
|
|
/// fixed wrong key -> the body no longer matches the plaintext.
|
|
#[test]
|
|
fn css_descrambles_with_title_key() {
|
|
let mut title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
|
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
|
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
|
|
// CSS descramble lives in `css::descramble_region` (the recovery seam
|
|
// calls it); `decrypt_sectors` only flags CSS sectors for recovery.
|
|
css::descramble_region(&mut sector, &mut title_key);
|
|
assert_eq!(
|
|
§or[0x80..2048],
|
|
&plaintext[0x80..2048],
|
|
"CSS body must round-trip to plaintext"
|
|
);
|
|
// Flag cleared by the descrambler.
|
|
assert_eq!(
|
|
sector[0x14] & 0x30,
|
|
0,
|
|
"scramble flag cleared after CSS decrypt"
|
|
);
|
|
}
|
|
|
|
/// The CSS path processes EACH 2048-byte sector independently in a
|
|
/// multi-sector buffer. Two scrambled sectors (with different seeds) in
|
|
/// one buffer must both round-trip — pinning that the loop steps by 2048
|
|
/// and applies the key to every sector, not just the first.
|
|
///
|
|
/// Grounding: `for chunk in buf.chunks_mut(2048)`.
|
|
/// Mutation: change the loop to descramble only the first chunk (e.g.
|
|
/// `.next()`) -> the second sector stays scrambled, assert fails.
|
|
#[test]
|
|
fn css_processes_every_sector_in_buffer() {
|
|
let title_key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
|
let (s0, p0) = make_css_sector(&title_key, &[0x11, 0x22, 0x33, 0x44, 0x55], 0x3C);
|
|
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
|
|
let mut buf = s0;
|
|
buf.extend_from_slice(&s1);
|
|
let mut title_key = title_key;
|
|
css::descramble_region(&mut buf, &mut title_key);
|
|
assert_eq!(
|
|
&buf[0x80..2048],
|
|
&p0[0x80..2048],
|
|
"sector 0 body must round-trip"
|
|
);
|
|
assert_eq!(
|
|
&buf[2048 + 0x80..4096],
|
|
&p1[0x80..2048],
|
|
"sector 1 body must round-trip (loop must reach the 2nd sector)"
|
|
);
|
|
}
|
|
|
|
/// Build a CSS sector whose clear header ends in a periodic run that
|
|
/// continues into the encrypted region — the crackable shape `attack_crib`/
|
|
/// `crack_title_key` recover a key from (a constant body fill gives a
|
|
/// degenerate crib the cracker can't pin a unique key on). Returns
|
|
/// (scrambled_sector, plaintext_body).
|
|
fn make_crackable_css_sector(
|
|
title_key: &[u8; 5],
|
|
seed: &[u8; 5],
|
|
period: usize,
|
|
) -> (Vec<u8>, Vec<u8>) {
|
|
let mut plaintext = vec![0u8; 2048];
|
|
plaintext[0x14] = 0x10; // scramble flag
|
|
// Periodic run from 0x59 (just above the seed) through 0x80 and on into
|
|
// the encrypted region; phase anchored to offset 0 so it is continuous
|
|
// across the 0x80 boundary.
|
|
let pat: Vec<u8> = (0..period)
|
|
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
|
.collect();
|
|
for (i, b) in plaintext.iter_mut().enumerate().skip(0x59) {
|
|
*b = pat[i % period];
|
|
}
|
|
plaintext[0x54..0x59].copy_from_slice(seed); // seed sits below the run
|
|
let body = plaintext.clone();
|
|
css::lfsr::scramble_sector(title_key, &mut plaintext);
|
|
(plaintext, body)
|
|
}
|
|
|
|
/// CSS title keys are per-VTS/VOB region: a real disc holds DIFFERENT keys
|
|
/// for different regions and the only way to get each is to crack it. The
|
|
/// decrypt path must re-crack when the cached key stops descrambling (its
|
|
/// crib no longer reappears at 0x80) instead of blindly applying one key
|
|
/// across a region boundary — the bug that pixelated every freemkv DVD rip.
|
|
///
|
|
/// Two sectors scrambled under DIFFERENT keys, cache primed to ONLY the
|
|
/// first (exactly what the one-shot scan crack leaves). Sector 0 validates +
|
|
/// descrambles with the cached key; sector 1's cached-key descramble fails
|
|
/// the crib, so the path re-cracks sector 1's own key and recovers its
|
|
/// plaintext. Before the fix (blind single-key apply) sector 1 was garbage.
|
|
///
|
|
/// Grounding: the CSS arm's `attack_crib` → `chunk[0x80..] != crib` →
|
|
/// `crack_title_key` → `*title_key = fresh` rekey.
|
|
/// Mutation: drop the rekey branch (apply the cached key always) → sector 1's
|
|
/// body no longer matches its plaintext; this fails.
|
|
#[test]
|
|
fn css_rekeys_when_title_key_region_changes() {
|
|
let key_a = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
|
let key_b = [0x07, 0x5A, 0xC3, 0x10, 0x88]; // a DIFFERENT region's key
|
|
let (s0, p0) = make_crackable_css_sector(&key_a, &[0x11, 0x22, 0x33, 0x44, 0x55], 4);
|
|
let (s1, p1) = make_crackable_css_sector(&key_b, &[0x66, 0x77, 0x88, 0x99, 0xAA], 4);
|
|
// Precondition: each sector must be crackable on its own (the rekey
|
|
// depends on it). If this fails the fixture, not the path, is at fault.
|
|
assert_eq!(
|
|
crate::css::stevenson::crack_title_key(&s0),
|
|
Some(key_a),
|
|
"fixture s0 must crack to key_a standalone"
|
|
);
|
|
assert_eq!(
|
|
crate::css::stevenson::crack_title_key(&s1),
|
|
Some(key_b),
|
|
"fixture s1 must crack to key_b standalone"
|
|
);
|
|
let mut buf = s0;
|
|
buf.extend_from_slice(&s1);
|
|
|
|
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
|
|
let mut title_key = key_a;
|
|
css::descramble_region(&mut buf, &mut title_key);
|
|
|
|
assert_eq!(
|
|
&buf[0x80..2048],
|
|
&p0[0x80..2048],
|
|
"region A sector descrambles with the cached (primed) key"
|
|
);
|
|
assert_eq!(
|
|
&buf[2048 + 0x80..4096],
|
|
&p1[0x80..2048],
|
|
"region B sector must descramble after the path re-cracks its own key"
|
|
);
|
|
// The cache must have advanced to region B's key.
|
|
assert_eq!(
|
|
title_key, key_b,
|
|
"cache must hold region B's key after the rekey"
|
|
);
|
|
}
|
|
|
|
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
|
|
/// untouched — descramble_sector early-returns on a zero flag. A clear
|
|
/// sector mixed into the buffer must not be corrupted.
|
|
///
|
|
/// Grounding: descramble_sector returns immediately when
|
|
/// `(sector[0x14] >> 4) & 0x03 == 0`.
|
|
/// Mutation: remove that early return in lfsr.rs -> a clear sector would
|
|
/// be XORed with a keystream and change; this fails.
|
|
#[test]
|
|
fn css_leaves_clear_sector_unchanged() {
|
|
let title_key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
|
let mut sector = vec![0x77u8; 2048];
|
|
sector[0x14] = 0x00; // not scrambled
|
|
let snapshot = sector.clone();
|
|
let mut keys = DecryptKeys::Css { title_key };
|
|
decrypt_sectors(&mut sector, &mut keys, 0).unwrap();
|
|
assert_eq!(sector, snapshot, "clear CSS sector must be left untouched");
|
|
}
|
|
|
|
/// CSS decrypt always returns Ok (it cannot fail — descrambling is XOR,
|
|
/// no key validity check), even for an empty buffer.
|
|
///
|
|
/// Grounding: the CSS arm has no `return Err` path; `chunks_mut` over an
|
|
/// empty slice is a no-op; the function ends `Ok(())`.
|
|
/// Mutation: make the CSS arm return Err -> this fails.
|
|
#[test]
|
|
fn css_empty_buffer_is_ok() {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
let mut keys = DecryptKeys::Css { title_key: [0; 5] };
|
|
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
|
}
|
|
|
|
// ── AACS unit-key index selection ──────────────────────────────────────
|
|
|
|
/// AACS decrypt with an out-of-range unit_key_idx must fail loud with
|
|
/// DecryptFailed — never silently fall back to a wrong key or pass
|
|
/// encrypted data through as clear.
|
|
///
|
|
/// Grounding: `let uk = match unit_keys.get(unit_key_idx) { Some => ...,
|
|
/// None => return Err(DecryptFailed) }`.
|
|
/// Mutation: change `unit_keys.get(unit_key_idx)` to `unit_keys.get(0)` or
|
|
/// `.unwrap_or` a default -> the out-of-range index would not error; this
|
|
/// fails.
|
|
#[test]
|
|
fn aacs_out_of_range_unit_key_idx_errors() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, [0xAB; 16])],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
|
.expect_err("unit_key_idx 5 is out of range for a 1-key list");
|
|
assert_eq!(
|
|
err.code(),
|
|
crate::error::Error::DecryptFailed.code(),
|
|
"out-of-range unit key index must be DecryptFailed"
|
|
);
|
|
}
|
|
|
|
/// AACS with an empty unit_keys list and any index errors (no key to use).
|
|
///
|
|
/// Grounding: `unit_keys.get(0)` on an empty Vec is None -> DecryptFailed.
|
|
/// Mutation: defaulting to [0u8;16] on None would proceed; this fails.
|
|
#[test]
|
|
fn aacs_empty_unit_keys_errors() {
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
|
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
|
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
|
|
}
|
|
|
|
// ── Multi-CPS-unit key selection ──────────────────────────────────────
|
|
|
|
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
|
|
/// `aacs::content::decrypt_unit` with the same key recovers the plaintext. Mirrors
|
|
/// the `aacs_encrypt_unit` helper in `aacs::content::tests`.
|
|
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
|
use aes::Aes128;
|
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
// CPI bits on byte 0 so the unit reads as encrypted; set before deriving
|
|
// the per-unit key so the recovered plaintext header matches.
|
|
unit[0] |= 0xC0;
|
|
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
|
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
|
let mut k = [0u8; 16];
|
|
for i in 0..16 {
|
|
k[i] = derived[i] ^ header[i];
|
|
}
|
|
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
|
let mut prev = crate::aacs::crypto::AACS_IV;
|
|
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
|
for i in 0..num_blocks {
|
|
let off = 16 + i * 16;
|
|
for j in 0..16 {
|
|
unit[off + j] ^= prev[j];
|
|
}
|
|
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
|
cipher.encrypt_block(&mut block);
|
|
unit[off..off + 16].copy_from_slice(&block);
|
|
prev.copy_from_slice(&unit[off..off + 16]);
|
|
}
|
|
}
|
|
|
|
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
|
|
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
|
|
/// `decrypt_unit` verifies it as clear after decryption.
|
|
fn clear_ts_unit() -> Vec<u8> {
|
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
let mut off = 4;
|
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
|
unit[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
unit
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// Before the fix, `decrypt_one` used only `unit_keys[unit_key_idx]`
|
|
/// (i.e. always key 0). On a multi-CPS-unit disc this produced silent
|
|
/// garbage for content under key ≥ 1. The fix tries every key and
|
|
/// accepts the one whose output passes the TS-sync verify.
|
|
///
|
|
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
|
|
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
|
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
|
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
|
|
/// assert.
|
|
#[test]
|
|
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
|
let key0 = [0x11u8; 16]; // CPS unit 0 key — NOT the correct key for this unit
|
|
let key1 = [0x22u8; 16]; // CPS unit 1 key — the correct key
|
|
|
|
// Build and encrypt a clear unit under key1 (the non-default CPS unit).
|
|
let mut unit = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
|
assert!(
|
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
|
"encrypted unit must look scrambled before decrypt"
|
|
);
|
|
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
|
|
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
|
let mut buf = unit;
|
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
|
|
|
assert!(
|
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
|
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
|
);
|
|
// Every sync position must carry 0x47.
|
|
assert_eq!(
|
|
aacs::content::ts_sync_count(&buf),
|
|
aacs::content::ts_packet_total(&buf),
|
|
"all TS sync bytes must be restored after decrypting under key1"
|
|
);
|
|
}
|
|
|
|
/// Single-key disc: the common case is unaffected — the single key is
|
|
/// tried first (via the hint) and validates, so no second-pass overhead.
|
|
///
|
|
/// Grounding: the `hint = last_key_idx.load(…)` path returns on the first
|
|
/// `try_order` iteration. A regression that always tried all keys (instead
|
|
/// of accepting the first hit) would still pass this test — correctness is
|
|
/// the invariant here, not the performance shortcut.
|
|
#[test]
|
|
fn aacs_single_key_disc_still_decrypts_correctly() {
|
|
let key = [0x55u8; 16];
|
|
let mut unit = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit, &key);
|
|
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, key)],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = unit;
|
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
|
assert!(
|
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
|
"single-key disc: TS syncs must be restored"
|
|
);
|
|
assert_eq!(
|
|
aacs::content::ts_sync_count(&buf),
|
|
aacs::content::ts_packet_total(&buf),
|
|
"all TS sync bytes must be restored for single-key disc"
|
|
);
|
|
}
|
|
|
|
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
|
|
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
|
|
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
|
|
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
|
|
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
|
|
/// muxer). This is the single decrypt authority applying no policy.
|
|
///
|
|
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
|
|
/// the removal of the `copy_from_slice(&original)` restore.
|
|
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
|
|
#[test]
|
|
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
|
|
let real_key = [0x33u8; 16];
|
|
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
|
|
|
// Encrypt a clear unit under real_key, then offer ONLY the wrong key.
|
|
let mut unit = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
|
let ciphertext = unit.clone();
|
|
assert!(
|
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
|
"encrypted unit must look scrambled going in"
|
|
);
|
|
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, wrong_key)],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = unit;
|
|
let unverified =
|
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
|
|
|
|
assert_eq!(
|
|
unverified,
|
|
aacs::content::ALIGNED_UNIT_LEN,
|
|
"a unit that did not reach clean TS is reported unverified"
|
|
);
|
|
assert_ne!(
|
|
buf, ciphertext,
|
|
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
|
|
);
|
|
}
|
|
|
|
/// The dropped-byte tally accumulates across a multi-unit buffer where some
|
|
/// units decrypt and others don't: a 2-unit buffer with one good and one
|
|
/// bad unit reports exactly one unit's worth of loss, and the good unit is
|
|
/// fully decrypted. Confirms the count is per-unit, not all-or-nothing.
|
|
///
|
|
/// Grounding: the per-chunk `decrypt_one` closure tallies only the units
|
|
/// that fail; the good unit takes the `return` before the tally.
|
|
#[test]
|
|
fn aacs_mixed_buffer_tallies_only_failed_units() {
|
|
let key = [0x55u8; 16];
|
|
let wrong = [0x66u8; 16];
|
|
|
|
// Unit A: encrypted under `key` (decryptable). Unit B: encrypted under
|
|
// `wrong` (NOT in the key list → undecryptable).
|
|
let mut unit_a = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit_a, &key);
|
|
let mut unit_b = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
|
|
let unit_b_ciphertext = unit_b.clone();
|
|
|
|
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
|
|
buf.extend_from_slice(&unit_a);
|
|
buf.extend_from_slice(&unit_b);
|
|
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, key)],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
|
|
|
assert_eq!(
|
|
dropped,
|
|
aacs::content::ALIGNED_UNIT_LEN,
|
|
"exactly one unit's worth of bytes must be reported unverified"
|
|
);
|
|
assert!(
|
|
crate::aacs::content::is_clean(
|
|
&buf[..aacs::content::ALIGNED_UNIT_LEN],
|
|
crate::disc::ContentFormat::BdTs
|
|
),
|
|
"the decryptable unit must come out clear"
|
|
);
|
|
assert_ne!(
|
|
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
|
&unit_b_ciphertext[..],
|
|
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
|
|
);
|
|
}
|
|
|
|
/// A fully-decryptable single-key buffer reports zero dropped bytes — the
|
|
/// loss tally must not fire on the clean path.
|
|
#[test]
|
|
fn aacs_all_units_decrypt_reports_zero_dropped() {
|
|
let key = [0x77u8; 16];
|
|
let mut unit = clear_ts_unit();
|
|
aacs_encrypt_unit_for_test(&mut unit, &key);
|
|
let mut keys = DecryptKeys::Aacs {
|
|
unit_keys: vec![(0, key)],
|
|
read_data_key: None,
|
|
format: crate::disc::ContentFormat::BdTs,
|
|
};
|
|
let mut buf = unit;
|
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
|
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
|
|
}
|
|
|
|
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
|
|
|
|
/// The default (auto) decrypt thread count is always a usable pool size:
|
|
/// at least 1 (a 0-thread rayon pool is invalid) and never above
|
|
/// MAX_THREADS (rayon stack-memory cap). This test reads the resolved
|
|
/// value without mutating the process-global override, so it is safe to
|
|
/// run in parallel with other tests.
|
|
///
|
|
/// Grounding: `cores.clamp(1, MAX_THREADS)` in the default branch;
|
|
/// `env.min(MAX_THREADS)` in the env branch.
|
|
/// Mutation: change `.clamp(1, MAX_THREADS)` to `.clamp(0, MAX_THREADS)`
|
|
/// on a 0-core probe (unlikely) — more robustly, change the cap to
|
|
/// `MAX_THREADS * 2` -> on a many-core CI box the upper-bound assert can
|
|
/// fail. The lower-bound (>=1) guard is the load-bearing invariant.
|
|
#[test]
|
|
fn decrypt_threads_within_valid_pool_range() {
|
|
let n = decrypt_threads();
|
|
assert!(n >= 1, "decrypt thread count must be at least 1, got {n}");
|
|
assert!(
|
|
n <= MAX_THREADS,
|
|
"decrypt thread count must not exceed MAX_THREADS ({MAX_THREADS}), got {n}"
|
|
);
|
|
}
|
|
}
|