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.
This commit is contained in:
Matthew Jackson
2026-06-28 15:03:52 -07:00
parent f49ef023cf
commit a7bd574c34
23 changed files with 3098 additions and 207 deletions
+209 -43
View File
@@ -101,24 +101,68 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
// ── Content decryption ────────────────────────────────────────────────────── // ── 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`) /// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source /// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is /// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) /// byte-indistinguishable. So this answers only *"does this unit look like valid
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably /// clear TS, or not"*, nothing about encryption.
/// across discs/players.
/// ///
/// This is the single shared definition of "encrypted" for the whole ecosystem /// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key /// the caller, because it needs context this function lacks:
/// service's validation gate all call THIS, so they always agree on what is /// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`, /// (see [`crate::decrypt::decrypt_sectors_in_content`] and
/// so the decrypt path never double-decrypts and there is no flag to clear. /// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
pub fn is_aacs_scrambled(unit: &[u8]) -> bool { /// 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) 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 /// 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 /// (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 /// 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 ts_sync_count(unit) > ts_packet_total(unit) / 2
} }
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact). /// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
fn verify_ts(unit: &[u8]) -> bool { /// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
ts_syncs_intact(unit) /// 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. /// 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 { if unit.len() < ALIGNED_UNIT_LEN {
return false; return false;
} }
if !is_aacs_scrambled(unit) { if !aacs_unit_encrypted(unit) {
return true; // not encrypted return true; // CPI flag clear → plaintext, pass through untouched
} }
// Save original first 16 bytes (they're plaintext TP_extra_header) // 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 // Step 3: Decrypt bytes 16..6143 with AES-CBC
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Decryption restored the TS syncs; verify the unit now looks like clear TS. // Decryption restored the TS syncs; accept the key only if the unit is now
verify_ts(unit) // 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. /// 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 /// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
/// identical to the slow path. /// 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 /// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
/// per-candidate scramble re-scan is intentionally skipped here. /// 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 /// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
/// `None` if no key worked (the unit is restored to its original bytes). /// `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<UnitKeyResult> { pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
if !is_aacs_scrambled(unit) { if !aacs_unit_encrypted(unit) {
return Some(UnitKeyResult::AlreadyClear); return Some(UnitKeyResult::AlreadyClear);
} }
@@ -314,7 +381,7 @@ pub fn decrypt_unit_full(
unit_key: &[u8; 16], unit_key: &[u8; 16],
read_data_key: Option<&[u8; 16]>, read_data_key: Option<&[u8; 16]>,
) -> bool { ) -> bool {
if !is_aacs_scrambled(unit) { if !ts_sync_destroyed(unit) {
return true; return true;
} }
if let Some(rdk) = read_data_key { if let Some(rdk) = read_data_key {
@@ -386,7 +453,7 @@ mod tests {
off += BD_SOURCE_PACKET_BYTES; off += BD_SOURCE_PACKET_BYTES;
} }
let key = [0u8; 16]; let key = [0u8; 16];
assert!(!is_aacs_scrambled(&unit)); assert!(!ts_sync_destroyed(&unit));
assert!(decrypt_unit(&mut unit, &key)); assert!(decrypt_unit(&mut unit, &key));
} }
@@ -429,9 +496,9 @@ mod tests {
assert_eq!(ts_sync_count(&set_syncs(17)), 17); assert_eq!(ts_sync_count(&set_syncs(17)), 17);
// Exactly half intact → classified scrambled (16 > 16 is false). // 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. // One past half → classified clear.
assert!(!is_aacs_scrambled(&set_syncs(17))); assert!(!ts_sync_destroyed(&set_syncs(17)));
} }
#[test] #[test]
@@ -447,13 +514,13 @@ mod tests {
} }
assert_eq!(ts_sync_count(&clear), 32); assert_eq!(ts_sync_count(&clear), 32);
assert!( assert!(
!is_aacs_scrambled(&clear), !ts_sync_destroyed(&clear),
"fully-clear unit → not scrambled" "fully-clear unit → not scrambled"
); );
let scrambled = vec![0u8; ALIGNED_UNIT_LEN]; let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_sync_count(&scrambled), 0); assert_eq!(ts_sync_count(&scrambled), 0);
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled"); assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
} }
#[test] #[test]
@@ -502,8 +569,10 @@ mod tests {
plain[offset] = TS_SYNC; plain[offset] = TS_SYNC;
offset += BD_SOURCE_PACKET_BYTES; offset += BD_SOURCE_PACKET_BYTES;
} }
// No flag set: CBC-encrypting the body below scrambles packets 1..31's // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. // 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) // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
let header: [u8; 16] = plain[..16].try_into().unwrap(); let header: [u8; 16] = plain[..16].try_into().unwrap();
@@ -530,9 +599,9 @@ mod tests {
// Now plain contains encrypted data. Decrypt it. // Now plain contains encrypted data. Decrypt it.
let mut unit = plain; let mut unit = plain;
assert!(is_aacs_scrambled(&unit)); assert!(ts_sync_destroyed(&unit));
assert!(decrypt_unit(&mut unit, &unit_key)); 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 // Verify TS sync bytes
let mut count = 0; let mut count = 0;
@@ -557,6 +626,10 @@ mod tests {
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the /// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
/// fixed AACS IV. /// fixed AACS IV.
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { 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 header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = aes_ecb_encrypt(unit_key, &header); let derived = aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16]; let mut k = [0u8; 16];
@@ -721,14 +794,14 @@ mod tests {
let mut unit = clear_unit(); let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key); aacs_encrypt_unit(&mut unit, &unit_key);
assert!( assert!(
is_aacs_scrambled(&unit), ts_sync_destroyed(&unit),
"encrypted unit must look scrambled" "encrypted unit must look scrambled"
); );
assert!(decrypt_unit(&mut unit, &unit_key)); assert!(decrypt_unit(&mut unit, &unit_key));
// All 32 stride positions carry sync after decrypt. // All 32 stride positions carry sync after decrypt.
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
assert!(!is_aacs_scrambled(&unit)); assert!(!ts_sync_destroyed(&unit));
} }
#[test] #[test]
@@ -756,9 +829,10 @@ mod tests {
// left untouched by decrypt (only unit[16..] is CBC-processed). // left untouched by decrypt (only unit[16..] is CBC-processed).
let unit_key = [0x9Au8; 16]; let unit_key = [0x9Au8; 16];
let mut clear = clear_unit(); 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(&[ 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, 0xAE, 0xAF,
]); ]);
let header_before: [u8; 16] = clear[..16].try_into().unwrap(); let header_before: [u8; 16] = clear[..16].try_into().unwrap();
@@ -800,7 +874,7 @@ mod tests {
Some(UnitKeyResult::DecryptedWith(2)) Some(UnitKeyResult::DecryptedWith(2))
); );
assert!( assert!(
!is_aacs_scrambled(&unit), !ts_sync_destroyed(&unit),
"unit must be clear after the hit" "unit must be clear after the hit"
); );
} }
@@ -819,6 +893,59 @@ mod tests {
assert_eq!(unit, snapshot, "failed try must restore the original bytes"); 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 ───────── // ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
#[test] #[test]
@@ -840,6 +967,45 @@ mod tests {
assert!(!decrypt_unit(&mut probe2, &bad)); 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] #[test]
fn unit_key_validates_is_non_mutating() { fn unit_key_validates_is_non_mutating() {
// The accelerator must never write its input (it operates on the // 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]); 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!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); 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] #[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 // The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
// shorter is reported NOT scrambled (so the decrypt gate skips it) // shorter is reported NOT scrambled (so the decrypt gate skips it)
// rather than indexing past the end. // rather than indexing past the end.
assert!(!is_aacs_scrambled(&[])); assert!(!ts_sync_destroyed(&[]));
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1])); assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
// A scrambled-looking buffer that is one byte short is still "not // A scrambled-looking buffer that is one byte short is still "not
// scrambled" by the length guard. // scrambled" by the length guard.
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1]; let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
almost[4] = 0x00; // no syncs almost[4] = 0x00; // no syncs
assert!(!is_aacs_scrambled(&almost)); assert!(!ts_sync_destroyed(&almost));
} }
#[test] #[test]
+55 -33
View File
@@ -171,21 +171,31 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
return None; return None;
} }
// Title → CPS unit mapping // Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
// index to a 0-based key index. We mirror that so the stored value is a safe,
// ready-to-use key index rather than a raw 1-based number.
let to_key_idx = |cps: u16| -> u16 {
if cps >= 1 && cps as usize <= num_uk {
cps - 1
} else {
0
}
};
let mut title_cps_unit = Vec::new(); let mut title_cps_unit = Vec::new();
if data.len() >= 26 { if data.len() >= 26 {
let first_play = u16::from_be_bytes([data[20], data[21]]); let first_play = u16::from_be_bytes([data[20], data[21]]);
let top_menu = u16::from_be_bytes([data[22], data[23]]); let top_menu = u16::from_be_bytes([data[22], data[23]]);
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize; let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
title_cps_unit.push(first_play); title_cps_unit.push(to_key_idx(first_play));
title_cps_unit.push(top_menu); title_cps_unit.push(to_key_idx(top_menu));
for i in 0..num_titles { for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() { if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]); 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. /// Parse a Content Certificate (ContentXXX.cer) file.
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> { pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
if data.len() < 8 { if data.len() < 20 {
return None; return None;
} }
// Content Certificate format: // Content Certificate layout (matches libaacs content_cert.c):
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2) // [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bus_encryption_enabled (bit 0) // [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
// [2..8] cc_id (6 bytes) // [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
let version = if data[0] == 0x00 { let version = if data[0] == 0x00 {
AacsVersion::V10 AacsVersion::V10
} else { } else {
AacsVersion::V20 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]; let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[2..8]); cc_id.copy_from_slice(&data[14..20]);
Some(ContentCert { Some(ContentCert {
bus_encryption, bus_encryption,
@@ -2314,19 +2327,26 @@ mod tests {
} }
#[test] #[test]
fn test_content_cert_parse() { fn test_content_cert_parse() {
// AACS 1.0 cert // AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
let mut data = vec![0u8; 16]; // BIT 7 of byte 1, cc_id at bytes 14..20.
let mut data = vec![0u8; 20];
data[0] = 0x00; // AACS 1.0 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(); let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V10); assert_eq!(cc.version, AacsVersion::V10);
assert!(!cc.bus_encryption); assert!(!cc.bus_encryption);
// AACS 2.0 with bus encryption assert_eq!(cc.cc_id, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
data[0] = 0x01; // AACS 2.0 // AACS 2.0 with bus encryption: type 0x10, flag is BIT 7 (0x80) of byte 1
data[1] = 0x01; // bus encryption enabled // — 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(); let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20); assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption); 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 // Hardening additions
@@ -2613,29 +2633,31 @@ mod tests {
// ── Content Certificate parsing ──────────────────────────────────────── // ── Content Certificate parsing ────────────────────────────────────────
#[test] #[test]
fn parse_content_cert_rejects_short_buffer() { fn parse_content_cert_rejects_short_buffer() {
// < 8 bytes → None (cc_id slice [2..8] would index OOB). // < 20 bytes → None (cc_id slice [14..20] would index OOB).
assert!(parse_content_cert(&[0x00; 7]).is_none()); assert!(parse_content_cert(&[0x00; 19]).is_none());
assert!(parse_content_cert(&[0x00; 20]).is_some());
} }
#[test] #[test]
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() { 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. // libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
let mut data = vec![0u8; 8]; // non-0x00 type → V20.
data[0] = 0x02; // not 0x00 and not 0x01 → still V20 let mut data = vec![0u8; 20];
data[0] = 0x10; // AACS2 type marker → V20
data[1] = 0x00; 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(); let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20); assert_eq!(cc.version, AacsVersion::V20);
assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
assert!(!cc.bus_encryption); assert!(!cc.bus_encryption);
} }
#[test] #[test]
fn parse_content_cert_bus_encryption_only_reads_bit0() { fn parse_content_cert_bus_encryption_reads_bit7() {
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with // bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
// bit0 clear → false. Pins the mask, not a truthiness of the byte. // clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
let mut data = vec![0u8; 8]; let mut data = vec![0u8; 20];
data[1] = 0x02; // bit 1 set, bit 0 clear data[1] = 0x7F; // bits 0..6 set, bit 7 clear
assert!(!parse_content_cert(&data).unwrap().bus_encryption); 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); assert!(parse_content_cert(&data).unwrap().bus_encryption);
} }
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ────── // ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
@@ -2732,10 +2754,10 @@ mod tests {
unit_keys: Vec::new(), unit_keys: Vec::new(),
}), }),
}; };
// Content cert: AACS2 + bus encryption enabled. // Content cert: AACS2 (type 0x10) + bus encryption enabled (bit 7 of byte 1).
let mut cc = vec![0u8; 8]; let mut cc = vec![0u8; 20];
cc[0] = 0x01; cc[0] = 0x10;
cc[1] = 0x01; cc[1] = 0x80;
let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext { let ctx = ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
+5 -4
View File
@@ -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. // 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. // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{ pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit, ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
ts_sync_count, unit_key_validates, 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 // `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 // 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 // Touch a representative function from each re-export group so a
// dropped/renamed export fails to compile. These are smoke calls, not // dropped/renamed export fails to compile. These are smoke calls, not
// behavioural assertions (behaviour is covered in each module). // 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 _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[])); let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x")); let _ = disc_hash_hex(&disc_hash(b"x"));
+6 -3
View File
@@ -280,9 +280,12 @@ fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
// ── Step 2: Disc Key ────────────────────────────────────────────────────── // ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue the disc-key REPORT KEY (READ DVD STRUCTURE, format 0x02) purely /// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
/// for the bus-auth unlock side effect. The returned block contents are /// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
/// not used — the descramble title key is recovered keylessly elsewhere. /// 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<()> { fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
let scsi = drive.scsi_mut(); let scsi = drive.scsi_mut();
+3 -1
View File
@@ -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 /// AttackPattern: find a repeating pattern just before the encrypted region
/// and assume the plaintext at 0x80 continues it. /// 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 /// `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 /// 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 /// cycles), the known plaintext at 0x80 is taken to be the periodic run
+331 -21
View File
@@ -185,6 +185,48 @@ pub fn decrypt_sectors(
buf: &mut [u8], buf: &mut [u8],
keys: &mut DecryptKeys, keys: &mut DecryptKeys,
unit_key_idx: usize, unit_key_idx: usize,
) -> Result<usize, crate::error::Error> {
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<usize, crate::error::Error> {
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<usize, crate::error::Error> { ) -> Result<usize, crate::error::Error> {
let dropped: usize = match keys { let dropped: usize = match keys {
DecryptKeys::None => 0, DecryptKeys::None => 0,
@@ -223,7 +265,7 @@ pub fn decrypt_sectors(
// silent corruption. We fail loud (Error::DecryptFailed), matching // silent corruption. We fail loud (Error::DecryptFailed), matching
// the highway path's Error::ExtentNotUnitAligned policy. // 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 // buffer shorter than a full unit, so it cannot judge a partial. We
// instead apply the same TS-sync-intactness test it uses internally // instead apply the same TS-sync-intactness test it uses internally
// (ts_sync_count vs ts_packet_total) directly to the available // (ts_sync_count vs ts_packet_total) directly to the available
@@ -235,12 +277,23 @@ pub fn decrypt_sectors(
// tolerate rather than risk a false positive on conformant tails. // tolerate rather than risk a false positive on conformant tails.
let partial_len = buf.len() % unit_len; let partial_len = buf.len() % unit_len;
if partial_len != 0 { if partial_len != 0 {
// 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 partial = &buf[buf.len() - partial_len..];
let packets = aacs::ts_packet_total(partial); let packets = aacs::ts_packet_total(partial);
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 { if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
return Err(crate::error::Error::DecryptFailed); return Err(crate::error::Error::DecryptFailed);
} }
} }
}
let nthreads = decrypt_threads(); let nthreads = decrypt_threads();
let nunits = buf.len() / unit_len; let nunits = buf.len() / unit_len;
@@ -273,7 +326,7 @@ pub fn decrypt_sectors(
// must happen first — it's a shared layer on top that is key-independent // must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc. // across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| { 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; return;
} }
// Save original bytes so we can restore if no key validates. // 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 // No key validated — restore the original encrypted bytes and
// tally the loss. The unit was scrambled (we only reach here past // tally the loss. The unit is flagged encrypted (we only reach
// the `is_aacs_scrambled` gate) but no key applied: a clear // here past the CPI gate) but no key applied: genuine encrypted
// nav-file unit that legitimately fails the cipher, or genuine // content with a missing/wrong sub-key. We always tally; the mux
// encrypted content with a missing/wrong sub-key. We can't tell // read path treats
// them apart here, so we always tally; the mux read path treats
// the count as loss (its extents are real content), while // the count as loss (its extents are real content), while
// metadata-probe callers that don't install a loss sink ignore it. // metadata-probe callers that don't install a loss sink ignore it.
chunk.copy_from_slice(&original); chunk.copy_from_slice(&original);
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed); 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 { if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
// Serial path: avoids thread-pool overhead for tiny // Serial path: avoids thread-pool overhead for tiny
// buffers; also the only path when caller pinned // buffers; also the only path when caller pinned
// single-threaded via FREEMKV_THREADS=1. Iterate the // single-threaded via FREEMKV_THREADS=1. Iterate the
// chunks directly — no Vec of slice pointers needed. // chunks directly — no Vec of slice pointers needed.
for chunk in buf.chunks_mut(unit_len) { for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(chunk); process(idx, chunk);
} }
} else { } else {
// Parallel path via rayon's persistent thread pool. // Parallel path via rayon's persistent thread pool.
@@ -336,14 +403,14 @@ pub fn decrypt_sectors(
Some(pool) => { Some(pool) => {
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect(); let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
pool.install(|| { pool.install(|| {
chunks.into_par_iter().for_each(|chunk| { chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
decrypt_one(chunk); process(idx, chunk);
}); });
}); });
} }
None => { None => {
for chunk in buf.chunks_mut(unit_len) { for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(chunk); process(idx, chunk);
} }
} }
} }
@@ -406,7 +473,7 @@ mod tests {
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here /// 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 /// 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 — /// the TS-sync verification, and must be restored to its original bytes —
/// not left scrambled. /// not left scrambled.
#[test] #[test]
@@ -455,9 +522,249 @@ mod tests {
v[off] = 0xA5; v[off] = 0xA5;
off += 192; 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 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, /// Whole leading units plus a CLEAR trailing partial (the benign,
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS /// 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 /// 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]) { fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
use aes::Aes128; use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; 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 header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16]; 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 /// 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. /// `decrypt_unit` verifies it as clear after decryption.
fn clear_ts_unit() -> Vec<u8> { fn clear_ts_unit() -> Vec<u8> {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; 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) { … } }` /// 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 /// 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 /// `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. /// assert.
#[test] #[test]
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() { fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
@@ -875,7 +1185,7 @@ mod tests {
let mut unit = clear_ts_unit(); let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1); aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!( assert!(
aacs::is_aacs_scrambled(&unit), aacs::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled before decrypt" "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"); decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!( assert!(
!aacs::is_aacs_scrambled(&buf), !aacs::ts_sync_destroyed(&buf),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)" "unit encrypted under key1 must be fully decrypted (TS syncs restored)"
); );
// Every sync position must carry 0x47. // Every sync position must carry 0x47.
@@ -920,7 +1230,7 @@ mod tests {
let mut buf = unit; let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt"); decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!( assert!(
!aacs::is_aacs_scrambled(&buf), !aacs::ts_sync_destroyed(&buf),
"single-key disc: TS syncs must be restored" "single-key disc: TS syncs must be restored"
); );
assert_eq!( assert_eq!(
@@ -953,7 +1263,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit, &real_key); aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone(); let ciphertext = unit.clone();
assert!( assert!(
aacs::is_aacs_scrambled(&unit), aacs::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled going in" "encrypted unit must look scrambled going in"
); );
@@ -1012,7 +1322,7 @@ mod tests {
"exactly one unit's worth of bytes must be reported dropped" "exactly one unit's worth of bytes must be reported dropped"
); );
assert!( 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" "the decryptable unit must come out clear"
); );
assert_eq!( assert_eq!(
+82 -15
View File
@@ -304,6 +304,60 @@ impl Disc {
None => base_keys.clone(), 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<crate::disc::verify::ClipLayout> {
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
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 /// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
@@ -473,21 +527,12 @@ fn extract_one_file<S: SectorSource>(
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
let mut sector_off: u32 = 0; let mut sector_off: u32 = 0;
while sector_off < sectors { while sector_off < sectors {
let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS); // AACS: read whole units (see `whole_unit_batch`). The tail batch may
// AACS: read whole units. Round the batch DOWN to a multiple of 3 // be a 12 sector partial unit, which `decrypt_sectors` handles via
// unless this is the final (possibly short) tail of the extent. // its trailing-partial contract: a clear partial stays clear (AACS
// Every preceding batch is a whole number of units, so the tail // leaves the final short unit unencrypted on disc), a scrambled
// batch always BEGINS on a unit boundary (the gate measures // partial fails loud as DecryptFailed.
// `lba - unit_base`, which stays unit-aligned). The tail itself may let batch = whole_unit_batch(sectors - sector_off);
// be 12 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;
}
let lba = abs_lba + sector_off; let lba = abs_lba + sector_off;
let want = batch as usize * SECTOR_BYTES; let want = batch as usize * SECTOR_BYTES;
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]); let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
@@ -530,6 +575,22 @@ fn extract_one_file<S: SectorSource>(
Ok((fr, false)) 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 12 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. /// Read one batch through the decrypting decorator with bounded retries.
/// Returns `true` on success, `false` once retries are exhausted (the caller /// Returns `true` on success, `false` once retries are exhausted (the caller
/// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT /// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT
@@ -1033,6 +1094,8 @@ mod tests {
} }
off += 192; 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 header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16]; let mut k = [0u8; 16];
@@ -1066,6 +1129,9 @@ mod tests {
} }
off += 192; 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 unit
} }
@@ -1117,6 +1183,7 @@ mod tests {
std::fs::read(dir.join(rel)).ok() std::fs::read(dir.join(rel)).ok()
} }
// ── Tests ───────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────
/// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via /// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via
+229 -19
View File
@@ -17,6 +17,7 @@ pub mod mapfile;
mod patch; mod patch;
pub mod read_error; pub mod read_error;
mod sweep; mod sweep;
pub mod verify;
use crate::drive::{Drive, extract_scsi_context}; use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -436,6 +437,15 @@ pub struct Extent {
pub sector_count: u32, 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<Item = &'a Extent>) -> 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 /// Correct a title's TrueHD audio-stream metadata by probing the first
/// decrypted access units — channel count, real sample rate, and Atmos /// decrypted access units — channel count, real sample rate, and Atmos
/// detection in a single major-sync read. The MPLS descriptors declare the BASE /// 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 /// next candidate (and ultimately surfaces a key error rather than silently
/// writing ciphertext). /// 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. /// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate( fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])], unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>, read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>], samples: &[Vec<u8>],
) -> bool { ) -> 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 let scrambled: Vec<&[u8]> = samples
.iter() .iter()
.map(|s| s.as_slice()) .map(|s| s.as_slice())
.filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s)) .filter(|s| aacs_unit_needs_decrypt(s))
.collect(); .collect();
if scrambled.is_empty() { if scrambled.is_empty() {
return true; // nothing to disprove against — accept 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 /// 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 /// 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 /// a [`Error::NoDiscKey`] so the application can tell the user which disc to
@@ -2644,6 +2674,7 @@ impl Disc {
halt: opts.halt.clone(), halt: opts.halt.clone(),
vid: opts.vid, vid: opts.vid,
unit_keys: opts.unit_keys.clone(), unit_keys: opts.unit_keys.clone(),
key_fetch: opts.key_fetch.clone(),
}; };
self.sweep(reader, path, &sweep_opts) self.sweep(reader, path, &sweep_opts)
} }
@@ -2670,6 +2701,7 @@ impl Disc {
wedged_threshold: 50, wedged_threshold: 50,
progress: opts.progress, progress: opts.progress,
halt: opts.halt.clone(), halt: opts.halt.clone(),
key_fetch: opts.key_fetch.clone(),
}; };
let pr = self.patch(reader, path, &patch_opts)?; let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!( tracing::info!(
@@ -2722,25 +2754,73 @@ impl Disc {
self.ensure_decryptable(!opts.decrypt)?; self.ensure_decryptable(!opts.decrypt)?;
let total_bytes = self.capacity_sectors as u64 * 2048; 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 { let keys = if opts.decrypt {
self.decrypt_keys() self.decrypt_keys()
} else { } else {
crate::decrypt::DecryptKeys::None 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 { .. }); 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 let mut reader = {
// yields plaintext. `DecryptKeys::None` makes the decorator a let mut dec = DecryptingSectorSource::new(reader, keys);
// pass-through, so the wrapping is cheap when --raw / unencrypted if opts.decrypt && can_gate {
// discs are being swept and we keep the pipeline shape uniform. dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
// Replaces the inline `decrypt::decrypt_sectors` calls that used }
// to live in this loop and in the bisect inner loop below. if decrypt_is_aacs && opts.decrypt {
let mut reader = DecryptingSectorSource::new(reader, keys); if let Some(cb) = &opts.key_fetch {
dec = dec.with_key_fetch(cb.clone());
}
}
dec
};
let reader = &mut reader; 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. // Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path); let mapfile_path = self.mapfile_for(path);
// covers_disc reconciliation. A resume against a mapfile whose total // covers_disc reconciliation. A resume against a mapfile whose total
@@ -3019,6 +3099,18 @@ impl Disc {
// The consumer thread sees decrypted bytes; the // The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here. // 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 // Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused // owned Vec. The producer's `buf` is reused
// for the next read. // for the next read.
@@ -3027,6 +3119,26 @@ impl Disc {
producer_err = Some(consumer_gone()); producer_err = Some(consumer_gone());
break 'outer; 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); bytes_done = bytes_done.saturating_add(block_bytes);
pos += 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. /// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
/// Caller wires this from `Disc::aacs.unit_keys`. /// Caller wires this from `Disc::aacs.unit_keys`.
pub unit_keys: Vec<(u32, [u8; 16])>, 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<crate::sector::KeyFetch>,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -3474,6 +3592,8 @@ pub struct SweepOptions<'a> {
/// Resolved AACS unit keys persisted into the mapfile when the sweep /// Resolved AACS unit keys persisted into the mapfile when the sweep
/// creates / opens it. When non-empty these win over `vid`. /// creates / opens it. When non-empty these win over `vid`.
pub unit_keys: Vec<(u32, [u8; 16])>, pub unit_keys: Vec<(u32, [u8; 16])>,
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]).
pub key_fetch: Option<crate::sector::KeyFetch>,
} }
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges). /// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
@@ -3485,6 +3605,9 @@ pub struct PatchOptions<'a> {
pub wedged_threshold: u64, pub wedged_threshold: u64,
pub progress: Option<&'a dyn crate::progress::Progress>, pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// 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<crate::sector::KeyFetch>,
} }
/// Result returned by [`Disc::patch`]. /// Result returned by [`Disc::patch`].
@@ -3736,6 +3859,53 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
mod tests { mod tests {
use super::*; 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 /// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never
/// fall through to the block default (8192 sectors = 16 MiB, well over the /// 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 /// optical 510-sector cap). It has no forward slash, so the Linux-sysfs
@@ -4640,7 +4810,7 @@ mod tests {
#[test] #[test]
fn unit_key_validation_gates_on_real_ciphertext() { 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 // No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected). // like resume / mapfile must be unaffected).
@@ -4658,7 +4828,7 @@ mod tests {
clear[off] = 0x47; clear[off] = 0x47;
off += 192; off += 192;
} }
assert!(!is_aacs_scrambled(&clear)); assert!(!ts_sync_destroyed(&clear));
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])], &[(0, [0x11u8; 16])],
None, None,
@@ -4669,7 +4839,7 @@ mod tests {
let uk = [0x5au8; 16]; let uk = [0x5au8; 16];
let enc = encrypt_unit_for_test(&clear, &uk); let enc = encrypt_unit_for_test(&clear, &uk);
assert!( assert!(
is_aacs_scrambled(&enc), ts_sync_destroyed(&enc),
"encrypted unit must read scrambled" "encrypted unit must read scrambled"
); );
@@ -4698,7 +4868,7 @@ mod tests {
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the // 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 // ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered. // 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 clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4; let mut off = 4;
@@ -4711,8 +4881,8 @@ mod tests {
let uk1 = [0x22u8; 16]; let uk1 = [0x22u8; 16];
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
assert!(is_aacs_scrambled(&sample0)); assert!(ts_sync_destroyed(&sample0));
assert!(is_aacs_scrambled(&sample1)); assert!(ts_sync_destroyed(&sample1));
let samples = vec![sample0.clone(), sample1.clone()]; let samples = vec![sample0.clone(), sample1.clone()];
@@ -4748,6 +4918,10 @@ mod tests {
use aes::Aes128; use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec(); 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]; let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]); header.copy_from_slice(&unit[..16]);
let cipher = Aes128::new(GenericArray::from_slice(uk)); let cipher = Aes128::new(GenericArray::from_slice(uk));
@@ -4916,6 +5090,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
assert!( assert!(
@@ -4949,6 +5125,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let err = disc let err = disc
.copy(&mut reader, &iso_path, &opts) .copy(&mut reader, &iso_path, &opts)
@@ -4989,6 +5167,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
assert!( assert!(
disc.copy(&mut reader, &iso_path, &opts).is_ok(), disc.copy(&mut reader, &iso_path, &opts).is_ok(),
@@ -5013,6 +5193,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!( assert!(
@@ -5063,6 +5245,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
disc.sweep(&mut reader, &iso_path, &opts).expect("sweep"); disc.sweep(&mut reader, &iso_path, &opts).expect("sweep");
@@ -5167,6 +5351,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
small_disc small_disc
.sweep(&mut small_reader, &iso_path, &opts0) .sweep(&mut small_reader, &iso_path, &opts0)
@@ -5243,6 +5429,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
disc.sweep(&mut reader, &iso_path, &opts0) disc.sweep(&mut reader, &iso_path, &opts0)
.expect("initial clean sweep"); .expect("initial clean sweep");
@@ -5342,6 +5530,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc let result = disc
.sweep(&mut reader, &iso_path, &opts) .sweep(&mut reader, &iso_path, &opts)
@@ -5398,6 +5588,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.sweep(&mut reader, &iso_path, &opts); let result = disc.sweep(&mut reader, &iso_path, &opts);
assert!( assert!(
@@ -5429,6 +5621,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!( assert!(
@@ -5520,6 +5714,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(result.is_ok(), "resume copy failed: {:?}", result.err()); assert!(result.is_ok(), "resume copy failed: {:?}", result.err());
@@ -5616,6 +5812,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
assert!( assert!(
@@ -5667,6 +5865,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts); let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!( assert!(
@@ -5686,6 +5886,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts); let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!( assert!(
@@ -5720,6 +5922,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap(); let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
@@ -5734,6 +5938,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts); let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!( assert!(
@@ -5768,6 +5974,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
let r = result.expect("100-batch clean sweep should succeed"); let r = result.expect("100-batch clean sweep should succeed");
@@ -6024,6 +6232,8 @@ mod tests {
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
key_fetch: None,
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
+75 -13
View File
@@ -1731,25 +1731,54 @@ impl Disc {
); );
let bytes_good_before = initial_stats.bytes_good; let bytes_good_before = initial_stats.bytes_good;
let bytes_good_start = bytes_good_before; 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 { let keys = if opts.decrypt {
self.decrypt_keys() self.decrypt_keys()
} else { } else {
crate::decrypt::DecryptKeys::None 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 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; let reader = &mut reader;
// Spawn the consumer. The `WritebackFile` (same bounded-cache // Spawn the consumer. The `WritebackFile` (same bounded-cache
@@ -2083,6 +2112,37 @@ impl Disc {
// behaviour. // behaviour.
let summary = pipe.finish()?; 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( let outcome = build_outcome(
&state, &state,
&summary, &summary,
@@ -2177,6 +2237,8 @@ mod tests {
wedged_threshold: 50, wedged_threshold: 50,
progress: None, progress: None,
halt: None, halt: None,
key_fetch: None,
} }
} }
+14
View File
@@ -66,6 +66,14 @@ pub(super) enum WorkItem {
/// tell them apart without parsing a flag. /// tell them apart without parsing a flag.
GapFill { pos: u64, len: u64 }, 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 /// Producer wants the latest mapfile stats for the progress
/// callback. Consumer responds on `prog_tx` with a fresh /// callback. Consumer responds on `prog_tx` with a fresh
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't /// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
@@ -174,6 +182,12 @@ impl Sink<WorkItem> for SweepSink {
} }
self.map.record(pos, len, SectorStatus::NonTrimmed)?; 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 => { WorkItem::StatsRequest => {
let stats = self.map.stats(); let stats = self.map.stats();
let bad_ranges = self.map.ranges_with(&[ let bad_ranges = self.map.ranges_with(&[
+867
View File
@@ -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<ExtentRec>,
/// Number of FULL (6144) units per clip; the partial tail unit is excluded.
full_units: Vec<u32>,
/// Content unit keys to try (resolved keys plus any fetched + cached).
keys: Vec<[u8; 16]>,
fetch: Option<KeyFetch>,
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<KeyFetch>) -> Option<Self> {
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<S: crate::sector::SectorSource>(
&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<u32> = 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<u8> {
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<ClipLayout> {
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<u8>]| 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<u8>]| 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<u8>]| 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<u8>]| {
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<u8>]| 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<u8>]| 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<u32, [u8; 2048]>,
err_lba: Option<u32>,
}
impl crate::sector::SectorSource for MockIso {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::Result<usize> {
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<u8>]| 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<u8>]| 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"
);
}
}
+164 -3
View File
@@ -280,6 +280,56 @@ pub fn resolve_and_apply_traced(
(false, trace) (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<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
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<Fn>`): 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<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
) -> crate::sector::KeyFetch {
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> 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 /// 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 /// decrypt) — the content samples that populate [`DiscInputs::samples`] for a
/// key server to validate a candidate against, and that [`resolve_and_apply`] /// 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 /// `start_lba`), which the library owns. A key source is *handed* these bytes
/// via `DiscInputs.samples`; it never reads the disc itself. /// 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 /// 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 /// 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 /// 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, title: &crate::disc::DiscTitle,
n: usize, n: usize,
) -> Vec<Vec<u8>> { ) -> Vec<Vec<u8>> {
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 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 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; break;
} }
let u = &buf[o..o + ALIGNED_UNIT_LEN]; let u = &buf[o..o + ALIGNED_UNIT_LEN];
if is_aacs_scrambled(u) { if ts_sync_destroyed(u) {
out.push(u.to_vec()); out.push(u.to_vec());
if out.len() >= n { if out.len() >= n {
return out; return out;
@@ -353,6 +403,7 @@ pub fn read_encrypted_units(
mod tests { mod tests {
use super::*; use super::*;
use crate::aacs::UnitKey; use crate::aacs::UnitKey;
use std::sync::{Arc, Mutex};
// ── KeySource default-method behaviour ──────────────────────────────────── // ── KeySource default-method behaviour ────────────────────────────────────
@@ -460,4 +511,114 @@ mod tests {
let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect(); let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect();
assert_eq!(whos, vec!["keydb", "my-custom-source"]); 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<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
}
struct ErroringSource;
impl KeySource for ErroringSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Err(Error::AacsNoKeys)
}
}
struct HasKey([u8; 16]);
impl KeySource for HasKey {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, 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<Box<dyn KeySource>> = 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<Box<dyn KeySource>> = 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<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let builds = Arc::new(Mutex::new(0usize));
struct Probe {
key: [u8; 16],
seen: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl KeySource for Probe {
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, 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<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
*builds_c.lock().unwrap() += 1;
vec![Box::new(Probe {
key,
seen: Arc::clone(&seen_c),
}) as Box<dyn KeySource>]
});
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");
}
} }
+2 -2
View File
@@ -253,8 +253,8 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
pub use mux::build_iso_pipeline; pub use mux::build_iso_pipeline;
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives}; pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use sector::{ pub use sector::{
DecryptingSectorSource, FileSectorSink, FileSectorSource, PrefetchedSectorSource, SectorSink, DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
SectorSource, SectorSink, SectorSource,
}; };
pub use speed::DriveSpeed; pub use speed::DriveSpeed;
pub use udf::{UdfFs, read_filesystem}; pub use udf::{UdfFs, read_filesystem};
+80 -12
View File
@@ -13,6 +13,7 @@
//! We skip AC-3 frames and only emit TrueHD access units. //! We skip AC-3 frames and only emit TrueHD access units.
use super::{CodecParser, Frame, PesPacket, pts_to_ns}; 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 /// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family
/// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and /// (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. // the next PES legitimately begins a new AU and seeds the base.
if self.buf.is_empty() { if self.buf.is_empty() {
if let Some(pts) = pes.pts { if let Some(pts) = pes.pts {
// Resync to the authoritative PES PTS, but NEVER snap backward. // Resync to the authoritative PES PTS. TrueHD AUs are a fixed
// TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the // sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS`
// per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so // cadence is sample-accurate — more so than the disc's per-PES
// than the disc's per-PES PTS, which carries the source muxer's // PTS, which carries the source muxer's own rounding jitter.
// own rounding jitter. When the buffer empties exactly on a PES //
// boundary and that PES's PTS lands a few ticks *below* the // Two distinct backward steps must be handled OPPOSITELY:
// running cadence, an unconditional reset would set the next //
// AU's timestamp below the AU just emitted, producing the // 1. Small backward jitter (sub-second PES rounding): when the
// non-monotonic block timestamps a muxer rejects. Clamp to the // buffer empties exactly on a PES boundary and that PES's PTS
// running position so output stays strictly monotonic; a // lands a few ticks *below* the running cadence, an
// genuine forward gap/discontinuity is still adopted. // unconditional reset would set the next AU's timestamp below
self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts)); // 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] #[test]
fn skip_interleaved_ac3() { fn skip_interleaved_ac3() {
let mut parser = TrueHdParser::new(); let mut parser = TrueHdParser::new();
+1 -1
View File
@@ -1036,7 +1036,7 @@ mod tests {
/// Recording `SectorSource`: logs every `(lba, count)` request and /// Recording `SectorSource`: logs every `(lba, count)` request and
/// returns `Err` whenever the requested range covers `bad_sector`. /// returns `Err` whenever the requested range covers `bad_sector`.
/// Successful reads return zeroed sectors (which are NOT /// 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). /// even with synthetic AACS keys — no real decrypt is attempted).
struct RecordingReader { struct RecordingReader {
capacity: u32, capacity: u32,
+47 -1
View File
@@ -245,7 +245,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
} }
/// Options for opening an input stream. /// Options for opening an input stream.
#[derive(Debug, Clone, Default)] #[derive(Clone, Default)]
pub struct InputOptions { pub struct InputOptions {
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc /// 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 /// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
@@ -257,6 +257,26 @@ pub struct InputOptions {
pub title_index: Option<usize>, pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes. /// Skip decryption — return raw encrypted bytes.
pub raw: bool, 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<crate::sector::KeyFetch>,
}
// `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). /// Open a PES input stream (produces PES frames).
@@ -381,6 +401,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
} else { } else {
keys keys
}; };
// Install the shared fetch closure (if the app supplied one) so a
// unit no held key decrypts is re-tried via the app's key source.
// Suppressed in --raw (no decrypt step to recover).
let fetch = if opts.raw {
None
} else {
opts.key_fetch.clone()
};
let stream = build_iso_pipeline( let stream = build_iso_pipeline(
reader, reader,
title, title,
@@ -389,6 +417,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
format, format,
None, None,
None, None,
fetch,
)?; )?;
Ok(Box::new(stream)) Ok(Box::new(stream))
} }
@@ -573,6 +602,13 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the /// - `halt`: cooperative cancel token (not a timeout); when cancelled the
/// pipeline stops at the next boundary. `None` disables cancellation. /// pipeline stops at the next boundary. `None` disables cancellation.
/// - `event_fn`: optional progress/event callback invoked by the prefetcher. /// - `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<S: SectorSource + Send + 'static>( pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
reader: S, reader: S,
title: DiscTitle, title: DiscTitle,
@@ -581,6 +617,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
format: ContentFormat, format: ContentFormat,
halt: Option<crate::halt::Halt>, halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>, event_fn: Option<crate::sector::prefetched::EventFn>,
fetch: Option<crate::sector::KeyFetch>,
) -> io::Result<PipelinedPesStream> { ) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone(); let extents = title.extents.clone();
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector) // Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
@@ -594,6 +631,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
}; };
let mut decrypting = let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys); crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, 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 // Grab the decrypt-loss counter before the decorator is moved into the
// producer thread. It tracks bytes of scrambled AACS units no key could // producer thread. It tracks bytes of scrambled AACS units no key could
// decrypt — silent loss the demux drops; the consuming stream surfaces it // decrypt — silent loss the demux drops; the consuming stream surfaces it
@@ -1116,6 +1159,7 @@ mod tests {
ContentFormat::BdTs, ContentFormat::BdTs,
None, None,
None, None,
None,
) )
.expect("pipeline builds"); .expect("pipeline builds");
let first = stream.read().expect("read must not error on clean EOF"); let first = stream.read().expect("read must not error on clean EOF");
@@ -1156,6 +1200,7 @@ mod tests {
ContentFormat::BdTs, ContentFormat::BdTs,
None, None,
None, None,
None,
) )
.expect("pipeline builds"); .expect("pipeline builds");
@@ -1207,6 +1252,7 @@ mod tests {
ContentFormat::BdTs, ContentFormat::BdTs,
None, None,
None, None,
None,
); );
assert!(res.is_err(), "zero batch_sectors must be rejected"); assert!(res.is_err(), "zero batch_sectors must be rejected");
} }
+895 -14
View File
@@ -13,13 +13,69 @@
//! pass-through, so callers can wire it unconditionally and keep //! pass-through, so callers can wire it unconditionally and keep
//! their pipeline shape uniform regardless of encryption state. //! 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 crate::error::Result;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use super::SectorSource; 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<Fn>` — 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<dyn Fn(&[Vec<u8>]) -> 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 /// Decorator: read from `inner`, then run the configured
/// AACS / CSS decrypt over the bytes that landed in `buf`. /// AACS / CSS decrypt over the bytes that landed in `buf`.
/// ///
@@ -52,6 +108,38 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// ///
/// [`decrypt_loss`]: Self::decrypt_loss /// [`decrypt_loss`]: Self::decrypt_loss
decrypt_dropped: Arc<AtomicU64>, decrypt_dropped: Arc<AtomicU64>,
/// 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<KeyFetch>,
/// 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<Arc<[(u32, u32)]>>,
/// 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<u8>,
} }
impl<S: SectorSource> DecryptingSectorSource<S> { impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -67,9 +155,37 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_key_idx: 0, unit_key_idx: 0,
unit_base: 0, unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(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 /// A handle to this decorator's decrypt-loss counter — the cumulative bytes
/// of scrambled AACS units that no key could decrypt (see /// of scrambled AACS units that no key could decrypt (see
/// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this /// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this
@@ -87,6 +203,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
self 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. /// Replace the configured keys without unwrapping the decorator.
/// Used by `DiscStream::set_raw()` to flip from encrypted-disc /// Used by `DiscStream::set_raw()` to flip from encrypted-disc
/// decryption to a pass-through after the inner reader is already /// decryption to a pass-through after the inner reader is already
@@ -113,6 +239,175 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
pub fn into_inner(self) -> S { pub fn into_inner(self) -> S {
self.inner 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<usize> {
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<u8>> = 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<S: SectorSource> SectorSource for DecryptingSectorSource<S> { impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
@@ -143,16 +438,110 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
{ {
return Err(crate::error::Error::DecryptFailed); return Err(crate::error::Error::DecryptFailed);
} }
let read_t0 = std::time::Instant::now();
let n = self.inner.read_sectors(lba, count, buf, recovery)?; let n = self.inner.read_sectors(lba, count, buf, recovery)?;
// Apply the crate-wide AACS/CSS/None decrypt entry point in-place let read_ms = read_t0.elapsed().as_millis() as u64;
// over the bytes just read. No-op for DecryptKeys::None. The returned // Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*`
// count is bytes of scrambled units no key could decrypt — silent // dispatches on the keys (None / CSS / AACS) and returns the count of
// decrypt loss the TS assembler will drop. Tally it so the mux loss // bytes that SHOULD have decrypted but couldn't — the silent-bad-read
// accounting (and the abort gate) can see partial decrypt failure. // signal. Only AACS ever produces a non-zero count, so nothing below
let dropped = decrypt_sectors(&mut buf[..n], &mut self.keys, self.unit_key_idx)?; // 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 { if dropped > 0 {
self.decrypt_dropped self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed); .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) Ok(n)
} }
@@ -484,7 +873,7 @@ mod tests {
/// A source that yields exactly one CLEAR AACS aligned unit (6144 /// A source that yields exactly one CLEAR AACS aligned unit (6144
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS /// 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 /// reports such a unit as NOT scrambled, so the AACS decrypt path
/// reaches the per-unit closure and leaves it untouched — letting /// reaches the per-unit closure and leaves it untouched — letting
/// us prove the unit-key LOOKUP (not the cipher) is what fails for /// us prove the unit-key LOOKUP (not the cipher) is what fails for
@@ -740,6 +1129,8 @@ mod tests {
unit[off] = 0x47; unit[off] = 0x47;
off += 192; 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 header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header); let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16]; let mut k = [0u8; 16];
@@ -797,7 +1188,10 @@ mod tests {
let unit = encrypt_aacs_unit(&real_key); 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( let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() }, EncUnitSource { unit: unit.clone() },
DecryptKeys::Aacs { DecryptKeys::Aacs {
@@ -809,17 +1203,24 @@ mod tests {
assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero"); assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero");
let mut buf = vec![0u8; 3 * 2048]; let mut buf = vec![0u8; 3 * 2048];
wrapped let err = wrapped
.read_sectors(0, 3, &mut buf, false) .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!( assert_eq!(
loss.load(Ordering::Relaxed), loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64, 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. // A second read of the same bad unit accumulates further (and errors).
wrapped.read_sectors(0, 3, &mut buf, false).unwrap(); assert!(
wrapped.read_sectors(0, 3, &mut buf, false).is_err(),
"the same bad unit fails the read again"
);
assert_eq!( assert_eq!(
loss.load(Ordering::Relaxed), loss.load(Ordering::Relaxed),
2 * crate::aacs::ALIGNED_UNIT_LEN as u64, 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<u8>,
}
impl SectorSource for EncUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
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<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
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 /// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods. /// source unchanged. Grounding: the accessor methods.
#[test] #[test]
@@ -854,4 +1349,390 @@ mod tests {
let recovered = wrapped.into_inner(); let recovered = wrapped.into_inner();
assert_eq!(recovered.capacity_sectors(), 42); 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<u8>,
}
impl SectorSource for EncUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
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<usize> {
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<u8>,
}
impl SectorSource for FixedUnit {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
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<usize> {
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<u8>,
}
impl SectorSource for AnyLbaUnit {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
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<u8>]| {
*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<u8>]| 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<u8>]| {
*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"
);
}
} }
+1 -1
View File
@@ -131,7 +131,7 @@ pub trait SectorSink: Send {
} }
pub use crate::io::file_sector_source::FileSectorSource; 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 file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource; pub use prefetched::PrefetchedSectorSource;
+4 -2
View File
@@ -1250,8 +1250,10 @@ fn parse_udf_name(data: &[u8]) -> String {
} }
} }
/// Merge overlapping or adjacent (start, count) ranges. /// Merge overlapping or adjacent (start, count) ranges. Caller sorts by start
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { /// 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() { if ranges.is_empty() {
return Vec::new(); return Vec::new();
} }
+19 -12
View File
@@ -90,8 +90,11 @@ fn aacs_decrypt_unit_roundtrip() {
plain[offset] = 0x47; // TS sync byte plain[offset] = 0x47; // TS sync byte
offset += 192; offset += 192;
} }
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative gate
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. // `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 // Save original plaintext for comparison
let expected = plain.clone(); let expected = plain.clone();
@@ -128,7 +131,7 @@ fn aacs_decrypt_unit_roundtrip() {
} }
// Verify it looks encrypted (body TS syncs scrambled) // Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::is_aacs_scrambled(&plain)); assert!(aacs::ts_sync_destroyed(&plain));
// Now decrypt // Now decrypt
let result = aacs::decrypt_unit(&mut plain, &unit_key); 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" "decrypt_unit should return true on valid encrypted unit"
); );
assert!( assert!(
!aacs::is_aacs_scrambled(&plain), !aacs::ts_sync_destroyed(&plain),
"decrypted unit should read as clear (TS syncs restored)" "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"); 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] #[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 // A clear unit: TS sync (0x47) intact at every 192-byte packet → not
// scrambled. (Flag bits play no role.) // scrambled. (Flag bits play no role.)
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN]; let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
@@ -267,7 +270,7 @@ fn aacs_is_aacs_scrambled_detection() {
off += 192; off += 192;
} }
assert!( assert!(
!aacs::is_aacs_scrambled(&clear), !aacs::ts_sync_destroyed(&clear),
"clear unit (syncs intact) must not be scrambled" "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[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits flagged[7] = 0xC0; // TSC bits
assert!( assert!(
!aacs::is_aacs_scrambled(&flagged), !aacs::ts_sync_destroyed(&flagged),
"flag bits must not be read as encryption" "flag bits must not be read as encryption"
); );
// A scrambled body (syncs destroyed) → scrambled. // A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
assert!( assert!(
aacs::is_aacs_scrambled(&scrambled), aacs::ts_sync_destroyed(&scrambled),
"unit with no intact TS syncs must read as scrambled" "unit with no intact TS syncs must read as scrambled"
); );
// Too short // Too short
let short = vec![0xFFu8; 100]; let short = vec![0xFFu8; 100];
assert!( assert!(
!aacs::is_aacs_scrambled(&short), !aacs::ts_sync_destroyed(&short),
"short buffer should not be detected" "short buffer should not be detected"
); );
} }
@@ -307,10 +310,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
unit[off] = 0x47; unit[off] = 0x47;
off += 192; off += 192;
} }
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
unit[0] &= 0x3F;
let original = unit.clone(); let original = unit.clone();
let key = [0xAA; 16]; let key = [0xAA; 16];
assert!(!aacs::is_aacs_scrambled(&unit)); assert!(!aacs::ts_sync_destroyed(&unit));
let result = aacs::decrypt_unit(&mut unit, &key); let result = aacs::decrypt_unit(&mut unit, &key);
assert!(result, "clear unit should return true"); assert!(result, "clear unit should return true");
assert_eq!(unit, original, "clear unit should be unchanged"); 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; 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(); let expected = plaintext.clone();
+1
View File
@@ -177,6 +177,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
ContentFormat::MpegPs, ContentFormat::MpegPs,
None, None,
None, None,
None,
) )
.expect("pipeline builds"); .expect("pipeline builds");
+3 -3
View File
@@ -90,16 +90,16 @@ fn aacs_encryption_flag_detection() {
off += 192; off += 192;
} }
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit. // 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. // Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0; unit[0] = 0xC0;
unit[7] = 0xC0; unit[7] = 0xC0;
assert!(!aacs::is_aacs_scrambled(&unit)); assert!(!aacs::ts_sync_destroyed(&unit));
// Scrambled body (syncs gone) → encrypted. // Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; 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. /// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
+1
View File
@@ -274,6 +274,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
wedged_threshold: 0, wedged_threshold: 0,
progress: None, progress: None,
halt: Some(halt.clone()), halt: Some(halt.clone()),
key_fetch: None,
}; };
let outcome = disc.patch(&mut reader, &iso_path, &opts); let outcome = disc.patch(&mut reader, &iso_path, &opts);