//! 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>>` 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>> = 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> { // 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 = std::sync::OnceLock::new(); *DEFAULT_THREADS.get_or_init(|| { let env = std::env::var("FREEMKV_THREADS") .ok() .and_then(|v| v.parse::().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 { let mut v: Vec = 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 { 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 { 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 { 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 = 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 { let mut v: Vec = (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 { let mut v: Vec = (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 { 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 = (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 = 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 = (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, Vec) { 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, Vec) { 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 = (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 = 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 { 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}" ); } }