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:
+209
-43
@@ -101,24 +101,68 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
|
||||
/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196,
|
||||
/// 388, … (one per 192-byte source packet).
|
||||
///
|
||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
||||
/// across discs/players.
|
||||
/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
|
||||
/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
|
||||
/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
|
||||
/// byte-indistinguishable. So this answers only *"does this unit look like valid
|
||||
/// clear TS, or not"*, nothing about encryption.
|
||||
///
|
||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
||||
/// service's validation gate all call THIS, so they always agree on what is
|
||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
|
||||
/// the caller, because it needs context this function lacks:
|
||||
/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
|
||||
/// (see [`crate::decrypt::decrypt_sectors_in_content`] and
|
||||
/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
|
||||
/// primitive separates an encrypted/garbled unit (destroyed) from a clear
|
||||
/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem
|
||||
/// bytes is what produced the first-2 GB false-positive this split fixes.
|
||||
///
|
||||
/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or
|
||||
/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably.
|
||||
pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
|
||||
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
|
||||
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
|
||||
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
|
||||
/// this is readable WITHOUT a key:
|
||||
/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
|
||||
/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
|
||||
///
|
||||
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
|
||||
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
|
||||
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
|
||||
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
|
||||
/// disc-absolute / mis-aligned read makes byte 0 arbitrary mid-stream data, so
|
||||
/// the CPI bits are meaningless (which is precisely why per-unit verify must run
|
||||
/// clip-anchored, not in the whole-disc sweep).
|
||||
pub fn aacs_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
}
|
||||
|
||||
/// True when an aligned unit is flagged encrypted (CPI set) AND still looks
|
||||
/// scrambled (TS syncs not yet restored) — i.e. genuine encrypted content that
|
||||
/// has NOT been decrypted yet.
|
||||
///
|
||||
/// [`aacs_unit_encrypted`] is the authoritative spec gate, but the CPI bits live
|
||||
/// in the plaintext header (bytes `0..16`) which decryption never rewrites, so a
|
||||
/// successfully decrypted unit still reports CPI-set. Buffer-iterating sites that
|
||||
/// may run twice over the same `buf` (the post-fetch re-decrypt, sample
|
||||
/// collection, failure diagnosis) need an IDEMPOTENT "does this still need work?"
|
||||
/// test, so they compose CPI with the sync-restored check: once a unit decrypts,
|
||||
/// its syncs come back and it drops out. Single-shot callers that always operate
|
||||
/// on fresh ciphertext (`decrypt_unit`) gate on [`aacs_unit_encrypted`] alone.
|
||||
///
|
||||
/// Like CPI itself this is only meaningful at the clip-FILE-anchored boundary.
|
||||
pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
|
||||
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
|
||||
}
|
||||
|
||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
||||
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
|
||||
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
|
||||
@@ -148,9 +192,29 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
ts_sync_count(unit) > ts_packet_total(unit) / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
ts_syncs_intact(unit)
|
||||
/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
|
||||
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
|
||||
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
|
||||
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
|
||||
/// independent of (and not coupled to) `decrypt_unit`.
|
||||
///
|
||||
/// It is deliberately stricter than the majority-vote `ts_syncs_intact`
|
||||
/// scramble *heuristic*: a wrong-key decrypt that coincidentally restores >16
|
||||
/// syncs passes the majority test and would silently corrupt content, but fails
|
||||
/// here. Non-mutating — purely a verdict; it neither decrypts nor clears the CPI
|
||||
/// bits (those stay the concern of the unchanged `decrypt_unit`).
|
||||
pub fn unit_is_clean_ts(unit: &[u8]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < ALIGNED_UNIT_LEN {
|
||||
if unit[i + 4] != TS_SYNC {
|
||||
return false;
|
||||
}
|
||||
i += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||
@@ -170,8 +234,8 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true; // not encrypted
|
||||
if !aacs_unit_encrypted(unit) {
|
||||
return true; // CPI flag clear → plaintext, pass through untouched
|
||||
}
|
||||
|
||||
// Save original first 16 bytes (they're plaintext TP_extra_header)
|
||||
@@ -190,8 +254,11 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
||||
verify_ts(unit)
|
||||
// Decryption restored the TS syncs; accept the key only if the unit is now
|
||||
// STRICTLY clean MPEG-TS (all 32 syncs) — the standards-correct gate, shared
|
||||
// with the post-read verify stage. A wrong key that coincidentally restores
|
||||
// a majority of syncs is rejected here, not silently accepted.
|
||||
unit_is_clean_ts(unit)
|
||||
}
|
||||
|
||||
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
|
||||
@@ -206,7 +273,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
|
||||
/// identical to the slow path.
|
||||
///
|
||||
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
|
||||
/// The caller MUST pass an aligned, already-[`ts_sync_destroyed`] unit
|
||||
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
|
||||
/// per-candidate scramble re-scan is intentionally skipped here.
|
||||
///
|
||||
@@ -272,7 +339,7 @@ pub enum UnitKeyResult {
|
||||
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
|
||||
/// `None` if no key worked (the unit is restored to its original bytes).
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
if !aacs_unit_encrypted(unit) {
|
||||
return Some(UnitKeyResult::AlreadyClear);
|
||||
}
|
||||
|
||||
@@ -314,7 +381,7 @@ pub fn decrypt_unit_full(
|
||||
unit_key: &[u8; 16],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
) -> bool {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
if !ts_sync_destroyed(unit) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rdk) = read_data_key {
|
||||
@@ -386,7 +453,7 @@ mod tests {
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
let key = [0u8; 16];
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(!ts_sync_destroyed(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &key));
|
||||
}
|
||||
|
||||
@@ -429,9 +496,9 @@ mod tests {
|
||||
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
|
||||
|
||||
// Exactly half intact → classified scrambled (16 > 16 is false).
|
||||
assert!(is_aacs_scrambled(&set_syncs(16)));
|
||||
assert!(ts_sync_destroyed(&set_syncs(16)));
|
||||
// One past half → classified clear.
|
||||
assert!(!is_aacs_scrambled(&set_syncs(17)));
|
||||
assert!(!ts_sync_destroyed(&set_syncs(17)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -447,13 +514,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(ts_sync_count(&clear), 32);
|
||||
assert!(
|
||||
!is_aacs_scrambled(&clear),
|
||||
!ts_sync_destroyed(&clear),
|
||||
"fully-clear unit → not scrambled"
|
||||
);
|
||||
|
||||
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
assert_eq!(ts_sync_count(&scrambled), 0);
|
||||
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled");
|
||||
assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -502,8 +569,10 @@ mod tests {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
// Flag the unit encrypted via the CPI bits (byte 0) — the authoritative
|
||||
// gate `decrypt_unit` now consults. Set before key derivation so the
|
||||
// recovered plaintext header matches.
|
||||
plain[0] |= 0xC0;
|
||||
|
||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
@@ -530,9 +599,9 @@ mod tests {
|
||||
|
||||
// Now plain contains encrypted data. Decrypt it.
|
||||
let mut unit = plain;
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(ts_sync_destroyed(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
||||
assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored
|
||||
|
||||
// Verify TS sync bytes
|
||||
let mut count = 0;
|
||||
@@ -557,6 +626,10 @@ mod tests {
|
||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
||||
/// fixed AACS IV.
|
||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
|
||||
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
|
||||
// header the real decrypt recovers matches what we encrypt under.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
@@ -721,14 +794,14 @@ mod tests {
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||
assert!(
|
||||
is_aacs_scrambled(&unit),
|
||||
ts_sync_destroyed(&unit),
|
||||
"encrypted unit must look scrambled"
|
||||
);
|
||||
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
// All 32 stride positions carry sync after decrypt.
|
||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(!ts_sync_destroyed(&unit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -756,9 +829,10 @@ mod tests {
|
||||
// left untouched by decrypt (only unit[16..] is CBC-processed).
|
||||
let unit_key = [0x9Au8; 16];
|
||||
let mut clear = clear_unit();
|
||||
// Put a distinctive header so we can confirm it survives.
|
||||
// Put a distinctive header so we can confirm it survives. Byte 0 carries
|
||||
// both CPI bits (0xE0) so it is stable under the fixture's `|= 0xC0`.
|
||||
clear[..16].copy_from_slice(&[
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xE0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
]);
|
||||
let header_before: [u8; 16] = clear[..16].try_into().unwrap();
|
||||
@@ -800,7 +874,7 @@ mod tests {
|
||||
Some(UnitKeyResult::DecryptedWith(2))
|
||||
);
|
||||
assert!(
|
||||
!is_aacs_scrambled(&unit),
|
||||
!ts_sync_destroyed(&unit),
|
||||
"unit must be clear after the hit"
|
||||
);
|
||||
}
|
||||
@@ -819,6 +893,59 @@ mod tests {
|
||||
assert_eq!(unit, snapshot, "failed try must restore the original bytes");
|
||||
}
|
||||
|
||||
// ── CPI gate: the authoritative encrypted-vs-clear decision ────────────
|
||||
|
||||
#[test]
|
||||
fn cpi_gate_clear_flag_passes_through_even_when_body_looks_scrambled() {
|
||||
// THE false-fail fix: a unit whose CPI bits are clear (byte 0 & 0xC0 == 0)
|
||||
// is plaintext by spec, even if its body has no TS syncs (non-TS clear
|
||||
// data, or a mis-probed body). `decrypt_unit` must pass it through
|
||||
// untouched and report success — never attempt a decrypt that would fail.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
// Body looks scrambled (no syncs at the stride) but byte 0 stays 0x00.
|
||||
for (i, b) in unit.iter_mut().enumerate().skip(16) {
|
||||
*b = (i as u8).wrapping_mul(31) | 1; // never 0x47 at the sync stride
|
||||
}
|
||||
assert!(ts_sync_destroyed(&unit), "body has no syncs");
|
||||
assert!(!aacs_unit_encrypted(&unit), "CPI clear");
|
||||
assert!(
|
||||
!aacs_unit_needs_decrypt(&unit),
|
||||
"CPI-clear ⇒ no decrypt attempt"
|
||||
);
|
||||
let snapshot = unit.clone();
|
||||
// Any key: passthrough success, bytes untouched (no false DecryptFailed).
|
||||
assert!(decrypt_unit(&mut unit, &[0xABu8; 16]));
|
||||
assert_eq!(unit, snapshot, "CPI-clear unit must be left byte-identical");
|
||||
assert_eq!(
|
||||
decrypt_unit_try_keys(&mut unit, &[[0xABu8; 16]]),
|
||||
Some(UnitKeyResult::AlreadyClear),
|
||||
"CPI-clear unit consumes no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() {
|
||||
// A CPI-set encrypted unit decrypts with the right key. CPI lives in the
|
||||
// plaintext header, so it survives decryption — `aacs_unit_encrypted`
|
||||
// still reports true afterward, but `aacs_unit_needs_decrypt` flips to
|
||||
// false (syncs restored), keeping the re-decrypt paths idempotent.
|
||||
let key = [0x5au8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body
|
||||
assert!(aacs_unit_encrypted(&unit), "CPI set");
|
||||
assert!(aacs_unit_needs_decrypt(&unit), "flagged + still scrambled");
|
||||
|
||||
assert!(decrypt_unit(&mut unit, &key), "right key decrypts");
|
||||
assert!(
|
||||
aacs_unit_encrypted(&unit),
|
||||
"CPI bits live in the preserved header ⇒ still set post-decrypt"
|
||||
);
|
||||
assert!(
|
||||
!aacs_unit_needs_decrypt(&unit),
|
||||
"syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
|
||||
|
||||
#[test]
|
||||
@@ -840,6 +967,45 @@ mod tests {
|
||||
assert!(!decrypt_unit(&mut probe2, &bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_is_clean_ts_is_strict_all_32_syncs() {
|
||||
// Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
|
||||
// packet syncs is required. A fully-synced clear unit passes.
|
||||
let clear = clear_unit();
|
||||
assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");
|
||||
|
||||
// Drop a SINGLE sync (packet 17 of 32). 31/32 remain, so the majority
|
||||
// heuristic still passes — that is exactly the silent-corruption hole.
|
||||
// The strict gate must REJECT it.
|
||||
let mut one_missing = clear_unit();
|
||||
one_missing[17 * BD_SOURCE_PACKET_BYTES + 4] = 0x00;
|
||||
assert!(
|
||||
ts_syncs_intact(&one_missing),
|
||||
"majority heuristic still passes one missing sync (the hole)"
|
||||
);
|
||||
assert!(
|
||||
!unit_is_clean_ts(&one_missing),
|
||||
"strict gate rejects even one missing sync"
|
||||
);
|
||||
|
||||
// A correctly decrypted unit is clean; a wrong-key decrypt is not.
|
||||
let key = [0x33u8; 16];
|
||||
let mut enc = clear_unit();
|
||||
aacs_encrypt_unit(&mut enc, &key);
|
||||
let mut good = enc.clone();
|
||||
decrypt_unit(&mut good, &key);
|
||||
assert!(unit_is_clean_ts(&good), "right-key decrypt yields clean TS");
|
||||
let mut wrong = enc.clone();
|
||||
decrypt_unit(&mut wrong, &[0x34u8; 16]);
|
||||
assert!(
|
||||
!unit_is_clean_ts(&wrong),
|
||||
"wrong-key decrypt must fail the strict gate"
|
||||
);
|
||||
|
||||
// Short buffer is never vacuously clean.
|
||||
assert!(!unit_is_clean_ts(&clear[..ALIGNED_UNIT_LEN - 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_validates_is_non_mutating() {
|
||||
// The accelerator must never write its input (it operates on the
|
||||
@@ -947,25 +1113,25 @@ mod tests {
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(ts_sync_destroyed(&unit));
|
||||
assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
|
||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
||||
}
|
||||
|
||||
// ── is_aacs_scrambled / ts_sync_count edge cases ───────────────────────
|
||||
// ── ts_sync_destroyed / ts_sync_count edge cases ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_aacs_scrambled_false_for_sub_unit_length() {
|
||||
fn ts_sync_destroyed_false_for_sub_unit_length() {
|
||||
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
|
||||
// shorter is reported NOT scrambled (so the decrypt gate skips it)
|
||||
// rather than indexing past the end.
|
||||
assert!(!is_aacs_scrambled(&[]));
|
||||
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
|
||||
assert!(!ts_sync_destroyed(&[]));
|
||||
assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
|
||||
// A scrambled-looking buffer that is one byte short is still "not
|
||||
// scrambled" by the length guard.
|
||||
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||
almost[4] = 0x00; // no syncs
|
||||
assert!(!is_aacs_scrambled(&almost));
|
||||
assert!(!ts_sync_destroyed(&almost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+55
-33
@@ -171,21 +171,31 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
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();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(first_play);
|
||||
title_cps_unit.push(top_menu);
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(cps);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1050,22 +1060,25 @@ pub struct ContentCert {
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 8 {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate format:
|
||||
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
|
||||
// [1] bus_encryption_enabled (bit 0)
|
||||
// [2..8] cc_id (6 bytes)
|
||||
// Content Certificate layout (matches libaacs content_cert.c):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
let bus_encryption = (data[1] & 0x01) != 0;
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[2..8]);
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
@@ -2314,19 +2327,26 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert
|
||||
let mut data = vec![0u8; 16];
|
||||
// AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
|
||||
// BIT 7 of byte 1, cc_id at bytes 14..20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x00; // AACS 1.0
|
||||
data[1] = 0x00; // no bus encryption
|
||||
data[1] = 0x00; // bus_encryption flag (bit 7) clear
|
||||
data[14..20].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
|
||||
let cc = parse_content_cert(&data).unwrap();
|
||||
assert_eq!(cc.version, AacsVersion::V10);
|
||||
assert!(!cc.bus_encryption);
|
||||
// AACS 2.0 with bus encryption
|
||||
data[0] = 0x01; // AACS 2.0
|
||||
data[1] = 0x01; // bus encryption enabled
|
||||
assert_eq!(cc.cc_id, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
|
||||
// AACS 2.0 with bus encryption: type 0x10, flag is BIT 7 (0x80) of byte 1
|
||||
// — NOT bit 0. A cert with byte1=0x01 must therefore read as bus-OFF.
|
||||
data[0] = 0x10; // AACS 2.0
|
||||
data[1] = 0x80; // bus_encryption_enabled_flag = bit 7
|
||||
let cc = parse_content_cert(&data).unwrap();
|
||||
assert_eq!(cc.version, AacsVersion::V20);
|
||||
assert!(cc.bus_encryption);
|
||||
// Regression guard: bit 0 set, bit 7 clear -> bus OFF (the old bug read this as ON).
|
||||
data[1] = 0x01;
|
||||
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
|
||||
}
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Hardening additions
|
||||
@@ -2613,29 +2633,31 @@ mod tests {
|
||||
// ── Content Certificate parsing ────────────────────────────────────────
|
||||
#[test]
|
||||
fn parse_content_cert_rejects_short_buffer() {
|
||||
// < 8 bytes → None (cc_id slice [2..8] would index OOB).
|
||||
assert!(parse_content_cert(&[0x00; 7]).is_none());
|
||||
// < 20 bytes → None (cc_id slice [14..20] would index OOB).
|
||||
assert!(parse_content_cert(&[0x00; 19]).is_none());
|
||||
assert!(parse_content_cert(&[0x00; 20]).is_some());
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
|
||||
// [0]=type, [1]=bus-enc bit0, [2..8]=cc_id. Any non-0x00 type → V20.
|
||||
let mut data = vec![0u8; 8];
|
||||
data[0] = 0x02; // not 0x00 and not 0x01 → still V20
|
||||
// libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
|
||||
// non-0x00 type → V20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x10; // AACS2 type marker → V20
|
||||
data[1] = 0x00;
|
||||
data[2..8].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
data[14..20].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
let cc = parse_content_cert(&data).unwrap();
|
||||
assert_eq!(cc.version, AacsVersion::V20);
|
||||
assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
assert!(!cc.bus_encryption);
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_bus_encryption_only_reads_bit0() {
|
||||
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with
|
||||
// bit0 clear → false. Pins the mask, not a truthiness of the byte.
|
||||
let mut data = vec![0u8; 8];
|
||||
data[1] = 0x02; // bit 1 set, bit 0 clear
|
||||
fn parse_content_cert_bus_encryption_reads_bit7() {
|
||||
// bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
|
||||
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
|
||||
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
|
||||
data[1] = 0x03; // bit 0 set
|
||||
data[1] = 0x80; // bit 7 set
|
||||
assert!(parse_content_cert(&data).unwrap().bus_encryption);
|
||||
}
|
||||
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
|
||||
@@ -2732,10 +2754,10 @@ mod tests {
|
||||
unit_keys: Vec::new(),
|
||||
}),
|
||||
};
|
||||
// Content cert: AACS2 + bus encryption enabled.
|
||||
let mut cc = vec![0u8; 8];
|
||||
cc[0] = 0x01;
|
||||
cc[1] = 0x01;
|
||||
// Content cert: AACS2 (type 0x10) + bus encryption enabled (bit 7 of byte 1).
|
||||
let mut cc = vec![0u8; 20];
|
||||
cc[0] = 0x10;
|
||||
cc[1] = 0x80;
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
|
||||
+5
-4
@@ -31,9 +31,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
||||
ts_sync_count, unit_key_validates,
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ts,
|
||||
unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
@@ -97,7 +98,7 @@ mod tests {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
let _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
|
||||
+6
-3
@@ -280,9 +280,12 @@ fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
/// Issue the disc-key REPORT KEY (READ DVD STRUCTURE, format 0x02) purely
|
||||
/// for the bus-auth unlock side effect. The returned block contents are
|
||||
/// not used — the descramble title key is recovered keylessly elsewhere.
|
||||
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
|
||||
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
|
||||
/// effect. The returned block contents are not used — the descramble title key
|
||||
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
|
||||
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
|
||||
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
|
||||
@@ -225,7 +225,9 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Exact port of libdvdcss `AttackPattern` (css.c). Scans cleartext
|
||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||
/// differs from the C on phase-misaligned runs. Scans cleartext
|
||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||
|
||||
+331
-21
@@ -185,6 +185,48 @@ pub fn decrypt_sectors(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
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> {
|
||||
let dropped: usize = match keys {
|
||||
DecryptKeys::None => 0,
|
||||
@@ -223,7 +265,7 @@ pub fn decrypt_sectors(
|
||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||
//
|
||||
// Detection: is_aacs_scrambled() short-circuits to false for any
|
||||
// Detection: ts_sync_destroyed() short-circuits to false for any
|
||||
// buffer shorter than a full unit, so it cannot judge a partial. We
|
||||
// instead apply the same TS-sync-intactness test it uses internally
|
||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
||||
@@ -235,12 +277,23 @@ pub fn decrypt_sectors(
|
||||
// tolerate rather than risk a false positive on conformant tails.
|
||||
let partial_len = buf.len() % unit_len;
|
||||
if partial_len != 0 {
|
||||
// Gate the trailing partial on content too: a scrambled partial
|
||||
// OUTSIDE the encrypted m2ts extents is just clear non-TS bytes
|
||||
// (filesystem tail), not a malformed encrypted unit, so it must
|
||||
// not hard-fail. `nfull * 3` is the partial's absolute LBA.
|
||||
let nfull = (buf.len() / unit_len) as u32;
|
||||
let partial_in_content = match content {
|
||||
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
||||
None => true,
|
||||
};
|
||||
if partial_in_content {
|
||||
let partial = &buf[buf.len() - partial_len..];
|
||||
let packets = aacs::ts_packet_total(partial);
|
||||
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nthreads = decrypt_threads();
|
||||
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
|
||||
// across all CPS units on the disc.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() != unit_len || !aacs::is_aacs_scrambled(chunk) {
|
||||
if chunk.len() != unit_len || !aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
return;
|
||||
}
|
||||
// Save original bytes so we can restore if no key validates.
|
||||
@@ -305,24 +358,38 @@ pub fn decrypt_sectors(
|
||||
}
|
||||
|
||||
// No key validated — restore the original encrypted bytes and
|
||||
// tally the loss. The unit was scrambled (we only reach here past
|
||||
// the `is_aacs_scrambled` gate) but no key applied: a clear
|
||||
// nav-file unit that legitimately fails the cipher, or genuine
|
||||
// encrypted content with a missing/wrong sub-key. We can't tell
|
||||
// them apart here, so we always tally; the mux read path treats
|
||||
// tally the loss. The unit is flagged encrypted (we only reach
|
||||
// here past the CPI gate) but no key applied: genuine encrypted
|
||||
// content with a missing/wrong sub-key. We always tally; the mux
|
||||
// read path treats
|
||||
// the count as loss (its extents are real content), while
|
||||
// metadata-probe callers that don't install a loss sink ignore it.
|
||||
chunk.copy_from_slice(&original);
|
||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
};
|
||||
|
||||
// Content gate wrapper: when a gate is supplied, skip any unit whose
|
||||
// absolute LBA lies OUTSIDE the encrypted-content extents — it is
|
||||
// clear non-TS data (filesystem / nav) and must never be decrypted,
|
||||
// verified, or counted as loss. Each aligned unit is 3 sectors.
|
||||
let unit_sectors = (unit_len / 2048) as u32;
|
||||
let process = |idx: usize, chunk: &mut [u8]| {
|
||||
if let Some((base, ranges)) = content {
|
||||
let unit_lba = base.saturating_add((idx as u32) * unit_sectors);
|
||||
if !lba_in_ranges(unit_lba, ranges) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
decrypt_one(chunk);
|
||||
};
|
||||
|
||||
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||
// Serial path: avoids thread-pool overhead for tiny
|
||||
// buffers; also the only path when caller pinned
|
||||
// single-threaded via FREEMKV_THREADS=1. Iterate the
|
||||
// chunks directly — no Vec of slice pointers needed.
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
decrypt_one(chunk);
|
||||
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||
process(idx, chunk);
|
||||
}
|
||||
} else {
|
||||
// Parallel path via rayon's persistent thread pool.
|
||||
@@ -336,14 +403,14 @@ pub fn decrypt_sectors(
|
||||
Some(pool) => {
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
pool.install(|| {
|
||||
chunks.into_par_iter().for_each(|chunk| {
|
||||
decrypt_one(chunk);
|
||||
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
|
||||
process(idx, chunk);
|
||||
});
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
decrypt_one(chunk);
|
||||
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||
process(idx, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +473,7 @@ mod tests {
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
|
||||
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
@@ -455,9 +522,249 @@ mod tests {
|
||||
v[off] = 0xA5;
|
||||
off += 192;
|
||||
}
|
||||
// Flag every aligned unit's CPI bits (byte 0) so it reads as encrypted
|
||||
// under the authoritative `aacs_unit_encrypted`/`aacs_unit_needs_decrypt`
|
||||
// gate — real encrypted content always carries these.
|
||||
let mut u = 0;
|
||||
while u < len {
|
||||
v[u] |= 0xC0;
|
||||
u += aacs::ALIGNED_UNIT_LEN;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ──
|
||||
|
||||
#[test]
|
||||
fn lba_in_ranges_membership() {
|
||||
// (start, count) ⇒ [10,15) and [100,110).
|
||||
let r = &[(10u32, 5u32), (100, 10)];
|
||||
assert!(!lba_in_ranges(0, r), "before first range");
|
||||
assert!(!lba_in_ranges(9, r), "just before first range");
|
||||
assert!(lba_in_ranges(10, r), "at first range start");
|
||||
assert!(lba_in_ranges(14, r), "inside first range");
|
||||
assert!(!lba_in_ranges(15, r), "first range end is exclusive");
|
||||
assert!(!lba_in_ranges(50, r), "in the gap between ranges");
|
||||
assert!(lba_in_ranges(100, r), "at second range start");
|
||||
assert!(lba_in_ranges(109, r), "inside second range");
|
||||
assert!(!lba_in_ranges(110, r), "second range end is exclusive");
|
||||
assert!(!lba_in_ranges(5, &[]), "empty set has no members");
|
||||
}
|
||||
|
||||
/// The content gate at the decrypt primitive: a scrambled-LOOKING unit
|
||||
/// OUTSIDE the content extents (e.g. UDF filesystem) must be SKIPPED — never
|
||||
/// decrypted, never counted as loss. The SAME bytes INSIDE content are
|
||||
/// checked and counted. This is the first-2 GB false-positive fix.
|
||||
#[test]
|
||||
fn content_gate_skips_non_content_units() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
|
||||
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped, 0,
|
||||
"a non-content unit must not count as decrypt loss"
|
||||
);
|
||||
assert_eq!(
|
||||
buf, original,
|
||||
"a non-content unit must be left byte-for-byte untouched"
|
||||
);
|
||||
|
||||
// Same bytes INSIDE content (base_lba 100, range covers LBA 100..103).
|
||||
let mut buf2 = original.clone();
|
||||
let dropped2 =
|
||||
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped2,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
"an undecryptable CONTENT unit IS counted as loss"
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-unit gating across a content boundary: in a 2-unit buffer where only
|
||||
/// the second unit (LBA 3..6) is content, only the second is decrypt-checked.
|
||||
#[test]
|
||||
fn content_gate_is_per_unit_across_a_boundary() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A content range covering the whole buffer must behave EXACTLY like the
|
||||
/// ungated `decrypt_sectors` — the gate adds nothing when everything is content.
|
||||
#[test]
|
||||
fn content_gate_covering_whole_buffer_matches_ungated() {
|
||||
let mut keys_g = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut keys_u = keys_g.clone();
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut g = original.clone();
|
||||
let mut u = original.clone();
|
||||
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
|
||||
let ungated = decrypt_sectors(&mut u, &mut keys_u, 0).unwrap();
|
||||
assert_eq!(
|
||||
gated, ungated,
|
||||
"gated-covering-all == ungated dropped count"
|
||||
);
|
||||
assert_eq!(g, u, "gated-covering-all == ungated bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_in_ranges_more_edges() {
|
||||
// Single range [5,8).
|
||||
assert!(!lba_in_ranges(4, &[(5, 3)]), "just before single range");
|
||||
assert!(lba_in_ranges(5, &[(5, 3)]), "at single range start");
|
||||
assert!(lba_in_ranges(7, &[(5, 3)]), "inside single range");
|
||||
assert!(
|
||||
!lba_in_ranges(8, &[(5, 3)]),
|
||||
"single range end is exclusive"
|
||||
);
|
||||
// After the last range.
|
||||
assert!(
|
||||
!lba_in_ranges(200, &[(10, 5), (100, 10)]),
|
||||
"past the last range"
|
||||
);
|
||||
// Saturating: a range whose start+count overflows u32 must not panic. The
|
||||
// end saturates to u32::MAX, so the very top LBA is excluded — a harmless
|
||||
// edge (real disc LBAs never reach u32::MAX). The range start is still in.
|
||||
assert!(
|
||||
lba_in_ranges(u32::MAX - 1, &[(u32::MAX - 1, 5)]),
|
||||
"saturating range start is in"
|
||||
);
|
||||
assert!(
|
||||
!lba_in_ranges(u32::MAX, &[(u32::MAX - 1, 5)]),
|
||||
"saturated end excludes the top"
|
||||
);
|
||||
}
|
||||
|
||||
/// An EMPTY content map gates EVERYTHING out — even a scrambled unit is
|
||||
/// skipped (treated as non-content). This is the no-titles fallback at the
|
||||
/// primitive level.
|
||||
#[test]
|
||||
fn content_gate_empty_ranges_skips_everything() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
|
||||
assert_eq!(
|
||||
dropped, 0,
|
||||
"empty content map ⇒ nothing is content ⇒ no loss"
|
||||
);
|
||||
assert_eq!(buf, original, "empty content map ⇒ buffer untouched");
|
||||
}
|
||||
|
||||
/// A CLEAR (sync-intact) unit INSIDE content is not ciphertext, so even though
|
||||
/// it is in-content it is skipped by the ts-sync check and never counted.
|
||||
#[test]
|
||||
fn content_gate_clear_unit_in_content_not_counted() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
|
||||
assert_eq!(buf, original, "a clear in-content unit is left untouched");
|
||||
}
|
||||
|
||||
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
|
||||
#[test]
|
||||
fn content_gate_none_keys_is_noop() {
|
||||
let mut keys = DecryptKeys::None;
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(buf, original);
|
||||
}
|
||||
|
||||
/// CSS ignores the content gate (it lives in the AACS arm) and always reports
|
||||
/// `0` — confirming the gate is a no-op for CSS and the read stays
|
||||
/// scheme-agnostic (the litmus test: adding CSS verify touches only the CSS
|
||||
/// arm, never the read).
|
||||
#[test]
|
||||
fn content_gate_css_keys_is_noop() {
|
||||
let mut keys = DecryptKeys::Css { title_key: [0; 5] };
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped, 0,
|
||||
"CSS arm returns 0; content gate is a no-op for CSS"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
||||
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
||||
#[test]
|
||||
fn content_gate_mixed_three_units() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let u = aacs::ALIGNED_UNIT_LEN;
|
||||
let mut buf = vec![0u8; 3 * u];
|
||||
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
|
||||
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
|
||||
buf[2 * u..].copy_from_slice(&scrambled_region(u)); // unit2 @ LBA6 scrambled
|
||||
// Content = LBA 0..6 (units 0 and 1); unit2 (LBA6) is out of content.
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 6)]).unwrap();
|
||||
assert_eq!(dropped, u, "only unit0 (in-content + scrambled) counts");
|
||||
}
|
||||
|
||||
/// Mirror of the boundary test: content covers the FIRST unit only.
|
||||
#[test]
|
||||
fn content_gate_covers_first_unit_only() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, aacs::ALIGNED_UNIT_LEN, "only unit0 counts");
|
||||
}
|
||||
|
||||
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
|
||||
/// OUTSIDE content is clear filesystem tail, not a malformed encrypted unit,
|
||||
/// so it must NOT hard-fail.
|
||||
#[test]
|
||||
fn content_gate_scrambled_partial_outside_content_is_tolerated() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
||||
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&scrambled_region(2048));
|
||||
// content far away → both the full unit and the partial are non-content.
|
||||
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"a scrambled partial outside content must not hard-fail"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whole leading units plus a CLEAR trailing partial (the benign,
|
||||
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS
|
||||
/// tail in the clear on disc. We must return `Ok` and leave the partial
|
||||
@@ -818,6 +1125,9 @@ mod tests {
|
||||
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
// CPI bits on byte 0 so the unit reads as encrypted; set before deriving
|
||||
// the per-unit key so the recovered plaintext header matches.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
@@ -840,7 +1150,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
|
||||
/// (offset 4 + k*192) so `is_aacs_scrambled` reports false and
|
||||
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
|
||||
/// `decrypt_unit` verifies it as clear after decryption.
|
||||
fn clear_ts_unit() -> Vec<u8> {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -864,7 +1174,7 @@ mod tests {
|
||||
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
|
||||
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
||||
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
||||
/// garbled bytes that still look scrambled, failing the `!is_aacs_scrambled`
|
||||
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
|
||||
/// assert.
|
||||
#[test]
|
||||
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
||||
@@ -875,7 +1185,7 @@ mod tests {
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&unit),
|
||||
aacs::ts_sync_destroyed(&unit),
|
||||
"encrypted unit must look scrambled before decrypt"
|
||||
);
|
||||
|
||||
@@ -889,7 +1199,7 @@ mod tests {
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf),
|
||||
!aacs::ts_sync_destroyed(&buf),
|
||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||
);
|
||||
// Every sync position must carry 0x47.
|
||||
@@ -920,7 +1230,7 @@ mod tests {
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf),
|
||||
!aacs::ts_sync_destroyed(&buf),
|
||||
"single-key disc: TS syncs must be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -953,7 +1263,7 @@ mod tests {
|
||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||
let ciphertext = unit.clone();
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&unit),
|
||||
aacs::ts_sync_destroyed(&unit),
|
||||
"encrypted unit must look scrambled going in"
|
||||
);
|
||||
|
||||
@@ -1012,7 +1322,7 @@ mod tests {
|
||||
"exactly one unit's worth of bytes must be reported dropped"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf[..aacs::ALIGNED_UNIT_LEN]),
|
||||
!aacs::ts_sync_destroyed(&buf[..aacs::ALIGNED_UNIT_LEN]),
|
||||
"the decryptable unit must come out clear"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+82
-15
@@ -304,6 +304,60 @@ impl Disc {
|
||||
None => base_keys.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
|
||||
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
|
||||
fn is_aacs_clip(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
|
||||
}
|
||||
|
||||
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
|
||||
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
|
||||
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
|
||||
/// UDF tree through `reader`.
|
||||
///
|
||||
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
|
||||
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
|
||||
/// today. Enumeration must never break a rip, so the error is logged, not
|
||||
/// propagated.
|
||||
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<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
|
||||
@@ -473,21 +527,12 @@ fn extract_one_file<S: SectorSource>(
|
||||
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
|
||||
let mut sector_off: u32 = 0;
|
||||
while sector_off < sectors {
|
||||
let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS);
|
||||
// AACS: read whole units. Round the batch DOWN to a multiple of 3
|
||||
// unless this is the final (possibly short) tail of the extent.
|
||||
// Every preceding batch is a whole number of units, so the tail
|
||||
// batch always BEGINS on a unit boundary (the gate measures
|
||||
// `lba - unit_base`, which stays unit-aligned). The tail itself may
|
||||
// be 1–2 sectors past a unit boundary; `decrypt_sectors` handles
|
||||
// that trailing partial unit explicitly (see its "Trailing-partial
|
||||
// contract"): a clear partial is left in the clear (the conformant
|
||||
// case — AACS leaves the final short unit unencrypted on disc), a
|
||||
// scrambled partial fails loud as DecryptFailed. So the short tail
|
||||
// is correct without padding the read up to a whole unit.
|
||||
if batch >= AACS_UNIT_SECTORS && (sector_off + batch) < sectors {
|
||||
batch -= batch % AACS_UNIT_SECTORS;
|
||||
}
|
||||
// AACS: read whole units (see `whole_unit_batch`). The tail batch may
|
||||
// be a 1–2 sector partial unit, which `decrypt_sectors` handles via
|
||||
// its trailing-partial contract: a clear partial stays clear (AACS
|
||||
// leaves the final short unit unencrypted on disc), a scrambled
|
||||
// partial fails loud as DecryptFailed.
|
||||
let batch = whole_unit_batch(sectors - sector_off);
|
||||
let lba = abs_lba + sector_off;
|
||||
let want = batch as usize * SECTOR_BYTES;
|
||||
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
|
||||
@@ -530,6 +575,22 @@ fn extract_one_file<S: SectorSource>(
|
||||
Ok((fr, false))
|
||||
}
|
||||
|
||||
/// Size the next FILE-ANCHORED content read in whole AACS units. `remaining` is
|
||||
/// the sectors left in the current extent; the batch is capped at
|
||||
/// [`READ_BATCH_SECTORS`] and rounded DOWN to a whole number of 3-sector units
|
||||
/// UNLESS it is the extent's final (possibly short) tail — the tail always
|
||||
/// begins on a unit boundary, so a 1–2 sector partial there is handled by
|
||||
/// `decrypt_sectors`' trailing-partial contract. Shared by `extract_one_file`
|
||||
/// (write) and `verify_one_clip` (dead-range) so this rounding rule lives in
|
||||
/// exactly one place.
|
||||
fn whole_unit_batch(remaining: u32) -> u32 {
|
||||
let mut batch = remaining.min(READ_BATCH_SECTORS);
|
||||
if batch >= AACS_UNIT_SECTORS && batch < remaining {
|
||||
batch -= batch % AACS_UNIT_SECTORS;
|
||||
}
|
||||
batch
|
||||
}
|
||||
|
||||
/// Read one batch through the decrypting decorator with bounded retries.
|
||||
/// Returns `true` on success, `false` once retries are exhausted (the caller
|
||||
/// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT
|
||||
@@ -1033,6 +1094,8 @@ mod tests {
|
||||
}
|
||||
off += 192;
|
||||
}
|
||||
// Flag encrypted via CPI bits (byte 0) before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
@@ -1066,6 +1129,9 @@ mod tests {
|
||||
}
|
||||
off += 192;
|
||||
}
|
||||
// decrypt preserves the plaintext header, so the recovered unit carries
|
||||
// the CPI bits the encrypt fixture set — the expected plaintext must too.
|
||||
unit[0] |= 0xC0;
|
||||
unit
|
||||
}
|
||||
|
||||
@@ -1117,6 +1183,7 @@ mod tests {
|
||||
std::fs::read(dir.join(rel)).ok()
|
||||
}
|
||||
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via
|
||||
|
||||
+229
-19
@@ -17,6 +17,7 @@ pub mod mapfile;
|
||||
mod patch;
|
||||
pub mod read_error;
|
||||
mod sweep;
|
||||
pub mod verify;
|
||||
|
||||
use crate::drive::{Drive, extract_scsi_context};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -436,6 +437,15 @@ pub struct Extent {
|
||||
pub sector_count: u32,
|
||||
}
|
||||
|
||||
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
|
||||
/// sector_count)` ranges — the pure, testable core of
|
||||
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
|
||||
fn merged_extents<'a>(extents: impl Iterator<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
|
||||
/// decrypted access units — channel count, real sample rate, and Atmos
|
||||
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
|
||||
@@ -1980,18 +1990,18 @@ pub enum Key {
|
||||
/// next candidate (and ultimately surfaces a key error rather than silently
|
||||
/// writing ciphertext).
|
||||
///
|
||||
/// Reuses the ecosystem's single `is_aacs_scrambled` predicate and the full
|
||||
/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full
|
||||
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
|
||||
fn aligned_unit_keys_validate(
|
||||
unit_keys: &[(u32, [u8; 16])],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
samples: &[Vec<u8>],
|
||||
) -> bool {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, decrypt_unit_full, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
|
||||
let scrambled: Vec<&[u8]> = samples
|
||||
.iter()
|
||||
.map(|s| s.as_slice())
|
||||
.filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s))
|
||||
.filter(|s| aacs_unit_needs_decrypt(s))
|
||||
.collect();
|
||||
if scrambled.is_empty() {
|
||||
return true; // nothing to disprove against — accept
|
||||
@@ -2050,6 +2060,26 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
|
||||
/// `(start_lba, sector_count)` ranges — the union of every title's m2ts
|
||||
/// stream extents.
|
||||
///
|
||||
/// This is the authoritative "which sectors are encrypted" map for a
|
||||
/// whole-disc read. AACS only encrypts the m2ts AV streams, so a sector is
|
||||
/// encrypted content **iff** it falls inside one of these ranges; everything
|
||||
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
|
||||
///
|
||||
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
|
||||
/// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about
|
||||
/// non-content bytes — filesystem data has no TS sync and would otherwise be
|
||||
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
|
||||
///
|
||||
/// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned);
|
||||
/// callers treat an empty map as "no content gate" and fall back accordingly.
|
||||
pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
|
||||
merged_extents(self.titles.iter().flat_map(|t| &t.extents))
|
||||
}
|
||||
|
||||
/// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or
|
||||
/// empty when this disc has no captured AACS state. Used to name the disc in
|
||||
/// a [`Error::NoDiscKey`] so the application can tell the user which disc to
|
||||
@@ -2644,6 +2674,7 @@ impl Disc {
|
||||
halt: opts.halt.clone(),
|
||||
vid: opts.vid,
|
||||
unit_keys: opts.unit_keys.clone(),
|
||||
key_fetch: opts.key_fetch.clone(),
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
@@ -2670,6 +2701,7 @@ impl Disc {
|
||||
wedged_threshold: 50,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
key_fetch: opts.key_fetch.clone(),
|
||||
};
|
||||
let pr = self.patch(reader, path, &patch_opts)?;
|
||||
tracing::info!(
|
||||
@@ -2722,25 +2754,73 @@ impl Disc {
|
||||
self.ensure_decryptable(!opts.decrypt)?;
|
||||
|
||||
let total_bytes = self.capacity_sectors as u64 * 2048;
|
||||
// Decrypt-aware read.
|
||||
//
|
||||
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
||||
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
||||
//
|
||||
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
|
||||
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
|
||||
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
|
||||
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
|
||||
// a SCSI error, and flows into the SAME read-error recovery (skip /
|
||||
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
|
||||
// mean "read AND decrypts" — everything downstream is unchanged. With no
|
||||
// usable AACS keys (no keydb) it degrades to a plain pass-through.
|
||||
//
|
||||
// A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through:
|
||||
// the user asked for the raw image, untouched and unchecked.
|
||||
// The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE
|
||||
// (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT
|
||||
// decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned
|
||||
// units are anchored to each clip's FILE start and clips can be non-6144-
|
||||
// aligned OR fragmented across UDF extents — so a disc-absolute verify
|
||||
// mis-aligns the unit grid and false-fails good clips (it skipped the
|
||||
// ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the
|
||||
// clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep,
|
||||
// reading each clip file-order-anchored from the ISO. The read here stays
|
||||
// a fail-safe copy; alignment is never assumed.
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
// Captured before `keys` moves into the decorator below. A decrypting
|
||||
// AACS-keyed sweep needs unit-aligned (3-sector) batch sizing + region
|
||||
// read-starts (see the batch computation further down).
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
// Content extent map — only the in-place decrypt path (`opts.decrypt`) gates
|
||||
// on it so clear filesystem / nav sectors pass through untouched.
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
|
||||
// Wrap the producer-side reader once so every read_sectors call
|
||||
// yields plaintext. `DecryptKeys::None` makes the decorator a
|
||||
// pass-through, so the wrapping is cheap when --raw / unencrypted
|
||||
// discs are being swept and we keep the pipeline shape uniform.
|
||||
// Replaces the inline `decrypt::decrypt_sectors` calls that used
|
||||
// to live in this loop and in the bisect inner loop below.
|
||||
let mut reader = DecryptingSectorSource::new(reader, keys);
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
// Post-read verify gate (universal `read -> verify -> sign-off`). Built
|
||||
// ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip
|
||||
// path) so `observe` always sees on-disc ciphertext and never
|
||||
// double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself
|
||||
// fail-safe: it returns `None` (verify disabled, behavior unchanged) for
|
||||
// a non-AACS disc, no keys, the kill-switch off, or an empty clip
|
||||
// enumeration. We resolve the REAL AACS keys here even though the sweep
|
||||
// copies ciphertext, and reuse the application's key-fetch seam.
|
||||
let mut verifier = if opts.decrypt {
|
||||
None
|
||||
} else {
|
||||
let verify_keys = self.decrypt_keys();
|
||||
let layouts = extract::clip_layouts(&mut *reader);
|
||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
||||
};
|
||||
|
||||
// Mapfile: load if resuming, else wipe + recreate.
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
// covers_disc reconciliation. A resume against a mapfile whose total
|
||||
@@ -3019,6 +3099,18 @@ impl Disc {
|
||||
// The consumer thread sees decrypted bytes; the
|
||||
// pre-0.18 inline decrypt_sectors call lived here.
|
||||
|
||||
// Post-read verify: observe the just-read ciphertext
|
||||
// BEFORE it is moved into the channel, collecting the
|
||||
// clip units this batch completes that are confidently
|
||||
// undecryptable. Sent as `MarkBad` AFTER the `Good`
|
||||
// below so the FIFO pipe records `Finished` first and the
|
||||
// downgrade to `NonTrimmed` last. No-op when the gate is
|
||||
// disabled (`verifier` is `None`).
|
||||
let verify_bad = verifier
|
||||
.as_mut()
|
||||
.map(|v| v.observe(block_lba, &buf[..block_bytes as usize]))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Move the batch into the channel via fresh
|
||||
// owned Vec. The producer's `buf` is reused
|
||||
// for the next read.
|
||||
@@ -3027,6 +3119,26 @@ impl Disc {
|
||||
producer_err = Some(consumer_gone());
|
||||
break 'outer;
|
||||
}
|
||||
// Downgrade any unit that failed verify (decrypt-fail ==
|
||||
// bad read). decrypt-fail is NOT physical damage, so it
|
||||
// deliberately does not touch the damage-jump window.
|
||||
let mut send_failed = false;
|
||||
for (bad_lba, bad_cnt) in verify_bad {
|
||||
if pipe
|
||||
.send(WorkItem::MarkBad {
|
||||
pos: bad_lba as u64 * 2048,
|
||||
len: bad_cnt as u64 * 2048,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
producer_err = Some(consumer_gone());
|
||||
send_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if send_failed {
|
||||
break 'outer;
|
||||
}
|
||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||
pos += block_bytes;
|
||||
}
|
||||
@@ -3446,6 +3558,12 @@ pub struct CopyOptions<'a> {
|
||||
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
|
||||
/// Caller wires this from `Disc::aacs.unit_keys`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// On-decrypt-miss key fetch (see [`crate::keysource::key_fetch_factory`]).
|
||||
/// When set, a read that hits AACS ciphertext no held key opens asks the
|
||||
/// application's key sources for the CPS unit's key, caches it, and retries —
|
||||
/// recovering an orphan CPS unit never sampled at resolve time. `None`
|
||||
/// disables it (the prior behaviour). Threaded into sweep + patch.
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -3474,6 +3592,8 @@ pub struct SweepOptions<'a> {
|
||||
/// Resolved AACS unit keys persisted into the mapfile when the sweep
|
||||
/// creates / opens it. When non-empty these win over `vid`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]).
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
|
||||
@@ -3485,6 +3605,9 @@ pub struct PatchOptions<'a> {
|
||||
pub wedged_threshold: u64,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<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`].
|
||||
@@ -3736,6 +3859,53 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── encrypted-content map (`merged_extents` core) ────────────────────────
|
||||
|
||||
fn ext(start_lba: u32, sector_count: u32) -> Extent {
|
||||
Extent {
|
||||
start_lba,
|
||||
sector_count,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_extents_empty_is_empty() {
|
||||
assert_eq!(merged_extents([].iter()), Vec::<(u32, u32)>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_extents_single() {
|
||||
assert_eq!(merged_extents([ext(100, 50)].iter()), vec![(100, 50)]);
|
||||
}
|
||||
|
||||
/// Out-of-order extents from several titles, with an OVERLAP, an ADJACENT
|
||||
/// pair, and a DISJOINT one, must come back sorted + merged + disjoint.
|
||||
#[test]
|
||||
fn merged_extents_unions_sorts_and_merges() {
|
||||
// [300,310) ; [100,150) ; [150,200) adjacent→merges with prev ;
|
||||
// [120,160) overlaps [100,150)&[150,200) ; [500,505) disjoint.
|
||||
let v = vec![
|
||||
ext(300, 10),
|
||||
ext(100, 50),
|
||||
ext(150, 50),
|
||||
ext(120, 40),
|
||||
ext(500, 5),
|
||||
];
|
||||
assert_eq!(
|
||||
merged_extents(v.iter()),
|
||||
vec![(100, 100), (300, 10), (500, 5)],
|
||||
"[100,200) merged, [300,310), [500,505)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same clip referenced by two titles (identical extents) de-duplicates
|
||||
/// to a single range — no double-counting of shared content.
|
||||
#[test]
|
||||
fn merged_extents_dedups_shared_clip() {
|
||||
let v = vec![ext(100, 50), ext(100, 50)];
|
||||
assert_eq!(merged_extents(v.iter()), vec![(100, 50)]);
|
||||
}
|
||||
|
||||
/// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never
|
||||
/// fall through to the block default (8192 sectors = 16 MiB, well over the
|
||||
/// optical 510-sector cap). It has no forward slash, so the Linux-sysfs
|
||||
@@ -4640,7 +4810,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||
// like resume / mapfile must be unaffected).
|
||||
@@ -4658,7 +4828,7 @@ mod tests {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(!is_aacs_scrambled(&clear));
|
||||
assert!(!ts_sync_destroyed(&clear));
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(0, [0x11u8; 16])],
|
||||
None,
|
||||
@@ -4669,7 +4839,7 @@ mod tests {
|
||||
let uk = [0x5au8; 16];
|
||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||
assert!(
|
||||
is_aacs_scrambled(&enc),
|
||||
ts_sync_destroyed(&enc),
|
||||
"encrypted unit must read scrambled"
|
||||
);
|
||||
|
||||
@@ -4698,7 +4868,7 @@ mod tests {
|
||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||
// that leaves any scrambled sample uncovered.
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
@@ -4711,8 +4881,8 @@ mod tests {
|
||||
let uk1 = [0x22u8; 16];
|
||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||
assert!(is_aacs_scrambled(&sample0));
|
||||
assert!(is_aacs_scrambled(&sample1));
|
||||
assert!(ts_sync_destroyed(&sample0));
|
||||
assert!(ts_sync_destroyed(&sample1));
|
||||
|
||||
let samples = vec![sample0.clone(), sample1.clone()];
|
||||
|
||||
@@ -4748,6 +4918,10 @@ mod tests {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||||
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
|
||||
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
|
||||
// attempts the decrypt.
|
||||
unit[0] |= 0xC0;
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(uk));
|
||||
@@ -4916,6 +5090,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -4949,6 +5125,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let err = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
@@ -4989,6 +5167,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
assert!(
|
||||
disc.copy(&mut reader, &iso_path, &opts).is_ok(),
|
||||
@@ -5013,6 +5193,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -5063,6 +5245,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.sweep(&mut reader, &iso_path, &opts).expect("sweep");
|
||||
|
||||
@@ -5167,6 +5351,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
small_disc
|
||||
.sweep(&mut small_reader, &iso_path, &opts0)
|
||||
@@ -5243,6 +5429,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.sweep(&mut reader, &iso_path, &opts0)
|
||||
.expect("initial clean sweep");
|
||||
@@ -5342,6 +5530,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc
|
||||
.sweep(&mut reader, &iso_path, &opts)
|
||||
@@ -5398,6 +5588,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.sweep(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -5429,6 +5621,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -5520,6 +5714,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(result.is_ok(), "resume copy failed: {:?}", result.err());
|
||||
@@ -5616,6 +5812,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -5667,6 +5865,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
|
||||
assert!(
|
||||
@@ -5686,6 +5886,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
|
||||
assert!(
|
||||
@@ -5720,6 +5922,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
|
||||
|
||||
@@ -5734,6 +5938,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
|
||||
assert!(
|
||||
@@ -5768,6 +5974,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
let r = result.expect("100-batch clean sweep should succeed");
|
||||
@@ -6024,6 +6232,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
|
||||
+75
-13
@@ -1731,25 +1731,54 @@ impl Disc {
|
||||
);
|
||||
let bytes_good_before = initial_stats.bytes_good;
|
||||
let bytes_good_start = bytes_good_before;
|
||||
|
||||
// Post-read verify gate for the patch pass (ciphertext multipass only,
|
||||
// `!opts.decrypt`). Built here from the raw reader's UDF enumeration;
|
||||
// reused AFTER the recovery loop (`reverify_iso`) to re-check the units
|
||||
// this pass touched by reading them WHOLE back from the patched ISO —
|
||||
// patch re-reads only the bad sectors of a unit, so per-unit verify
|
||||
// can't run live. Fail-safe `None` when disabled / non-AACS / no keys.
|
||||
let mut verifier = if opts.decrypt {
|
||||
None
|
||||
} else {
|
||||
let verify_keys = self.decrypt_keys();
|
||||
let layouts = crate::disc::extract::clip_layouts(&mut *reader);
|
||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
||||
};
|
||||
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
|
||||
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
|
||||
// patch (the multipass / `--raw --multipass` path) resolves the keys and
|
||||
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
|
||||
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
|
||||
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
|
||||
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
|
||||
// fresh read (the drive returned different bytes) is recovered for free.
|
||||
// With no usable AACS keys this degrades to a plain pass-through.
|
||||
// Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass /
|
||||
// `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt-
|
||||
// VERIFY — the disc-absolute read can't anchor to a clip's file-relative
|
||||
// unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover
|
||||
// bad sectors; the clip-anchored verify pass re-checks them afterward.
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
|
||||
// Wrap the producer-side reader once so every read_sectors
|
||||
// call (the main recovery read, the backtrack read, and the
|
||||
// non-NOT_READY retry read) yields plaintext. Replaces three
|
||||
// inline decrypt_sectors call sites that all keyed off the
|
||||
// same `keys`. `DecryptKeys::None` keeps the unencrypted /
|
||||
// --raw path a pass-through.
|
||||
// AACS reads must start on a 3-sector unit boundary and span whole
|
||||
// units (DecryptingSectorSource rejects mid-unit reads as DecryptFailed).
|
||||
// The patch cursor derives from arbitrary mapfile byte offsets, so a
|
||||
// single-sector recovery read can land mid-unit — see the aligned read
|
||||
// at the read call site below.
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
let mut reader = DecryptingSectorSource::new(reader, keys);
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
// Spawn the consumer. The `WritebackFile` (same bounded-cache
|
||||
@@ -2083,6 +2112,37 @@ impl Disc {
|
||||
// behaviour.
|
||||
let summary = pipe.finish()?;
|
||||
|
||||
// Scoped post-read re-verify (decrypt-fail == bad read). The consumer
|
||||
// has flushed the ISO + mapfile; re-read each clip unit this pass touched
|
||||
// WHOLE from the patched ISO and downgrade any that still won't decrypt
|
||||
// to NonTrimmed, so the orchestrator's end-of-recovery promotion
|
||||
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
|
||||
// disabled gate / unreadable ISO / load failure all leave the pass as-is.
|
||||
if let Some(mut v) = verifier.take() {
|
||||
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
let bad = v.reverify_iso(&mut iso, &bad_ranges);
|
||||
if !bad.is_empty() {
|
||||
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
|
||||
let n: usize = bad.len();
|
||||
for (lba, cnt) in bad {
|
||||
let _ = m.record(
|
||||
lba as u64 * 2048,
|
||||
cnt as u64 * 2048,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
);
|
||||
}
|
||||
let _ = m.flush();
|
||||
tracing::info!(
|
||||
target: "freemkv::verify",
|
||||
phase = "patch_reverify",
|
||||
downgraded_ranges = n,
|
||||
"post-read re-verify downgraded undecryptable units to NonTrimmed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = build_outcome(
|
||||
&state,
|
||||
&summary,
|
||||
@@ -2177,6 +2237,8 @@ mod tests {
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
|
||||
key_fetch: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ pub(super) enum WorkItem {
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
||||
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
||||
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
||||
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
||||
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
||||
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
||||
MarkBad { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
@@ -174,6 +182,12 @@ impl Sink<WorkItem> for SweepSink {
|
||||
}
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::MarkBad { pos, len } => {
|
||||
// Verify downgrade: the ISO bytes are already written by the
|
||||
// preceding Good; only the mapfile status changes so patch
|
||||
// re-reads this range. No file write.
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
|
||||
@@ -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
@@ -280,6 +280,56 @@ pub fn resolve_and_apply_traced(
|
||||
(false, trace)
|
||||
}
|
||||
|
||||
/// THE single key-fetch: drive `sources` in order and return the first non-empty
|
||||
/// Unit Key set. This is exactly what both paths do — only the samples differ:
|
||||
/// * at disc open, `ctx` carries reachable-content samples → resolves the
|
||||
/// up-front CPS units (the common one),
|
||||
/// * in the read, on a decrypt miss, `ctx` carries the FAILING unit's ciphertext
|
||||
/// → resolves the CPS unit that wasn't sampled up front.
|
||||
///
|
||||
/// Same sources, same call; there is no separate "fetch". Unlike
|
||||
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
|
||||
/// decorator re-decrypts with the returned keys, which is the validation.
|
||||
pub fn fetch_unit_keys(sources: &[Box<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
|
||||
/// decrypt) — the content samples that populate [`DiscInputs::samples`] for a
|
||||
/// key server to validate a candidate against, and that [`resolve_and_apply`]
|
||||
@@ -290,7 +340,7 @@ pub fn resolve_and_apply_traced(
|
||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::is_aacs_scrambled`] — the SAME
|
||||
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
|
||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
||||
/// clear unit proves nothing, so this collects only scrambled ones, sampling the
|
||||
@@ -300,7 +350,7 @@ pub fn read_encrypted_units(
|
||||
title: &crate::disc::DiscTitle,
|
||||
n: usize,
|
||||
) -> Vec<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 MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
|
||||
|
||||
@@ -336,7 +386,7 @@ pub fn read_encrypted_units(
|
||||
break;
|
||||
}
|
||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||
if is_aacs_scrambled(u) {
|
||||
if ts_sync_destroyed(u) {
|
||||
out.push(u.to_vec());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
@@ -353,6 +403,7 @@ pub fn read_encrypted_units(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::UnitKey;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||
|
||||
@@ -460,4 +511,114 @@ mod tests {
|
||||
let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect();
|
||||
assert_eq!(whos, vec!["keydb", "my-custom-source"]);
|
||||
}
|
||||
|
||||
// ── fetch_unit_keys / key_fetch (the one shared fetch path) ───────────────
|
||||
|
||||
fn empty_inputs() -> DiscInputs {
|
||||
DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptySource;
|
||||
impl KeySource for EmptySource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<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
@@ -253,8 +253,8 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use mux::build_iso_pipeline;
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use sector::{
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, PrefetchedSectorSource, SectorSink,
|
||||
SectorSource,
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||
SectorSink, SectorSource,
|
||||
};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
+80
-12
@@ -13,6 +13,7 @@
|
||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
|
||||
|
||||
/// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family
|
||||
/// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and
|
||||
@@ -138,18 +139,47 @@ impl CodecParser for TrueHdParser {
|
||||
// the next PES legitimately begins a new AU and seeds the base.
|
||||
if self.buf.is_empty() {
|
||||
if let Some(pts) = pes.pts {
|
||||
// Resync to the authoritative PES PTS, but NEVER snap backward.
|
||||
// TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the
|
||||
// per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so
|
||||
// than the disc's per-PES PTS, which carries the source muxer's
|
||||
// own rounding jitter. When the buffer empties exactly on a PES
|
||||
// boundary and that PES's PTS lands a few ticks *below* the
|
||||
// running cadence, an unconditional reset would set the next
|
||||
// AU's timestamp below the AU just emitted, producing the
|
||||
// non-monotonic block timestamps a muxer rejects. Clamp to the
|
||||
// running position so output stays strictly monotonic; a
|
||||
// genuine forward gap/discontinuity is still adopted.
|
||||
self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts));
|
||||
// Resync to the authoritative PES PTS. TrueHD AUs are a fixed
|
||||
// sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS`
|
||||
// cadence is sample-accurate — more so than the disc's per-PES
|
||||
// PTS, which carries the source muxer's own rounding jitter.
|
||||
//
|
||||
// Two distinct backward steps must be handled OPPOSITELY:
|
||||
//
|
||||
// 1. Small backward jitter (sub-second PES rounding): when the
|
||||
// buffer empties exactly on a PES boundary and that PES's PTS
|
||||
// lands a few ticks *below* the running cadence, an
|
||||
// unconditional reset would set the next AU's timestamp below
|
||||
// the AU just emitted, producing non-monotonic block
|
||||
// timestamps a muxer rejects. CLAMP to the running position so
|
||||
// output stays strictly monotonic.
|
||||
//
|
||||
// 2. Large backward step (> DISCONTINUITY_BACKSTEP_NS): this is a
|
||||
// clip-boundary PTS reset — the title's clips are read as one
|
||||
// concatenated stream and a non-seamless boundary resets the
|
||||
// source PES PTS near zero. This is NOT jitter and must NOT be
|
||||
// clamped: clamping strands the audio at the previous clip's
|
||||
// tail cadence, so when `TimelineContinuity` later bumps the
|
||||
// global offset for the new epoch (driven by the video
|
||||
// back-jump) the stranded-high audio PTS is flung ~a whole
|
||||
// clip past the frontier, producing the non-monotonic
|
||||
// audio-DTS band on multi-clip titles (Dune: Part Two, Top
|
||||
// Gun). ADOPT the raw reset so the per-track raw PTS that
|
||||
// reaches `TimelineContinuity` carries the true boundary, and
|
||||
// the corrector rebases it exactly as it already does for the
|
||||
// DTS / AC-3 parsers (which never clamp). Same threshold the
|
||||
// timeline corrector uses to classify a discontinuity.
|
||||
//
|
||||
// A genuine forward gap/discontinuity is always adopted by the
|
||||
// `.max()`.
|
||||
let new = pts_to_ns(pts);
|
||||
if new < self.next_pts_ns - DISCONTINUITY_BACKSTEP_NS {
|
||||
// Clip-boundary reset: take the raw PTS, restart the cadence.
|
||||
self.next_pts_ns = new;
|
||||
} else {
|
||||
// Within-clip jitter (or forward progression): stay monotonic.
|
||||
self.next_pts_ns = self.next_pts_ns.max(new);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +520,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_boundary_pts_reset_is_adopted_not_clamped() {
|
||||
// Regression (Dune: Part Two / Top Gun non-monotonic audio-DTS band):
|
||||
// a title's clips are read as one concatenated stream, so at a
|
||||
// non-seamless boundary the source PES PTS resets near zero — a LARGE
|
||||
// backward step (> DISCONTINUITY_BACKSTEP_NS), NOT muxer jitter. The
|
||||
// parser must ADOPT that reset (restart the cadence at the raw PTS), the
|
||||
// same way the DTS / AC-3 parsers pass raw PTS through, so the per-track
|
||||
// raw PTS reaching TimelineContinuity carries the true boundary and the
|
||||
// corrector can rebase it. Clamping it forward (the old `.max()`) stranded
|
||||
// the audio at the previous clip's tail; when the global offset later
|
||||
// bumped for the new epoch the stranded audio was flung ~a clip past the
|
||||
// frontier — the non-monotonic band.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let au = make_truehd_unit(100);
|
||||
// Clip 1: an AU at PES PTS = 10s (90000 ticks/s → 900_000 ticks). Buffer
|
||||
// empties, so the next PES seeds a fresh base.
|
||||
let clip1_pts = 90_000 * 10; // 10 s in 90 kHz ticks
|
||||
let f1 = parser.parse(&make_pes(au.clone(), Some(clip1_pts)));
|
||||
assert_eq!(f1.len(), 1);
|
||||
let last1 = f1[0].pts_ns;
|
||||
assert_eq!(last1, pts_to_ns(clip1_pts));
|
||||
// Clip 2: PES PTS resets to 0 — 10 s backward, far beyond the 3 s
|
||||
// discontinuity threshold. Must be adopted, not clamped to the cadence.
|
||||
let f2 = parser.parse(&make_pes(au.clone(), Some(0)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(
|
||||
f2[0].pts_ns, 0,
|
||||
"clip-boundary PTS reset must be adopted raw (got {}, expected the \
|
||||
reset value 0 — clamping to the previous clip's cadence is the bug)",
|
||||
f2[0].pts_ns
|
||||
);
|
||||
assert!(
|
||||
f2[0].pts_ns < last1,
|
||||
"the reset frame must land below the previous clip's tail, not above it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_interleaved_ac3() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
|
||||
+1
-1
@@ -1036,7 +1036,7 @@ mod tests {
|
||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||
/// Successful reads return zeroed sectors (which are NOT
|
||||
/// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through
|
||||
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
|
||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||
struct RecordingReader {
|
||||
capacity: u32,
|
||||
|
||||
+47
-1
@@ -245,7 +245,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InputOptions {
|
||||
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
|
||||
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
|
||||
@@ -257,6 +257,26 @@ pub struct InputOptions {
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
/// Optional fresh-key-on-failure closure (a shared [`crate::sector::KeyFetch`]).
|
||||
/// `None` (default) keeps the prior behaviour: a unit no held key decrypts is
|
||||
/// counted as decrypt loss. When set, the mux installs it (cloned `Arc`) so a
|
||||
/// still-scrambled unit is re-tried via the application's key source.
|
||||
/// Application seam only; the library makes no network call.
|
||||
pub key_fetch: Option<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).
|
||||
@@ -381,6 +401,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
} else {
|
||||
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(
|
||||
reader,
|
||||
title,
|
||||
@@ -389,6 +417,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
format,
|
||||
None,
|
||||
None,
|
||||
fetch,
|
||||
)?;
|
||||
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
|
||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
||||
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
|
||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||
// point; grouping them into a struct would only move the same fields around.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
reader: S,
|
||||
title: DiscTitle,
|
||||
@@ -581,6 +617,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
format: ContentFormat,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
fetch: Option<crate::sector::KeyFetch>,
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
let extents = title.extents.clone();
|
||||
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
||||
@@ -594,6 +631,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
};
|
||||
let mut decrypting =
|
||||
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
|
||||
// producer thread. It tracks bytes of scrambled AACS units no key could
|
||||
// decrypt — silent loss the demux drops; the consuming stream surfaces it
|
||||
@@ -1116,6 +1159,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
let first = stream.read().expect("read must not error on clean EOF");
|
||||
@@ -1156,6 +1200,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
|
||||
@@ -1207,6 +1252,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(res.is_err(), "zero batch_sectors must be rejected");
|
||||
}
|
||||
|
||||
+895
-14
@@ -13,13 +13,69 @@
|
||||
//! pass-through, so callers can wire it unconditionally and keep
|
||||
//! their pipeline shape uniform regardless of encryption state.
|
||||
|
||||
use crate::decrypt::{DecryptKeys, decrypt_sectors};
|
||||
use crate::decrypt::{DecryptKeys, decrypt_sectors, decrypt_sectors_in_content};
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::SectorSource;
|
||||
|
||||
/// Application-supplied "fetch a fresh key for THIS data" callback.
|
||||
///
|
||||
/// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS
|
||||
/// units that NONE of the currently-held unit keys could decrypt. The argument
|
||||
/// is those still-scrambled 6144-byte aligned units (real on-disc ciphertext);
|
||||
/// the return is any additional unit keys to add to the pool and retry with —
|
||||
/// empty if the source can't help. Mirrors the DVD model (try the held key,
|
||||
/// then ask the key source for the failing data) generalised to AACS.
|
||||
///
|
||||
/// The library performs NO key lookup or network I/O itself; this closure is
|
||||
/// the seam an application uses to call its key source (e.g. an online key
|
||||
/// service) with the exact ciphertext that failed. A **stateless, shared**
|
||||
/// `Arc<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
|
||||
/// 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_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> {
|
||||
@@ -67,9 +155,37 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
unit_key_idx: 0,
|
||||
unit_base: 0,
|
||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||
fetch: None,
|
||||
fetch_spent: false,
|
||||
fetch_calls: 0,
|
||||
verify_only: false,
|
||||
content_ranges: None,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict decrypt/verify to the disc's encrypted-content extents
|
||||
/// (sorted/merged `(start_lba, sector_count)` — see
|
||||
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
||||
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
|
||||
/// so [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) is never consulted
|
||||
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
|
||||
/// mux leaves it unset because it only ever reads title extents.
|
||||
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
|
||||
self.content_ranges = Some(ranges);
|
||||
self
|
||||
}
|
||||
|
||||
/// Switch to verify-only mode: decrypt-CHECK each read on a scratch copy and
|
||||
/// fail the read (`DECRYPT_VERIFY_READ`) when a scrambled AACS unit won't
|
||||
/// decrypt, but leave `buf` as the original ciphertext. The multipass sweep
|
||||
/// uses this so its ISO stays encrypted while still rejecting silent-bad
|
||||
/// reads. No-op effect for `DecryptKeys::None` (nothing to check).
|
||||
pub fn verify_only(mut self) -> Self {
|
||||
self.verify_only = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// A handle to this decorator's decrypt-loss counter — the cumulative bytes
|
||||
/// of scrambled AACS units that no key could decrypt (see
|
||||
/// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this
|
||||
@@ -87,6 +203,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Install a [`KeyFetch`] callback: when a read holds scrambled AACS units
|
||||
/// that no current key decrypts, the decorator hands those units to `cb` and
|
||||
/// adds any keys it returns to the pool, then re-decrypts. Only meaningful
|
||||
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
|
||||
/// call — `cb` is the application's seam to its key source.
|
||||
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
|
||||
self.fetch = Some(cb);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the configured keys without unwrapping the decorator.
|
||||
/// Used by `DiscStream::set_raw()` to flip from encrypted-disc
|
||||
/// decryption to a pass-through after the inner reader is already
|
||||
@@ -113,6 +239,175 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
pub fn into_inner(self) -> S {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Decrypt `buf` in place with the active keys, applying the content gate
|
||||
/// when one is installed (whole-disc readers) or running ungated (the mux).
|
||||
/// The single dispatch both the first read and the post-fetch retry share,
|
||||
/// so they agree on which units are content and on the unit-key try order.
|
||||
fn decrypt_buf(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
lba: u32,
|
||||
content: Option<&[(u32, u32)]>,
|
||||
) -> Result<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> {
|
||||
@@ -143,16 +438,110 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
{
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
let read_t0 = std::time::Instant::now();
|
||||
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
|
||||
// Apply the crate-wide AACS/CSS/None decrypt entry point in-place
|
||||
// over the bytes just read. No-op for DecryptKeys::None. The returned
|
||||
// count is bytes of scrambled units no key could decrypt — silent
|
||||
// decrypt loss the TS assembler will drop. Tally it so the mux loss
|
||||
// accounting (and the abort gate) can see partial decrypt failure.
|
||||
let dropped = decrypt_sectors(&mut buf[..n], &mut self.keys, self.unit_key_idx)?;
|
||||
let read_ms = read_t0.elapsed().as_millis() as u64;
|
||||
// Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*`
|
||||
// dispatches on the keys (None / CSS / AACS) and returns the count of
|
||||
// bytes that SHOULD have decrypted but couldn't — the silent-bad-read
|
||||
// signal. Only AACS ever produces a non-zero count, so nothing below
|
||||
// needs a per-scheme check. When a content map is installed (whole-disc
|
||||
// readers), the `*_in_content` entry skips units OUTSIDE the encrypted
|
||||
// content extents, so clear filesystem / nav bytes are never mistaken for
|
||||
// ciphertext. The mux installs no map (it reads title extents only).
|
||||
//
|
||||
// VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
|
||||
// keeps its ciphertext (the ISO stays encrypted) and the hot path pays no
|
||||
// per-read allocation. NORMAL: decrypt in place; a fetch callback may
|
||||
// recover a unit no held key opened.
|
||||
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
|
||||
let content_ref = content.as_deref();
|
||||
// Whether a fresh-key fetch is still worth attempting on this decorator.
|
||||
let fetch_viable =
|
||||
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
||||
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
|
||||
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
|
||||
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
|
||||
// keeps its ciphertext (the ISO stays encrypted), but STILL fetch —
|
||||
// the whole point is to CACHE the key. The fetched key is added to the
|
||||
// pool, so the unit that triggered it now verifies clean (no false
|
||||
// read-failure / damage-jump) and every later unit this pass — and any
|
||||
// later read on this decorator — reuses it instead of re-asking the key
|
||||
// server. Without this a CPS unit whose key wasn't sampled up front
|
||||
// (an orphan clip not reachable from any playlist) hard-fails the whole
|
||||
// range even though one key fetch would recover it.
|
||||
// * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry.
|
||||
// The fetch re-decrypt targets the post-decrypt buffer (scratch / buf),
|
||||
// whose still-scrambled units ARE the failures.
|
||||
let dropped = if self.verify_only {
|
||||
let mut scratch = std::mem::take(&mut self.scratch);
|
||||
scratch.clear();
|
||||
scratch.extend_from_slice(&buf[..n]);
|
||||
let mut d = match Self::decrypt_buf(
|
||||
&mut scratch,
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
lba,
|
||||
content_ref,
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.scratch = scratch;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut scratch, lba, content_ref, d);
|
||||
}
|
||||
self.scratch = scratch;
|
||||
d
|
||||
} else {
|
||||
let mut d = Self::decrypt_buf(
|
||||
&mut buf[..n],
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
lba,
|
||||
content_ref,
|
||||
)?;
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut buf[..n], lba, content_ref, d);
|
||||
}
|
||||
d
|
||||
};
|
||||
if dropped > 0 {
|
||||
self.decrypt_dropped
|
||||
.fetch_add(dropped as u64, Ordering::Relaxed);
|
||||
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
|
||||
// means this read did NOT truly succeed — it returned ciphertext the
|
||||
// TS assembler would silently drop. Fail the read loud so the caller's
|
||||
// read-error recovery re-reads it off the disc (rip) or the mux hard-
|
||||
// fails (no clean data to mux). Scheme-agnostic (only AACS reaches a
|
||||
// non-zero count); clear filesystem (gated out) and zero-fill (not
|
||||
// scrambled) never get here.
|
||||
if DECRYPT_VERIFY_READ {
|
||||
// FACT-FINDING: on a fresh rip these bytes came straight off the
|
||||
// drive, so each failing unit's signature (all-zero? entropy?
|
||||
// does any held key get it closer to clear TS?) plus the inner
|
||||
// read latency are ground truth about the SOURCE — enough to
|
||||
// classify the failure as source-zeros, marginal-media garbage,
|
||||
// or a clean read no held key opens. In verify-only mode `buf`
|
||||
// is untouched ciphertext (every unit looks scrambled), so the
|
||||
// post-decrypt `scratch` is what distinguishes failed units
|
||||
// (restored to ciphertext) from succeeded ones (now plaintext).
|
||||
let diag: &[u8] = if self.verify_only {
|
||||
&self.scratch
|
||||
} else {
|
||||
&buf[..n]
|
||||
};
|
||||
Self::diagnose_decrypt_failure(
|
||||
lba,
|
||||
diag,
|
||||
read_ms,
|
||||
self.content_ranges.as_deref(),
|
||||
&self.keys,
|
||||
);
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -484,7 +873,7 @@ mod tests {
|
||||
|
||||
/// A source that yields exactly one CLEAR AACS aligned unit (6144
|
||||
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS
|
||||
/// stride (offset 4, then every 192 bytes). `is_aacs_scrambled`
|
||||
/// stride (offset 4, then every 192 bytes). `ts_sync_destroyed`
|
||||
/// reports such a unit as NOT scrambled, so the AACS decrypt path
|
||||
/// reaches the per-unit closure and leaves it untouched — letting
|
||||
/// us prove the unit-key LOOKUP (not the cipher) is what fails for
|
||||
@@ -740,6 +1129,8 @@ mod tests {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// CPI bits on byte 0 so it reads as encrypted; set before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
@@ -797,7 +1188,10 @@ mod tests {
|
||||
|
||||
let unit = encrypt_aacs_unit(&real_key);
|
||||
|
||||
// Wrong key → undecryptable → loss counted, read still Ok.
|
||||
// Wrong key → undecryptable → loss counted AND the read fails loud
|
||||
// (DECRYPT_VERIFY_READ: a read that returns an undecryptable AACS unit
|
||||
// did not truly succeed). The loss counter is still bumped before the
|
||||
// error so the abort accounting sees the byte count.
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
EncUnitSource { unit: unit.clone() },
|
||||
DecryptKeys::Aacs {
|
||||
@@ -809,17 +1203,24 @@ mod tests {
|
||||
assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero");
|
||||
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
wrapped
|
||||
let err = wrapped
|
||||
.read_sectors(0, 3, &mut buf, false)
|
||||
.expect("undecryptable unit must NOT hard-error (per-unit tolerance)");
|
||||
.expect_err("DECRYPT_VERIFY_READ: an undecryptable AACS unit fails the read loud");
|
||||
assert!(
|
||||
matches!(err, crate::error::Error::DecryptFailed),
|
||||
"undecryptable unit errors with DecryptFailed, got {err:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
"one undecryptable unit must add its byte length to the loss counter"
|
||||
"the undecryptable unit is tallied as loss before the read errors"
|
||||
);
|
||||
|
||||
// A second read of the same bad unit accumulates further.
|
||||
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
|
||||
// A second read of the same bad unit accumulates further (and errors).
|
||||
assert!(
|
||||
wrapped.read_sectors(0, 3, &mut buf, false).is_err(),
|
||||
"the same bad unit fails the read again"
|
||||
);
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
@@ -843,6 +1244,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
|
||||
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
|
||||
/// that returns that key, the decorator must hand the still-scrambled unit to
|
||||
/// the callback, add the returned key, re-decrypt, and register ZERO loss.
|
||||
/// Without the callback the same read accumulates loss (the baseline).
|
||||
///
|
||||
/// Grounding: `read_sectors` invokes `fetch_failed_units` when
|
||||
/// `decrypt_sectors` leaves a scrambled unit and a callback is installed.
|
||||
#[test]
|
||||
fn key_fetch_recovers_unit_with_a_fresh_key() {
|
||||
let real_key = [0x5au8; 16]; // the key the unit is actually under
|
||||
let wrong_key = [0x11u8; 16]; // the only key we start with
|
||||
|
||||
struct EncUnitSource {
|
||||
unit: Vec<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
|
||||
/// source unchanged. Grounding: the accessor methods.
|
||||
#[test]
|
||||
@@ -854,4 +1349,390 @@ mod tests {
|
||||
let recovered = wrapped.into_inner();
|
||||
assert_eq!(recovered.capacity_sectors(), 42);
|
||||
}
|
||||
|
||||
/// Verify-only mode (the multipass sweep/patch path): a read decrypt-CHECKS
|
||||
/// the bytes but NEVER mutates `buf`, so the ISO keeps its ciphertext. An
|
||||
/// undecryptable unit still fails the read (DECRYPT_VERIFY_READ) so the
|
||||
/// existing read-error recovery treats it like a SCSI failure; a decryptable
|
||||
/// unit returns Ok with the ciphertext intact (the check is non-destructive).
|
||||
#[test]
|
||||
fn verify_only_checks_without_mutating_and_fails_on_undecryptable() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16];
|
||||
|
||||
struct EncUnitSource {
|
||||
unit: Vec<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
@@ -131,7 +131,7 @@ pub trait SectorSink: Send {
|
||||
}
|
||||
|
||||
pub use crate::io::file_sector_source::FileSectorSource;
|
||||
pub use decrypting::DecryptingSectorSource;
|
||||
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
|
||||
pub use file::FileSectorSink;
|
||||
pub use prefetched::PrefetchedSectorSource;
|
||||
|
||||
|
||||
+4
-2
@@ -1250,8 +1250,10 @@ fn parse_udf_name(data: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping or adjacent (start, count) ranges.
|
||||
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
/// Merge overlapping or adjacent (start, count) ranges. Caller sorts by start
|
||||
/// first. Shared range utility — also used to build the disc's encrypted-content
|
||||
/// extent map (see `Disc::encrypted_content_ranges`).
|
||||
pub(crate) fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
if ranges.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
+19
-12
@@ -90,8 +90,11 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
plain[offset] = 0x47; // TS sync byte
|
||||
offset += 192;
|
||||
}
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS
|
||||
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
// Flag the unit encrypted via the CPI bits (byte 0) — the authoritative gate
|
||||
// `decrypt_unit` consults. Set BEFORE snapshotting `expected`: decrypt
|
||||
// preserves the plaintext header, so the round-tripped unit carries these
|
||||
// bits too.
|
||||
plain[0] |= 0xC0;
|
||||
|
||||
// Save original plaintext for comparison
|
||||
let expected = plain.clone();
|
||||
@@ -128,7 +131,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
}
|
||||
|
||||
// Verify it looks encrypted (body TS syncs scrambled)
|
||||
assert!(aacs::is_aacs_scrambled(&plain));
|
||||
assert!(aacs::ts_sync_destroyed(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
||||
@@ -137,7 +140,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&plain),
|
||||
!aacs::ts_sync_destroyed(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
);
|
||||
|
||||
@@ -255,9 +258,9 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||
}
|
||||
|
||||
/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs.
|
||||
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
|
||||
#[test]
|
||||
fn aacs_is_aacs_scrambled_detection() {
|
||||
fn aacs_ts_sync_destroyed_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -267,7 +270,7 @@ fn aacs_is_aacs_scrambled_detection() {
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&clear),
|
||||
!aacs::ts_sync_destroyed(&clear),
|
||||
"clear unit (syncs intact) must not be scrambled"
|
||||
);
|
||||
|
||||
@@ -276,21 +279,21 @@ fn aacs_is_aacs_scrambled_detection() {
|
||||
flagged[0] = 0xC0; // copy-control bits
|
||||
flagged[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&flagged),
|
||||
!aacs::ts_sync_destroyed(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&scrambled),
|
||||
aacs::ts_sync_destroyed(&scrambled),
|
||||
"unit with no intact TS syncs must read as scrambled"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&short),
|
||||
!aacs::ts_sync_destroyed(&short),
|
||||
"short buffer should not be detected"
|
||||
);
|
||||
}
|
||||
@@ -307,10 +310,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||
unit[0] &= 0x3F;
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
let result = aacs::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
@@ -380,7 +385,9 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
}
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
// Flag the unit encrypted via the CPI bits (byte 0) — the gate `decrypt_unit`
|
||||
// consults. Set before snapshotting `expected`; decrypt preserves the header.
|
||||
plaintext[0] |= 0xC0;
|
||||
|
||||
let expected = plaintext.clone();
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
|
||||
ContentFormat::MpegPs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
|
||||
|
||||
@@ -90,16 +90,16 @@ fn aacs_encryption_flag_detection() {
|
||||
off += 192;
|
||||
}
|
||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
|
||||
// Flag bits on a synced unit do not make it look encrypted.
|
||||
unit[0] = 0xC0;
|
||||
unit[7] = 0xC0;
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
|
||||
// Scrambled body (syncs gone) → encrypted.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::is_aacs_scrambled(&scrambled));
|
||||
assert!(aacs::ts_sync_destroyed(&scrambled));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
|
||||
@@ -274,6 +274,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
||||
wedged_threshold: 0,
|
||||
progress: None,
|
||||
halt: Some(halt.clone()),
|
||||
key_fetch: None,
|
||||
};
|
||||
|
||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||
|
||||
Reference in New Issue
Block a user