mux/decrypt: audit-nit cleanup + defensive conceal fallback

Non-blocking follow-ups from the 1.2.0 audit:
- conceal loop: if decrypt reported loss but the padding-aware predicate
  matched nothing to conceal (a ~256^-31 contradiction), fall back to the
  strict predicate and conceal whatever it flags, loudly — belt-and-
  suspenders so ciphertext can never reach the mux.
- decrypt_dropped doc: reflect 1.2.0 (mux-path loss is concealed + tallied,
  not silently dropped).
- direct unit test for aacs_unit_still_ciphertext (the padding-aware
  conceal predicate): clear/all-zero/full-decrypted/full-ciphertext/
  decrypted-short-tail.
- fix three stale "v1.1.1" comment refs (the fragment-tail fix ships in
  1.2.0; there is no v1.1.1 release).
This commit is contained in:
Matthew Jackson
2026-06-29 09:51:28 -07:00
parent 789b699f95
commit c5515d310f
2 changed files with 87 additions and 7 deletions
+57 -1
View File
@@ -174,7 +174,7 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
/// 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
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
/// 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
@@ -1442,4 +1442,60 @@ mod tests {
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
/// Direct coverage for the padding-aware conceal predicate. It must conceal
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
#[test]
fn aacs_unit_still_ciphertext_is_padding_aware() {
let pkt = BD_SOURCE_PACKET_BYTES;
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
let build = |cpi: bool, kinds: &[u8]| {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
if cpi {
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
}
for (i, &k) in kinds.iter().enumerate() {
let off = i * pkt;
match k {
b'S' => {
u[off + 4] = TS_SYNC;
for b in &mut u[off + 5..off + pkt] {
*b = 0x10;
}
}
b'C' => {
// Scrambled: non-zero payload, no 0x47 at the sync position.
for b in &mut u[off + 4..off + pkt] {
*b = 0x5A;
}
}
_ => {} // b'P' → leave zero
}
}
u
};
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
// All-zero unit (CPI clear) → not encrypted → false.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
// Fully decrypted (all packets carry their sync) → false.
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
// Fully ciphertext (no packet carries its sync) → true.
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
// skips the zero padding and sees every non-zero packet has its sync.
let mut tail = [b'P'; 32];
for k in tail.iter_mut().take(11) {
*k = b'S';
}
assert!(
!aacs_unit_still_ciphertext(&build(true, &tail)),
"a decrypted short padding-tail must NOT be flagged as ciphertext"
);
}
}