mux: conceal only genuinely-undecryptable units (padding-aware)

The P3 concealment loop in DecryptingSectorSource::read_sectors keyed on
aacs_unit_needs_decrypt, whose sync check is the majority-vote
ts_sync_destroyed (<=16 of 32 syncs). A successfully padding-aware-
decrypted content-fragment TAIL unit (e.g. 11 content packets + 21 zero
padding) has only 11 syncs, so the majority vote called it "still
encrypted" — and when such a good unit shared a read buffer with a
genuinely-undecryptable one (dropped>0), the loop overwrote the GOOD
decrypted tail with NULL-TS, silently discarding correct video and
over-counting concealed units vs the tallied dropped bytes.

Add aacs_unit_still_ciphertext (padding-aware): encrypted AND at least
one non-zero (non-padding) 192-byte packet missing its 0x47 sync — the
same discriminator decrypt_unit uses to accept a fragment tail. The
conceal loop now uses it, so only genuinely-unrestored ciphertext is
concealed. Full and fully-ciphertext units are unchanged. Regression
test: a decrypted short-padding-tail co-resident with a failed unit is
left byte-for-byte intact while the failed unit is concealed.
This commit is contained in:
Matthew Jackson
2026-06-29 00:29:20 -07:00
parent e6180a429b
commit 067fd207d5
3 changed files with 180 additions and 8 deletions
+47
View File
@@ -163,6 +163,53 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit) aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
} }
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
/// bytes.
///
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
/// syncs after decrypt, so the majority vote calls it "destroyed" and
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
/// NULL-TS, silently discarding correct video (the bug this fixes; the v1.1.1
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
///
/// The correct, padding-aware notion of "still ciphertext", checkable on the
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
/// (content) packet carries `0x47`; padding packets are all-zero.
///
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
if !aacs_unit_encrypted(unit) {
return false;
}
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut off = 0;
while off + PKT <= limit {
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
// other (content) packet that lacks its TS sync is un-restored ciphertext.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
}
off += PKT;
}
false
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS /// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
/// source packets — the [A2] mux loss-concealment fill for a content unit that /// source packets — the [A2] mux loss-concealment fill for a content unit that
/// genuinely would not decrypt. /// genuinely would not decrypt.
+4 -3
View File
@@ -44,9 +44,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs. // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{ pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full, aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
decrypt_unit_try_keys, fill_null_ts_unit, is_unit_aligned, ts_packet_total, ts_sync_count, decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
ts_sync_destroyed, unit_is_clean_ps, unit_is_clean_ts, unit_key_validates, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
unit_is_clean_ts, unit_key_validates,
}; };
// `probe` is a reproduction-harness helper (see keys.rs), not part of the // `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we // documented 1.0 surface; keep it reachable but off the rendered docs so we
+129 -5
View File
@@ -575,11 +575,19 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
if chunk.len() < unit_len { if chunk.len() < unit_len {
continue; // trailing partial can't be a whole scrambled unit continue; // trailing partial can't be a whole scrambled unit
} }
// A unit still flagged-encrypted + scrambled after the decrypt // Conceal ONLY a unit that is GENUINELY still ciphertext, using
// pass is the genuinely-undecryptable content. In-content gating // the PADDING-AWARE test that matches `decrypt_unit`'s success
// already happened in `decrypt_buf`, which restored only those // criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
// units to ciphertext; clear nav passed through clean. // A successfully padding-aware-decrypted content-fragment TAIL
if crate::aacs::aacs_unit_needs_decrypt(chunk) { // (the v1.1.1 fix: a few real packets + source-zero padding) has
// <16 TS syncs, so the majority vote would mis-flag it as
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
// padding-aware predicate excludes zero-payload (padding) packets
// and flags the unit only when a real content packet is still
// un-restored ciphertext. In-content gating already happened in
// `decrypt_buf`, which restored only failed units to ciphertext;
// clear nav and decrypted tails pass through clean.
if crate::aacs::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 { if concealed == 0 {
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS; first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
} }
@@ -1417,6 +1425,122 @@ mod tests {
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read"); assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
} }
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
/// content packets + source-zero padding — the v1.1.1 shape) carries <16 TS
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
/// good tail byte-for-byte intact.
#[test]
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
assert!(
crate::aacs::decrypt_unit(&mut expected_tail, &good_key),
"padding-tail must decrypt under good_key"
);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
"exactly one unit (the undecryptable one) is counted as loss"
);
// Unit A → NULL TS (concealed).
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
assert_eq!(
unit0[off + 6],
0xFF,
"unit A null packet PID low 0xFF at {off}"
);
off += 192;
}
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
// overwritten with NULL TS). This is the silent-data-loss the old
// majority-vote predicate caused.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decrypted padding-tail unit must be left byte-for-byte intact"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes /// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes
/// a well-formed TS null packet, and a TS demuxer tracking a real PID sees /// a well-formed TS null packet, and a TS demuxer tracking a real PID sees
/// none of them (PID 0x1FFF matches nothing) — the basis for A2 concealment. /// none of them (PID 0x1FFF matches nothing) — the basis for A2 concealment.