diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index e48a60d..25262d7 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -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. diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 4fd17e1..88a2402 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -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 diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index f686ab6..cf01a4f 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -575,11 +575,19 @@ impl SectorSource for DecryptingSectorSource { 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, + } + 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 { + 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.