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:
@@ -163,6 +163,53 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
|
||||
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
|
||||
/// source packets — the [A2] mux loss-concealment fill for a content unit that
|
||||
/// genuinely would not decrypt.
|
||||
|
||||
+4
-3
@@ -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.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
|
||||
decrypt_unit_try_keys, fill_null_ts_unit, is_unit_aligned, ts_packet_total, ts_sync_count,
|
||||
ts_sync_destroyed, unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
|
||||
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
|
||||
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
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
|
||||
+129
-5
@@ -575,11 +575,19 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
if chunk.len() < unit_len {
|
||||
continue; // trailing partial can't be a whole scrambled unit
|
||||
}
|
||||
// A unit still flagged-encrypted + scrambled after the decrypt
|
||||
// pass is the genuinely-undecryptable content. In-content gating
|
||||
// already happened in `decrypt_buf`, which restored only those
|
||||
// units to ciphertext; clear nav passed through clean.
|
||||
if crate::aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
|
||||
// the PADDING-AWARE test that matches `decrypt_unit`'s success
|
||||
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
|
||||
// A successfully padding-aware-decrypted content-fragment TAIL
|
||||
// (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 {
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user