From a7bd574c34686b4667e9b7ffec20cba9252a2b50 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:03:52 -0700 Subject: [PATCH] verify: post-read decrypt-verify gate + libaacs-strict verify + audit fixes Post-read verify gate (new src/disc/verify.rs): UnitVerifier buffers/aligns the disc-absolute read stream into clip-file 6144-byte units, then makes one decryptability() decision per unit (CPI gate -> held keys -> key_fetch -> strict TS). POST_READ_VERIFY const kill-switch; fail-safe contract (only ever downgrades units it is confident are undecryptable; every doubt skips). Hooked into Disc::sweep (producer observes ciphertext -> WorkItem::MarkBad after the Good, FIFO-ordered) and Disc::patch (post-loop reverify_iso reads recovered units whole from the patched ISO). extract::clip_layouts enumerates AACS clips for the gate. Standards-correct AACS verify: aacs::unit_is_clean_ts is a strict port of libaacs _verify_ts (all 32 TS syncs, not a majority vote); decrypt_unit accepts a key only on it; the majority verify_ts is removed. Deleted the Disc::verify_clips post-pass bolt-on (its primitive is absorbed by the read-path gate). libaacs/DVD audit fixes: content-cert bus_encryption flag now read from bit 7 (was bit 0 - defeated the bus-key fail-loud gate); cc_id read from offset 14; title_cps_unit range-validated + 1->0 index-converted per libaacs. Corrected attack_crib ("functionally-equivalent" not "exact" port) and read_disc_key (READ DVD STRUCTURE 0xAD, not REPORT KEY) doc comments. Also includes accumulated uncommitted work: key-fetch seam and TrueHD/DTS audio fix. --- src/aacs/decrypt.rs | 252 +++++++-- src/aacs/keys.rs | 88 ++-- src/aacs/mod.rs | 9 +- src/css/auth.rs | 9 +- src/css/stevenson.rs | 4 +- src/decrypt.rs | 360 ++++++++++++- src/disc/extract.rs | 97 +++- src/disc/mod.rs | 248 ++++++++- src/disc/patch.rs | 88 +++- src/disc/sweep.rs | 14 + src/disc/verify.rs | 867 ++++++++++++++++++++++++++++++ src/keysource.rs | 167 +++++- src/lib.rs | 4 +- src/mux/codec/truehd.rs | 92 +++- src/mux/disc.rs | 2 +- src/mux/resolve.rs | 48 +- src/sector/decrypting.rs | 909 +++++++++++++++++++++++++++++++- src/sector/mod.rs | 2 +- src/udf.rs | 6 +- tests/crypto_tests.rs | 31 +- tests/fvi_pipeline.rs | 1 + tests/pass_n_patch_fix.rs | 6 +- tests/pass_n_size_aware_skip.rs | 1 + 23 files changed, 3098 insertions(+), 207 deletions(-) create mode 100644 src/disc/verify.rs diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 7b6eeae..d4385ff 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -101,24 +101,68 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { // ── Content decryption ────────────────────────────────────────────────────── -/// True if a 6144-byte aligned unit is AACS-scrambled on disc. +/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it +/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196, +/// 388, … (one per 192-byte source packet). /// -/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`) -/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source -/// packet). So "scrambled" = "the TS syncs are NOT intact". This is -/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) -/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably -/// across discs/players. +/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A +/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR +/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are +/// byte-indistinguishable. So this answers only *"does this unit look like valid +/// clear TS, or not"*, nothing about encryption. /// -/// This is the single shared definition of "encrypted" for the whole ecosystem -/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key -/// service's validation gate all call THIS, so they always agree on what is -/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`, -/// so the decrypt path never double-decrypts and there is no flag to clear. -pub fn is_aacs_scrambled(unit: &[u8]) -> bool { +/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by +/// the caller, because it needs context this function lacks: +/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts` +/// (see [`crate::decrypt::decrypt_sectors_in_content`] and +/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this +/// primitive separates an encrypted/garbled unit (destroyed) from a clear +/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem +/// bytes is what produced the first-2 GB false-positive this split fixes. +/// +/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or +/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably. +pub fn ts_sync_destroyed(unit: &[u8]) -> bool { unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit) } +/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy +/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first +/// byte of the first source packet's `TP_extra_header`, which AACS always leaves +/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So +/// this is readable WITHOUT a key: +/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through. +/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt. +/// +/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`) +/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte +/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct +/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A +/// disc-absolute / mis-aligned read makes byte 0 arbitrary mid-stream data, so +/// the CPI bits are meaningless (which is precisely why per-unit verify must run +/// clip-anchored, not in the whole-disc sweep). +pub fn aacs_unit_encrypted(unit: &[u8]) -> bool { + unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0 +} + +/// True when an aligned unit is flagged encrypted (CPI set) AND still looks +/// scrambled (TS syncs not yet restored) — i.e. genuine encrypted content that +/// has NOT been decrypted yet. +/// +/// [`aacs_unit_encrypted`] is the authoritative spec gate, but the CPI bits live +/// in the plaintext header (bytes `0..16`) which decryption never rewrites, so a +/// successfully decrypted unit still reports CPI-set. Buffer-iterating sites that +/// may run twice over the same `buf` (the post-fetch re-decrypt, sample +/// collection, failure diagnosis) need an IDEMPOTENT "does this still need work?" +/// test, so they compose CPI with the sync-restored check: once a unit decrypts, +/// its syncs come back and it drops out. Single-shot callers that always operate +/// on fresh ciphertext (`decrypt_unit`) gate on [`aacs_unit_encrypted`] alone. +/// +/// Like CPI itself this is only meaningful at the clip-FILE-anchored boundary. +pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool { + aacs_unit_encrypted(unit) && ts_sync_destroyed(unit) +} + /// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride /// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte /// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per @@ -148,9 +192,29 @@ fn ts_syncs_intact(unit: &[u8]) -> bool { ts_sync_count(unit) > ts_packet_total(unit) / 2 } -/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact). -fn verify_ts(unit: &[u8]) -> bool { - ts_syncs_intact(unit) +/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check — +/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source +/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first +/// miss fails. This is the authoritative gate for the POST-READ verify stage, +/// independent of (and not coupled to) `decrypt_unit`. +/// +/// It is deliberately stricter than the majority-vote `ts_syncs_intact` +/// scramble *heuristic*: a wrong-key decrypt that coincidentally restores >16 +/// syncs passes the majority test and would silently corrupt content, but fails +/// here. Non-mutating — purely a verdict; it neither decrypts nor clears the CPI +/// bits (those stay the concern of the unchanged `decrypt_unit`). +pub fn unit_is_clean_ts(unit: &[u8]) -> bool { + if unit.len() < ALIGNED_UNIT_LEN { + return false; + } + let mut i = 0; + while i < ALIGNED_UNIT_LEN { + if unit[i + 4] != TS_SYNC { + return false; + } + i += BD_SOURCE_PACKET_BYTES; + } + true } /// Decrypt one AACS aligned unit (6144 bytes) in-place. @@ -170,8 +234,8 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { if unit.len() < ALIGNED_UNIT_LEN { return false; } - if !is_aacs_scrambled(unit) { - return true; // not encrypted + if !aacs_unit_encrypted(unit) { + return true; // CPI flag clear → plaintext, pass through untouched } // Save original first 16 bytes (they're plaintext TP_extra_header) @@ -190,8 +254,11 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { // Step 3: Decrypt bytes 16..6143 with AES-CBC aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - // Decryption restored the TS syncs; verify the unit now looks like clear TS. - verify_ts(unit) + // Decryption restored the TS syncs; accept the key only if the unit is now + // STRICTLY clean MPEG-TS (all 32 syncs) — the standards-correct gate, shared + // with the post-read verify stage. A wrong key that coincidentally restores + // a majority of syncs is rejected here, not silently accepted. + unit_is_clean_ts(unit) } /// Fast, NON-MUTATING unit-key validation for the brute-force key search. @@ -206,7 +273,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { /// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit /// identical to the slow path. /// -/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit +/// The caller MUST pass an aligned, already-[`ts_sync_destroyed`] unit /// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the /// per-candidate scramble re-scan is intentionally skipped here. /// @@ -272,7 +339,7 @@ pub enum UnitKeyResult { /// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or /// `None` if no key worked (the unit is restored to its original bytes). pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option { - if !is_aacs_scrambled(unit) { + if !aacs_unit_encrypted(unit) { return Some(UnitKeyResult::AlreadyClear); } @@ -314,7 +381,7 @@ pub fn decrypt_unit_full( unit_key: &[u8; 16], read_data_key: Option<&[u8; 16]>, ) -> bool { - if !is_aacs_scrambled(unit) { + if !ts_sync_destroyed(unit) { return true; } if let Some(rdk) = read_data_key { @@ -386,7 +453,7 @@ mod tests { off += BD_SOURCE_PACKET_BYTES; } let key = [0u8; 16]; - assert!(!is_aacs_scrambled(&unit)); + assert!(!ts_sync_destroyed(&unit)); assert!(decrypt_unit(&mut unit, &key)); } @@ -429,9 +496,9 @@ mod tests { assert_eq!(ts_sync_count(&set_syncs(17)), 17); // Exactly half intact → classified scrambled (16 > 16 is false). - assert!(is_aacs_scrambled(&set_syncs(16))); + assert!(ts_sync_destroyed(&set_syncs(16))); // One past half → classified clear. - assert!(!is_aacs_scrambled(&set_syncs(17))); + assert!(!ts_sync_destroyed(&set_syncs(17))); } #[test] @@ -447,13 +514,13 @@ mod tests { } assert_eq!(ts_sync_count(&clear), 32); assert!( - !is_aacs_scrambled(&clear), + !ts_sync_destroyed(&clear), "fully-clear unit → not scrambled" ); let scrambled = vec![0u8; ALIGNED_UNIT_LEN]; assert_eq!(ts_sync_count(&scrambled), 0); - assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled"); + assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled"); } #[test] @@ -502,8 +569,10 @@ mod tests { plain[offset] = TS_SYNC; offset += BD_SOURCE_PACKET_BYTES; } - // No flag set: CBC-encrypting the body below scrambles packets 1..31's - // TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. + // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative + // gate `decrypt_unit` now consults. Set before key derivation so the + // recovered plaintext header matches. + plain[0] |= 0xC0; // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt) let header: [u8; 16] = plain[..16].try_into().unwrap(); @@ -530,9 +599,9 @@ mod tests { // Now plain contains encrypted data. Decrypt it. let mut unit = plain; - assert!(is_aacs_scrambled(&unit)); + assert!(ts_sync_destroyed(&unit)); assert!(decrypt_unit(&mut unit, &unit_key)); - assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored + assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored // Verify TS sync bytes let mut count = 0; @@ -557,6 +626,10 @@ mod tests { /// header) XOR header`, then CBC-encrypt bytes 16..6144 under the /// fixed AACS IV. fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { + // Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under + // `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext + // header the real decrypt recovers matches what we encrypt under. + unit[0] |= 0xC0; let header: [u8; 16] = unit[..16].try_into().unwrap(); let derived = aes_ecb_encrypt(unit_key, &header); let mut k = [0u8; 16]; @@ -721,14 +794,14 @@ mod tests { let mut unit = clear_unit(); aacs_encrypt_unit(&mut unit, &unit_key); assert!( - is_aacs_scrambled(&unit), + ts_sync_destroyed(&unit), "encrypted unit must look scrambled" ); assert!(decrypt_unit(&mut unit, &unit_key)); // All 32 stride positions carry sync after decrypt. assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); - assert!(!is_aacs_scrambled(&unit)); + assert!(!ts_sync_destroyed(&unit)); } #[test] @@ -756,9 +829,10 @@ mod tests { // left untouched by decrypt (only unit[16..] is CBC-processed). let unit_key = [0x9Au8; 16]; let mut clear = clear_unit(); - // Put a distinctive header so we can confirm it survives. + // Put a distinctive header so we can confirm it survives. Byte 0 carries + // both CPI bits (0xE0) so it is stable under the fixture's `|= 0xC0`. clear[..16].copy_from_slice(&[ - 0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, + 0xE0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, ]); let header_before: [u8; 16] = clear[..16].try_into().unwrap(); @@ -800,7 +874,7 @@ mod tests { Some(UnitKeyResult::DecryptedWith(2)) ); assert!( - !is_aacs_scrambled(&unit), + !ts_sync_destroyed(&unit), "unit must be clear after the hit" ); } @@ -819,6 +893,59 @@ mod tests { assert_eq!(unit, snapshot, "failed try must restore the original bytes"); } + // ── CPI gate: the authoritative encrypted-vs-clear decision ──────────── + + #[test] + fn cpi_gate_clear_flag_passes_through_even_when_body_looks_scrambled() { + // THE false-fail fix: a unit whose CPI bits are clear (byte 0 & 0xC0 == 0) + // is plaintext by spec, even if its body has no TS syncs (non-TS clear + // data, or a mis-probed body). `decrypt_unit` must pass it through + // untouched and report success — never attempt a decrypt that would fail. + let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; + // Body looks scrambled (no syncs at the stride) but byte 0 stays 0x00. + for (i, b) in unit.iter_mut().enumerate().skip(16) { + *b = (i as u8).wrapping_mul(31) | 1; // never 0x47 at the sync stride + } + assert!(ts_sync_destroyed(&unit), "body has no syncs"); + assert!(!aacs_unit_encrypted(&unit), "CPI clear"); + assert!( + !aacs_unit_needs_decrypt(&unit), + "CPI-clear ⇒ no decrypt attempt" + ); + let snapshot = unit.clone(); + // Any key: passthrough success, bytes untouched (no false DecryptFailed). + assert!(decrypt_unit(&mut unit, &[0xABu8; 16])); + assert_eq!(unit, snapshot, "CPI-clear unit must be left byte-identical"); + assert_eq!( + decrypt_unit_try_keys(&mut unit, &[[0xABu8; 16]]), + Some(UnitKeyResult::AlreadyClear), + "CPI-clear unit consumes no key" + ); + } + + #[test] + fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() { + // A CPI-set encrypted unit decrypts with the right key. CPI lives in the + // plaintext header, so it survives decryption — `aacs_unit_encrypted` + // still reports true afterward, but `aacs_unit_needs_decrypt` flips to + // false (syncs restored), keeping the re-decrypt paths idempotent. + let key = [0x5au8; 16]; + let mut unit = clear_unit(); + aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body + assert!(aacs_unit_encrypted(&unit), "CPI set"); + assert!(aacs_unit_needs_decrypt(&unit), "flagged + still scrambled"); + + assert!(decrypt_unit(&mut unit, &key), "right key decrypts"); + assert!( + aacs_unit_encrypted(&unit), + "CPI bits live in the preserved header ⇒ still set post-decrypt" + ); + assert!( + !aacs_unit_needs_decrypt(&unit), + "syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)" + ); + } + // ── unit_key_validates: matches decrypt_unit's verdict exactly ───────── #[test] @@ -840,6 +967,45 @@ mod tests { assert!(!decrypt_unit(&mut probe2, &bad)); } + #[test] + fn unit_is_clean_ts_is_strict_all_32_syncs() { + // Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32 + // packet syncs is required. A fully-synced clear unit passes. + let clear = clear_unit(); + assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean"); + + // Drop a SINGLE sync (packet 17 of 32). 31/32 remain, so the majority + // heuristic still passes — that is exactly the silent-corruption hole. + // The strict gate must REJECT it. + let mut one_missing = clear_unit(); + one_missing[17 * BD_SOURCE_PACKET_BYTES + 4] = 0x00; + assert!( + ts_syncs_intact(&one_missing), + "majority heuristic still passes one missing sync (the hole)" + ); + assert!( + !unit_is_clean_ts(&one_missing), + "strict gate rejects even one missing sync" + ); + + // A correctly decrypted unit is clean; a wrong-key decrypt is not. + let key = [0x33u8; 16]; + let mut enc = clear_unit(); + aacs_encrypt_unit(&mut enc, &key); + let mut good = enc.clone(); + decrypt_unit(&mut good, &key); + assert!(unit_is_clean_ts(&good), "right-key decrypt yields clean TS"); + let mut wrong = enc.clone(); + decrypt_unit(&mut wrong, &[0x34u8; 16]); + assert!( + !unit_is_clean_ts(&wrong), + "wrong-key decrypt must fail the strict gate" + ); + + // Short buffer is never vacuously clean. + assert!(!unit_is_clean_ts(&clear[..ALIGNED_UNIT_LEN - 1])); + } + #[test] fn unit_key_validates_is_non_mutating() { // The accelerator must never write its input (it operates on the @@ -947,25 +1113,25 @@ mod tests { prev.copy_from_slice(&unit[off..off + 16]); } } - assert!(is_aacs_scrambled(&unit)); + assert!(ts_sync_destroyed(&unit)); assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk))); assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); } - // ── is_aacs_scrambled / ts_sync_count edge cases ─────────────────────── + // ── ts_sync_destroyed / ts_sync_count edge cases ─────────────────────── #[test] - fn is_aacs_scrambled_false_for_sub_unit_length() { + fn ts_sync_destroyed_false_for_sub_unit_length() { // The function guards on `len >= ALIGNED_UNIT_LEN` first; anything // shorter is reported NOT scrambled (so the decrypt gate skips it) // rather than indexing past the end. - assert!(!is_aacs_scrambled(&[])); - assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1])); + assert!(!ts_sync_destroyed(&[])); + assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1])); // A scrambled-looking buffer that is one byte short is still "not // scrambled" by the length guard. let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1]; almost[4] = 0x00; // no syncs - assert!(!is_aacs_scrambled(&almost)); + assert!(!ts_sync_destroyed(&almost)); } #[test] diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index eb9213d..7dfbef6 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -171,21 +171,31 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option u16 { + if cps >= 1 && cps as usize <= num_uk { + cps - 1 + } else { + 0 + } + }; let mut title_cps_unit = Vec::new(); if data.len() >= 26 { let first_play = u16::from_be_bytes([data[20], data[21]]); let top_menu = u16::from_be_bytes([data[22], data[23]]); let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize; - title_cps_unit.push(first_play); - title_cps_unit.push(top_menu); + title_cps_unit.push(to_key_idx(first_play)); + title_cps_unit.push(to_key_idx(top_menu)); for i in 0..num_titles { let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit if off + 2 <= data.len() { let cps = u16::from_be_bytes([data[off], data[off + 1]]); - title_cps_unit.push(cps); + title_cps_unit.push(to_key_idx(cps)); } } } @@ -1050,22 +1060,25 @@ pub struct ContentCert { /// Parse a Content Certificate (ContentXXX.cer) file. pub fn parse_content_cert(data: &[u8]) -> Option { - if data.len() < 8 { + if data.len() < 20 { return None; } - // Content Certificate format: - // [0] certificate type (0x00 = AACS1, 0x01 = AACS2) - // [1] bus_encryption_enabled (bit 0) - // [2..8] cc_id (6 bytes) + // Content Certificate layout (matches libaacs content_cert.c): + // [0] certificate type (0x00 = AACS1, 0x10 = AACS2) + // [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`) + // [14..20] cc_id (6 bytes) (libaacs: `p + 14`) let version = if data[0] == 0x00 { AacsVersion::V10 } else { AacsVersion::V20 }; - let bus_encryption = (data[1] & 0x01) != 0; + // The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made + // a bus-encrypted cert (byte1=0x80) read as `false`, defeating the + // AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs. + let bus_encryption = (data[1] >> 7) & 1 == 1; let mut cc_id = [0u8; 6]; - cc_id.copy_from_slice(&data[2..8]); + cc_id.copy_from_slice(&data[14..20]); Some(ContentCert { bus_encryption, @@ -2314,19 +2327,26 @@ mod tests { } #[test] fn test_content_cert_parse() { - // AACS 1.0 cert - let mut data = vec![0u8; 16]; + // AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in + // BIT 7 of byte 1, cc_id at bytes 14..20. + let mut data = vec![0u8; 20]; data[0] = 0x00; // AACS 1.0 - data[1] = 0x00; // no bus encryption + data[1] = 0x00; // bus_encryption flag (bit 7) clear + data[14..20].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); let cc = parse_content_cert(&data).unwrap(); assert_eq!(cc.version, AacsVersion::V10); assert!(!cc.bus_encryption); - // AACS 2.0 with bus encryption - data[0] = 0x01; // AACS 2.0 - data[1] = 0x01; // bus encryption enabled + assert_eq!(cc.cc_id, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); + // AACS 2.0 with bus encryption: type 0x10, flag is BIT 7 (0x80) of byte 1 + // — NOT bit 0. A cert with byte1=0x01 must therefore read as bus-OFF. + data[0] = 0x10; // AACS 2.0 + data[1] = 0x80; // bus_encryption_enabled_flag = bit 7 let cc = parse_content_cert(&data).unwrap(); assert_eq!(cc.version, AacsVersion::V20); assert!(cc.bus_encryption); + // Regression guard: bit 0 set, bit 7 clear -> bus OFF (the old bug read this as ON). + data[1] = 0x01; + assert!(!parse_content_cert(&data).unwrap().bus_encryption); } // ════════════════════════════════════════════════════════════════════ // Hardening additions @@ -2613,29 +2633,31 @@ mod tests { // ── Content Certificate parsing ──────────────────────────────────────── #[test] fn parse_content_cert_rejects_short_buffer() { - // < 8 bytes → None (cc_id slice [2..8] would index OOB). - assert!(parse_content_cert(&[0x00; 7]).is_none()); + // < 20 bytes → None (cc_id slice [14..20] would index OOB). + assert!(parse_content_cert(&[0x00; 19]).is_none()); + assert!(parse_content_cert(&[0x00; 20]).is_some()); } #[test] fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() { - // [0]=type, [1]=bus-enc bit0, [2..8]=cc_id. Any non-0x00 type → V20. - let mut data = vec![0u8; 8]; - data[0] = 0x02; // not 0x00 and not 0x01 → still V20 + // libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any + // non-0x00 type → V20. + let mut data = vec![0u8; 20]; + data[0] = 0x10; // AACS2 type marker → V20 data[1] = 0x00; - data[2..8].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + data[14..20].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); let cc = parse_content_cert(&data).unwrap(); assert_eq!(cc.version, AacsVersion::V20); assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); assert!(!cc.bus_encryption); } #[test] - fn parse_content_cert_bus_encryption_only_reads_bit0() { - // bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with - // bit0 clear → false. Pins the mask, not a truthiness of the byte. - let mut data = vec![0u8; 8]; - data[1] = 0x02; // bit 1 set, bit 0 clear + fn parse_content_cert_bus_encryption_reads_bit7() { + // bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7 + // clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte. + let mut data = vec![0u8; 20]; + data[1] = 0x7F; // bits 0..6 set, bit 7 clear assert!(!parse_content_cert(&data).unwrap().bus_encryption); - data[1] = 0x03; // bit 0 set + data[1] = 0x80; // bit 7 set assert!(parse_content_cert(&data).unwrap().bus_encryption); } // ── resolve: version → stride wiring + V21 upgrade on variant MKB ────── @@ -2732,10 +2754,10 @@ mod tests { unit_keys: Vec::new(), }), }; - // Content cert: AACS2 + bus encryption enabled. - let mut cc = vec![0u8; 8]; - cc[0] = 0x01; - cc[1] = 0x01; + // Content cert: AACS2 (type 0x10) + bus encryption enabled (bit 7 of byte 1). + let mut cc = vec![0u8; 20]; + cc[0] = 0x10; + cc[1] = 0x80; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index e8711af..dd849d8 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -31,9 +31,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un // Explicit re-exports — only items needed by external consumers and sibling crate modules. // 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, decrypt_bus, decrypt_unit, - decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total, - ts_sync_count, unit_key_validates, + ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, + aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, + is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, 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 @@ -97,7 +98,7 @@ mod tests { // Touch a representative function from each re-export group so a // dropped/renamed export fails to compile. These are smoke calls, not // behavioural assertions (behaviour is covered in each module). - let _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]); + let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]); let _ = mkb_content_len(&[]); let _ = is_variant_mkb(&walk_mkb(&[])); let _ = disc_hash_hex(&disc_hash(b"x")); diff --git a/src/css/auth.rs b/src/css/auth.rs index e306814..405d07a 100644 --- a/src/css/auth.rs +++ b/src/css/auth.rs @@ -280,9 +280,12 @@ fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> { // ── Step 2: Disc Key ────────────────────────────────────────────────────── -/// Issue the disc-key REPORT KEY (READ DVD STRUCTURE, format 0x02) purely -/// for the bus-auth unlock side effect. The returned block contents are -/// not used — the descramble title key is recovered keylessly elsewhere. +/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD, +/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side +/// effect. The returned block contents are not used — the descramble title key +/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is +/// intentionally skipped. (If a drive is ever found where bus-auth alone does +/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.) fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> { let scsi = drive.scsi_mut(); diff --git a/src/css/stevenson.rs b/src/css/stevenson.rs index 63236bf..894f08b 100644 --- a/src/css/stevenson.rs +++ b/src/css/stevenson.rs @@ -225,7 +225,9 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool { /// AttackPattern: find a repeating pattern just before the encrypted region /// and assume the plaintext at 0x80 continues it. /// -/// Exact port of libdvdcss `AttackPattern` (css.c). Scans cleartext +/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the +/// same periodic cribs on real DVD data, though its byte-comparison anchor +/// differs from the C on phase-misaligned runs. Scans cleartext /// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in /// 2..0x2F. If the run is long enough (`plen > 3` and at least two full /// cycles), the known plaintext at 0x80 is taken to be the periodic run diff --git a/src/decrypt.rs b/src/decrypt.rs index 7273ce7..5dbdc21 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -185,6 +185,48 @@ pub fn decrypt_sectors( buf: &mut [u8], keys: &mut DecryptKeys, unit_key_idx: usize, +) -> Result { + decrypt_sectors_impl(buf, keys, unit_key_idx, None) +} + +/// Like [`decrypt_sectors`], but ONLY decrypts/verifies units whose absolute LBA +/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts +/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left +/// untouched and never counted as decrypt loss: they are clear by definition, so +/// [`ts_sync_destroyed`] must not be consulted about them (a filesystem unit has +/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is +/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors. +/// +/// `content_ranges` is sorted, merged, disjoint `[start_lba, end_lba)`. +pub fn decrypt_sectors_in_content( + buf: &mut [u8], + keys: &mut DecryptKeys, + unit_key_idx: usize, + base_lba: u32, + content_ranges: &[(u32, u32)], +) -> Result { + decrypt_sectors_impl(buf, keys, unit_key_idx, Some((base_lba, content_ranges))) +} + +/// True if `lba` falls inside one of the sorted, merged, disjoint +/// `(start, count)` ranges (same representation as [`crate::udf::merge_ranges`] +/// and `Extent`). O(log n) binary search — cheap enough to run per unit. +pub(crate) fn lba_in_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool { + match ranges.binary_search_by(|&(start, _)| start.cmp(&lba)) { + Ok(_) => true, // lba is exactly a range start + Err(0) => false, // before the first range + Err(i) => { + let (start, count) = ranges[i - 1]; + lba < start.saturating_add(count) // inside the range that starts before lba? + } + } +} + +fn decrypt_sectors_impl( + buf: &mut [u8], + keys: &mut DecryptKeys, + unit_key_idx: usize, + content: Option<(u32, &[(u32, u32)])>, ) -> Result { let dropped: usize = match keys { DecryptKeys::None => 0, @@ -223,7 +265,7 @@ pub fn decrypt_sectors( // silent corruption. We fail loud (Error::DecryptFailed), matching // the highway path's Error::ExtentNotUnitAligned policy. // - // Detection: is_aacs_scrambled() short-circuits to false for any + // Detection: ts_sync_destroyed() short-circuits to false for any // buffer shorter than a full unit, so it cannot judge a partial. We // instead apply the same TS-sync-intactness test it uses internally // (ts_sync_count vs ts_packet_total) directly to the available @@ -235,10 +277,21 @@ pub fn decrypt_sectors( // tolerate rather than risk a false positive on conformant tails. let partial_len = buf.len() % unit_len; if partial_len != 0 { - let partial = &buf[buf.len() - partial_len..]; - let packets = aacs::ts_packet_total(partial); - if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 { - return Err(crate::error::Error::DecryptFailed); + // Gate the trailing partial on content too: a scrambled partial + // OUTSIDE the encrypted m2ts extents is just clear non-TS bytes + // (filesystem tail), not a malformed encrypted unit, so it must + // not hard-fail. `nfull * 3` is the partial's absolute LBA. + let nfull = (buf.len() / unit_len) as u32; + let partial_in_content = match content { + Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges), + None => true, + }; + if partial_in_content { + let partial = &buf[buf.len() - partial_len..]; + let packets = aacs::ts_packet_total(partial); + if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 { + return Err(crate::error::Error::DecryptFailed); + } } } let nthreads = decrypt_threads(); @@ -273,7 +326,7 @@ pub fn decrypt_sectors( // 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]| { - if chunk.len() != unit_len || !aacs::is_aacs_scrambled(chunk) { + if chunk.len() != unit_len || !aacs::aacs_unit_needs_decrypt(chunk) { return; } // Save original bytes so we can restore if no key validates. @@ -305,24 +358,38 @@ pub fn decrypt_sectors( } // No key validated — restore the original encrypted bytes and - // tally the loss. The unit was scrambled (we only reach here past - // the `is_aacs_scrambled` gate) but no key applied: a clear - // nav-file unit that legitimately fails the cipher, or genuine - // encrypted content with a missing/wrong sub-key. We can't tell - // them apart here, so we always tally; the mux read path treats + // 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); dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed); }; + // Content gate wrapper: when a gate is supplied, skip any unit whose + // absolute LBA lies OUTSIDE the encrypted-content extents — it is + // clear non-TS data (filesystem / nav) and must never be decrypted, + // verified, or counted as loss. Each aligned unit is 3 sectors. + let unit_sectors = (unit_len / 2048) as u32; + let process = |idx: usize, chunk: &mut [u8]| { + if let Some((base, ranges)) = content { + let unit_lba = base.saturating_add((idx as u32) * unit_sectors); + if !lba_in_ranges(unit_lba, ranges) { + return; + } + } + decrypt_one(chunk); + }; + if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS { // Serial path: avoids thread-pool overhead for tiny // buffers; also the only path when caller pinned // single-threaded via FREEMKV_THREADS=1. Iterate the // chunks directly — no Vec of slice pointers needed. - for chunk in buf.chunks_mut(unit_len) { - decrypt_one(chunk); + for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() { + process(idx, chunk); } } else { // Parallel path via rayon's persistent thread pool. @@ -336,14 +403,14 @@ pub fn decrypt_sectors( Some(pool) => { let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect(); pool.install(|| { - chunks.into_par_iter().for_each(|chunk| { - decrypt_one(chunk); + chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| { + process(idx, chunk); }); }); } None => { - for chunk in buf.chunks_mut(unit_len) { - decrypt_one(chunk); + for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() { + process(idx, chunk); } } } @@ -406,7 +473,7 @@ mod tests { /// 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 `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails + /// 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. #[test] @@ -455,9 +522,249 @@ mod tests { v[off] = 0xA5; off += 192; } + // Flag every aligned unit's CPI bits (byte 0) so it reads as encrypted + // under the authoritative `aacs_unit_encrypted`/`aacs_unit_needs_decrypt` + // gate — real encrypted content always carries these. + let mut u = 0; + while u < len { + v[u] |= 0xC0; + u += aacs::ALIGNED_UNIT_LEN; + } v } + // ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ── + + #[test] + fn lba_in_ranges_membership() { + // (start, count) ⇒ [10,15) and [100,110). + let r = &[(10u32, 5u32), (100, 10)]; + assert!(!lba_in_ranges(0, r), "before first range"); + assert!(!lba_in_ranges(9, r), "just before first range"); + assert!(lba_in_ranges(10, r), "at first range start"); + assert!(lba_in_ranges(14, r), "inside first range"); + assert!(!lba_in_ranges(15, r), "first range end is exclusive"); + assert!(!lba_in_ranges(50, r), "in the gap between ranges"); + assert!(lba_in_ranges(100, r), "at second range start"); + assert!(lba_in_ranges(109, r), "inside second range"); + assert!(!lba_in_ranges(110, r), "second range end is exclusive"); + assert!(!lba_in_ranges(5, &[]), "empty set has no members"); + } + + /// The content gate at the decrypt primitive: a scrambled-LOOKING unit + /// OUTSIDE the content extents (e.g. UDF filesystem) must be SKIPPED — never + /// decrypted, never counted as loss. The SAME bytes INSIDE content are + /// checked and counted. This is the first-2 GB false-positive fix. + #[test] + fn content_gate_skips_non_content_units() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let original = scrambled_region(aacs::ALIGNED_UNIT_LEN); + + // base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content. + let mut buf = original.clone(); + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(100, 10)]).unwrap(); + assert_eq!( + dropped, 0, + "a non-content unit must not count as decrypt loss" + ); + assert_eq!( + buf, original, + "a non-content unit must be left byte-for-byte untouched" + ); + + // Same bytes INSIDE content (base_lba 100, range covers LBA 100..103). + let mut buf2 = original.clone(); + let dropped2 = + decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap(); + assert_eq!( + dropped2, + aacs::ALIGNED_UNIT_LEN, + "an undecryptable CONTENT unit IS counted as loss" + ); + } + + /// Per-unit gating across a content boundary: in a 2-unit buffer where only + /// the second unit (LBA 3..6) is content, only the second is decrypt-checked. + #[test] + fn content_gate_is_per_unit_across_a_boundary() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN); + // unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)]. + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap(); + assert_eq!( + dropped, + aacs::ALIGNED_UNIT_LEN, + "only the in-content unit (unit1) is checked; clear unit0 is skipped" + ); + } + + /// A content range covering the whole buffer must behave EXACTLY like the + /// ungated `decrypt_sectors` — the gate adds nothing when everything is content. + #[test] + fn content_gate_covering_whole_buffer_matches_ungated() { + let mut keys_g = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let mut keys_u = keys_g.clone(); + let original = scrambled_region(aacs::ALIGNED_UNIT_LEN); + let mut g = original.clone(); + let mut u = original.clone(); + let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap(); + let ungated = decrypt_sectors(&mut u, &mut keys_u, 0).unwrap(); + assert_eq!( + gated, ungated, + "gated-covering-all == ungated dropped count" + ); + assert_eq!(g, u, "gated-covering-all == ungated bytes"); + } + + #[test] + fn lba_in_ranges_more_edges() { + // Single range [5,8). + assert!(!lba_in_ranges(4, &[(5, 3)]), "just before single range"); + assert!(lba_in_ranges(5, &[(5, 3)]), "at single range start"); + assert!(lba_in_ranges(7, &[(5, 3)]), "inside single range"); + assert!( + !lba_in_ranges(8, &[(5, 3)]), + "single range end is exclusive" + ); + // After the last range. + assert!( + !lba_in_ranges(200, &[(10, 5), (100, 10)]), + "past the last range" + ); + // Saturating: a range whose start+count overflows u32 must not panic. The + // end saturates to u32::MAX, so the very top LBA is excluded — a harmless + // edge (real disc LBAs never reach u32::MAX). The range start is still in. + assert!( + lba_in_ranges(u32::MAX - 1, &[(u32::MAX - 1, 5)]), + "saturating range start is in" + ); + assert!( + !lba_in_ranges(u32::MAX, &[(u32::MAX - 1, 5)]), + "saturated end excludes the top" + ); + } + + /// An EMPTY content map gates EVERYTHING out — even a scrambled unit is + /// skipped (treated as non-content). This is the no-titles fallback at the + /// primitive level. + #[test] + fn content_gate_empty_ranges_skips_everything() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let original = scrambled_region(aacs::ALIGNED_UNIT_LEN); + let mut buf = original.clone(); + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap(); + assert_eq!( + dropped, 0, + "empty content map ⇒ nothing is content ⇒ no loss" + ); + assert_eq!(buf, original, "empty content map ⇒ buffer untouched"); + } + + /// A CLEAR (sync-intact) unit INSIDE content is not ciphertext, so even though + /// it is in-content it is skipped by the ts-sync check and never counted. + #[test] + fn content_gate_clear_unit_in_content_not_counted() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let original = clear_ts_region(aacs::ALIGNED_UNIT_LEN); + let mut buf = original.clone(); + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap(); + assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext"); + assert_eq!(buf, original, "a clear in-content unit is left untouched"); + } + + /// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes. + #[test] + fn content_gate_none_keys_is_noop() { + let mut keys = DecryptKeys::None; + let original = scrambled_region(aacs::ALIGNED_UNIT_LEN); + let mut buf = original.clone(); + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap(); + assert_eq!(dropped, 0); + assert_eq!(buf, original); + } + + /// CSS ignores the content gate (it lives in the AACS arm) and always reports + /// `0` — confirming the gate is a no-op for CSS and the read stays + /// scheme-agnostic (the litmus test: adding CSS verify touches only the CSS + /// arm, never the read). + #[test] + fn content_gate_css_keys_is_noop() { + let mut keys = DecryptKeys::Css { title_key: [0; 5] }; + let mut buf = vec![0u8; 2048]; + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap(); + assert_eq!( + dropped, 0, + "CSS arm returns 0; content gate is a no-op for CSS" + ); + } + + /// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an + /// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped. + #[test] + fn content_gate_mixed_three_units() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let u = aacs::ALIGNED_UNIT_LEN; + let mut buf = vec![0u8; 3 * u]; + buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled + buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear + buf[2 * u..].copy_from_slice(&scrambled_region(u)); // unit2 @ LBA6 scrambled + // Content = LBA 0..6 (units 0 and 1); unit2 (LBA6) is out of content. + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 6)]).unwrap(); + assert_eq!(dropped, u, "only unit0 (in-content + scrambled) counts"); + } + + /// Mirror of the boundary test: content covers the FIRST unit only. + #[test] + fn content_gate_covers_first_unit_only() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN); + // unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)]. + let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap(); + assert_eq!(dropped, aacs::ALIGNED_UNIT_LEN, "only unit0 counts"); + } + + /// The trailing-partial reject is ALSO content-gated: a scrambled partial + /// OUTSIDE content is clear filesystem tail, not a malformed encrypted unit, + /// so it must NOT hard-fail. + #[test] + fn content_gate_scrambled_partial_outside_content_is_tolerated() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + // One full clear unit + a scrambled single-sector partial, all OUTSIDE + // content → the partial must be tolerated (Ok), not DecryptFailed. + let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN); + buf.extend_from_slice(&scrambled_region(2048)); + // content far away → both the full unit and the partial are non-content. + let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]); + assert!( + res.is_ok(), + "a scrambled partial outside content must not hard-fail" + ); + } + /// Whole leading units plus a CLEAR trailing partial (the benign, /// conformant case): AACS leaves an incomplete final unit / clear nav-TS /// tail in the clear on disc. We must return `Ok` and leave the partial @@ -818,6 +1125,9 @@ mod tests { fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) { use aes::Aes128; use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; + // CPI bits on byte 0 so the unit reads as encrypted; set before deriving + // the per-unit key so the recovered plaintext header matches. + unit[0] |= 0xC0; let header: [u8; 16] = unit[..16].try_into().unwrap(); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let mut k = [0u8; 16]; @@ -840,7 +1150,7 @@ mod tests { } /// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride - /// (offset 4 + k*192) so `is_aacs_scrambled` reports false and + /// (offset 4 + k*192) so `ts_sync_destroyed` reports false and /// `decrypt_unit` verifies it as clear after decryption. fn clear_ts_unit() -> Vec { let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; @@ -864,7 +1174,7 @@ mod tests { /// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }` /// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where /// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as - /// garbled bytes that still look scrambled, failing the `!is_aacs_scrambled` + /// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed` /// assert. #[test] fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() { @@ -875,7 +1185,7 @@ mod tests { let mut unit = clear_ts_unit(); aacs_encrypt_unit_for_test(&mut unit, &key1); assert!( - aacs::is_aacs_scrambled(&unit), + aacs::ts_sync_destroyed(&unit), "encrypted unit must look scrambled before decrypt" ); @@ -889,7 +1199,7 @@ mod tests { decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed"); assert!( - !aacs::is_aacs_scrambled(&buf), + !aacs::ts_sync_destroyed(&buf), "unit encrypted under key1 must be fully decrypted (TS syncs restored)" ); // Every sync position must carry 0x47. @@ -920,7 +1230,7 @@ mod tests { let mut buf = unit; decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt"); assert!( - !aacs::is_aacs_scrambled(&buf), + !aacs::ts_sync_destroyed(&buf), "single-key disc: TS syncs must be restored" ); assert_eq!( @@ -953,7 +1263,7 @@ mod tests { aacs_encrypt_unit_for_test(&mut unit, &real_key); let ciphertext = unit.clone(); assert!( - aacs::is_aacs_scrambled(&unit), + aacs::ts_sync_destroyed(&unit), "encrypted unit must look scrambled going in" ); @@ -1012,7 +1322,7 @@ mod tests { "exactly one unit's worth of bytes must be reported dropped" ); assert!( - !aacs::is_aacs_scrambled(&buf[..aacs::ALIGNED_UNIT_LEN]), + !aacs::ts_sync_destroyed(&buf[..aacs::ALIGNED_UNIT_LEN]), "the decryptable unit must come out clear" ); assert_eq!( diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 9bddfea..cca2450 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -304,6 +304,60 @@ impl Disc { None => base_keys.clone(), } } + +} + +/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF +/// file is clear (nav / playlists / filesystem) and needs no decrypt verify. +fn is_aacs_clip(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".m2ts") || lower.ends_with(".ssif") +} + +/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as +/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each +/// clip's declared size plus its absolute disc extents in FILE order. Reads the +/// UDF tree through `reader`. +/// +/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an +/// EMPTY list — the verify gate then covers nothing and the sweep behaves as +/// today. Enumeration must never break a rip, so the error is logged, not +/// propagated. +pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec { + let result = (|| -> Result> { + let fs = udf::read_filesystem(reader)?; + let mut planned: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); + let mut seen_hosts: std::collections::HashMap = + std::collections::HashMap::new(); + plan_tree( + reader, + &fs, + &fs.root, + Path::new(""), + "", + true, + &mut planned, + &mut dirs, + &mut seen_hosts, + )?; + Ok(planned + .into_iter() + .filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name)) + .map(|pf| crate::disc::verify::ClipLayout { + size: pf.size, + extents: pf.extents, + }) + .collect()) + })(); + result.unwrap_or_else(|e| { + tracing::warn!( + target: "freemkv::verify", + error = %e, + "clip enumeration failed; post-read verify disabled for this pass" + ); + Vec::new() + }) } /// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an @@ -473,21 +527,12 @@ fn extract_one_file( let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; let mut sector_off: u32 = 0; while sector_off < sectors { - let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS); - // AACS: read whole units. Round the batch DOWN to a multiple of 3 - // unless this is the final (possibly short) tail of the extent. - // Every preceding batch is a whole number of units, so the tail - // batch always BEGINS on a unit boundary (the gate measures - // `lba - unit_base`, which stays unit-aligned). The tail itself may - // be 1–2 sectors past a unit boundary; `decrypt_sectors` handles - // that trailing partial unit explicitly (see its "Trailing-partial - // contract"): a clear partial is left in the clear (the conformant - // case — AACS leaves the final short unit unencrypted on disc), a - // scrambled partial fails loud as DecryptFailed. So the short tail - // is correct without padding the read up to a whole unit. - if batch >= AACS_UNIT_SECTORS && (sector_off + batch) < sectors { - batch -= batch % AACS_UNIT_SECTORS; - } + // AACS: read whole units (see `whole_unit_batch`). The tail batch may + // be a 1–2 sector partial unit, which `decrypt_sectors` handles via + // its trailing-partial contract: a clear partial stays clear (AACS + // leaves the final short unit unencrypted on disc), a scrambled + // partial fails loud as DecryptFailed. + let batch = whole_unit_batch(sectors - sector_off); let lba = abs_lba + sector_off; let want = batch as usize * SECTOR_BYTES; let read_ok = read_batch(dec, lba, batch, &mut buf[..want]); @@ -530,6 +575,22 @@ fn extract_one_file( Ok((fr, false)) } +/// Size the next FILE-ANCHORED content read in whole AACS units. `remaining` is +/// the sectors left in the current extent; the batch is capped at +/// [`READ_BATCH_SECTORS`] and rounded DOWN to a whole number of 3-sector units +/// UNLESS it is the extent's final (possibly short) tail — the tail always +/// begins on a unit boundary, so a 1–2 sector partial there is handled by +/// `decrypt_sectors`' trailing-partial contract. Shared by `extract_one_file` +/// (write) and `verify_one_clip` (dead-range) so this rounding rule lives in +/// exactly one place. +fn whole_unit_batch(remaining: u32) -> u32 { + let mut batch = remaining.min(READ_BATCH_SECTORS); + if batch >= AACS_UNIT_SECTORS && batch < remaining { + batch -= batch % AACS_UNIT_SECTORS; + } + batch +} + /// Read one batch through the decrypting decorator with bounded retries. /// Returns `true` on success, `false` once retries are exhausted (the caller /// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT @@ -1033,6 +1094,8 @@ mod tests { } off += 192; } + // Flag encrypted via CPI bits (byte 0) before key derivation. + unit[0] |= 0xC0; let header: [u8; 16] = unit[..16].try_into().unwrap(); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let mut k = [0u8; 16]; @@ -1066,6 +1129,9 @@ mod tests { } off += 192; } + // decrypt preserves the plaintext header, so the recovered unit carries + // the CPI bits the encrypt fixture set — the expected plaintext must too. + unit[0] |= 0xC0; unit } @@ -1117,6 +1183,7 @@ mod tests { std::fs::read(dir.join(rel)).ok() } + // ── Tests ───────────────────────────────────────────────────────────── /// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 6e2ad83..90e7cd2 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -17,6 +17,7 @@ pub mod mapfile; mod patch; pub mod read_error; mod sweep; +pub mod verify; use crate::drive::{Drive, extract_scsi_context}; use crate::error::{Error, Result}; @@ -436,6 +437,15 @@ pub struct Extent { pub sector_count: u32, } +/// Union a set of extents into sorted, merged, disjoint `(start_lba, +/// sector_count)` ranges — the pure, testable core of +/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`]. +fn merged_extents<'a>(extents: impl Iterator) -> Vec<(u32, u32)> { + let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect(); + ranges.sort_by_key(|r| r.0); + crate::udf::merge_ranges(&ranges) +} + /// Correct a title's TrueHD audio-stream metadata by probing the first /// decrypted access units — channel count, real sample rate, and Atmos /// detection in a single major-sync read. The MPLS descriptors declare the BASE @@ -1980,18 +1990,18 @@ pub enum Key { /// next candidate (and ultimately surfaces a key error rather than silently /// writing ciphertext). /// -/// Reuses the ecosystem's single `is_aacs_scrambled` predicate and the full +/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full /// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt. fn aligned_unit_keys_validate( unit_keys: &[(u32, [u8; 16])], read_data_key: Option<&[u8; 16]>, samples: &[Vec], ) -> bool { - use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, decrypt_unit_full, is_aacs_scrambled}; + use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full}; let scrambled: Vec<&[u8]> = samples .iter() .map(|s| s.as_slice()) - .filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s)) + .filter(|s| aacs_unit_needs_decrypt(s)) .collect(); if scrambled.is_empty() { return true; // nothing to disprove against — accept @@ -2050,6 +2060,26 @@ impl Disc { } } + /// The disc's AACS-encrypted content as a sorted, merged, disjoint set of + /// `(start_lba, sector_count)` ranges — the union of every title's m2ts + /// stream extents. + /// + /// This is the authoritative "which sectors are encrypted" map for a + /// whole-disc read. AACS only encrypts the m2ts AV streams, so a sector is + /// encrypted content **iff** it falls inside one of these ranges; everything + /// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear. + /// + /// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it + /// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about + /// non-content bytes — filesystem data has no TS sync and would otherwise be + /// mistaken for ciphertext (the first-2-GB false-positive this fixes). + /// + /// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned); + /// callers treat an empty map as "no content gate" and fall back accordingly. + pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> { + merged_extents(self.titles.iter().flat_map(|t| &t.extents)) + } + /// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or /// empty when this disc has no captured AACS state. Used to name the disc in /// a [`Error::NoDiscKey`] so the application can tell the user which disc to @@ -2644,6 +2674,7 @@ impl Disc { halt: opts.halt.clone(), vid: opts.vid, unit_keys: opts.unit_keys.clone(), + key_fetch: opts.key_fetch.clone(), }; self.sweep(reader, path, &sweep_opts) } @@ -2670,6 +2701,7 @@ impl Disc { wedged_threshold: 50, progress: opts.progress, halt: opts.halt.clone(), + key_fetch: opts.key_fetch.clone(), }; let pr = self.patch(reader, path, &patch_opts)?; tracing::info!( @@ -2722,25 +2754,73 @@ impl Disc { self.ensure_decryptable(!opts.decrypt)?; let total_bytes = self.capacity_sectors as u64 * 2048; + // Decrypt-aware read. + // + // A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without + // `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext. + // + // A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the + // autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we + // still resolve the keys and VERIFY each unit on a scratch copy: a unit + // that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like + // a SCSI error, and flows into the SAME read-error recovery (skip / + // NonTrimmed / patch). This is the one spot that makes "a read succeeded" + // mean "read AND decrypts" — everything downstream is unchanged. With no + // usable AACS keys (no keydb) it degrades to a plain pass-through. + // + // A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through: + // the user asked for the raw image, untouched and unchecked. + // The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE + // (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT + // decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned + // units are anchored to each clip's FILE start and clips can be non-6144- + // aligned OR fragmented across UDF extents — so a disc-absolute verify + // mis-aligns the unit grid and false-fails good clips (it skipped the + // ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the + // clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep, + // reading each clip file-order-anchored from the ISO. The read here stays + // a fail-safe copy; alignment is never assumed. let keys = if opts.decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; - // Captured before `keys` moves into the decorator below. A decrypting - // AACS-keyed sweep needs unit-aligned (3-sector) batch sizing + region - // read-starts (see the batch computation further down). let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }); + // Content extent map — only the in-place decrypt path (`opts.decrypt`) gates + // on it so clear filesystem / nav sectors pass through untouched. + let content_ranges = self.encrypted_content_ranges(); + let can_gate = !content_ranges.is_empty(); - // Wrap the producer-side reader once so every read_sectors call - // yields plaintext. `DecryptKeys::None` makes the decorator a - // pass-through, so the wrapping is cheap when --raw / unencrypted - // discs are being swept and we keep the pipeline shape uniform. - // Replaces the inline `decrypt::decrypt_sectors` calls that used - // to live in this loop and in the bisect inner loop below. - let mut reader = DecryptingSectorSource::new(reader, keys); + let mut reader = { + let mut dec = DecryptingSectorSource::new(reader, keys); + if opts.decrypt && can_gate { + dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges)); + } + if decrypt_is_aacs && opts.decrypt { + if let Some(cb) = &opts.key_fetch { + dec = dec.with_key_fetch(cb.clone()); + } + } + dec + }; let reader = &mut reader; + // Post-read verify gate (universal `read -> verify -> sign-off`). Built + // ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip + // path) so `observe` always sees on-disc ciphertext and never + // double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself + // fail-safe: it returns `None` (verify disabled, behavior unchanged) for + // a non-AACS disc, no keys, the kill-switch off, or an empty clip + // enumeration. We resolve the REAL AACS keys here even though the sweep + // copies ciphertext, and reuse the application's key-fetch seam. + let mut verifier = if opts.decrypt { + None + } else { + let verify_keys = self.decrypt_keys(); + let layouts = extract::clip_layouts(&mut *reader); + crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone()) + }; + // Mapfile: load if resuming, else wipe + recreate. let mapfile_path = self.mapfile_for(path); // covers_disc reconciliation. A resume against a mapfile whose total @@ -3019,6 +3099,18 @@ impl Disc { // The consumer thread sees decrypted bytes; the // pre-0.18 inline decrypt_sectors call lived here. + // Post-read verify: observe the just-read ciphertext + // BEFORE it is moved into the channel, collecting the + // clip units this batch completes that are confidently + // undecryptable. Sent as `MarkBad` AFTER the `Good` + // below so the FIFO pipe records `Finished` first and the + // downgrade to `NonTrimmed` last. No-op when the gate is + // disabled (`verifier` is `None`). + let verify_bad = verifier + .as_mut() + .map(|v| v.observe(block_lba, &buf[..block_bytes as usize])) + .unwrap_or_default(); + // Move the batch into the channel via fresh // owned Vec. The producer's `buf` is reused // for the next read. @@ -3027,6 +3119,26 @@ impl Disc { producer_err = Some(consumer_gone()); break 'outer; } + // Downgrade any unit that failed verify (decrypt-fail == + // bad read). decrypt-fail is NOT physical damage, so it + // deliberately does not touch the damage-jump window. + let mut send_failed = false; + for (bad_lba, bad_cnt) in verify_bad { + if pipe + .send(WorkItem::MarkBad { + pos: bad_lba as u64 * 2048, + len: bad_cnt as u64 * 2048, + }) + .is_err() + { + producer_err = Some(consumer_gone()); + send_failed = true; + break; + } + } + if send_failed { + break 'outer; + } bytes_done = bytes_done.saturating_add(block_bytes); pos += block_bytes; } @@ -3446,6 +3558,12 @@ pub struct CopyOptions<'a> { /// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID. /// Caller wires this from `Disc::aacs.unit_keys`. pub unit_keys: Vec<(u32, [u8; 16])>, + /// On-decrypt-miss key fetch (see [`crate::keysource::key_fetch_factory`]). + /// When set, a read that hits AACS ciphertext no held key opens asks the + /// application's key sources for the CPS unit's key, caches it, and retries — + /// recovering an orphan CPS unit never sampled at resolve time. `None` + /// disables it (the prior behaviour). Threaded into sweep + patch. + pub key_fetch: Option, } #[derive(Debug, Clone, Copy)] @@ -3474,6 +3592,8 @@ pub struct SweepOptions<'a> { /// Resolved AACS unit keys persisted into the mapfile when the sweep /// creates / opens it. When non-empty these win over `vid`. pub unit_keys: Vec<(u32, [u8; 16])>, + /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). + pub key_fetch: Option, } /// Options for [`Disc::patch`] (Pass N retry pass over bad ranges). @@ -3485,6 +3605,9 @@ pub struct PatchOptions<'a> { pub wedged_threshold: u64, pub progress: Option<&'a dyn crate::progress::Progress>, pub halt: Option>, + /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N + /// recover an orphan CPS unit's key when re-reading its bad range. + pub key_fetch: Option, } /// Result returned by [`Disc::patch`]. @@ -3736,6 +3859,53 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 { mod tests { use super::*; + // ── encrypted-content map (`merged_extents` core) ──────────────────────── + + fn ext(start_lba: u32, sector_count: u32) -> Extent { + Extent { + start_lba, + sector_count, + } + } + + #[test] + fn merged_extents_empty_is_empty() { + assert_eq!(merged_extents([].iter()), Vec::<(u32, u32)>::new()); + } + + #[test] + fn merged_extents_single() { + assert_eq!(merged_extents([ext(100, 50)].iter()), vec![(100, 50)]); + } + + /// Out-of-order extents from several titles, with an OVERLAP, an ADJACENT + /// pair, and a DISJOINT one, must come back sorted + merged + disjoint. + #[test] + fn merged_extents_unions_sorts_and_merges() { + // [300,310) ; [100,150) ; [150,200) adjacent→merges with prev ; + // [120,160) overlaps [100,150)&[150,200) ; [500,505) disjoint. + let v = vec![ + ext(300, 10), + ext(100, 50), + ext(150, 50), + ext(120, 40), + ext(500, 5), + ]; + assert_eq!( + merged_extents(v.iter()), + vec![(100, 100), (300, 10), (500, 5)], + "[100,200) merged, [300,310), [500,505)" + ); + } + + /// The same clip referenced by two titles (identical extents) de-duplicates + /// to a single range — no double-counting of shared content. + #[test] + fn merged_extents_dedups_shared_clip() { + let v = vec![ext(100, 50), ext(100, 50)]; + assert_eq!(merged_extents(v.iter()), vec![(100, 50)]); + } + /// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never /// fall through to the block default (8192 sectors = 16 MiB, well over the /// optical 510-sector cap). It has no forward slash, so the Linux-sysfs @@ -4640,7 +4810,7 @@ mod tests { #[test] fn unit_key_validation_gates_on_real_ciphertext() { - use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled}; + use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed}; // No samples -> nothing to disprove against -> accept (sample-less paths // like resume / mapfile must be unaffected). @@ -4658,7 +4828,7 @@ mod tests { clear[off] = 0x47; off += 192; } - assert!(!is_aacs_scrambled(&clear)); + assert!(!ts_sync_destroyed(&clear)); assert!(super::aligned_unit_keys_validate( &[(0, [0x11u8; 16])], None, @@ -4669,7 +4839,7 @@ mod tests { let uk = [0x5au8; 16]; let enc = encrypt_unit_for_test(&clear, &uk); assert!( - is_aacs_scrambled(&enc), + ts_sync_destroyed(&enc), "encrypted unit must read scrambled" ); @@ -4698,7 +4868,7 @@ mod tests { // CPS-unit-1 sectors then passed through as raw encrypted bytes into the // ISO/MKV with no error surfaced. The gate must now reject a key set // that leaves any scrambled sample uncovered. - use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled}; + use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed}; let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; let mut off = 4; @@ -4711,8 +4881,8 @@ mod tests { let uk1 = [0x22u8; 16]; let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body - assert!(is_aacs_scrambled(&sample0)); - assert!(is_aacs_scrambled(&sample1)); + assert!(ts_sync_destroyed(&sample0)); + assert!(ts_sync_destroyed(&sample1)); let samples = vec![sample0.clone(), sample1.clone()]; @@ -4748,6 +4918,10 @@ mod tests { use aes::Aes128; use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec(); + // Flag the unit encrypted (CPI bits on byte 0) before key derivation so + // the recovered plaintext header matches and `decrypt_unit`'s CPI gate + // attempts the decrypt. + unit[0] |= 0xC0; let mut header = [0u8; 16]; header.copy_from_slice(&unit[..16]); let cipher = Aes128::new(GenericArray::from_slice(uk)); @@ -4916,6 +5090,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, &iso_path, &opts); assert!( @@ -4949,6 +5125,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let err = disc .copy(&mut reader, &iso_path, &opts) @@ -4989,6 +5167,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; assert!( disc.copy(&mut reader, &iso_path, &opts).is_ok(), @@ -5013,6 +5193,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); assert!( @@ -5063,6 +5245,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; disc.sweep(&mut reader, &iso_path, &opts).expect("sweep"); @@ -5167,6 +5351,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; small_disc .sweep(&mut small_reader, &iso_path, &opts0) @@ -5243,6 +5429,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; disc.sweep(&mut reader, &iso_path, &opts0) .expect("initial clean sweep"); @@ -5342,6 +5530,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc .sweep(&mut reader, &iso_path, &opts) @@ -5398,6 +5588,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.sweep(&mut reader, &iso_path, &opts); assert!( @@ -5429,6 +5621,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); assert!( @@ -5520,6 +5714,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, &iso_path, &opts); assert!(result.is_ok(), "resume copy failed: {:?}", result.err()); @@ -5616,6 +5812,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, &iso_path, &opts); assert!( @@ -5667,6 +5865,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts); assert!( @@ -5686,6 +5886,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts); assert!( @@ -5720,6 +5922,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap(); @@ -5734,6 +5938,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts); assert!( @@ -5768,6 +5974,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, &iso_path, &opts); let r = result.expect("100-batch clean sweep should succeed"); @@ -6024,6 +6232,8 @@ mod tests { halt: None, vid: None, unit_keys: Vec::new(), + + key_fetch: None, }; let result = disc.copy(&mut reader, &iso_path, &opts); diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 0f23858..220f459 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -1731,25 +1731,54 @@ impl Disc { ); let bytes_good_before = initial_stats.bytes_good; let bytes_good_start = bytes_good_before; + + // Post-read verify gate for the patch pass (ciphertext multipass only, + // `!opts.decrypt`). Built here from the raw reader's UDF enumeration; + // reused AFTER the recovery loop (`reverify_iso`) to re-check the units + // this pass touched by reading them WHOLE back from the patched ISO — + // patch re-reads only the bad sectors of a unit, so per-unit verify + // can't run live. Fail-safe `None` when disabled / non-AACS / no keys. + let mut verifier = if opts.decrypt { + None + } else { + let verify_keys = self.decrypt_keys(); + let layouts = crate::disc::extract::clip_layouts(&mut *reader); + crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone()) + }; + // Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch + // (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting + // patch (the multipass / `--raw --multipass` path) resolves the keys and + // VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt + // fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the + // retry loop keeps re-reading it "until it decrypts or retries exhaust" + // exactly as for a SCSI read error — and a unit that DOES decrypt on a + // fresh read (the drive returned different bytes) is recovered for free. + // With no usable AACS keys this degrades to a plain pass-through. + // Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass / + // `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt- + // VERIFY — the disc-absolute read can't anchor to a clip's file-relative + // unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover + // bad sectors; the clip-anchored verify pass re-checks them afterward. let keys = if opts.decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; - - // Wrap the producer-side reader once so every read_sectors - // call (the main recovery read, the backtrack read, and the - // non-NOT_READY retry read) yields plaintext. Replaces three - // inline decrypt_sectors call sites that all keyed off the - // same `keys`. `DecryptKeys::None` keeps the unencrypted / - // --raw path a pass-through. - // AACS reads must start on a 3-sector unit boundary and span whole - // units (DecryptingSectorSource rejects mid-unit reads as DecryptFailed). - // The patch cursor derives from arbitrary mapfile byte offsets, so a - // single-sector recovery read can land mid-unit — see the aligned read - // at the read call site below. let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }); - let mut reader = DecryptingSectorSource::new(reader, keys); + let content_ranges = self.encrypted_content_ranges(); + let can_gate = !content_ranges.is_empty(); + let mut reader = { + let mut dec = DecryptingSectorSource::new(reader, keys); + if opts.decrypt && can_gate { + dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges)); + } + if decrypt_is_aacs && opts.decrypt { + if let Some(cb) = &opts.key_fetch { + dec = dec.with_key_fetch(cb.clone()); + } + } + dec + }; let reader = &mut reader; // Spawn the consumer. The `WritebackFile` (same bounded-cache @@ -2083,6 +2112,37 @@ impl Disc { // behaviour. let summary = pipe.finish()?; + // Scoped post-read re-verify (decrypt-fail == bad read). The consumer + // has flushed the ISO + mapfile; re-read each clip unit this pass touched + // WHOLE from the patched ISO and downgrade any that still won't decrypt + // to NonTrimmed, so the orchestrator's end-of-recovery promotion + // terminalizes it. Reuses the same verifier as the sweep. Fail-safe: + // disabled gate / unreadable ISO / load failure all leave the pass as-is. + if let Some(mut v) = verifier.take() { + if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) { + let bad = v.reverify_iso(&mut iso, &bad_ranges); + if !bad.is_empty() { + if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) { + let n: usize = bad.len(); + for (lba, cnt) in bad { + let _ = m.record( + lba as u64 * 2048, + cnt as u64 * 2048, + mapfile::SectorStatus::NonTrimmed, + ); + } + let _ = m.flush(); + tracing::info!( + target: "freemkv::verify", + phase = "patch_reverify", + downgraded_ranges = n, + "post-read re-verify downgraded undecryptable units to NonTrimmed" + ); + } + } + } + } + let outcome = build_outcome( &state, &summary, @@ -2177,6 +2237,8 @@ mod tests { wedged_threshold: 50, progress: None, halt: None, + + key_fetch: None, } } diff --git a/src/disc/sweep.rs b/src/disc/sweep.rs index 2c86838..414abc4 100644 --- a/src/disc/sweep.rs +++ b/src/disc/sweep.rs @@ -66,6 +66,14 @@ pub(super) enum WorkItem { /// tell them apart without parsing a flag. GapFill { pos: u64, len: u64 }, + /// Post-read verify downgrade. The producer's `UnitVerifier` found that the + /// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable + /// (a silent bad read). The consumer re-records the range as `NonTrimmed` so + /// the patch pass re-reads it — the ISO bytes (ciphertext) already written by + /// the preceding `Good` are left in place for the patch to overwrite. FIFO + /// pipe ordering guarantees this arrives AFTER the `Good` that wrote them. + MarkBad { pos: u64, len: u64 }, + /// Producer wants the latest mapfile stats for the progress /// callback. Consumer responds on `prog_tx` with a fresh /// [`ProgressSnapshot`]. Best-effort: if the producer hasn't @@ -174,6 +182,12 @@ impl Sink for SweepSink { } self.map.record(pos, len, SectorStatus::NonTrimmed)?; } + WorkItem::MarkBad { pos, len } => { + // Verify downgrade: the ISO bytes are already written by the + // preceding Good; only the mapfile status changes so patch + // re-reads this range. No file write. + self.map.record(pos, len, SectorStatus::NonTrimmed)?; + } WorkItem::StatsRequest => { let stats = self.map.stats(); let bad_ranges = self.map.ranges_with(&[ diff --git a/src/disc/verify.rs b/src/disc/verify.rs new file mode 100644 index 0000000..c9dddb9 --- /dev/null +++ b/src/disc/verify.rs @@ -0,0 +1,867 @@ +//! Universal post-read verify gate. +//! +//! `read() -> verify() -> sign-off`. A unit that fails verify is treated EXACTLY +//! like a bad read (the caller re-marks its disc range pending/lost). Reads are +//! disc-absolute, but the only alignment at which the AACS CPI flag and the +//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit +//! grid (clips can start off the 6144 grid and fragment across UDF extents). So +//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into +//! clip-file units, then applies the standards-correct +//! [`crate::aacs::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs). +//! +//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never +//! break a good read): the gate can ONLY downgrade a unit it is *confident* is +//! bad — a flagged-encrypted, fully-buffered, full-size unit that no held key +//! and no freshly-fetched key can decrypt to clean TS. EVERY other situation — +//! the [`POST_READ_VERIFY`] switch off, a non-AACS disc, no keys, an enumeration +//! failure, a partial tail unit, a unit whose key we simply lack (no key_fetch), +//! an evicted partial — SKIPS, leaving the read byte-for-byte as it is today. +//! +//! Bus encryption (AACS 2.x `read_data_key`) is deliberately NOT handled here: +//! it is a drive<->host transport layer stripped during drive auth. On the +//! unlocked drives this runs against it is off; if it were on and unhandled we +//! would fail at the read/auth stage long before reaching this gate, so by here +//! the bytes are content-layer only. + +use std::collections::{HashMap, VecDeque}; + +use crate::aacs::{self, ALIGNED_UNIT_LEN}; +use crate::consts::SECTOR_BYTES_U64; +use crate::decrypt::DecryptKeys; +use crate::sector::KeyFetch; + +/// Master kill-switch for the post-read verify gate. Hardcoded `true`. Flip to +/// `false` and the gate is inert: [`UnitVerifier::new`] returns `None`, nothing +/// is ever buffered, verified, or downgraded, and rip behavior is byte-for-byte +/// what it is today. The single lever to pull the whole feature. +pub const POST_READ_VERIFY: bool = true; + +/// Cap on in-flight partial units. Sequential sweeps complete units almost +/// immediately, so partials only accumulate at damage-jump skips (whose sectors +/// never arrive). When the cap is hit the oldest partial is evicted and simply +/// goes unverified — fail-safe. Bounds memory at `MAX_INFLIGHT_UNITS * 6144`. +const MAX_INFLIGHT_UNITS: usize = 4096; // ~24 MiB ceiling + +/// Cap on key-fetch invocations across the verifier's life. A fetched key is +/// cached and reused for every later unit of the same CPS unit, so in practice +/// one fetch resolves all orphan units; the cap is a runaway backstop only. +const MAX_FETCH_CALLS: u32 = 8; + +/// A clip's on-disc layout: declared file size plus its absolute disc extents in +/// FILE order. `extents` is `(disc_lba, byte_len)`; the verifier reuses exactly +/// the same `(abs_lba, byte_len)` extents the extractor enumerates. +#[derive(Debug, Clone)] +pub struct ClipLayout { + pub size: u64, + pub extents: Vec<(u32, u32)>, +} + +/// One extent placed in the (disc-LBA -> clip-file-offset) space, for routing an +/// incoming disc sector to the unit it backs. +#[derive(Debug, Clone)] +struct ExtentRec { + disc_lba: u32, + sectors: u32, + /// Byte offset within the clip FILE of this extent's first byte. + file_off: u64, + clip: u32, +} + +/// A unit being assembled from its (up to 3) backing disc sectors. +struct Partial { + buf: Box<[u8; ALIGNED_UNIT_LEN]>, + /// Bit `s` set once sector slot `s` (0..3) has been filled. + have: u8, + /// Disc LBA of each slot, for emitting the bad range if verify fails. + lba: [u32; 3], +} + +/// Whether a fully-assembled unit can be decrypted + verified — the single +/// answer the gate produces per unit (`is this decryptable?`, 3-state). +enum Decryptability { + /// Decrypts to strictly clean MPEG-TS (or is valid clear content). Keep good. + Decryptable, + /// Confidently does NOT decrypt — bad ciphertext or a bad read. The caller + /// downgrades this unit's disc range (decrypt-fail == bad read). + Undecryptable, + /// Can't tell — e.g. we may simply lack the key. Skip, leave the read as-is. + Unknown, +} + +/// The post-read verify gate. Built once per pass; fed the disc-absolute, +/// just-`Finished` byte ranges via [`observe`](Self::observe); emits the disc +/// ranges that are confidently undecryptable so the caller can mark them bad. +pub struct UnitVerifier { + /// Sorted by `disc_lba`; covers only AACS clip (`.m2ts`/`.ssif`) content. + extents: Vec, + /// Number of FULL (6144) units per clip; the partial tail unit is excluded. + full_units: Vec, + /// Content unit keys to try (resolved keys plus any fetched + cached). + keys: Vec<[u8; 16]>, + fetch: Option, + fetch_calls: u32, + fetch_spent: bool, + partials: HashMap<(u32, u32), Partial>, + lru: VecDeque<(u32, u32)>, +} + +impl UnitVerifier { + /// Build the gate, or `None` (verify disabled / nothing to verify) when: + /// the [`POST_READ_VERIFY`] switch is off, the disc is not AACS, there are no + /// keys to try and no fetch seam, or no AACS clip extents were enumerated. + /// Returning `None` is the fail-safe default — the caller then verifies + /// nothing and behaves exactly as today. + pub fn new(clips: &[ClipLayout], keys: &DecryptKeys, fetch: Option) -> Option { + if !POST_READ_VERIFY { + return None; + } + // Only AACS has the per-unit CPI flag + decrypt-verify this gate checks. + let DecryptKeys::Aacs { unit_keys, .. } = keys else { + return None; + }; + let held: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect(); + // With neither a held key nor a fetch seam there is nothing we could ever + // confidently reject, so disable rather than buffer for no reason. + if held.is_empty() && fetch.is_none() { + return None; + } + + let mut extents = Vec::new(); + let mut full_units = Vec::new(); + for (clip, layout) in clips.iter().enumerate() { + // Full units only; the partial tail (size not a multiple of 6144) is + // never verified (a < 6144 buffer can't satisfy the strict gate). + full_units.push((layout.size / ALIGNED_UNIT_LEN as u64) as u32); + let mut file_off: u64 = 0; + for &(disc_lba, byte_len) in &layout.extents { + let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; + if sectors > 0 { + extents.push(ExtentRec { + disc_lba, + sectors, + file_off, + clip: clip as u32, + }); + } + file_off = file_off.saturating_add(byte_len as u64); + } + } + if extents.is_empty() { + return None; + } + extents.sort_by_key(|e| e.disc_lba); + + Some(Self { + extents, + full_units, + keys: held, + fetch, + fetch_calls: 0, + fetch_spent: false, + partials: HashMap::new(), + lru: VecDeque::new(), + }) + } + + /// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc + /// sector `disc_lba`). Routes each backing sector into its clip-file unit; + /// every unit that becomes fully assembled is verified immediately. Returns + /// the disc ranges `(lba, sector_count)` of units that are CONFIDENTLY bad + /// (empty when nothing failed). Never errors — a read is never broken here. + pub fn observe(&mut self, disc_lba: u32, bytes: &[u8]) -> Vec<(u32, u32)> { + let mut bad: Vec<(u32, u32)> = Vec::new(); + let sector = crate::consts::SECTOR_BYTES; + let n = bytes.len() / sector; + for s in 0..n { + let lba = disc_lba.saturating_add(s as u32); + let Some((clip, unit, slot)) = self.locate(lba) else { + continue; // not AACS clip content, or an unalignable boundary + }; + // Tail / partial units are never verified. + if unit >= self.full_units[clip as usize] { + continue; + } + let off = s * sector; + self.fill(clip, unit, slot, lba, &bytes[off..off + sector]); + if let Some((raw, lbas)) = self.take_if_complete(clip, unit) { + match self.decryptability(&raw) { + Decryptability::Undecryptable => push_ranges(&mut bad, &lbas), + Decryptability::Decryptable | Decryptability::Unknown => {} + } + } + } + bad + } + + /// Disc LBA -> (clip, unit index, sector slot 0..3), or `None` if the sector + /// is not AACS-clip content or sits at an unalignable (non-sector) file + /// offset (which we conservatively skip). + fn locate(&self, lba: u32) -> Option<(u32, u32, usize)> { + // Largest extent whose disc_lba <= lba. + let idx = self.extents.partition_point(|e| e.disc_lba <= lba); + if idx == 0 { + return None; + } + let e = &self.extents[idx - 1]; + let delta = lba - e.disc_lba; + if delta >= e.sectors { + return None; // past this extent, not covered by any clip + } + let file_off = e.file_off + delta as u64 * SECTOR_BYTES_U64; + // Guard pathological non-sector-aligned extent boundaries. + if file_off % SECTOR_BYTES_U64 != 0 { + return None; + } + let unit = (file_off / ALIGNED_UNIT_LEN as u64) as u32; + let in_unit = (file_off % ALIGNED_UNIT_LEN as u64) as usize; + let slot = in_unit / crate::consts::SECTOR_BYTES; + Some((e.clip, unit, slot)) + } + + fn fill(&mut self, clip: u32, unit: u32, slot: usize, lba: u32, sector_bytes: &[u8]) { + let key = (clip, unit); + let entry = self.partials.entry(key); + let fresh = matches!(entry, std::collections::hash_map::Entry::Vacant(_)); + let p = entry.or_insert_with(|| Partial { + buf: Box::new([0u8; ALIGNED_UNIT_LEN]), + have: 0, + lba: [u32::MAX; 3], + }); + let off = slot * crate::consts::SECTOR_BYTES; + p.buf[off..off + crate::consts::SECTOR_BYTES].copy_from_slice(sector_bytes); + p.have |= 1 << slot; + p.lba[slot] = lba; + if fresh { + self.lru.push_back(key); + self.evict_if_needed(); + } + } + + /// Remove and return the unit if all three sectors have arrived. + fn take_if_complete( + &mut self, + clip: u32, + unit: u32, + ) -> Option<([u8; ALIGNED_UNIT_LEN], [u32; 3])> { + let key = (clip, unit); + let complete = self + .partials + .get(&key) + .map(|p| p.have == 0b111) + .unwrap_or(false); + if !complete { + return None; + } + let p = self.partials.remove(&key)?; + if let Some(pos) = self.lru.iter().position(|k| *k == key) { + self.lru.remove(pos); + } + Some((*p.buf, p.lba)) + } + + fn evict_if_needed(&mut self) { + while self.partials.len() > MAX_INFLIGHT_UNITS { + // Oldest partial goes unverified (fail-safe) to bound memory. + if let Some(key) = self.lru.pop_front() { + self.partials.remove(&key); + } else { + break; + } + } + } + + /// Can this fully-assembled unit be decrypted + verified? The authoritative + /// check is the strict [`aacs::unit_is_clean_ts`]; `decrypt_unit` only + /// restores the body. Returns the 3-state [`Decryptability`]. + fn decryptability(&mut self, raw: &[u8; ALIGNED_UNIT_LEN]) -> Decryptability { + // CPI clear -> the unit is plaintext by spec (no key needed). If it is + // clean TS, it is decryptable-as-is. If it ISN'T, we DELIBERATELY return + // Unknown, not Undecryptable: with no key to crypto-prove anything, a + // clear-but-not-clean unit could be a genuine bad read OR a mis-aligned + // read OR legitimately-odd clear content (some menu/nav units). We refuse + // to assert "bad" without proof — only ENCRYPTED units that no key opens + // are ever flagged. (A real bad READ of clear content is still caught by + // the normal SCSI read-error path; this gate just won't false-flag it.) + if !aacs::aacs_unit_encrypted(raw) { + return if aacs::unit_is_clean_ts(raw) { + Decryptability::Decryptable + } else { + Decryptability::Unknown + }; + } + // Encrypted: any held key that decrypts to clean TS -> decryptable. + if self.try_keys(raw) { + return Decryptability::Decryptable; + } + // No held key works. Ask the application's key source ONCE for this + // ciphertext; a fetched key is cached for later units. Only if the + // service hands us key(s) that STILL don't open it is the unit + // confidently undecryptable. No seam / no new key -> we may just lack the + // key -> Unknown (skip), never a false-bad. + if !self.fetch_spent && self.fetch_calls < MAX_FETCH_CALLS { + if let Some(cb) = self.fetch.clone() { + self.fetch_calls += 1; + let fresh = cb(&[raw.to_vec()]); + let mut added = false; + for k in fresh { + if !self.keys.contains(&k) { + self.keys.push(k); + added = true; + } + } + if !added { + self.fetch_spent = true; // service has nothing new; stop asking + return Decryptability::Unknown; + } + if self.try_keys(raw) { + return Decryptability::Decryptable; + } + return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext + } + } + Decryptability::Unknown + } + + /// True if any currently-held key decrypts `raw` to strictly clean TS. + fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN]) -> bool { + for k in &self.keys { + let mut scratch = *raw; + if aacs::decrypt_unit(&mut scratch, k) { + return true; + } + } + false + } + + /// Re-verify, from a 1:1 disc ISO image, every full clip unit that overlaps + /// `ranges` (disc BYTE ranges — e.g. the bad ranges a patch pass re-read). + /// Reads each unit's backing sectors from `iso` (ISO sector N == disc LBA N), + /// runs the same per-unit [`verdict`](Self::verdict), and returns the + /// confidently-bad disc ranges `(lba, count)` to re-mark. + /// + /// This is the PATCH counterpart to [`observe`](Self::observe): patch + /// re-reads only the bad sectors of a unit, so it can never complete a unit + /// from its live read stream (the unit's other sectors are already in the + /// ISO). Reading the whole unit back from the just-patched ISO is the only + /// alignment-correct way to re-check it. FAIL-SAFE: an ISO read error on any + /// of a unit's sectors skips that unit (no false-bad). + pub fn reverify_iso( + &mut self, + iso: &mut S, + ranges: &[(u64, u64)], + ) -> Vec<(u32, u32)> { + let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new(); + let mut bad: Vec<(u32, u32)> = Vec::new(); + for &(pos, len) in ranges { + if len == 0 { + continue; + } + let start = (pos / SECTOR_BYTES_U64) as u32; + let end = pos.saturating_add(len).div_ceil(SECTOR_BYTES_U64) as u32; + for lba in start..end { + let Some((clip, unit, _)) = self.locate(lba) else { + continue; + }; + if unit >= self.full_units[clip as usize] || !seen.insert((clip, unit)) { + continue; + } + let Some(lbas) = self.unit_disc_sectors(clip, unit) else { + continue; + }; + let mut raw = [0u8; ALIGNED_UNIT_LEN]; + let mut readable = true; + for (slot, &slba) in lbas.iter().enumerate() { + let off = slot * crate::consts::SECTOR_BYTES; + if iso + .read_sectors( + slba, + 1, + &mut raw[off..off + crate::consts::SECTOR_BYTES], + false, + ) + .is_err() + { + readable = false; + break; + } + } + if readable && matches!(self.decryptability(&raw), Decryptability::Undecryptable) { + push_ranges(&mut bad, &lbas); + } + } + } + bad + } + + /// Disc LBAs backing the 3 sectors of full `unit` in `clip`, walking that + /// clip's extents in file order. `None` if any sector is not covered by an + /// extent (never happens for a full unit of an enumerated clip). + fn unit_disc_sectors(&self, clip: u32, unit: u32) -> Option<[u32; 3]> { + let base = unit as u64 * ALIGNED_UNIT_LEN as u64; + let mut out = [u32::MAX; 3]; + for (slot, item) in out.iter_mut().enumerate() { + let foff = base + slot as u64 * SECTOR_BYTES_U64; + let e = self.extents.iter().find(|e| { + e.clip == clip + && e.file_off <= foff + && foff < e.file_off + e.sectors as u64 * SECTOR_BYTES_U64 + })?; + *item = e.disc_lba + ((foff - e.file_off) / SECTOR_BYTES_U64) as u32; + } + Some(out) + } +} + +/// Append `lbas` (a unit's up-to-3 backing sectors) to `out` as `(lba, count)` +/// ranges, coalescing contiguous sectors. Unset slots (`u32::MAX`) are skipped. +fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) { + let mut present: Vec = lbas.iter().copied().filter(|&l| l != u32::MAX).collect(); + present.sort_unstable(); + for lba in present { + if let Some(last) = out.last_mut() { + if last.0 + last.1 == lba { + last.1 += 1; + continue; + } + } + out.push((lba, 1)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + const TS_SYNC: u8 = 0x47; + + /// A clear aligned unit: TS sync at offset 4 + k*192, CPI bits clear. + fn clear_unit() -> Vec { + let mut u = vec![0u8; ALIGNED_UNIT_LEN]; + let mut off = 4; + while off < ALIGNED_UNIT_LEN { + u[off] = TS_SYNC; + off += 192; + } + u + } + + /// Encrypt a clear unit in place under `unit_key` (sets CPI, AES-CBC body) — + /// the exact inverse of `decrypt_unit`, so the right key restores clean TS. + fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { + use aes::Aes128; + use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; + unit[0] |= 0xC0; // CPI flag => reads as encrypted + let header: [u8; 16] = unit[..16].try_into().unwrap(); + let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); + let mut k = [0u8; 16]; + for i in 0..16 { + k[i] = derived[i] ^ header[i]; + } + let cipher = Aes128::new(GenericArray::from_slice(&k)); + let mut prev = crate::aacs::decrypt::AACS_IV; + for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 { + let off = 16 + i * 16; + for j in 0..16 { + unit[off + j] ^= prev[j]; + } + let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]); + cipher.encrypt_block(&mut block); + unit[off..off + 16].copy_from_slice(&block); + prev.copy_from_slice(&unit[off..off + 16]); + } + } + + fn aacs_keys(keys: &[[u8; 16]]) -> DecryptKeys { + DecryptKeys::Aacs { + unit_keys: keys + .iter() + .enumerate() + .map(|(i, k)| (i as u32, *k)) + .collect(), + read_data_key: None, + } + } + + /// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors). + fn one_clip(lba: u32) -> Vec { + vec![ClipLayout { + size: ALIGNED_UNIT_LEN as u64, + extents: vec![(lba, ALIGNED_UNIT_LEN as u32)], + }] + } + + // ── fail-safe: when the gate must NOT exist ──────────────────────────── + + #[test] + fn kill_switch_default_on() { + assert!(POST_READ_VERIFY, "shipping default: gate enabled"); + } + + #[test] + fn new_is_none_for_non_aacs() { + assert!(UnitVerifier::new(&one_clip(100), &DecryptKeys::None, None).is_none()); + assert!( + UnitVerifier::new( + &one_clip(100), + &DecryptKeys::Css { title_key: [0; 5] }, + None + ) + .is_none() + ); + } + + #[test] + fn new_is_none_with_no_keys_and_no_fetch() { + // Nothing could ever be confidently rejected -> don't even buffer. + assert!(UnitVerifier::new(&one_clip(100), &aacs_keys(&[]), None).is_none()); + } + + #[test] + fn new_is_none_with_no_extents() { + let clips = vec![ClipLayout { + size: 0, + extents: vec![], + }]; + assert!(UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).is_none()); + } + + // ── clear (CPI=0) content ────────────────────────────────────────────── + + #[test] + fn clear_clean_unit_is_good() { + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); + let bad = v.observe(100, &clear_unit()); + assert!(bad.is_empty(), "clean clear unit must not be flagged"); + } + + #[test] + fn clear_corrupted_unit_is_skipped_not_flagged() { + // A CPI-clear unit whose TS syncs don't check out is NOT flagged: with no + // key to crypto-prove anything we won't assert "bad" on clear content + // (could be a mis-aligned read or odd-but-valid clear/menu data). Only + // encrypted-won't-decrypt is ever a downgrade. A genuine bad READ is + // already caught by the SCSI read-error path; this gate must not + // false-flag it. + let mut u = clear_unit(); + u[4] = 0x00; // break the first packet's sync + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); + let bad = v.observe(100, &u); + assert!(bad.is_empty(), "clear-but-not-clean unit -> skip, never false-bad"); + } + + // ── encrypted (CPI set) content ──────────────────────────────────────── + + #[test] + fn encrypted_unit_held_key_decrypts_is_good() { + let key = [0x5a; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &key); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); + assert!(v.observe(100, &u).is_empty(), "right key -> good"); + } + + #[test] + fn encrypted_unit_wrong_key_no_fetch_is_uncertain_not_bad() { + // CRITICAL fail-safe: a unit we can't decrypt because we may simply LACK + // the key (no fetch seam) must be SKIPPED, never flagged bad. + let real = [0x11; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), None).unwrap(); + assert!( + v.observe(100, &u).is_empty(), + "missing key without a fetch seam must NOT be a false-bad" + ); + } + + #[test] + fn encrypted_unit_fetch_supplies_right_key_is_good() { + let real = [0x33; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let fetch: KeyFetch = Arc::new(move |_samples: &[Vec]| vec![real]); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x44; 16]]), Some(fetch)).unwrap(); + assert!( + v.observe(100, &u).is_empty(), + "fetched key recovers -> good" + ); + } + + #[test] + fn encrypted_unit_fetch_supplies_wrong_keys_is_bad() { + // The service handed us key(s) that still don't open it -> genuinely bad + // ciphertext, confidently flagged. + let real = [0x55; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let fetch: KeyFetch = Arc::new(move |_s: &[Vec]| vec![[0x99; 16], [0xAA; 16]]); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x66; 16]]), Some(fetch)).unwrap(); + assert_eq!( + v.observe(100, &u), + vec![(100, 3)], + "wrong fetched keys -> bad" + ); + } + + #[test] + fn encrypted_unit_fetch_supplies_nothing_is_uncertain() { + let real = [0x77; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let fetch: KeyFetch = Arc::new(|_s: &[Vec]| Vec::new()); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x88; 16]]), Some(fetch)).unwrap(); + assert!( + v.observe(100, &u).is_empty(), + "fetch returns nothing new -> uncertain -> skip" + ); + } + + #[test] + fn fetched_key_is_cached_for_later_units() { + // First orphan unit triggers one fetch; the second reuses the cached key + // with no further fetch call. + let real = [0xC3; 16]; + let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let c = calls.clone(); + let fetch: KeyFetch = Arc::new(move |_s: &[Vec]| { + c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + vec![real] + }); + let clips = vec![ClipLayout { + size: 2 * ALIGNED_UNIT_LEN as u64, + extents: vec![(200, 2 * ALIGNED_UNIT_LEN as u32)], + }]; + let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); + let mut u0 = clear_unit(); + encrypt_unit(&mut u0, &real); + let mut u1 = clear_unit(); + encrypt_unit(&mut u1, &real); + assert!(v.observe(200, &u0).is_empty()); + assert!(v.observe(203, &u1).is_empty()); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "second orphan unit reuses the cached fetched key (one fetch total)" + ); + } + + // ── alignment: fragmentation, tails, ordering, skips ─────────────────── + + #[test] + fn fragmented_unit_assembles_across_distant_extents() { + // Unit 0 spans extent A (sectors 0,1 at LBA 10) and extent B (sector 2 at + // disc-distant LBA 5000). It must still assemble and verify; a bad unit + // emits BOTH disc ranges. + let real = [0x42; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let clips = vec![ClipLayout { + size: ALIGNED_UNIT_LEN as u64, + extents: vec![(10, 4096), (5000, 2048)], + }]; + // Wrong key + a fetch that yields wrong keys => confident bad, fragmented. + let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); + let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); + // Feed the two extents in separate observe calls, distant order. + assert!( + v.observe(10, &u[..4096]).is_empty(), + "incomplete -> no verdict yet" + ); + let bad = v.observe(5000, &u[4096..]); + assert_eq!( + bad, + vec![(10, 2), (5000, 1)], + "fragmented bad unit -> both ranges" + ); + } + + #[test] + fn partial_tail_unit_is_never_verified() { + // Clip size 6144 + 2048: unit 0 is full, "unit 1" is a 2048 partial tail + // and must be skipped even if corrupt. + let key = [0x5a; 16]; + let mut u0 = clear_unit(); + encrypt_unit(&mut u0, &key); + let clips = vec![ClipLayout { + size: ALIGNED_UNIT_LEN as u64 + 2048, + extents: vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)], + }]; + let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); + // Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is + // judged; the tail is never a verdict. + let mut feed = u0.clone(); + feed.extend_from_slice(&[0xABu8; 2048]); // tail sector, not a full unit + assert!( + v.observe(100, &feed).is_empty(), + "tail partial never flagged" + ); + } + + #[test] + fn incomplete_unit_from_skip_is_never_flagged() { + // Damage-jump: only 2 of 3 sectors of a unit ever arrive. The unit never + // completes, so it is never verified (its sectors are already pending). + let real = [0x11; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); + // Only sectors 0 and 1 (skip sector 2). + assert!( + v.observe(100, &u[..4096]).is_empty(), + "incomplete unit -> no verdict" + ); + } + + #[test] + fn sectors_split_across_observe_calls_still_complete() { + let key = [0x5a; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &key); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); + assert!(v.observe(100, &u[..2048]).is_empty()); // sector 0 + assert!(v.observe(101, &u[2048..4096]).is_empty()); // sector 1 + assert!(v.observe(102, &u[4096..]).is_empty()); // sector 2 -> completes, good + } + + #[test] + fn sector_outside_any_clip_is_ignored() { + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); + // LBA 50 is before the clip at 100 -> not routed, no panic, no verdict. + assert!(v.observe(50, &[0u8; 2048]).is_empty()); + // LBA 200 is past the clip's 3 sectors -> ignored. + assert!(v.observe(200, &[0u8; 2048]).is_empty()); + } + + // ── reverify_iso (patch path: read whole units back from the ISO) ────── + + /// In-memory 1:1 ISO (sector N == disc LBA N). Unset sectors read as zeros; + /// `err_lba` forces a read error to exercise the fail-safe skip. + struct MockIso { + sectors: std::collections::HashMap, + err_lba: Option, + } + impl crate::sector::SectorSource for MockIso { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::Result { + for i in 0..count as u32 { + if self.err_lba == Some(lba + i) { + return Err(crate::error::Error::DiscRead { + sector: (lba + i) as u64, + status: None, + sense: None, + }); + } + let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]); + let off = i as usize * 2048; + buf[off..off + 2048].copy_from_slice(&s); + } + Ok(count as usize * 2048) + } + } + + /// Place a 6144-byte unit's 3 sectors at disc LBAs `lbas` in the mock ISO. + fn place_unit(iso: &mut MockIso, lbas: [u32; 3], unit: &[u8]) { + for (slot, &lba) in lbas.iter().enumerate() { + let mut s = [0u8; 2048]; + s.copy_from_slice(&unit[slot * 2048..slot * 2048 + 2048]); + iso.sectors.insert(lba, s); + } + } + + #[test] + fn reverify_iso_good_unit_returns_empty() { + let key = [0x5a; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &key); + let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + place_unit(&mut iso, [100, 101, 102], &u); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); + let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]); + assert!(bad.is_empty(), "decryptable unit re-read clean -> not bad"); + } + + #[test] + fn reverify_iso_bad_unit_returns_its_range() { + let real = [0x11; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + place_unit(&mut iso, [100, 101, 102], &u); + // Wrong held key + a fetch that yields a wrong key => confident bad. + let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); + // A range covering only ONE sector of the unit still re-reads the WHOLE + // unit from the ISO (patch re-reads partial units). + let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)]); + assert_eq!(bad, vec![(100, 3)], "undecryptable unit -> full 3-sector range"); + } + + #[test] + fn reverify_iso_fragmented_unit_reads_distant_sectors() { + let key = [0x5a; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &key); + // Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B). + let clips = vec![ClipLayout { + size: ALIGNED_UNIT_LEN as u64, + extents: vec![(10, 4096), (5000, 2048)], + }]; + let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + place_unit(&mut iso, [10, 11, 5000], &u); + let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); + // Range touches only the distant fragment; whole unit still assembled. + let bad = v.reverify_iso(&mut iso, &[(5000 * 2048, 2048)]); + assert!(bad.is_empty(), "fragmented decryptable unit re-read clean"); + } + + #[test] + fn reverify_iso_unreadable_sector_skips_unit() { + let real = [0x11; 16]; + let mut u = clear_unit(); + encrypt_unit(&mut u, &real); + let mut iso = MockIso { + sectors: Default::default(), + err_lba: Some(102), // 3rd sector unreadable + }; + place_unit(&mut iso, [100, 101, 102], &u); + let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); + let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]); + assert!(bad.is_empty(), "ISO read error on a sector -> skip (fail-safe)"); + } + + #[test] + fn eviction_bounds_inflight_partials() { + // Open more partials than the cap with single-sector feeds; the map must + // never exceed the cap (oldest evicted, unverified — fail-safe). + let mut v = UnitVerifier::new( + &vec![ClipLayout { + size: (MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64, + extents: vec![(0, u32::MAX / 2)], + }], + &aacs_keys(&[[1; 16]]), + None, + ) + .unwrap(); + // Feed only slot 0 of many distinct units (every 3rd sector). + for unit in 0..(MAX_INFLIGHT_UNITS as u32 + 50) { + let lba = unit * 3; + let _ = v.observe(lba, &[0u8; 2048]); + } + assert!( + v.partials.len() <= MAX_INFLIGHT_UNITS, + "in-flight partials bounded by the cap" + ); + } +} diff --git a/src/keysource.rs b/src/keysource.rs index 1378b74..0c1433b 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -280,6 +280,56 @@ pub fn resolve_and_apply_traced( (false, trace) } +/// THE single key-fetch: drive `sources` in order and return the first non-empty +/// Unit Key set. This is exactly what both paths do — only the samples differ: +/// * at disc open, `ctx` carries reachable-content samples → resolves the +/// up-front CPS units (the common one), +/// * in the read, on a decrypt miss, `ctx` carries the FAILING unit's ciphertext +/// → resolves the CPS unit that wasn't sampled up front. +/// +/// Same sources, same call; there is no separate "fetch". Unlike +/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's +/// decorator re-decrypts with the returned keys, which is the validation. +pub fn fetch_unit_keys(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec { + for source in sources { + if let Ok(uks) = source.get_uk(ctx) { + if !uks.is_empty() { + return uks; + } + } + } + Vec::new() +} + +/// Build the read-time key-fetch closure from the disc's public AACS inputs and +/// a way to (re)build the application's key sources. The decorator calls it with +/// the still-scrambled unit ciphertext when no held key opens that unit; it runs +/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys. +/// +/// One builder, used by every read path (sweep / patch / mux) and by every +/// consumer (CLI, autorip) — neither application contains the fetch logic, only +/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`] +/// (`Arc`): build it once, clone it into each read path. `make_sources` is +/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays +/// `Send + Sync` without requiring `KeySource: Send`. +pub fn key_fetch( + inputs: DiscInputs, + make_sources: std::sync::Arc Vec> + Send + Sync>, +) -> crate::sector::KeyFetch { + std::sync::Arc::new(move |samples: &[Vec]| -> Vec<[u8; 16]> { + let sources = make_sources(); + let mut di = inputs.clone(); + di.samples = samples.to_vec(); + // V20/V21 stride (BD/UHD AACS 2.x); the online /decode UK path forwards + // raw inf + samples and doesn't depend on the parsed title-key stride. + let ctx = DiscInputsCtx::new(&di, 2); + fetch_unit_keys(&sources, &ctx) + .into_iter() + .map(|u| u.key) + .collect() + }) +} + /// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no /// decrypt) — the content samples that populate [`DiscInputs::samples`] for a /// key server to validate a candidate against, and that [`resolve_and_apply`] @@ -290,7 +340,7 @@ pub fn resolve_and_apply_traced( /// `start_lba`), which the library owns. A key source is *handed* these bytes /// via `DiscInputs.samples`; it never reads the disc itself. /// -/// "Encrypted" is decided by [`crate::aacs::is_aacs_scrambled`] — the SAME +/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME /// predicate the decrypt gate uses — so all sides agree. A clip opens with clear /// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a /// clear unit proves nothing, so this collects only scrambled ones, sampling the @@ -300,7 +350,7 @@ pub fn read_encrypted_units( title: &crate::disc::DiscTitle, n: usize, ) -> Vec> { - use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, is_aacs_scrambled}; + use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed}; const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint @@ -336,7 +386,7 @@ pub fn read_encrypted_units( break; } let u = &buf[o..o + ALIGNED_UNIT_LEN]; - if is_aacs_scrambled(u) { + if ts_sync_destroyed(u) { out.push(u.to_vec()); if out.len() >= n { return out; @@ -353,6 +403,7 @@ pub fn read_encrypted_units( mod tests { use super::*; use crate::aacs::UnitKey; + use std::sync::{Arc, Mutex}; // ── KeySource default-method behaviour ──────────────────────────────────── @@ -460,4 +511,114 @@ mod tests { let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect(); assert_eq!(whos, vec!["keydb", "my-custom-source"]); } + + // ── fetch_unit_keys / key_fetch (the one shared fetch path) ─────────────── + + fn empty_inputs() -> DiscInputs { + DiscInputs { + disc_hash: String::new(), + volume_id: [0u8; 16], + mkb: Vec::new(), + unit_key_ro: Vec::new(), + samples: Vec::new(), + volume_label: None, + } + } + + struct EmptySource; + impl KeySource for EmptySource { + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(Vec::new()) + } + } + struct ErroringSource; + impl KeySource for ErroringSource { + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Err(Error::AacsNoKeys) + } + } + struct HasKey([u8; 16]); + impl KeySource for HasKey { + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { + Ok(vec![UnitKey { + idx: 0, + key: self.0, + }]) + } + } + + /// `fetch_unit_keys` returns the FIRST source's non-empty keys, skipping a + /// source that returns empty or errors; empty when no source answers. + #[test] + fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() { + let inputs = empty_inputs(); + let ctx = DiscInputsCtx::new(&inputs, 2); + let key = [0xABu8; 16]; + + let sources: Vec> = vec![ + Box::new(EmptySource), + Box::new(ErroringSource), + Box::new(HasKey(key)), + ]; + let got = fetch_unit_keys(&sources, &ctx); + assert_eq!(got.len(), 1, "the first source that answers wins"); + assert_eq!(got[0].key, key); + + let none: Vec> = vec![Box::new(EmptySource), Box::new(ErroringSource)]; + assert!( + fetch_unit_keys(&none, &ctx).is_empty(), + "no source answers ⇒ empty" + ); + } + + /// `key_fetch` builds a closure that runs the sources with the GIVEN failing + /// samples and returns their keys — the exact bytes are forwarded to the + /// source, and `make_sources` is invoked per call. + #[test] + fn key_fetch_closure_forwards_samples_and_returns_keys() { + let key = [0x5au8; 16]; + let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let builds = Arc::new(Mutex::new(0usize)); + + struct Probe { + key: [u8; 16], + seen: Arc>>>, + } + impl KeySource for Probe { + fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result, Error> { + if let Ok(s) = ctx.samples(8) { + self.seen.lock().unwrap().extend(s); + } + Ok(vec![UnitKey { + idx: 0, + key: self.key, + }]) + } + } + + let seen_c = Arc::clone(&seen); + let builds_c = Arc::clone(&builds); + let make: Arc Vec> + Send + Sync> = Arc::new(move || { + *builds_c.lock().unwrap() += 1; + vec![Box::new(Probe { + key, + seen: Arc::clone(&seen_c), + }) as Box] + }); + + let cb = key_fetch(empty_inputs(), make); + let samples = vec![vec![0xEEu8; crate::aacs::ALIGNED_UNIT_LEN]]; + let got = cb(&samples); + assert_eq!( + got, + vec![key], + "the source's key flows back through the closure" + ); + assert_eq!( + seen.lock().unwrap().len(), + 1, + "the failing ciphertext sample is forwarded to the source" + ); + assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); + } } diff --git a/src/lib.rs b/src/lib.rs index 12e89b4..aaaffe9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -253,8 +253,8 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; pub use mux::build_iso_pipeline; pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives}; pub use sector::{ - DecryptingSectorSource, FileSectorSink, FileSectorSource, PrefetchedSectorSource, SectorSink, - SectorSource, + DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource, + SectorSink, SectorSource, }; pub use speed::DriveSpeed; pub use udf::{UdfFs, read_filesystem}; diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index 38aa35f..e8480dc 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -13,6 +13,7 @@ //! We skip AC-3 frames and only emit TrueHD access units. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; +use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS; /// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family /// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and @@ -138,18 +139,47 @@ impl CodecParser for TrueHdParser { // the next PES legitimately begins a new AU and seeds the base. if self.buf.is_empty() { if let Some(pts) = pes.pts { - // Resync to the authoritative PES PTS, but NEVER snap backward. - // TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the - // per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so - // than the disc's per-PES PTS, which carries the source muxer's - // own rounding jitter. When the buffer empties exactly on a PES - // boundary and that PES's PTS lands a few ticks *below* the - // running cadence, an unconditional reset would set the next - // AU's timestamp below the AU just emitted, producing the - // non-monotonic block timestamps a muxer rejects. Clamp to the - // running position so output stays strictly monotonic; a - // genuine forward gap/discontinuity is still adopted. - self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts)); + // Resync to the authoritative PES PTS. TrueHD AUs are a fixed + // sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS` + // cadence is sample-accurate — more so than the disc's per-PES + // PTS, which carries the source muxer's own rounding jitter. + // + // Two distinct backward steps must be handled OPPOSITELY: + // + // 1. Small backward jitter (sub-second PES rounding): when the + // buffer empties exactly on a PES boundary and that PES's PTS + // lands a few ticks *below* the running cadence, an + // unconditional reset would set the next AU's timestamp below + // the AU just emitted, producing non-monotonic block + // timestamps a muxer rejects. CLAMP to the running position so + // output stays strictly monotonic. + // + // 2. Large backward step (> DISCONTINUITY_BACKSTEP_NS): this is a + // clip-boundary PTS reset — the title's clips are read as one + // concatenated stream and a non-seamless boundary resets the + // source PES PTS near zero. This is NOT jitter and must NOT be + // clamped: clamping strands the audio at the previous clip's + // tail cadence, so when `TimelineContinuity` later bumps the + // global offset for the new epoch (driven by the video + // back-jump) the stranded-high audio PTS is flung ~a whole + // clip past the frontier, producing the non-monotonic + // audio-DTS band on multi-clip titles (Dune: Part Two, Top + // Gun). ADOPT the raw reset so the per-track raw PTS that + // reaches `TimelineContinuity` carries the true boundary, and + // the corrector rebases it exactly as it already does for the + // DTS / AC-3 parsers (which never clamp). Same threshold the + // timeline corrector uses to classify a discontinuity. + // + // A genuine forward gap/discontinuity is always adopted by the + // `.max()`. + let new = pts_to_ns(pts); + if new < self.next_pts_ns - DISCONTINUITY_BACKSTEP_NS { + // Clip-boundary reset: take the raw PTS, restart the cadence. + self.next_pts_ns = new; + } else { + // Within-clip jitter (or forward progression): stay monotonic. + self.next_pts_ns = self.next_pts_ns.max(new); + } } } @@ -490,6 +520,44 @@ mod tests { ); } + #[test] + fn clip_boundary_pts_reset_is_adopted_not_clamped() { + // Regression (Dune: Part Two / Top Gun non-monotonic audio-DTS band): + // a title's clips are read as one concatenated stream, so at a + // non-seamless boundary the source PES PTS resets near zero — a LARGE + // backward step (> DISCONTINUITY_BACKSTEP_NS), NOT muxer jitter. The + // parser must ADOPT that reset (restart the cadence at the raw PTS), the + // same way the DTS / AC-3 parsers pass raw PTS through, so the per-track + // raw PTS reaching TimelineContinuity carries the true boundary and the + // corrector can rebase it. Clamping it forward (the old `.max()`) stranded + // the audio at the previous clip's tail; when the global offset later + // bumped for the new epoch the stranded audio was flung ~a clip past the + // frontier — the non-monotonic band. + let mut parser = TrueHdParser::new(); + let au = make_truehd_unit(100); + // Clip 1: an AU at PES PTS = 10s (90000 ticks/s → 900_000 ticks). Buffer + // empties, so the next PES seeds a fresh base. + let clip1_pts = 90_000 * 10; // 10 s in 90 kHz ticks + let f1 = parser.parse(&make_pes(au.clone(), Some(clip1_pts))); + assert_eq!(f1.len(), 1); + let last1 = f1[0].pts_ns; + assert_eq!(last1, pts_to_ns(clip1_pts)); + // Clip 2: PES PTS resets to 0 — 10 s backward, far beyond the 3 s + // discontinuity threshold. Must be adopted, not clamped to the cadence. + let f2 = parser.parse(&make_pes(au.clone(), Some(0))); + assert_eq!(f2.len(), 1); + assert_eq!( + f2[0].pts_ns, 0, + "clip-boundary PTS reset must be adopted raw (got {}, expected the \ + reset value 0 — clamping to the previous clip's cadence is the bug)", + f2[0].pts_ns + ); + assert!( + f2[0].pts_ns < last1, + "the reset frame must land below the previous clip's tail, not above it" + ); + } + #[test] fn skip_interleaved_ac3() { let mut parser = TrueHdParser::new(); diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 1b6e8b9..4c0d124 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1036,7 +1036,7 @@ mod tests { /// Recording `SectorSource`: logs every `(lba, count)` request and /// returns `Err` whenever the requested range covers `bad_sector`. /// Successful reads return zeroed sectors (which are NOT - /// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through + /// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through /// even with synthetic AACS keys — no real decrypt is attempted). struct RecordingReader { capacity: u32, diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 92695ea..87af81c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -245,7 +245,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { } /// Options for opening an input stream. -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct InputOptions { /// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc /// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the @@ -257,6 +257,26 @@ pub struct InputOptions { pub title_index: Option, /// Skip decryption — return raw encrypted bytes. pub raw: bool, + /// Optional fresh-key-on-failure closure (a shared [`crate::sector::KeyFetch`]). + /// `None` (default) keeps the prior behaviour: a unit no held key decrypts is + /// counted as decrypt loss. When set, the mux installs it (cloned `Arc`) so a + /// still-scrambled unit is re-tried via the application's key source. + /// Application seam only; the library makes no network call. + pub key_fetch: Option, +} + +// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the +// impl (the prior derive is preserved for every other field) so `InputOptions` +// stays printable without dumping key material. +impl std::fmt::Debug for InputOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InputOptions") + .field("unit_keys", &self.unit_keys.len()) + .field("title_index", &self.title_index) + .field("raw", &self.raw) + .field("key_fetch", &self.key_fetch.is_some()) + .finish() + } } /// Open a PES input stream (produces PES frames). @@ -381,6 +401,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Result DemuxState { /// - `halt`: cooperative cancel token (not a timeout); when cancelled the /// pipeline stops at the next boundary. `None` disables cancellation. /// - `event_fn`: optional progress/event callback invoked by the prefetcher. +/// - `fetch`: optional fresh-key-on-failure callback (see +/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the +/// decrypt decorator hands that ciphertext to `fetch` and adds any key it +/// returns. `None` keeps the prior behaviour (the unit is counted as loss). +// Eight reader/title/keys/tuning/callback params is inherent to the mux entry +// point; grouping them into a struct would only move the same fields around. +#[allow(clippy::too_many_arguments)] pub fn build_iso_pipeline( reader: S, title: DiscTitle, @@ -581,6 +617,7 @@ pub fn build_iso_pipeline( format: ContentFormat, halt: Option, event_fn: Option, + fetch: Option, ) -> io::Result { let extents = title.extents.clone(); // Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector) @@ -594,6 +631,12 @@ pub fn build_iso_pipeline( }; let mut decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); + // 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. + if let Some(cb) = fetch { + decrypting = decrypting.with_key_fetch(cb); + } // Grab the decrypt-loss counter 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 @@ -1116,6 +1159,7 @@ mod tests { ContentFormat::BdTs, None, None, + None, ) .expect("pipeline builds"); let first = stream.read().expect("read must not error on clean EOF"); @@ -1156,6 +1200,7 @@ mod tests { ContentFormat::BdTs, None, None, + None, ) .expect("pipeline builds"); @@ -1207,6 +1252,7 @@ mod tests { ContentFormat::BdTs, None, None, + None, ); assert!(res.is_err(), "zero batch_sectors must be rejected"); } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 3e86b82..102a69b 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -13,13 +13,69 @@ //! pass-through, so callers can wire it unconditionally and keep //! their pipeline shape uniform regardless of encryption state. -use crate::decrypt::{DecryptKeys, decrypt_sectors}; +use crate::decrypt::{DecryptKeys, decrypt_sectors, decrypt_sectors_in_content}; use crate::error::Result; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use super::SectorSource; +/// Application-supplied "fetch a fresh key for THIS data" callback. +/// +/// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS +/// units that NONE of the currently-held unit keys could decrypt. The argument +/// is those still-scrambled 6144-byte aligned units (real on-disc ciphertext); +/// the return is any additional unit keys to add to the pool and retry with — +/// empty if the source can't help. Mirrors the DVD model (try the held key, +/// then ask the key source for the failing data) generalised to AACS. +/// +/// The library performs NO key lookup or network I/O itself; this closure is +/// the seam an application uses to call its key source (e.g. an online key +/// service) with the exact ciphertext that failed. A **stateless, shared** +/// `Arc` — the decorator owns the only mutable state (its call-count cap and +/// spent flag), so one closure is built once and cloned cheaply into every read +/// path (sweep / patch / mux); no per-decorator factory is needed. `Send + Sync` +/// so it can ride the mux highway's producer thread. +pub type KeyFetch = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; + +/// Cap on how many times one decorator will call the fetch closure over its +/// lifetime — bounds key-server traffic to roughly O(distinct CPS units) even +/// if scrambled units keep arriving. A disc has only a handful of unit keys. +const MAX_FETCH_CALLS: usize = 16; + +/// Cap on how many still-scrambled sample units are handed to the fetch +/// closure per call — a few samples are plenty for a key service to identify +/// and validate the key, and it bounds the request size. +const MAX_FETCH_SAMPLES: usize = 8; + +/// Cap on how many per-unit decrypt-verify-failure diagnostics one read emits. +/// The diagnostic runs only on the failure (cold) path and bounds log volume so +/// a large undecryptable range can't flood the device log; the first few units +/// of any failed read fully characterise it (all-zero vs ciphertext, latency, +/// best-key-fit). +const MAX_DIAG_UNITS_PER_READ: usize = 4; + +/// Master switch: "a read is not successful unless it also DECRYPTS." +/// +/// When `true`, a read that returns scrambled AACS units which NO held key +/// (after any fetch) could decrypt fails loud with [`Error::DecryptFailed`] +/// instead of silently passing the still-encrypted bytes downstream. This turns +/// an undecryptable unit into a *read failure*, so: +/// * the rip's existing read-error recovery (sweep skip-ahead → patch +/// re-read) re-reads it off the disc while the disc is still present, and +/// * the mux path hard-fails (there is no clean data to mux) rather than +/// dropping content without a TS sync and reporting a clean rip. +/// +/// All-zero (zero-filled) units are NOT `ts_sync_destroyed`, so they never trip +/// this — allowed-loss zero-fill that some authoring deliberately leaves stays +/// allowed (logged loud + continue elsewhere). Only the keyless raw sweep is +/// unaffected: it carries [`DecryptKeys::None`], so the `Aacs` guard below is +/// never met there and ciphertext is written verbatim. +/// +/// Hardcoded `true`. Flip to `false` to ship without the behaviour — the unit +/// is then counted as decrypt loss exactly as before (the prior contract). +pub const DECRYPT_VERIFY_READ: bool = true; + /// Decorator: read from `inner`, then run the configured /// AACS / CSS decrypt over the bytes that landed in `buf`. /// @@ -52,6 +108,38 @@ pub struct DecryptingSectorSource { /// /// [`decrypt_loss`]: Self::decrypt_loss decrypt_dropped: Arc, + /// Optional "fetch a fresh key for THIS data" callback (see [`KeyFetch`]). + /// `None` for the common case (keys fully resolved up front); set via + /// [`with_key_fetch`](Self::with_key_fetch) by an application that wants + /// to ask its key source for a key when a unit fails to decrypt. + fetch: Option, + /// Latched once a fetch call returns no NEW key — further failures on this + /// decorator then skip the callback (the source has nothing more to offer, so + /// re-asking would only burn key-server requests). + fetch_spent: bool, + /// How many times the fetch closure has been invoked, capped at + /// [`MAX_FETCH_CALLS`]. + fetch_calls: usize, + /// Verify-only mode: a read decrypt-CHECKS a scratch copy of the bytes (to + /// detect undecryptable units) but NEVER mutates `buf` — the inner + /// ciphertext is returned unchanged. This is what makes a multipass sweep + /// decrypt-aware: the sweep must write the *encrypted* bytes to the ISO, yet + /// a unit that won't decrypt must still fail the read (`DECRYPT_VERIFY_READ`) + /// so the existing read-error recovery (skip / NonTrimmed / patch) handles + /// it. Default `false` (decrypt in place, the mux / `--no-raw` path). + verify_only: bool, + /// Encrypted-content extent map — the disc's m2ts ranges as sorted/merged + /// `(start_lba, sector_count)` (see + /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). + /// When `Some`, a unit whose absolute LBA is OUTSIDE these ranges is clear + /// (UDF filesystem / BDMV nav) and is passed through untouched: never + /// decrypted, verified, or counted as loss. `None` means "the caller only + /// reads encrypted content" (the mux reads title extents only) → every unit + /// is treated as content (the legacy behaviour). + content_ranges: Option>, + /// 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, } impl DecryptingSectorSource { @@ -67,9 +155,37 @@ impl DecryptingSectorSource { unit_key_idx: 0, unit_base: 0, decrypt_dropped: Arc::new(AtomicU64::new(0)), + fetch: None, + fetch_spent: false, + fetch_calls: 0, + verify_only: false, + content_ranges: None, + scratch: Vec::new(), } } + /// Restrict decrypt/verify to the disc's encrypted-content extents + /// (sorted/merged `(start_lba, sector_count)` — see + /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). + /// Units outside content (UDF filesystem / BDMV nav) pass through untouched, + /// so [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) is never consulted + /// about non-content bytes. Whole-disc readers (sweep / patch) set this; the + /// mux leaves it unset because it only ever reads title extents. + pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self { + self.content_ranges = Some(ranges); + self + } + + /// Switch to verify-only mode: decrypt-CHECK each read on a scratch copy and + /// fail the read (`DECRYPT_VERIFY_READ`) when a scrambled AACS unit won't + /// decrypt, but leave `buf` as the original ciphertext. The multipass sweep + /// uses this so its ISO stays encrypted while still rejecting silent-bad + /// reads. No-op effect for `DecryptKeys::None` (nothing to check). + pub fn verify_only(mut self) -> Self { + self.verify_only = true; + self + } + /// A handle to this decorator's decrypt-loss counter — the cumulative bytes /// of scrambled AACS units that no key could decrypt (see /// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this @@ -87,6 +203,16 @@ impl DecryptingSectorSource { self } + /// Install a [`KeyFetch`] callback: when a read holds scrambled AACS units + /// that no current key decrypts, the decorator hands those units to `cb` and + /// adds any keys it returns to the pool, then re-decrypts. Only meaningful + /// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network + /// call — `cb` is the application's seam to its key source. + pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self { + self.fetch = Some(cb); + self + } + /// Replace the configured keys without unwrapping the decorator. /// Used by `DiscStream::set_raw()` to flip from encrypted-disc /// decryption to a pass-through after the inner reader is already @@ -113,6 +239,175 @@ impl DecryptingSectorSource { pub fn into_inner(self) -> S { self.inner } + + /// Decrypt `buf` in place with the active keys, applying the content gate + /// when one is installed (whole-disc readers) or running ungated (the mux). + /// The single dispatch both the first read and the post-fetch retry share, + /// so they agree on which units are content and on the unit-key try order. + fn decrypt_buf( + buf: &mut [u8], + keys: &mut DecryptKeys, + unit_key_idx: usize, + lba: u32, + content: Option<&[(u32, u32)]>, + ) -> Result { + match content { + Some(ranges) => decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges), + None => decrypt_sectors(buf, keys, unit_key_idx), + } + } + + /// Collect the still-scrambled aligned units in `buf`, hand them to the + /// fetch callback, add any returned keys not already held to the AACS + /// pool (the CACHE — every later unit this pass, and any later read, reuses + /// them), and re-decrypt `buf`. Returns the post-retry dropped-byte count + /// (equal to `prev_dropped` when the callback could not help). The re-decrypt + /// is content-gated identically to the first read so a non-content unit is + /// never re-attempted. Caller guarantees the keys are `DecryptKeys::Aacs`, a + /// callback is installed, and the call budget is not yet spent. + fn fetch_failed_units( + &mut self, + buf: &mut [u8], + lba: u32, + content: Option<&[(u32, u32)]>, + prev_dropped: usize, + ) -> usize { + let unit_len = crate::aacs::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 (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::aacs_unit_needs_decrypt(chunk) { + samples.push(chunk.to_vec()); + if samples.len() >= MAX_FETCH_SAMPLES { + break; + } + } + } + if samples.is_empty() { + return prev_dropped; + } + // Ask the application's key source for keys that open this ciphertext. + self.fetch_calls += 1; + let fresh = match self.fetch.as_ref() { + Some(cb) => cb(&samples), + None => return prev_dropped, + }; + // Add only keys we don't already hold (dedup by value). + let mut added = 0usize; + if let DecryptKeys::Aacs { unit_keys, .. } = &mut self.keys { + for k in fresh { + if !unit_keys.iter().any(|(_, have)| *have == k) { + let idx = unit_keys.len() as u32; + unit_keys.push((idx, k)); + added += 1; + } + } + } + if added == 0 { + // Nothing new — stop asking for the rest of this decorator's life. + self.fetch_spent = true; + 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. + Self::decrypt_buf(buf, &mut self.keys, self.unit_key_idx, lba, content) + .unwrap_or(prev_dropped) + } + + /// Emit a bounded, structured diagnostic for each undecryptable unit in a + /// failed verify read. Called only on the failure (cold) path. On a fresh + /// rip `buf` holds the post-decrypt bytes straight off the drive, so the + /// per-unit signature is source ground truth (see the call site). + /// + /// Fields, per failing in-content unit: + /// * `lba` — absolute disc LBA of the unit + /// * `read_ms` — how long the inner drive read took (recovery grind vs clean + /// fast read) + /// * `all_zero` — the unit is every-byte-`0x00` (source zero-fill, seen fresh + /// off the disc — no ISO ambiguity) + /// * `ts_sync`/`ts_total` — TS sync bytes present vs possible (0/32 ⇒ + /// scrambled-looking) + /// * `distinct` — distinct byte values (entropy proxy: 1 ⇒ constant fill, + /// ~256 ⇒ ciphertext/garbage) + /// * `best_sync` — the most TS syncs ANY held key restores (≈0 ⇒ no key fits + /// → missing key / garbage; high ⇒ a key nearly works → marginal bytes) + /// * `head` — first 16 bytes (the plaintext TP_extra header) in hex + fn diagnose_decrypt_failure( + base_lba: u32, + buf: &[u8], + read_ms: u64, + content: Option<&[(u32, u32)]>, + keys: &DecryptKeys, + ) { + let unit_len = crate::aacs::ALIGNED_UNIT_LEN; + let unit_sectors = (unit_len / 2048) as u32; + // Only AACS produces decrypt-verify failures; None / CSS never reach here + // with a non-zero dropped count. + let (unit_keys, rdk) = match keys { + DecryptKeys::Aacs { + unit_keys, + read_data_key, + } => (unit_keys, *read_data_key), + _ => return, + }; + let mut emitted = 0usize; + for (i, chunk) in buf.chunks_exact(unit_len).enumerate() { + if emitted >= MAX_DIAG_UNITS_PER_READ { + break; + } + let unit_lba = base_lba.saturating_add(i as u32 * unit_sectors); + let in_content = match content { + Some(r) => crate::decrypt::lba_in_ranges(unit_lba, r), + None => true, + }; + // A unit that decrypted is no longer sync-destroyed; a CPI-clear or + // non-content unit is gated out. Only undecryptable in-content units + // that are flagged encrypted carry signal. + if !in_content || !crate::aacs::aacs_unit_needs_decrypt(chunk) { + continue; + } + let all_zero = chunk.iter().all(|&b| b == 0); + let ts_sync = crate::aacs::ts_sync_count(chunk); + let ts_total = crate::aacs::ts_packet_total(chunk); + let mut seen = [false; 256]; + for &b in chunk { + seen[b as usize] = true; + } + let distinct = seen.iter().filter(|&&x| x).count(); + // Does ANY held key get this unit closer to clear TS? + let mut best_sync = ts_sync; + for (_, k) in unit_keys.iter() { + let mut attempt = chunk.to_vec(); + if let Some(ref rdk_key) = rdk { + crate::aacs::decrypt_bus(&mut attempt, rdk_key); + } + crate::aacs::decrypt_unit(&mut attempt, k); + let s = crate::aacs::ts_sync_count(&attempt); + if s > best_sync { + best_sync = s; + } + } + let head: String = chunk[..16].iter().map(|b| format!("{b:02x}")).collect(); + tracing::warn!( + target: "freemkv::decrypt", + lba = unit_lba, + in_content, + read_ms, + all_zero, + ts_sync, + ts_total, + distinct, + best_sync, + keys_held = unit_keys.len(), + head, + "decrypt-verify fail" + ); + emitted += 1; + } + } } impl SectorSource for DecryptingSectorSource { @@ -143,16 +438,110 @@ impl SectorSource for DecryptingSectorSource { { return Err(crate::error::Error::DecryptFailed); } + let read_t0 = std::time::Instant::now(); let n = self.inner.read_sectors(lba, count, buf, recovery)?; - // Apply the crate-wide AACS/CSS/None decrypt entry point in-place - // over the bytes just read. No-op for DecryptKeys::None. The returned - // count is bytes of scrambled units no key could decrypt — silent - // decrypt loss the TS assembler will drop. Tally it so the mux loss - // accounting (and the abort gate) can see partial decrypt failure. - let dropped = decrypt_sectors(&mut buf[..n], &mut self.keys, self.unit_key_idx)?; + let read_ms = read_t0.elapsed().as_millis() as u64; + // Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*` + // dispatches on the keys (None / CSS / AACS) and returns the count of + // bytes that SHOULD have decrypted but couldn't — the silent-bad-read + // signal. Only AACS ever produces a non-zero count, so nothing below + // needs a per-scheme check. When a content map is installed (whole-disc + // readers), the `*_in_content` entry skips units OUTSIDE the encrypted + // content extents, so clear filesystem / nav bytes are never mistaken for + // ciphertext. The mux installs no map (it reads title extents only). + // + // VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf` + // keeps its ciphertext (the ISO stays encrypted) and the hot path pays no + // per-read allocation. NORMAL: decrypt in place; a fetch callback may + // recover a unit no held key opened. + let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow + let content_ref = content.as_deref(); + // Whether a fresh-key fetch is still worth attempting on this decorator. + let fetch_viable = + !self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS; + // 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` + // keeps its ciphertext (the ISO stays encrypted), but STILL fetch — + // the whole point is to CACHE the key. The fetched key is added to the + // pool, so the unit that triggered it now verifies clean (no false + // read-failure / damage-jump) and every later unit this pass — and any + // later read on this decorator — reuses it instead of re-asking the key + // server. Without this a CPS unit whose key wasn't sampled up front + // (an orphan clip not reachable from any playlist) hard-fails the whole + // range even though one key fetch would recover it. + // * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry. + // The fetch re-decrypt targets the post-decrypt buffer (scratch / buf), + // whose still-scrambled units ARE the failures. + let dropped = if self.verify_only { + let mut scratch = std::mem::take(&mut self.scratch); + scratch.clear(); + scratch.extend_from_slice(&buf[..n]); + let mut d = match Self::decrypt_buf( + &mut scratch, + &mut self.keys, + self.unit_key_idx, + lba, + content_ref, + ) { + Ok(d) => d, + Err(e) => { + self.scratch = scratch; + return Err(e); + } + }; + if d > 0 && fetch_viable { + d = self.fetch_failed_units(&mut scratch, lba, content_ref, d); + } + self.scratch = scratch; + d + } else { + let mut d = Self::decrypt_buf( + &mut buf[..n], + &mut self.keys, + self.unit_key_idx, + lba, + content_ref, + )?; + if d > 0 && fetch_viable { + d = self.fetch_failed_units(&mut buf[..n], lba, content_ref, d); + } + d + }; if dropped > 0 { self.decrypt_dropped .fetch_add(dropped as u64, Ordering::Relaxed); + // 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 + // read-error recovery re-reads it off the disc (rip) or the mux hard- + // fails (no clean data to mux). Scheme-agnostic (only AACS reaches a + // non-zero count); clear filesystem (gated out) and zero-fill (not + // scrambled) never get here. + if DECRYPT_VERIFY_READ { + // FACT-FINDING: on a fresh rip these bytes came straight off the + // drive, so each failing unit's signature (all-zero? entropy? + // 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). + let diag: &[u8] = if self.verify_only { + &self.scratch + } else { + &buf[..n] + }; + Self::diagnose_decrypt_failure( + lba, + diag, + read_ms, + self.content_ranges.as_deref(), + &self.keys, + ); + return Err(crate::error::Error::DecryptFailed); + } } Ok(n) } @@ -484,7 +873,7 @@ mod tests { /// A source that yields exactly one CLEAR AACS aligned unit (6144 /// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS - /// stride (offset 4, then every 192 bytes). `is_aacs_scrambled` + /// stride (offset 4, then every 192 bytes). `ts_sync_destroyed` /// reports such a unit as NOT scrambled, so the AACS decrypt path /// reaches the per-unit closure and leaves it untouched — letting /// us prove the unit-key LOOKUP (not the cipher) is what fails for @@ -740,6 +1129,8 @@ mod tests { unit[off] = 0x47; off += 192; } + // CPI bits on byte 0 so it reads as encrypted; set before key derivation. + unit[0] |= 0xC0; let header: [u8; 16] = unit[..16].try_into().unwrap(); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let mut k = [0u8; 16]; @@ -797,7 +1188,10 @@ mod tests { let unit = encrypt_aacs_unit(&real_key); - // Wrong key → undecryptable → loss counted, read still Ok. + // Wrong key → undecryptable → loss counted AND the read fails loud + // (DECRYPT_VERIFY_READ: a read that returns an undecryptable AACS unit + // did not truly succeed). The loss counter is still bumped before the + // error so the abort accounting sees the byte count. let mut wrapped = DecryptingSectorSource::new( EncUnitSource { unit: unit.clone() }, DecryptKeys::Aacs { @@ -809,17 +1203,24 @@ mod tests { assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero"); let mut buf = vec![0u8; 3 * 2048]; - wrapped + let err = wrapped .read_sectors(0, 3, &mut buf, false) - .expect("undecryptable unit must NOT hard-error (per-unit tolerance)"); + .expect_err("DECRYPT_VERIFY_READ: an undecryptable AACS unit fails the read loud"); + assert!( + matches!(err, crate::error::Error::DecryptFailed), + "undecryptable unit errors with DecryptFailed, got {err:?}" + ); assert_eq!( loss.load(Ordering::Relaxed), crate::aacs::ALIGNED_UNIT_LEN as u64, - "one undecryptable unit must add its byte length to the loss counter" + "the undecryptable unit is tallied as loss before the read errors" ); - // A second read of the same bad unit accumulates further. - wrapped.read_sectors(0, 3, &mut buf, false).unwrap(); + // A second read of the same bad unit accumulates further (and errors). + assert!( + wrapped.read_sectors(0, 3, &mut buf, false).is_err(), + "the same bad unit fails the read again" + ); assert_eq!( loss.load(Ordering::Relaxed), 2 * crate::aacs::ALIGNED_UNIT_LEN as u64, @@ -843,6 +1244,100 @@ mod tests { ); } + /// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set + /// would normally count as decrypt loss. With a [`with_key_fetch`] callback + /// that returns that key, the decorator must hand the still-scrambled unit to + /// the callback, add the returned key, re-decrypt, and register ZERO loss. + /// Without the callback the same read accumulates loss (the baseline). + /// + /// Grounding: `read_sectors` invokes `fetch_failed_units` when + /// `decrypt_sectors` leaves a scrambled unit and a callback is installed. + #[test] + fn key_fetch_recovers_unit_with_a_fresh_key() { + let real_key = [0x5au8; 16]; // the key the unit is actually under + let wrong_key = [0x11u8; 16]; // the only key we start with + + struct EncUnitSource { + unit: Vec, + } + impl SectorSource for EncUnitSource { + 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.unit); + Ok(bytes) + } + } + + let unit = encrypt_aacs_unit(&real_key); + + // Capture what the callback was handed, and how many times it fired. + let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let seen_cb = Arc::clone(&seen); + let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec]| { + seen_cb.lock().unwrap().extend_from_slice(samples); + vec![real_key] + }); + + let mut wrapped = DecryptingSectorSource::new( + EncUnitSource { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong_key)], + read_data_key: None, + }, + ) + .with_key_fetch(fetch); + let loss = wrapped.decrypt_loss(); + + let mut buf = vec![0u8; 3 * 2048]; + wrapped.read_sectors(0, 3, &mut buf, false).unwrap(); + + assert_eq!( + loss.load(Ordering::Relaxed), + 0, + "fetch supplied the key → the unit decrypts → zero loss" + ); + let got = seen.lock().unwrap(); + assert_eq!( + got.len(), + 1, + "callback must be invoked once with the failing unit" + ); + assert!( + crate::aacs::ts_sync_destroyed(&got[0]), + "the sample handed to the callback is the still-scrambled ciphertext" + ); + assert_eq!( + got[0], unit, + "the exact on-disc unit is forwarded for fetch" + ); + + // Baseline: same setup WITHOUT a callback accumulates loss. + let mut nocb = DecryptingSectorSource::new( + EncUnitSource { unit }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong_key)], + read_data_key: None, + }, + ); + let nocb_loss = nocb.decrypt_loss(); + let mut buf2 = vec![0u8; 3 * 2048]; + assert!( + nocb.read_sectors(0, 3, &mut buf2, false).is_err(), + "without a fetch callback the undecryptable unit fails the read (DECRYPT_VERIFY_READ)" + ); + assert_eq!( + nocb_loss.load(Ordering::Relaxed), + crate::aacs::ALIGNED_UNIT_LEN as u64, + "without a fetch callback the undecryptable unit is loss" + ); + } + /// `into_inner` / `inner` / `inner_mut` must hand back the original /// source unchanged. Grounding: the accessor methods. #[test] @@ -854,4 +1349,390 @@ mod tests { let recovered = wrapped.into_inner(); assert_eq!(recovered.capacity_sectors(), 42); } + + /// Verify-only mode (the multipass sweep/patch path): a read decrypt-CHECKS + /// the bytes but NEVER mutates `buf`, so the ISO keeps its ciphertext. An + /// undecryptable unit still fails the read (DECRYPT_VERIFY_READ) so the + /// existing read-error recovery treats it like a SCSI failure; a decryptable + /// unit returns Ok with the ciphertext intact (the check is non-destructive). + #[test] + fn verify_only_checks_without_mutating_and_fails_on_undecryptable() { + let real_key = [0x33u8; 16]; + let wrong_key = [0x44u8; 16]; + + struct EncUnitSource { + unit: Vec, + } + impl SectorSource for EncUnitSource { + 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.unit); + Ok(bytes) + } + } + + let unit = encrypt_aacs_unit(&real_key); + + // Wrong key → undecryptable → read FAILS, but buf is untouched ciphertext. + let mut bad = DecryptingSectorSource::new( + EncUnitSource { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong_key)], + read_data_key: None, + }, + ) + .verify_only(); + let mut buf = vec![0u8; 3 * 2048]; + let err = bad + .read_sectors(0, 3, &mut buf, false) + .expect_err("verify-only: an undecryptable unit must fail the read"); + assert!(matches!(err, crate::error::Error::DecryptFailed)); + assert_eq!( + buf, unit, + "verify-only must NOT mutate buf — ISO stays ciphertext" + ); + + // Right key → read OK, and buf is STILL the original ciphertext (the + // decrypt happened on a scratch copy, not in place). + let mut good = DecryptingSectorSource::new( + EncUnitSource { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, real_key)], + read_data_key: None, + }, + ) + .verify_only(); + let mut buf2 = vec![0u8; 3 * 2048]; + good.read_sectors(0, 3, &mut buf2, false) + .expect("verify-only: a decryptable unit reads OK"); + assert_eq!( + buf2, unit, + "verify-only leaves ciphertext in buf even when the unit decrypts" + ); + } + + /// THE first-2 GB regression at the READ level. With a content map installed, + /// a verify-only read of a scrambled-LOOKING but CLEAR region (UDF filesystem + /// OUTSIDE the content extents) must read OK — not false-fail — while a read + /// INSIDE content that won't decrypt still fails. Before the content gate, the + /// filesystem read was mis-classified as undecryptable ciphertext and the + /// whole opening of every disc was marked NonTrimmed. + #[test] + fn verify_only_content_gate_passes_clear_filesystem_fails_content() { + // Source returns sync-destroyed bytes (looks like ciphertext) for any LBA. + struct ScrambledSource; + impl SectorSource for ScrambledSource { + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + let bytes = count as usize * 2048; + for (i, b) in buf[..bytes].iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(31); + } + let mut off = 4; + while off < bytes { + buf[off] = 0xA5; // force a NON-sync byte at every TS probe stride + off += 192; + } + // CPI bits on each aligned unit's byte 0 so it reads as encrypted. + let mut u = 0; + while u < bytes { + buf[u] |= 0xC0; + u += crate::aacs::ALIGNED_UNIT_LEN; + } + Ok(bytes) + } + } + + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + // Content lives at LBA 1002..1101 (3-aligned start so reads pass the + // unit-alignment gate). Everything before it is "filesystem". + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); + let mut dec = DecryptingSectorSource::new(ScrambledSource, keys) + .verify_only() + .with_content_ranges(ranges); + let mut buf = vec![0u8; 3 * 2048]; + + // LBA 0 — OUTSIDE content (filesystem). Scrambled-looking, but clear by + // position → must read OK (the regression that broke the first 2 GB). + dec.read_sectors(0, 3, &mut buf, false) + .expect("a clear filesystem region must read OK — no false decrypt-fail"); + + // LBA 1002 — INSIDE content, undecryptable → the read must fail loud. + let err = dec + .read_sectors(1002, 3, &mut buf, false) + .expect_err("an undecryptable content unit must fail the read"); + assert!(matches!(err, crate::error::Error::DecryptFailed)); + } + + /// Source that returns a fixed unit's bytes for any read. + struct FixedUnit { + unit: Vec, + } + impl SectorSource for FixedUnit { + 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.unit); + Ok(bytes) + } + } + + /// verify-only + content map: an in-content unit that DOES decrypt reads OK, + /// and `buf` keeps its CIPHERTEXT (the verify is non-mutating). + #[test] + fn verify_only_content_gate_decryptable_unit_keeps_ciphertext() { + let key = [0x5a; 16]; + let unit = encrypt_aacs_unit(&key); + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]); // LBA 0..3 is content + let mut dec = DecryptingSectorSource::new( + FixedUnit { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + }, + ) + .verify_only() + .with_content_ranges(ranges); + let mut buf = vec![0u8; 3 * 2048]; + dec.read_sectors(0, 3, &mut buf, false) + .expect("a decryptable content unit reads OK"); + assert_eq!( + buf, unit, + "verify-only keeps ciphertext even when the unit decrypts" + ); + } + + /// NO content map (None) ⇒ ungated legacy behaviour: a scrambled-looking read + /// fails. This is what the mux relies on (it only reads content), and the very + /// reason the whole-disc sweep MUST install the map. + #[test] + fn verify_only_without_content_map_is_ungated() { + struct ScrambledSource; + impl SectorSource for ScrambledSource { + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> Result { + let b = count as usize * 2048; + for (i, x) in buf[..b].iter_mut().enumerate() { + *x = (i as u8).wrapping_mul(31); + } + let mut o = 4; + while o < b { + buf[o] = 0xA5; + o += 192; + } + let mut u = 0; + while u < b { + buf[u] |= 0xC0; // CPI bits → reads as encrypted + u += crate::aacs::ALIGNED_UNIT_LEN; + } + Ok(b) + } + } + let mut dec = DecryptingSectorSource::new( + ScrambledSource, + DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }, + ) + .verify_only(); // no content map installed + let mut buf = vec![0u8; 3 * 2048]; + let err = dec + .read_sectors(0, 3, &mut buf, false) + .expect_err("ungated verify fails on scrambled bytes (legacy / mux behaviour)"); + assert!(matches!(err, crate::error::Error::DecryptFailed)); + } + + /// In-place decrypt + content map: a NON-content read passes through unchanged + /// (ciphertext, not decrypted); an in-content read is decrypted IN PLACE. + #[test] + fn inplace_decrypt_content_gate_passes_clear_decrypts_content() { + let key = [0x5a; 16]; + let cipher_unit = encrypt_aacs_unit(&key); + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); // content @ 1002.. + let mut dec = DecryptingSectorSource::new( + FixedUnit { + unit: cipher_unit.clone(), + }, + DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + }, + ) + .with_content_ranges(ranges); // in-place (NOT verify_only) + + // Non-content read (LBA 0): not decrypted → buf stays ciphertext. + let mut buf = vec![0u8; 3 * 2048]; + dec.read_sectors(0, 3, &mut buf, false).unwrap(); + assert_eq!( + buf, cipher_unit, + "a non-content read is passed through, not decrypted" + ); + + // In-content read (LBA 1002): decrypted in place → TS sync restored. + let mut buf2 = vec![0u8; 3 * 2048]; + dec.read_sectors(1002, 3, &mut buf2, false).unwrap(); + assert_ne!( + buf2, cipher_unit, + "an in-content read is decrypted in place" + ); + assert_eq!(buf2[4], 0x47, "decrypted content carries the TS sync byte"); + } + + /// A source that returns a fixed encrypted unit for ANY read — used to drive + /// the verify-only fetch + cache tests below. + struct AnyLbaUnit { + unit: Vec, + } + impl SectorSource for AnyLbaUnit { + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> Result { + let b = count as usize * 2048; + buf[..b].copy_from_slice(&self.unit); + Ok(b) + } + } + + /// THE cps2 fix at the read level. Verify-only (sweep) mode now fetches: a + /// content unit no HELD key opens hands its ciphertext to the fetch closure, + /// the returned key is added to the pool (the CACHE), the unit re-verifies + /// clean — and `buf` is left as ciphertext (the ISO stays encrypted). Then the + /// cached key serves the NEXT unit WITHOUT another callback (≈one fetch per CPS + /// unit). This is what stops an orphan CPS unit from hard-failing the sweep. + #[test] + fn verify_only_fetch_recovers_caches_and_keeps_ciphertext() { + let real_key = [0x5au8; 16]; // the key the unit is actually under + let wrong_key = [0x11u8; 16]; // the only key we start with + let unit = encrypt_aacs_unit(&real_key); + + let calls = Arc::new(Mutex::new(0usize)); + let calls_cb = Arc::clone(&calls); + let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + // The closure is handed the still-scrambled on-disc ciphertext. + assert!(!samples.is_empty(), "fetch receives the failing units"); + vec![real_key] + }); + + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content + let mut dec = DecryptingSectorSource::new( + AnyLbaUnit { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong_key)], + read_data_key: None, + }, + ) + .verify_only() + .with_content_ranges(ranges) + .with_key_fetch(fetch); + + // First read (LBA 0): wrong key fails → fetch supplies real_key → Ok, + // and buf is still ciphertext (verify-only never mutates the ISO bytes). + let mut buf = vec![0u8; 3 * 2048]; + dec.read_sectors(0, 3, &mut buf, false) + .expect("fetch recovers the orphan unit's key"); + assert_eq!(buf, unit, "verify-only keeps ciphertext even after a fetch"); + assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once"); + + // Second read (LBA 3): real_key now CACHED → decrypts with no new callback. + let mut buf2 = vec![0u8; 3 * 2048]; + dec.read_sectors(3, 3, &mut buf2, false) + .expect("cached key serves the next unit"); + assert_eq!( + *calls.lock().unwrap(), + 1, + "cache hit — the fetch callback must NOT fire again" + ); + } + + /// Verify-only fetch that comes back empty (the key source can't help) must + /// still hard-fail the read (DECRYPT_VERIFY_READ) — recovery, not silent loss. + #[test] + fn verify_only_fetch_exhausted_still_hard_fails() { + let real_key = [0x5au8; 16]; + let wrong = [0x11u8; 16]; + let unit = encrypt_aacs_unit(&real_key); + let fetch: super::KeyFetch = std::sync::Arc::new(|_: &[Vec]| Vec::new()); + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]); + let mut dec = DecryptingSectorSource::new( + FixedUnit { unit: unit.clone() }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong)], + read_data_key: None, + }, + ) + .verify_only() + .with_content_ranges(ranges) + .with_key_fetch(fetch); + let mut buf = vec![0u8; 3 * 2048]; + let err = dec + .read_sectors(0, 3, &mut buf, false) + .expect_err("a fetch that returns no key must still fail the read"); + assert!(matches!(err, crate::error::Error::DecryptFailed)); + } + + /// The fetch is content-gated: a scrambled unit OUTSIDE the content extents + /// is clear filesystem, not ciphertext, so the read succeeds and the fetch + /// callback is never consulted (no wasted key-server traffic on nav/UDF). + #[test] + fn verify_only_fetch_not_called_outside_content() { + let real_key = [0x5au8; 16]; + let wrong = [0x11u8; 16]; + let unit = encrypt_aacs_unit(&real_key); + let calls = Arc::new(Mutex::new(0usize)); + let calls_cb = Arc::clone(&calls); + let fetch: super::KeyFetch = std::sync::Arc::new(move |_: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + vec![real_key] + }); + // Content lives far away; LBA 0 is "filesystem". + let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); + let mut dec = DecryptingSectorSource::new( + AnyLbaUnit { unit }, + DecryptKeys::Aacs { + unit_keys: vec![(0, wrong)], + read_data_key: None, + }, + ) + .verify_only() + .with_content_ranges(ranges) + .with_key_fetch(fetch); + let mut buf = vec![0u8; 3 * 2048]; + dec.read_sectors(0, 3, &mut buf, false) + .expect("non-content scrambled-looking bytes read OK (gated out)"); + assert_eq!( + *calls.lock().unwrap(), + 0, + "fetch must NOT fire for a non-content unit" + ); + } } diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 965447b..f7fe038 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -131,7 +131,7 @@ pub trait SectorSink: Send { } pub use crate::io::file_sector_source::FileSectorSource; -pub use decrypting::DecryptingSectorSource; +pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch}; pub use file::FileSectorSink; pub use prefetched::PrefetchedSectorSource; diff --git a/src/udf.rs b/src/udf.rs index 8c28818..1183bb8 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1250,8 +1250,10 @@ fn parse_udf_name(data: &[u8]) -> String { } } -/// Merge overlapping or adjacent (start, count) ranges. -fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { +/// Merge overlapping or adjacent (start, count) ranges. Caller sorts by start +/// first. Shared range utility — also used to build the disc's encrypted-content +/// extent map (see `Disc::encrypted_content_ranges`). +pub(crate) fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { if ranges.is_empty() { return Vec::new(); } diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index afe9338..328c8ba 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -90,8 +90,11 @@ fn aacs_decrypt_unit_roundtrip() { plain[offset] = 0x47; // TS sync byte offset += 192; } - // No flag set: CBC-encrypting the body below scrambles packets 1..31's TS - // syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. + // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative gate + // `decrypt_unit` consults. Set BEFORE snapshotting `expected`: decrypt + // preserves the plaintext header, so the round-tripped unit carries these + // bits too. + plain[0] |= 0xC0; // Save original plaintext for comparison let expected = plain.clone(); @@ -128,7 +131,7 @@ fn aacs_decrypt_unit_roundtrip() { } // Verify it looks encrypted (body TS syncs scrambled) - assert!(aacs::is_aacs_scrambled(&plain)); + assert!(aacs::ts_sync_destroyed(&plain)); // Now decrypt let result = aacs::decrypt_unit(&mut plain, &unit_key); @@ -137,7 +140,7 @@ fn aacs_decrypt_unit_roundtrip() { "decrypt_unit should return true on valid encrypted unit" ); assert!( - !aacs::is_aacs_scrambled(&plain), + !aacs::ts_sync_destroyed(&plain), "decrypted unit should read as clear (TS syncs restored)" ); @@ -255,9 +258,9 @@ fn aacs_vuk_derivation_roundtrip() { assert_eq!(vuk, vuk2, "derive_vuk not deterministic"); } -/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs. +/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs. #[test] -fn aacs_is_aacs_scrambled_detection() { +fn aacs_ts_sync_destroyed_detection() { // A clear unit: TS sync (0x47) intact at every 192-byte packet → not // scrambled. (Flag bits play no role.) let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN]; @@ -267,7 +270,7 @@ fn aacs_is_aacs_scrambled_detection() { off += 192; } assert!( - !aacs::is_aacs_scrambled(&clear), + !aacs::ts_sync_destroyed(&clear), "clear unit (syncs intact) must not be scrambled" ); @@ -276,21 +279,21 @@ fn aacs_is_aacs_scrambled_detection() { flagged[0] = 0xC0; // copy-control bits flagged[7] = 0xC0; // TSC bits assert!( - !aacs::is_aacs_scrambled(&flagged), + !aacs::ts_sync_destroyed(&flagged), "flag bits must not be read as encryption" ); // A scrambled body (syncs destroyed) → scrambled. let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; assert!( - aacs::is_aacs_scrambled(&scrambled), + aacs::ts_sync_destroyed(&scrambled), "unit with no intact TS syncs must read as scrambled" ); // Too short let short = vec![0xFFu8; 100]; assert!( - !aacs::is_aacs_scrambled(&short), + !aacs::ts_sync_destroyed(&short), "short buffer should not be detected" ); } @@ -307,10 +310,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() { unit[off] = 0x47; off += 192; } + // CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext. + unit[0] &= 0x3F; let original = unit.clone(); let key = [0xAA; 16]; - assert!(!aacs::is_aacs_scrambled(&unit)); + assert!(!aacs::ts_sync_destroyed(&unit)); let result = aacs::decrypt_unit(&mut unit, &key); assert!(result, "clear unit should return true"); assert_eq!(unit, original, "clear unit should be unchanged"); @@ -380,7 +385,9 @@ fn aacs_cross_validation_encrypt_then_decrypt() { plaintext[i] = (i % 251) as u8; } } - // No flag set: the CBC-encrypted body scrambles the packet syncs. + // Flag the unit encrypted via the CPI bits (byte 0) — the gate `decrypt_unit` + // consults. Set before snapshotting `expected`; decrypt preserves the header. + plaintext[0] |= 0xC0; let expected = plaintext.clone(); diff --git a/tests/fvi_pipeline.rs b/tests/fvi_pipeline.rs index 6039176..335f08f 100644 --- a/tests/fvi_pipeline.rs +++ b/tests/fvi_pipeline.rs @@ -177,6 +177,7 @@ fn run_to_fvi(image: Vec, title: DiscTitle, path: &std::path::Path) { ContentFormat::MpegPs, None, None, + None, ) .expect("pipeline builds"); diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 94c3579..35d1d0b 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -90,16 +90,16 @@ fn aacs_encryption_flag_detection() { off += 192; } // Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit. - assert!(!aacs::is_aacs_scrambled(&unit)); + assert!(!aacs::ts_sync_destroyed(&unit)); // Flag bits on a synced unit do not make it look encrypted. unit[0] = 0xC0; unit[7] = 0xC0; - assert!(!aacs::is_aacs_scrambled(&unit)); + assert!(!aacs::ts_sync_destroyed(&unit)); // Scrambled body (syncs gone) → encrypted. let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; - assert!(aacs::is_aacs_scrambled(&scrambled)); + assert!(aacs::ts_sync_destroyed(&scrambled)); } /// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state. diff --git a/tests/pass_n_size_aware_skip.rs b/tests/pass_n_size_aware_skip.rs index bacbb18..549dd37 100644 --- a/tests/pass_n_size_aware_skip.rs +++ b/tests/pass_n_size_aware_skip.rs @@ -274,6 +274,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() { wedged_threshold: 0, progress: None, halt: Some(halt.clone()), + key_fetch: None, }; let outcome = disc.patch(&mut reader, &iso_path, &opts);