Account for decrypt-time loss so partial AACS/CSS failures can't pass as a perfect rip
When a scrambled AACS unit fails to decrypt under every available key (a missing/wrong CPS sub-key, or a marginal unit that fails the TS-sync verify), decrypt_sectors restored the original encrypted bytes and returned Ok with no signal. Those still-encrypted bytes flowed to the TS assembler, which silently dropped the non-syncing packets with no loss counter. The only loss accounting was DiscStream's read-error zero-fill path, so mux reported lost_video_secs=0 for decrypt-dropped content and the abort gate accepted the rip even under abort_on_lost_secs=0. A rip missing real video/audio segments was published as a perfect success. decrypt_sectors now returns the number of bytes in scrambled units that no key could decrypt. DecryptingSectorSource accumulates that into a shared counter exposed via decrypt_loss(); both mux pipelines fold it into lost_bytes() — the inline DiscStream path directly, and the file-backed highway via PipelinedPesStream sharing the producer's counter. Restore-to-original is unchanged, so clear nav-files are never corrupted; metadata-probe callers that don't read the counter are unaffected. Adds regression tests at the decrypt and decorator layers.
This commit is contained in:
+141
-6
@@ -172,13 +172,22 @@ impl DecryptKeys {
|
|||||||
///
|
///
|
||||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||||
/// Never produces silently corrupted output.
|
/// Never produces silently corrupted output.
|
||||||
|
///
|
||||||
|
/// On success returns the number of bytes belonging to scrambled AACS units
|
||||||
|
/// that **no available key could decrypt** — those units are restored to their
|
||||||
|
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
|
||||||
|
/// genuine encrypted content this is silent data loss the downstream TS
|
||||||
|
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
|
||||||
|
/// count into the mux loss accounting so a partial key failure can't be reported
|
||||||
|
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
|
||||||
|
/// scrambled unit decrypted.
|
||||||
pub fn decrypt_sectors(
|
pub fn decrypt_sectors(
|
||||||
buf: &mut [u8],
|
buf: &mut [u8],
|
||||||
keys: &DecryptKeys,
|
keys: &DecryptKeys,
|
||||||
unit_key_idx: usize,
|
unit_key_idx: usize,
|
||||||
) -> Result<(), crate::error::Error> {
|
) -> Result<usize, crate::error::Error> {
|
||||||
match keys {
|
let dropped: usize = match keys {
|
||||||
DecryptKeys::None => {}
|
DecryptKeys::None => 0,
|
||||||
DecryptKeys::Aacs {
|
DecryptKeys::Aacs {
|
||||||
unit_keys,
|
unit_keys,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
@@ -243,6 +252,14 @@ pub fn decrypt_sectors(
|
|||||||
// never a wrong result (TS-sync verify gates correctness).
|
// never a wrong result (TS-sync verify gates correctness).
|
||||||
let last_key_idx = AtomicUsize::new(unit_key_idx);
|
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 decrypt closure. For a scrambled full aligned unit:
|
// Per-unit decrypt closure. For a scrambled full aligned unit:
|
||||||
// 1. Try the cached key index first (avoids scanning all keys on the
|
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||||
// common case where a disc run uses one CPS unit throughout).
|
// common case where a disc run uses one CPS unit throughout).
|
||||||
@@ -287,8 +304,16 @@ pub fn decrypt_sectors(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No key validated — restore the original encrypted bytes.
|
// No key validated — restore the original encrypted bytes and
|
||||||
|
// tally the loss. The unit was scrambled (we only reach here past
|
||||||
|
// the `is_aacs_scrambled` gate) but no key applied: a clear
|
||||||
|
// nav-file unit that legitimately fails the cipher, or genuine
|
||||||
|
// encrypted content with a missing/wrong sub-key. We can't tell
|
||||||
|
// them apart here, so we always tally; the mux read path treats
|
||||||
|
// the count as loss (its extents are real content), while
|
||||||
|
// metadata-probe callers that don't install a loss sink ignore it.
|
||||||
chunk.copy_from_slice(&original);
|
chunk.copy_from_slice(&original);
|
||||||
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||||
};
|
};
|
||||||
|
|
||||||
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||||
@@ -323,14 +348,16 @@ pub fn decrypt_sectors(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
dropped_bytes.into_inner()
|
||||||
}
|
}
|
||||||
DecryptKeys::Css { title_key } => {
|
DecryptKeys::Css { title_key } => {
|
||||||
for chunk in buf.chunks_mut(2048) {
|
for chunk in buf.chunks_mut(2048) {
|
||||||
css::lfsr::descramble_sector(title_key, chunk);
|
css::lfsr::descramble_sector(title_key, chunk);
|
||||||
}
|
}
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
Ok(())
|
Ok(dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -775,6 +802,114 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
|
||||||
|
/// unit that NO supplied key can decrypt is restored to its original
|
||||||
|
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
|
||||||
|
/// returns the unit's byte length as the dropped count. Before the fix this
|
||||||
|
/// returned `()` and the still-encrypted bytes flowed downstream to be
|
||||||
|
/// silently dropped by the TS assembler with zero loss accounting — a rip
|
||||||
|
/// missing real content reported `lost_video_secs=0` and passed the abort
|
||||||
|
/// gate even under `abort_on_lost_secs=0`.
|
||||||
|
///
|
||||||
|
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
|
||||||
|
/// no-key-validated restore path; the function returns that tally.
|
||||||
|
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
|
||||||
|
/// this fails.
|
||||||
|
#[test]
|
||||||
|
fn aacs_undecryptable_unit_reports_dropped_bytes() {
|
||||||
|
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!(
|
||||||
|
aacs::is_aacs_scrambled(&unit),
|
||||||
|
"encrypted unit must look scrambled going in"
|
||||||
|
);
|
||||||
|
|
||||||
|
let keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, wrong_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
};
|
||||||
|
let mut buf = unit;
|
||||||
|
let dropped =
|
||||||
|
decrypt_sectors(&mut buf, &keys, 0).expect("undecryptable unit is not a hard error");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dropped,
|
||||||
|
aacs::ALIGNED_UNIT_LEN,
|
||||||
|
"the whole scrambled unit must be reported as dropped when no key validates"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buf, ciphertext,
|
||||||
|
"an undecryptable unit must be restored to its original ciphertext, not garbled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::ALIGNED_UNIT_LEN);
|
||||||
|
buf.extend_from_slice(&unit_a);
|
||||||
|
buf.extend_from_slice(&unit_b);
|
||||||
|
|
||||||
|
let keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, key)],
|
||||||
|
read_data_key: None,
|
||||||
|
};
|
||||||
|
let dropped = decrypt_sectors(&mut buf, &keys, 0).expect("partial decrypt is Ok");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dropped,
|
||||||
|
aacs::ALIGNED_UNIT_LEN,
|
||||||
|
"exactly one unit's worth of bytes must be reported dropped"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!aacs::is_aacs_scrambled(&buf[..aacs::ALIGNED_UNIT_LEN]),
|
||||||
|
"the decryptable unit must come out clear"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&buf[aacs::ALIGNED_UNIT_LEN..],
|
||||||
|
&unit_b_ciphertext[..],
|
||||||
|
"the undecryptable unit must be 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 keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, key)],
|
||||||
|
read_data_key: None,
|
||||||
|
};
|
||||||
|
let mut buf = unit;
|
||||||
|
let dropped = decrypt_sectors(&mut buf, &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) ─────────
|
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
|
||||||
|
|
||||||
/// The default (auto) decrypt thread count is always a usable pool size:
|
/// The default (auto) decrypt thread count is always a usable pool size:
|
||||||
|
|||||||
+11
-1
@@ -811,7 +811,17 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
fn lost_bytes(&self) -> u64 {
|
||||||
self.lost_bytes
|
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
||||||
|
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
||||||
|
// and passed through still encrypted (the TS assembler silently drops
|
||||||
|
// them). Both are real missing content the abort gate must see; without
|
||||||
|
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
||||||
|
// missing segments passes even under abort_on_lost_secs=0.
|
||||||
|
self.lost_bytes.saturating_add(
|
||||||
|
self.reader
|
||||||
|
.decrypt_loss()
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ pub struct PipelinedPesStream {
|
|||||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||||
/// per-poll reads it replaces were needless hot-path overhead.
|
/// per-poll reads it replaces were needless hot-path overhead.
|
||||||
skip_parse: bool,
|
skip_parse: bool,
|
||||||
|
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
|
||||||
|
/// could not decrypt — silent decrypt loss the demux drops without a sync.
|
||||||
|
/// Shared with the producer thread's [`DecryptingSectorSource`]
|
||||||
|
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
|
||||||
|
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
|
||||||
|
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
|
||||||
|
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
|
||||||
|
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipelinedPesStream {
|
impl PipelinedPesStream {
|
||||||
@@ -84,9 +92,24 @@ impl PipelinedPesStream {
|
|||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
eof: false,
|
eof: false,
|
||||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||||
|
decrypt_loss: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
|
||||||
|
/// reports bytes of scrambled AACS units that could not be decrypted (and
|
||||||
|
/// were therefore silently dropped downstream). Obtained from the
|
||||||
|
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
|
||||||
|
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
|
||||||
|
/// unset.
|
||||||
|
pub(crate) fn with_decrypt_loss(
|
||||||
|
mut self,
|
||||||
|
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
) -> Self {
|
||||||
|
self.decrypt_loss = Some(loss);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull one batch of `PesPacket`s from the demux thread, run
|
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||||
@@ -263,6 +286,18 @@ impl Stream for PipelinedPesStream {
|
|||||||
.find(|(p, _)| *p == pid)
|
.find(|(p, _)| *p == pid)
|
||||||
.and_then(|(_, parser)| parser.codec_private())
|
.and_then(|(_, parser)| parser.codec_private())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lost_bytes(&self) -> u64 {
|
||||||
|
// The file-backed highway has no read-error zero-fill term (resolve
|
||||||
|
// tracks read loss separately), but the producer's decrypt step can
|
||||||
|
// pass scrambled units through undecrypted — silent loss the demux
|
||||||
|
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
|
||||||
|
// decrypt failure rather than reporting a perfect rip.
|
||||||
|
self.decrypt_loss
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+10
-7
@@ -549,6 +549,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
};
|
};
|
||||||
let decrypting =
|
let decrypting =
|
||||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||||
|
// Grab the decrypt-loss counter before the decorator is moved into the
|
||||||
|
// producer thread. It tracks bytes of scrambled AACS units no key could
|
||||||
|
// decrypt — silent loss the demux drops; the consuming stream surfaces it
|
||||||
|
// through `lost_bytes()` so the mux abort gate sees a partial decrypt
|
||||||
|
// failure rather than a clean rip.
|
||||||
|
let decrypt_loss = decrypting.decrypt_loss();
|
||||||
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||||
decrypting,
|
decrypting,
|
||||||
extents,
|
extents,
|
||||||
@@ -564,13 +570,10 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
let (demux_thread, demux_rx) =
|
let (demux_thread, demux_rx) =
|
||||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
Ok(PipelinedPesStream::new(
|
Ok(
|
||||||
demux_thread,
|
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
||||||
demux_rx,
|
.with_decrypt_loss(decrypt_loss),
|
||||||
title,
|
)
|
||||||
parsers,
|
|
||||||
pid_to_track,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
||||||
|
|||||||
+148
-2
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
use crate::decrypt::{DecryptKeys, decrypt_sectors};
|
use crate::decrypt::{DecryptKeys, decrypt_sectors};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use super::SectorSource;
|
use super::SectorSource;
|
||||||
|
|
||||||
@@ -30,6 +32,17 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
|||||||
inner: S,
|
inner: S,
|
||||||
keys: DecryptKeys,
|
keys: DecryptKeys,
|
||||||
unit_key_idx: usize,
|
unit_key_idx: usize,
|
||||||
|
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
|
||||||
|
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
|
||||||
|
/// clear nav-file is never corrupted), but for genuine encrypted content the
|
||||||
|
/// still-encrypted bytes are silently dropped by the downstream TS assembler
|
||||||
|
/// — real, unaccounted loss. Mux read paths share this counter into their
|
||||||
|
/// loss accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
|
||||||
|
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
|
||||||
|
/// producer thread and the consuming `Stream` see the same tally.
|
||||||
|
///
|
||||||
|
/// [`decrypt_loss`]: Self::decrypt_loss
|
||||||
|
decrypt_dropped: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: SectorSource> DecryptingSectorSource<S> {
|
impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||||
@@ -43,9 +56,20 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
inner,
|
inner,
|
||||||
keys,
|
keys,
|
||||||
unit_key_idx: 0,
|
unit_key_idx: 0,
|
||||||
|
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A handle to this decorator's decrypt-loss counter — the cumulative bytes
|
||||||
|
/// of scrambled AACS units that no key could decrypt (see
|
||||||
|
/// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this
|
||||||
|
/// to fold decrypt-time loss into their `lost_bytes` accounting; the highway
|
||||||
|
/// shares it across the producer thread and the consuming `Stream`. Returns
|
||||||
|
/// the live counter, so reads after a decrypt observe the updated total.
|
||||||
|
pub fn decrypt_loss(&self) -> Arc<AtomicU64> {
|
||||||
|
Arc::clone(&self.decrypt_dropped)
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the AACS unit-key index. Only meaningful for
|
/// Override the AACS unit-key index. Only meaningful for
|
||||||
/// [`DecryptKeys::Aacs`]; other variants ignore it.
|
/// [`DecryptKeys::Aacs`]; other variants ignore it.
|
||||||
pub fn with_unit_key_idx(mut self, idx: usize) -> Self {
|
pub fn with_unit_key_idx(mut self, idx: usize) -> Self {
|
||||||
@@ -107,8 +131,15 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
}
|
}
|
||||||
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
|
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
|
||||||
// Apply the crate-wide AACS/CSS/None decrypt entry point in-place
|
// Apply the crate-wide AACS/CSS/None decrypt entry point in-place
|
||||||
// over the bytes just read. No-op for DecryptKeys::None.
|
// over the bytes just read. No-op for DecryptKeys::None. The returned
|
||||||
decrypt_sectors(&mut buf[..n], &self.keys, self.unit_key_idx)?;
|
// count is bytes of scrambled units no key could decrypt — silent
|
||||||
|
// decrypt loss the TS assembler will drop. Tally it so the mux loss
|
||||||
|
// accounting (and the abort gate) can see partial decrypt failure.
|
||||||
|
let dropped = decrypt_sectors(&mut buf[..n], &self.keys, self.unit_key_idx)?;
|
||||||
|
if dropped > 0 {
|
||||||
|
self.decrypt_dropped
|
||||||
|
.fetch_add(dropped as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +656,121 @@ mod tests {
|
|||||||
assert_eq!(n, 2048, "CSS reads must not be unit-alignment gated");
|
assert_eq!(n, 2048, "CSS reads must not be unit-alignment gated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then
|
||||||
|
/// encrypt it under `unit_key` so `aacs::decrypt_unit` recovers it. Mirrors
|
||||||
|
/// the encrypt helper in `crate::decrypt`'s tests.
|
||||||
|
fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec<u8> {
|
||||||
|
use aes::Aes128;
|
||||||
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||||
|
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||||
|
let mut off = 4;
|
||||||
|
while off < unit.len() {
|
||||||
|
unit[off] = 0x47;
|
||||||
|
off += 192;
|
||||||
|
}
|
||||||
|
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||||
|
let derived = crate::aacs::decrypt::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::decrypt::AACS_IV;
|
||||||
|
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||||
|
for i in 0..blocks {
|
||||||
|
let o = 16 + i * 16;
|
||||||
|
for j in 0..16 {
|
||||||
|
unit[o + j] ^= prev[j];
|
||||||
|
}
|
||||||
|
let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]);
|
||||||
|
cipher.encrypt_block(&mut blk);
|
||||||
|
unit[o..o + 16].copy_from_slice(&blk);
|
||||||
|
prev.copy_from_slice(&unit[o..o + 16]);
|
||||||
|
}
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: when the decrypt step can't decrypt a scrambled AACS unit
|
||||||
|
/// (wrong/missing key), the decorator must accumulate the dropped bytes in
|
||||||
|
/// its `decrypt_loss()` counter while STILL returning `Ok` (per-unit
|
||||||
|
/// tolerance). The mux pipelines read this counter into `lost_bytes()` so a
|
||||||
|
/// partial decrypt failure can't be reported as a perfect rip. A
|
||||||
|
/// decryptable unit must leave the counter at zero.
|
||||||
|
///
|
||||||
|
/// Grounding: `read_sectors` folds `decrypt_sectors`' dropped count into
|
||||||
|
/// `decrypt_dropped`; `decrypt_loss()` exposes it.
|
||||||
|
#[test]
|
||||||
|
fn decrypt_loss_counter_accumulates_undecryptable_units() {
|
||||||
|
let real_key = [0x33u8; 16];
|
||||||
|
let wrong_key = [0x44u8; 16];
|
||||||
|
|
||||||
|
// A source that always yields one unit encrypted under `real_key`.
|
||||||
|
struct EncUnitSource {
|
||||||
|
unit: Vec<u8>,
|
||||||
|
}
|
||||||
|
impl SectorSource for EncUnitSource {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let bytes = count as usize * 2048;
|
||||||
|
assert_eq!(bytes, self.unit.len(), "test reads one whole unit");
|
||||||
|
buf[..bytes].copy_from_slice(&self.unit);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = encrypt_aacs_unit(&real_key);
|
||||||
|
|
||||||
|
// Wrong key → undecryptable → loss counted, read still Ok.
|
||||||
|
let mut wrapped = DecryptingSectorSource::new(
|
||||||
|
EncUnitSource { unit: unit.clone() },
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, wrong_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let loss = wrapped.decrypt_loss();
|
||||||
|
assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero");
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 3 * 2048];
|
||||||
|
wrapped
|
||||||
|
.read_sectors(0, 3, &mut buf, false)
|
||||||
|
.expect("undecryptable unit must NOT hard-error (per-unit tolerance)");
|
||||||
|
assert_eq!(
|
||||||
|
loss.load(Ordering::Relaxed),
|
||||||
|
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||||
|
"one undecryptable unit must add its byte length to the loss counter"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second read of the same bad unit accumulates further.
|
||||||
|
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loss.load(Ordering::Relaxed),
|
||||||
|
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||||
|
"loss must accumulate across reads"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Correct key → no loss.
|
||||||
|
let mut good = DecryptingSectorSource::new(
|
||||||
|
EncUnitSource { unit },
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, real_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let good_loss = good.decrypt_loss();
|
||||||
|
good.read_sectors(0, 3, &mut buf, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
good_loss.load(Ordering::Relaxed),
|
||||||
|
0,
|
||||||
|
"a decryptable unit must not register any loss"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
||||||
/// source unchanged. Grounding: the accessor methods.
|
/// source unchanged. Grounding: the accessor methods.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user