diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 1116270..e28090f 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -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" + ); + } } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 58af3cd..bf29edc 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -99,10 +99,11 @@ pub struct DecryptingSectorSource { unit_base: u32, /// 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 + /// clear nav-file is never corrupted). On the mux read path + /// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied + /// here (not silently dropped); on the rip path the read fails loud for + /// re-read. Either way this counter is the loss signal: mux read paths fold it + /// into their 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. /// @@ -579,7 +580,7 @@ impl SectorSource for DecryptingSectorSource { // 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 + // (the 1.2.0 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 @@ -603,6 +604,29 @@ impl SectorSource for DecryptingSectorSource { bytes = dropped, "mux: undecryptable content concealed as NULL TS (loss tallied)" ); + } else { + // dropped > 0 (decrypt reported undecryptable bytes) yet the + // padding-aware predicate matched NOTHING to conceal — a + // contradiction: the only way a genuinely-ciphertext unit passes + // `aacs_unit_still_ciphertext` is if every one of its non-zero + // packets coincidentally carried a 0x47 (~256^-31). Belt-and- + // suspenders so ciphertext can NEVER reach the mux: fall back to + // the strict majority-vote predicate and conceal whatever it + // flags, loudly. Cryptographically unreachable in practice. + let mut forced = 0usize; + for chunk in buf[..n].chunks_mut(unit_len) { + if chunk.len() == unit_len && crate::aacs::aacs_unit_needs_decrypt(chunk) { + crate::aacs::fill_null_ts_unit(chunk); + forced += 1; + } + } + tracing::warn!( + target: "freemkv::decrypt", + lba, + bytes = dropped, + forced, + "mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)" + ); } return Ok(n); } @@ -1427,7 +1451,7 @@ mod tests { /// 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 + /// content packets + source-zero padding — the 1.2.0 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