diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbbcf9..4281c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.4.2] — 2026-07-15 + +### Fixed + +- **Mux no longer nulls decryptable video or storms the key server on a + bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding + machinery in place. On a unit that a key *decrypted* but that did not + reassemble to clean MPEG-TS, the read path still restored the ciphertext, + tallied it as loss, re-asked the online key server (which returned the same + correct key, forever), and the mux concealed the unit as NULL TS. On a UHD + title with an authored bad-encoded run this stalled each region for 30–90 s per + unit — the key server brute-forcing and re-returning the one right key — while + nulling video that had, in fact, already decrypted. The root cause was one + conflation duplicated across several sites: *"did a key produce clean TS?"* was + treated as the verdict *"did we decrypt?"*. They are not the same — a correct + key can decrypt content whose underlying encoding is broken, and broken TS is a + muxer concern (the demuxer drops the packet and resyncs), never a decrypt + verdict. + +### Changed + +- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a + pure decrypt: it applies the CPS unit key to every encrypted unit in place, + leaves the plaintext, and reports how many bytes did not reach clean TS + ("unverified"). It never restores ciphertext, nulls, or re-fetches a key. + Clean TS is used only as a multi-key *selection* hint and a read *verify* + signal. The callers decide what an unverified unit means: + - **mux** (`read → decrypt → mux`): pass the decrypted bytes to the muxer, + whatever they are; the muxer handles bad TS. The mux never conceals, + re-fetches, or counts broken TS as loss — it fails loud only when it + genuinely cannot decrypt (no key / misaligned unit), since a mux over + already-captured data must otherwise always succeed. + - **sweep / patch** (reading from a disc): an unverified unit means the read + did not prove out; recover a fresh key and retry, or fail the read so the + disc-recovery path re-reads it. + + This removes three duplicated decisions — the decrypt-time ciphertext restore, + the mux NULL-TS conceal loop, and the per-unit key-server refetch — and the + dead `aacs_unit_still_ciphertext` predicate. The key-fetch recovery now samples + the on-disc ciphertext explicitly (a pure decrypt leaves the buffer plaintext) + and lives only on the rip/verify path, never the mux. + ## [1.4.1] — 2026-07-14 ### Fixed diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 056aeca..d541b90 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -167,16 +167,6 @@ pub fn unit_content_decrypted(unit: &[u8]) -> bool { content == 0 || synced * 4 >= content * 3 } -/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the -/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt -/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it -/// (below the supermajority-sync gate). A unit the right key opened — even one -/// carrying a few defective packets — is NOT ciphertext and is never concealed; -/// its bytes belong to the muxer. -pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool { - aacs_unit_encrypted(unit) && !unit_content_decrypted(unit) -} - /// 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. @@ -962,10 +952,6 @@ mod tests { "defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)" ); assert_eq!(unit[off + 5], 0xAB, "defect payload untouched"); - assert!( - !aacs_unit_still_ciphertext(&unit), - "an opened unit is NOT ciphertext -> the mux never conceals it" - ); for p in 0..32 { if p == 17 { continue; @@ -1009,23 +995,10 @@ mod tests { assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false))); } - #[test] - fn still_ciphertext_tracks_the_open_verdict() { - // Fully clean -> opened, not ciphertext. - assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true))); - // One defect -> still opened, still not ciphertext. - assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true))); - // Wrong-key noise floor -> not opened -> ciphertext (concealable). - assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true))); - // CPI-clear bytes are never "ciphertext" regardless of sync count. - assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false))); - } - #[test] fn all_padding_unit_is_trivially_opened() { // CPI set but every packet is source-zero padding: nothing to decrypt. assert!(unit_content_decrypted(&decrypted_shape(0, 0, true))); - assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true))); } #[test] @@ -1606,60 +1579,4 @@ 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/decrypt.rs b/src/decrypt.rs index 7094b86..ee6654c 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -180,14 +180,16 @@ impl DecryptKeys { /// Returns `Err` if decryption was expected but keys are missing or invalid. /// Never produces silently corrupted output. /// -/// On success returns the number of bytes belonging to scrambled AACS units -/// that **no available key could decrypt** — those units are restored to their -/// original encrypted bytes (so a clear nav-file is never corrupted), but for -/// genuine encrypted content this is silent data loss the downstream TS -/// assembler will drop without a sync. The decrypt-on-read decorator folds this -/// count into the mux loss accounting so a partial key failure can't be reported -/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every -/// scrambled unit decrypted. +/// Pure decrypt: every encrypted unit has a key APPLIED in place and the +/// plaintext is left as-is — this function applies NO policy (it never restores +/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes +/// belonging to units a key was applied to but that did NOT reassemble to clean +/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION / +/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can +/// decrypt content whose encoding is broken. The caller decides what an +/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch +/// verify path recovers a key and retries, or fails the read. `0` for `None` / +/// `Css` and for any AACS buffer where every unit reached clean TS. pub fn decrypt_sectors( buf: &mut [u8], keys: &mut DecryptKeys, @@ -320,27 +322,31 @@ fn decrypt_sectors_impl( // accounting so a partial key failure isn't reported as a clean rip. let dropped_bytes = AtomicUsize::new(0); - // Per-unit decrypt closure. For a scrambled full aligned unit: + // Per-unit PURE decrypt closure. For a scrambled full aligned unit: // 1. Try the cached key index first (avoids scanning all keys on the // common case where a disc run uses one CPS unit throughout). // 2. On miss, try every key in order (multi-CPS-unit discs). - // 3. Accept the first key whose output passes the TS-sync verify. - // 4. Only restore-to-original if NO key validates (non-m2ts unit or - // genuine decrypt failure). See test - // `nav_file_unit_survives_decrypt_attempt`. + // 3. Select the first key whose output passes the TS-sync verify. + // 4. If NONE yields clean TS, keep the applied-key plaintext anyway + // (a key WAS applied — bad TS is the caller's/muxer's concern) and + // tally the unit as unverified. Never restore ciphertext / null. + // Nav protection is the caller's content gate, not a restore here. // // If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt // must happen first — it's a shared layer on top that is key-independent // across all CPS units on the disc. let decrypt_one = |chunk: &mut [u8]| { + // Gate on `aacs_unit_needs_decrypt` (CPI set AND TS syncs not yet + // restored): CPI alone isn't enough because the plaintext seed keeps + // the CPI bit set after decryption, so an already-decrypted unit would + // be decrypted a SECOND time (scrambling it) on any re-run of this + // pass. The intact-TS half makes it idempotent. if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) { return; } - // Save original bytes so we can restore if no key validates. - let original: Vec = chunk.to_vec(); - // Build a bus-decrypted copy to try unit keys against, or work - // in-place when there is no bus layer. + // Bus-decrypt (AACS 2.0) in place first — a shared layer under every + // CPS unit key. Whatever we do below operates on the bus-clear bytes. if let Some(ref rdk_key) = rdk { aacs::content::decrypt_bus(chunk, rdk_key); } @@ -351,6 +357,17 @@ fn decrypt_sectors_impl( let try_order = std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint)); + // DECRYPT the unit — apply a key, leave the plaintext. "Did a key + // produce clean TS?" is NOT "did we decrypt?": a correct key can + // decrypt content whose underlying encoding is broken (bad TS sync), + // which is a MUXER concern, never a decrypt verdict. Clean TS is used + // ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first + // key that yields clean TS is the definite match. When none does we + // STILL decrypted (the cached-hint key is applied): keep those bytes + // and report the unit as UNVERIFIED. This function applies no policy; + // the caller decides what an unverified unit means (the mux passes it + // to the muxer; sweep/patch treat it as a read to recover or fail). + let mut applied: Option> = None; for idx in try_order { if let Some(key) = raw_keys.get(idx) { // Work on a per-key copy so a failing attempt doesn't @@ -361,17 +378,19 @@ fn decrypt_sectors_impl( last_key_idx.store(idx, Ordering::Relaxed); return; } + if applied.is_none() { + applied = Some(attempt); + } } } - // No key validated — restore the original encrypted bytes and - // tally the loss. The unit is flagged encrypted (we only reach - // here past the CPI gate) but no key applied: genuine encrypted - // content with a missing/wrong sub-key. We always tally; the mux - // read path treats - // the count as loss (its extents are real content), while - // metadata-probe callers that don't install a loss sink ignore it. - chunk.copy_from_slice(&original); + // No key yielded clean TS. Keep the applied-key plaintext (the pool is + // non-empty past the guard, so `applied` is always `Some`) and tally + // the unit as unverified. Never restore ciphertext; that is a caller + // concern, threaded through the recovery ciphertext, not this seam. + if let Some(decrypted) = applied { + chunk.copy_from_slice(&decrypted); + } dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed); }; @@ -442,13 +461,15 @@ fn decrypt_sectors_impl( mod tests { use super::*; - /// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here - /// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled - /// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails - /// the TS-sync verification, and must be restored to its original bytes — - /// not left scrambled. + /// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts + /// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally + /// sets the CPI bits, so it reads as encrypted) must never be scrambled by a + /// decrypt attempt. The decrypter applies NO policy and no longer restores — so + /// nav protection is the CALLER's content gate: a real read (sweep/patch) is + /// content-gated, and every whole-disc caller passes the encrypted-content + /// extents so nav LBAs are skipped entirely and left untouched. #[test] - fn nav_file_unit_survives_decrypt_attempt() { + fn nav_file_unit_survives_when_gated_out_of_content() { let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN]; unit[0] = b'M'; unit[1] = b'P'; @@ -463,10 +484,12 @@ mod tests { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; - decrypt_sectors(&mut unit, &mut keys, 0).unwrap(); + // The unit sits at LBA 0..3; the content extents are elsewhere (100..110), + // so this nav unit is OUTSIDE content and the gate skips it untouched. + decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap(); assert_eq!( unit, snapshot, - "non-m2ts unit must be restored after failed decrypt" + "a nav unit outside the content extents must be left untouched by the gate" ); } @@ -1290,21 +1313,18 @@ mod tests { ); } - /// Regression for the silent partial-decrypt-loss defect: a scrambled AACS - /// unit that NO supplied key can decrypt is restored to its original - /// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors` - /// returns the unit's byte length as the dropped count. Before the fix this - /// returned `()` and the still-encrypted bytes flowed downstream to be - /// silently dropped by the TS assembler with zero loss accounting — a rip - /// missing real content reported `lost_video_secs=0` and passed the abort - /// gate even under `abort_on_lost_secs=0`. + /// A unit no supplied key opens to clean TS is still DECRYPTED in place (the + /// key is applied — decryption ran; a broken result is bad data, not a decrypt + /// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns + /// the unit's byte length as the UNVERIFIED count — the read-verify signal the + /// sweep/patch caller consumes (the mux ignores it and passes the bytes to the + /// muxer). This is the single decrypt authority applying no policy. /// - /// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the - /// no-key-validated restore path; the function returns that tally. - /// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0, - /// this fails. + /// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and + /// the removal of the `copy_from_slice(&original)` restore. + /// Mutation: re-add the restore → `buf == ciphertext`, this fails. #[test] - fn aacs_undecryptable_unit_reports_dropped_bytes() { + fn aacs_undecryptable_unit_is_decrypted_not_restored() { let real_key = [0x33u8; 16]; let wrong_key = [0x44u8; 16]; // not the encrypting key @@ -1322,17 +1342,17 @@ mod tests { read_data_key: None, }; let mut buf = unit; - let dropped = decrypt_sectors(&mut buf, &mut keys, 0) - .expect("undecryptable unit is not a hard error"); + let unverified = + decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error"); assert_eq!( - dropped, + unverified, aacs::content::ALIGNED_UNIT_LEN, - "the whole scrambled unit must be reported as dropped when no key validates" + "a unit that did not reach clean TS is reported unverified" ); - assert_eq!( + assert_ne!( buf, ciphertext, - "an undecryptable unit must be restored to its original ciphertext, not garbled" + "the unit must be DECRYPTED in place (key applied), never restored to ciphertext" ); } @@ -1369,16 +1389,16 @@ mod tests { assert_eq!( dropped, aacs::content::ALIGNED_UNIT_LEN, - "exactly one unit's worth of bytes must be reported dropped" + "exactly one unit's worth of bytes must be reported unverified" ); assert!( !aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]), "the decryptable unit must come out clear" ); - assert_eq!( + assert_ne!( &buf[aacs::content::ALIGNED_UNIT_LEN..], &unit_b_ciphertext[..], - "the undecryptable unit must be restored to ciphertext" + "the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext" ); } diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 721d9e1..63ecceb 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -237,10 +237,11 @@ impl DiscStream { // CSS/unencrypted content needs a decrypting wrapper to yield plaintext // VOB bytes before the AC-3 sub-stream probe can read real `acmod`s. - // MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS - // fill) + tally + log rather than abort the stream (P3). DiscStream is a - // decode/mux stream (live-drive single-pass / direct), never the - // ciphertext-preserving sweep, so concealment is always correct here. + // MUX path (read > decrypt > mux): decrypt every unit in place and pass the + // bytes to the muxer; a unit that decrypts to broken TS is the muxer's + // concern, never conceal / re-fetch / count as loss (fail loud only on a + // genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive + // single-pass / direct), never the ciphertext-preserving sweep. let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss(); diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 050a874..26edd6c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -645,26 +645,20 @@ pub fn build_iso_pipeline( crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 1, }; - // MUX path: tolerate decrypt loss. An undecryptable content unit is concealed - // (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP - // gate, not a mux gate (P3). The rip's own read paths keep their fail-loud - // decorator; only this mux pipeline opts in. + // MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and + // passes the bytes to the muxer; a unit that decrypts to broken TS is the + // muxer's problem, not a decrypt failure, so the mux never conceals, re-fetches + // a key, or counts it as loss — it fails only when it genuinely can't decrypt + // (no key / misaligned unit). The `fetch` key-recovery seam is a rip/verify + // concern (Disc::sweep / Disc::patch), deliberately NOT installed on the mux: + // key recovery happens up front, and the mux never re-asks mid-stream. let mut decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys) .tolerate_decrypt_loss(); - // Install the fresh-key-on-failure callback (if any) so a unit no held key - // decrypts is re-tried via the application's key source before being counted - // as loss. An AACS 2.1 forensic-segment unit that no key opens is just an - // undecryptable unit like any other: concealed and counted as decrypt loss — - // a loss is a loss, no FMTS special casing. - if let Some(cb) = fetch { - decrypting = decrypting.with_key_fetch(cb); - } - // Grab the loss counters before the decorator is moved into the producer - // thread. It tracks bytes of scrambled AACS units no key could decrypt — - // silent loss the demux drops; the consuming stream surfaces it through - // `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather - // than a clean rip. Forensic (2.1) undecryptable units land here too. + let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it + // Loss counter: the mux does not tally broken-TS units (the muxer handles them), + // so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the + // abort gate, which now reflects only a genuine can't-decrypt. let decrypt_loss = decrypting.decrypt_loss(); // Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 8a3d4a9..b190e1e 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -87,15 +87,14 @@ pub struct DecryptingSectorSource { /// /// [`set_unit_base`]: Self::set_unit_base 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). 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. + /// Cumulative bytes of AACS units that did not reassemble to clean TS on the + /// VERIFY paths (sweep / patch / --no-raw verify). `decrypt_sectors` is a pure + /// decrypt (it leaves the applied-key plaintext and never restores/nulls), so + /// this counter is populated only on the fail-loud verify path — the mux path + /// returns pass-through before tallying, treating broken TS as a muxer concern + /// rather than decrypt loss. Where it is populated it feeds [`decrypt_loss`] + /// so a partial verify failure can't be reported as a perfect rip. Shared `Arc` + /// so the highway's producer thread and the consuming `Stream` see one tally. /// /// [`decrypt_loss`]: Self::decrypt_loss decrypt_dropped: Arc, @@ -125,16 +124,16 @@ pub struct DecryptingSectorSource { /// Reused scratch buffer for verify-only decrypt checks — avoids a per-read /// allocation on the sweep's hot path. Grown on demand, never shrunk. scratch: Vec, - /// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit - /// that genuinely won't decrypt is NOT a read failure: it is overwritten with - /// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into - /// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and - /// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an - /// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not - /// a MUX gate": the rip path leaves this `false` (default) and fails loud via - /// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only - /// the mux read path opts in. Ciphertext is NEVER passed downstream either - /// way — fail-loud re-reads it, conceal replaces it with null packets. + /// MUX pass-through switch (read > decrypt > mux). When `true`, every + /// encrypted unit is decrypted in place and the bytes pass straight to the + /// muxer — a unit that decrypts to broken TS is the muxer's concern, so the mux + /// never conceals, re-fetches a key, or counts it as loss, and the read returns + /// `Ok` (it can never abort over a bad-encoded unit). It hard-fails only on a + /// genuine can't-decrypt (no key / misaligned unit), which surfaces as `Err`. + /// This is "decrypt-verify is a RIP gate, not a MUX gate": the rip/verify path + /// leaves this `false` (default) and fails loud via [`DECRYPT_VERIFY_READ`] so + /// its read-error recovery re-reads the disc. Ciphertext is NEVER passed + /// downstream either way — with a keyed disc every unit has a key applied. /// /// Mutually meaningful only with `!verify_only` (the in-place decrypt path /// the mux uses); a verify-only sweep keeps the rip's fail-loud contract. @@ -165,11 +164,11 @@ impl DecryptingSectorSource { } } - /// Opt into MUX loss-concealment: an undecryptable content unit is concealed - /// (filled with NULL TS packets), tallied, logged loud, and the read still - /// succeeds — the mux never aborts over it. See - /// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must - /// NOT set this (it relies on fail-loud read-error recovery). + /// Opt into the MUX pass-through policy (read > decrypt > mux): decrypt every + /// unit in place and pass the bytes to the muxer, never conceal / re-fetch / + /// count broken TS as loss; fail loud only on a genuine can't-decrypt. See the + /// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss) field. The rip/verify + /// path must NOT set this (it relies on fail-loud read-error recovery). pub fn tolerate_decrypt_loss(mut self) -> Self { self.tolerate_decrypt_loss = true; self @@ -428,6 +427,29 @@ impl SectorSource for DecryptingSectorSource { // recovery closure self-limits (its budget lives in its captures), so the // decorator simply calls it whenever there is a miss. let unit_key_idx = self.unit_key_idx; + + // ── MUX: read > decrypt > mux ────────────────────────────────────────── + // `tolerate_decrypt_loss` (and not verify_only) is the mux path: it is fed + // already-captured data and reads only content extents. `decrypt_buf` + // applies the CPS unit key to every encrypted unit IN PLACE; the resulting + // bytes — clean MPEG-TS or a bad-encoded region — belong to the muxer, which + // drops broken packets and resyncs. TS validity is NOT a decrypt verdict, so + // the mux never conceals, never re-fetches a key, and never counts broken TS + // as loss. Its ONLY failure is a genuine can't-decrypt (no key for a unit, or + // a misaligned unit), which `decrypt_buf` surfaces as `Err` — propagate it + // (fail loud), because a mux over captured data must otherwise always succeed. + if self.tolerate_decrypt_loss && !self.verify_only { + Self::decrypt_buf( + &mut buf[..n], + &mut self.keys, + self.unit_key_idx, + lba, + content_ref, + )?; + return Ok(n); + } + + // ── SWEEP / PATCH / VERIFY: decrypt to verify the disc read ───────────── // First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on // fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes: // * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf` @@ -468,12 +490,23 @@ impl SectorSource for DecryptingSectorSource { content: content.clone(), prev_dropped: d, }; - r(&mut scratch, &mut self.keys, &rctx) + // Target = the decrypted scratch; ciphertext = the untouched + // `buf` (verify-only keeps the ISO encrypted). + r(&mut scratch, &buf[..n], &mut self.keys, &rctx) } }; self.scratch = scratch; o } else { + // In-place decrypt (decrypt-sweep / decrypt-patch). Pure decrypt + // overwrites `buf` with plaintext, so keep the on-disc ciphertext for + // the recovery retry BEFORE decrypting — but only when recovery is + // installed (the retry's sole consumer). + let ciphertext: Option> = if self.recovery.is_some() { + Some(buf[..n].to_vec()) + } else { + None + }; let d = Self::decrypt_buf( &mut buf[..n], &mut self.keys, @@ -490,7 +523,10 @@ impl SectorSource for DecryptingSectorSource { content: content.clone(), prev_dropped: d, }; - r(&mut buf[..n], &mut self.keys, &rctx) + let cipher = ciphertext + .as_deref() + .expect("recovery installed → captured"); + r(&mut buf[..n], cipher, &mut self.keys, &rctx) } } }; @@ -501,80 +537,9 @@ impl SectorSource for DecryptingSectorSource { if dropped > 0 { self.decrypt_dropped .fetch_add(dropped as u64, Ordering::Relaxed); - // MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable - // content unit is NOT a read failure — never abort the mux over it. - // Overwrite each still-scrambled in-content unit with valid NULL TS - // packets (A2: keeps the demuxer byte-synced; the lost video/audio PID - // packets surface as a CC gap the TS assembler already drops a partial - // PES on), tally it (done above), log it LOUD with the LBA, and return - // Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the - // rip stays fail-loud. Ciphertext is never passed downstream: it is - // replaced by null packets, not emitted. - if self.tolerate_decrypt_loss && !self.verify_only { - let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN; - let mut concealed = 0usize; - let mut first_lba = lba; - for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() { - if chunk.len() < unit_len { - continue; // trailing partial can't be a whole scrambled unit - } - // 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 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 - // 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::content::aacs_unit_still_ciphertext(chunk) { - if concealed == 0 { - first_lba = - lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS; - } - crate::aacs::content::fill_null_ts_unit(chunk); - concealed += 1; - } - } - if concealed > 0 { - tracing::warn!( - target: "freemkv::decrypt", - lba = first_lba, - units = concealed, - 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::content::aacs_unit_needs_decrypt(chunk) - { - crate::aacs::content::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); - } + // The mux path already returned above (read > decrypt > mux); only the + // verify callers (sweep / patch / --no-raw verify) reach here. An + // unverified unit means this disc read did NOT prove out. // DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't // means this read did NOT truly succeed — it returned ciphertext the // TS assembler would silently drop. Fail the read loud so the caller's @@ -593,10 +558,10 @@ impl SectorSource for DecryptingSectorSource { // does any held key get it closer to clear TS?) plus the inner // read latency are ground truth about the SOURCE — enough to // classify the failure as source-zeros, marginal-media garbage, - // or a clean read no held key opens. In verify-only mode `buf` - // is untouched ciphertext (every unit looks scrambled), so the - // post-decrypt `scratch` is what distinguishes failed units - // (restored to ciphertext) from succeeded ones (now plaintext). + // or a clean read no held key opens. In verify-only mode `buf` is + // untouched ciphertext, so the post-decrypt `scratch` is what + // distinguishes failed units (still TS-destroyed) from succeeded + // ones (now clean TS). let diag: &[u8] = if self.verify_only { &self.scratch } else { @@ -1313,13 +1278,13 @@ mod tests { ); } - /// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS - /// content unit must NOT fail the read. Instead the decorator (a) tallies the - /// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync - /// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going. - /// This is the inverse of the fail-loud rip path proven directly above. + /// MUX (read > decrypt > mux): with `tolerate_decrypt_loss()` an undecryptable + /// AACS content unit must NOT fail the read and must NOT be nulled. The best key + /// is applied and the (bad) bytes pass through to the muxer; broken TS is a + /// muxer concern, so it is NOT counted as decrypt loss. The mux only hard-fails + /// on a genuine can't-decrypt (no key at all / misaligned unit). #[test] - fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() { + fn tolerate_decrypt_loss_passes_undecryptable_unit_through() { let real_key = [0x33u8; 16]; let wrong_key = [0x44u8; 16]; @@ -1369,29 +1334,23 @@ mod tests { // Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt. let n = wrapped .read_sectors(0, 6, &mut buf, false) - .expect("tolerate_decrypt_loss must conceal, not error"); + .expect("the mux never aborts on a bad-decrypt unit"); assert_eq!(n, 6 * 2048); - // The undecryptable unit is tallied as loss. + // Broken TS is a muxer concern, not decrypt loss — the mux counts none. assert_eq!( loss.load(Ordering::Relaxed), - crate::aacs::content::ALIGNED_UNIT_LEN as u64, - "the concealed unit is still counted as loss" + 0, + "the mux does not count broken TS as decrypt loss" ); - // Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte - // stride (offset 4), PID 0x1FFF — and carries no ciphertext. + // Unit 0 is passed through DECRYPTED (the wrong key was applied), NOT + // null-TS concealed: it is not the all-0x47/PID-0x1FFF null pattern. let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN]; - let mut off = 0; - while off + 192 <= unit0.len() { - assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}"); - assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF"); - assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF"); - off += 192; - } + let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF); assert!( - !crate::aacs::content::ts_sync_destroyed(unit0), - "concealed unit reads as well-formed TS, not scrambled" + !all_null, + "the undecryptable unit is passed through, never null-TS concealed" ); // Unit 1 (clear) passed through untouched. @@ -1400,17 +1359,14 @@ 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 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 - /// 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. + /// MUX pass-through, mixed buffer: a unit the pool CAN decrypt (a + /// content-fragment TAIL — a few real packets + source-zero padding, the 1.2.0 + /// shape, <16 TS syncs) comes out byte-for-byte correct, and a unit it CANNOT + /// (encrypted under an absent key) is passed through best-effort — never + /// null-TS filled, never counted as loss. The old path nulled the good tail + /// (silent data loss) whenever it shared a buffer with an undecryptable unit. #[test] - fn conceal_leaves_decrypted_padding_tail_unit_intact() { + fn mux_passes_both_decryptable_and_undecryptable_units_through() { let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided) let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided) @@ -1471,38 +1427,31 @@ mod tests { 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"); + .expect("the mux never aborts on a bad-decrypt unit"); assert_eq!(n, 6 * 2048); - // ONLY the genuinely-undecryptable unit A is tallied / concealed. + // Broken TS is a muxer concern — the mux counts no loss. assert_eq!( loss.load(Ordering::Relaxed), - crate::aacs::content::ALIGNED_UNIT_LEN as u64, - "exactly one unit (the undecryptable one) is counted as loss" + 0, + "the mux does not count broken TS as decrypt loss" ); - // Unit A → NULL TS (concealed). + // Unit A (absent key) → passed through best-effort, NOT null-TS concealed. let unit0 = &buf[..crate::aacs::content::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; - } + let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF); + assert!( + !all_null, + "the undecryptable unit is passed through, never null-TS concealed" + ); - // 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. + // Unit B → the GOOD decrypted padding tail, byte-for-byte intact. let unit1 = &buf [crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN]; assert_eq!( unit1, &expected_tail[..], - "the decrypted padding-tail unit must be left byte-for-byte intact" + "the decryptable padding-tail unit comes out byte-for-byte correct" ); // Sanity: its real content packets carry their TS sync; its padding is zero. for p in 0..KEEP { diff --git a/src/sector/recovery.rs b/src/sector/recovery.rs index b5375b0..a2c02b9 100644 --- a/src/sector/recovery.rs +++ b/src/sector/recovery.rs @@ -103,25 +103,30 @@ pub struct RecoverCtx { pub prev_dropped: usize, } -/// A recovery: given a read's still-scrambled `buf` and the **generic** -/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or -/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme -/// — the installed recovery decides what to do with the generic keys, so any -/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different -/// [`Recover`] the input stream installs. `FnMut` so per-recovery -/// state (the AACS dedup set / call budget) lives in the closure's captures with -/// no lock; `Send` so it can ride the mux highway's producer thread. -pub type Recover = Box MissOutcome + Send>; +/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the +/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic** +/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or +/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place +/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it. +/// `ciphertext` is separate from `target` because a pure decrypt overwrites the +/// target with plaintext — the key server still needs the original on-disc bytes, +/// and the retry re-decrypts from them. The type names NO encryption scheme; any +/// scheme is just a different [`Recover`] the input stream installs. `FnMut` so +/// per-recovery state (dedup set / call budget) lives in the closure's captures; +/// `Send` so it can ride the mux highway's producer thread. +pub type Recover = + Box MissOutcome + Send>; -/// The AACS key-fetch step used by [`key_fetch`]: gather the -/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and -/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget. -/// Returns the post-retry dropped-byte count. +/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did +/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt. +/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the +/// post-retry unverified-byte count. fn aacs_fetch_step( dry: &mut HashSet, calls: &mut usize, fetch: &KeyFetch, - buf: &mut [u8], + target: &mut [u8], + ciphertext: &[u8], keys: &mut DecryptKeys, ctx: &RecoverCtx, ) -> usize { @@ -130,14 +135,19 @@ fn aacs_fetch_step( return prev_dropped; } let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN; - // Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact - // on-disc ciphertext no held key could open. A trailing partial unit + // Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect + // them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened + // one is now clean TS and is skipped), but SAMPLE the matching on-disc + // `ciphertext` — the exact bytes the key server needs. A trailing partial unit // (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is // correct. let mut samples: Vec> = Vec::new(); - for chunk in buf.chunks_exact(unit_len) { - if crate::aacs::content::aacs_unit_needs_decrypt(chunk) { - samples.push(chunk.to_vec()); + for (t, c) in target + .chunks_exact(unit_len) + .zip(ciphertext.chunks_exact(unit_len)) + { + if crate::aacs::content::aacs_unit_needs_decrypt(t) { + samples.push(c.to_vec()); if samples.len() >= MAX_FETCH_SAMPLES { break; } @@ -172,10 +182,13 @@ fn aacs_fetch_step( dry.extend(fps); return prev_dropped; } - // Retry now that the pool has grown; a unit that still won't decrypt is - // genuine loss. A retry error must not mask the original count. + // Retry now that the pool has grown. Reset the target to the on-disc + // ciphertext first (a pure decrypt already overwrote it with the failed + // plaintext), then re-run the ONE decrypt. A unit that still won't reach clean + // TS stays unverified; a retry error must not mask the original count. + target.copy_from_slice(ciphertext); redecrypt( - buf, + target, keys, ctx.unit_key_idx, ctx.lba, @@ -187,7 +200,7 @@ fn aacs_fetch_step( /// No recovery: a miss is loss. Equivalent to installing nothing — provided so a /// caller that wants an explicit "give up" recovery has one. pub fn none() -> Recover { - Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped)) + Box::new(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped)) } /// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key @@ -195,9 +208,9 @@ pub fn none() -> Recover { pub fn key_fetch(fetch: KeyFetch) -> Recover { let mut dry: HashSet = HashSet::new(); let mut calls: usize = 0; - Box::new(move |buf, keys, ctx| { + Box::new(move |target, ciphertext, keys, ctx| { MissOutcome::loss(aacs_fetch_step( - &mut dry, &mut calls, &fetch, buf, keys, ctx, + &mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx, )) }) } @@ -244,7 +257,8 @@ mod tests { unit_keys: vec![], read_data_key: None, }; - let out = r(&mut buf, &mut keys, &ctx(0, 6144)); + let cipher = buf.clone(); + let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144)); assert_eq!(out.dropped, 6144); } @@ -266,7 +280,8 @@ mod tests { unit_keys: vec![], read_data_key: None, }; - r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); + let cipher = buf.clone(); + r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once"); let DecryptKeys::Aacs { unit_keys, .. } = &keys else { unreachable!() @@ -291,9 +306,11 @@ mod tests { read_data_key: None, }; let mut buf = scrambled_unit(0x44); - r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); + let cipher = buf.clone(); + r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); let mut buf2 = scrambled_unit(0x44); // identical ciphertext - r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); + let cipher2 = buf2.clone(); + r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); assert_eq!( calls.load(Ordering::SeqCst), 1, @@ -319,7 +336,8 @@ mod tests { // so the decorator can call it unconditionally. for i in 0..(MAX_FETCH_CALLS as u8 + 5) { let mut buf = scrambled_unit(i); - r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); + let cipher = buf.clone(); + r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); } assert_eq!( calls.load(Ordering::SeqCst),