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:
+57
-1
@@ -174,7 +174,7 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
|
|||||||
/// syncs after decrypt, so the majority vote calls it "destroyed" and
|
/// syncs after decrypt, so the majority vote calls it "destroyed" and
|
||||||
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
|
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
|
||||||
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
|
/// 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).
|
/// 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
|
/// The correct, padding-aware notion of "still ciphertext", checkable on the
|
||||||
@@ -1442,4 +1442,60 @@ mod tests {
|
|||||||
// 6144 = 32 packets.
|
// 6144 = 32 packets.
|
||||||
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,10 +99,11 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
|||||||
unit_base: u32,
|
unit_base: u32,
|
||||||
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
|
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
|
||||||
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
|
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
|
||||||
/// clear nav-file is never corrupted), but for genuine encrypted content the
|
/// clear nav-file is never corrupted). On the mux read path
|
||||||
/// still-encrypted bytes are silently dropped by the downstream TS assembler
|
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
|
||||||
/// — real, unaccounted loss. Mux read paths share this counter into their
|
/// here (not silently dropped); on the rip path the read fails loud for
|
||||||
/// loss accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
|
/// 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
|
/// 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.
|
/// producer thread and the consuming `Stream` see the same tally.
|
||||||
///
|
///
|
||||||
@@ -579,7 +580,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
// the PADDING-AWARE test that matches `decrypt_unit`'s success
|
// the PADDING-AWARE test that matches `decrypt_unit`'s success
|
||||||
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
|
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
|
||||||
// A successfully padding-aware-decrypted content-fragment TAIL
|
// 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
|
// <16 TS syncs, so the majority vote would mis-flag it as
|
||||||
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
|
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
|
||||||
// padding-aware predicate excludes zero-payload (padding) packets
|
// padding-aware predicate excludes zero-payload (padding) packets
|
||||||
@@ -603,6 +604,29 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
bytes = dropped,
|
bytes = dropped,
|
||||||
"mux: undecryptable content concealed as NULL TS (loss tallied)"
|
"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);
|
return Ok(n);
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1451,7 @@ mod tests {
|
|||||||
|
|
||||||
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
|
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
|
||||||
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
|
/// 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`
|
/// 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
|
/// 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
|
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
|
||||||
|
|||||||
Reference in New Issue
Block a user