diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ba40f..9e98c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.1.1] + +### Fixed + +- **ISO mux no longer drops real video at content-fragment tails.** A title's + encrypted content can end mid-AACS-unit, with the disc zero-padding the rest + of the 6144-byte aligned unit to the next fragment. The decrypt-verify + demanded the TS sync byte on *all 32* source packets, so it rejected such a + tail unit over its legitimate padding — discarding the real video packets it + contained. On a flawless rip this surfaced as a small phantom "loss" at mux + (and, once retries were exhausted, a truncated MKV). Unit acceptance is now + **padding-aware**: only packets whose *source* (pre-decrypt) bytes are + non-zero must restore their TS sync; the zero padding is excluded from the + check and emitted as clean zeros. A full content unit still requires all 32 + (unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails + to decrypt is still rejected as a genuine bad read. +- **ISO online key resolution now sends the Media Key Block.** Capturing a + disc's AACS inputs at scan read the MKB with a full `read_file` of the + ~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers — + leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key + service and the request was rejected (no key → no decrypt). Scan now reads the + MKB through the same bounded prefix-grow + trim reader as the out-of-band + path, so `Disc::inputs()` is the single complete source of AACS inputs — one + reader for every caller. + ## [1.1.0] ### Added diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 0a3aa6d..c7f7759 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -258,7 +258,62 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool { /// Decryption restores the TS sync bytes, so the unit reads as clear afterward; /// there is no flag to clear. pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { - decrypt_unit_checked(unit, unit_key, unit_is_clean_ts) + if unit.len() < ALIGNED_UNIT_LEN { + return false; + } + if !aacs_unit_encrypted(unit) { + return true; // CPI flag clear → plaintext, pass through untouched + } + + // PADDING-AWARE acceptance. The question this answers is "did we read good, + // decryptable data?" — NOT "are all 32 packets present". A content fragment + // can END mid-unit, with the disc zero-padding the rest of the aligned unit + // to the next fragment. Such a tail unit is `[real encrypted packets][source + // zeros]`: the real packets decrypt perfectly, but the strict all-32 + // `unit_is_clean_ts` would reject the whole unit over the padding tail and + // discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet + // (all 192 bytes zero before decrypt) can only be padding, never content. + // + // So: a packet whose SOURCE bytes are all zero is padding — excluded from the + // verify and emitted as clean zeros. Every other (content) packet must + // restore its TS sync. A full content unit has no zero-source packets, so this + // is byte-identical to the old all-32 check (no regression, no wrong-key + // hole). The discriminator between a legitimate short tail and a genuine + // misread is exactly this: a misread leaves the failing packets' SOURCE + // non-zero (real ciphertext that won't decrypt) → still rejected. + const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 + let npkt = ALIGNED_UNIT_LEN / PKT; + let mut pad = [false; ALIGNED_UNIT_LEN / 192]; + for (p, slot) in pad.iter_mut().enumerate().take(npkt) { + let off = p * PKT; + *slot = unit[off..off + PKT].iter().all(|&b| b == 0); + } + + // Save original first 16 bytes (the plaintext seed / header) and derive the + // per-unit decrypt key (identical to `decrypt_unit_checked`). + let mut header = [0u8; 16]; + header.copy_from_slice(&unit[..16]); + let derived = aes_ecb_encrypt(unit_key, &header); + let mut decrypt_key = [0u8; 16]; + for i in 0..16 { + decrypt_key[i] = derived[i] ^ header[i]; + } + aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); + + // Verify content packets; zero out padding packets (their decrypted bytes are + // garbage from AES-decrypting zeros, but the source was zero so a clean zero + // fill is lossless and gives the demux a tidy gap instead of garbage). + for (p, &is_pad) in pad.iter().enumerate().take(npkt) { + let off = p * PKT; + if is_pad { + for b in unit[off..off + PKT].iter_mut() { + *b = 0; + } + } else if unit[off + 4] != TS_SYNC { + return false; // a real content packet failed → genuinely undecryptable + } + } + true } /// Decrypt an AACS aligned unit in place, accepting the key only when `accept` @@ -703,6 +758,101 @@ mod tests { unit } + // ── Padding-aware IsDecryptable (fragment-tail recovery) ─────────────── + // + // A content fragment can end mid-unit, with the disc zero-padding the rest + // of the aligned unit to the next fragment (proven on Dunkirk: ~11 real + // video packets + source-zero pad). `decrypt_unit` must accept such a unit + // — its real packets decrypt; the source-zero tail is padding, not content + // — while still REJECTING a unit whose undecryptable tail is non-zero (a + // genuine misread / wrong key). The discriminator is the SOURCE bytes of the + // failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data. + + /// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from + /// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a + /// non-zero `fill` models a corrupt/misread tail. + fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec { + let mut unit = clear_unit(); + aacs_encrypt_unit(&mut unit, unit_key); + for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() { + *b = fill; + } + unit + } + + #[test] + fn decryptable_full_content_unit_under_correct_key() { + let key = [0x5Au8; 16]; + let mut unit = clear_unit(); + aacs_encrypt_unit(&mut unit, &key); + assert!( + decrypt_unit(&mut unit, &key), + "full clean unit is decryptable" + ); + assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored"); + } + + #[test] + fn fragment_tail_with_source_zero_pad_is_decryptable() { + // 11 real content packets, then source-zero padding (the Dunkirk shape). + let key = [0x5Au8; 16]; + let mut unit = tail_filled_unit(&key, 11, 0x00); + assert!( + decrypt_unit(&mut unit, &key), + "real prefix + source-zero pad IS decryptable" + ); + for p in 0..11 { + assert_eq!( + unit[p * BD_SOURCE_PACKET_BYTES + 4], + TS_SYNC, + "content pkt {p} restored its sync" + ); + } + // Padding emitted as clean zeros, not decrypted garbage. + for p in 11..32 { + let off = p * BD_SOURCE_PACKET_BYTES; + assert!( + unit[off..off + BD_SOURCE_PACKET_BYTES] + .iter() + .all(|&b| b == 0), + "padding pkt {p} zeroed" + ); + } + } + + #[test] + fn fragment_tail_with_nonzero_garbage_is_not_decryptable() { + // Same shape, but the tail is NON-zero — a genuine misread, not padding. + let key = [0x5Au8; 16]; + let mut unit = tail_filled_unit(&key, 11, 0xC3); + assert!( + !decrypt_unit(&mut unit, &key), + "real prefix + non-zero garbage tail is NOT decryptable (misread)" + ); + } + + #[test] + fn wrong_key_full_unit_is_not_decryptable() { + let mut unit = clear_unit(); + aacs_encrypt_unit(&mut unit, &[0x11u8; 16]); + assert!( + !decrypt_unit(&mut unit, &[0x22u8; 16]), + "wrong key on a full content unit is rejected" + ); + } + + #[test] + fn cpi_clear_unit_passes_through_decryptable() { + // CPI-clear (plaintext) unit: decryptable by definition, untouched. + let mut unit = clear_unit(); // byte 0 high bits clear + let before = unit.clone(); + assert!( + decrypt_unit(&mut unit, &[0u8; 16]), + "clear unit passes through as decryptable" + ); + assert_eq!(unit, before, "clear unit left untouched"); + } + // ── AES-ECB KAT (FIPS-197 Appendix C.1) ──────────────────────────────── #[test] diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 2dbd692..59274b3 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -356,22 +356,34 @@ impl Disc { ); return Err(Error::AacsBusKeyUnavailable); } - // MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to - // the real record length (same as `read_aacs_inputs`). Without this the - // MKB stashed on `AacsState` — which `Disc::inputs()` and the device/ - // processing-key `decrypt_with` derivation consume, and which a key - // source ships to an online service — is the full 128 MiB pad, not the - // ~few-MB record stream. - let mkb_bytes = udf_fs - .read_file(reader, "/AACS/MKB_RO.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf")) - .ok() - .unwrap_or_default(); - // Trim to the real record length. Use trim_mkb rather than a raw - // truncate: trim_mkb only truncates when content_len > 0 and strictly - // inside the buffer, so a malformed/unrecognised MKB is preserved - // intact instead of being zeroed by truncate(0). - let mkb_bytes = aacs::trim_mkb(mkb_bytes); + // Read the MKB record stream via the SAME bounded reader the + // out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow + // read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a + // fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on + // file-backed / large readers — which left `a.mkb` empty here, silently + // breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to + // the decode service and it 404'd, while autorip's separate + // `read_aacs_inputs` path (this same helper) worked. One reader now, so + // `Disc::inputs()` is the single complete source of AACS inputs. + // A read ERROR is surfaced (logged), not silently emptied: an empty MKB + // here is invisible until an online key service rejects the request, so + // a transient I/O hiccup must not masquerade as "no MKB". We still + // continue with an empty MKB (disc-hash-keyed keydb lookups don't need + // it), but the cause is now on the log. + let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) { + Ok(m) => m, + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + phase = "scan_aacs_mkb", + error = %e, + "MKB read failed at scan; AACS inputs will carry an empty MKB \ + (online key resolution cannot proceed without it). Continuing \ + — disc-hash-keyed lookups are unaffected." + ); + Vec::new() + } + }; let mkb_ver = aacs::mkb_version(&mkb_bytes); tracing::debug!( diff --git a/src/disc/verify.rs b/src/disc/verify.rs index 0262d1e..492e662 100644 --- a/src/disc/verify.rs +++ b/src/disc/verify.rs @@ -477,7 +477,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) { present.sort_unstable(); for lba in present { if let Some(last) = out.last_mut() { - if last.0 + last.1 == lba { + // Saturating: LBAs come from disc-controlled ICB extents, so a + // corrupt disc must not panic here (matches `udf::merge_ranges`). + if last.0.saturating_add(last.1) == lba { last.1 += 1; continue; } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 102a69b..55c29e3 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -113,10 +113,13 @@ pub struct DecryptingSectorSource { /// [`with_key_fetch`](Self::with_key_fetch) by an application that wants /// to ask its key source for a key when a unit fails to decrypt. fetch: Option, - /// Latched once a fetch call returns no NEW key — further failures on this - /// decorator then skip the callback (the source has nothing more to offer, so - /// re-asking would only burn key-server requests). - fetch_spent: bool, + /// Fingerprints (hash over the unit ciphertext) of failing units a fetch + /// already returned NO new key for. A later failure re-asks the source only + /// for units NOT in this set — so on a multi-CPS disc the source is still + /// asked for the *second* CPS unit's key even after the first came back dry + /// (the old global latch blocked that), while the *same* failing unit is + /// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`). + fetch_dry: std::collections::HashSet, /// How many times the fetch closure has been invoked, capped at /// [`MAX_FETCH_CALLS`]. fetch_calls: usize, @@ -156,7 +159,7 @@ impl DecryptingSectorSource { unit_base: 0, decrypt_dropped: Arc::new(AtomicU64::new(0)), fetch: None, - fetch_spent: false, + fetch_dry: std::collections::HashSet::new(), fetch_calls: 0, verify_only: false, content_ranges: None, @@ -289,6 +292,15 @@ impl DecryptingSectorSource { if samples.is_empty() { return prev_dropped; } + // Skip the call when EVERY failing unit here is one a prior fetch already + // came back empty for — re-asking the identical ciphertext only burns a + // key-server request. A unit we have NOT asked about yet (e.g. a second + // CPS unit on a multi-CPS disc) still gets its one chance, where the old + // global `fetch_spent` latch wrongly blocked it. + let fps: Vec = samples.iter().map(|s| Self::sample_fp(s)).collect(); + if fps.iter().all(|fp| self.fetch_dry.contains(fp)) { + 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() { @@ -307,8 +319,9 @@ impl DecryptingSectorSource { } } if added == 0 { - // Nothing new — stop asking for the rest of this decorator's life. - self.fetch_spent = true; + // Nothing new for THESE units — remember them so we don't re-ask the + // same ciphertext, but leave the door open for other units. + self.fetch_dry.extend(fps); return prev_dropped; } // Retry now that the pool has grown; a unit that still won't decrypt is @@ -317,6 +330,16 @@ impl DecryptingSectorSource { .unwrap_or(prev_dropped) } + /// Stable per-run fingerprint of a failing unit's ciphertext, for the + /// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to + /// equal fingerprints within a process — all the dedup needs. + fn sample_fp(sample: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + sample.hash(&mut h); + h.finish() + } + /// 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 @@ -457,8 +480,7 @@ impl SectorSource for DecryptingSectorSource { 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; + let fetch_viable = 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` @@ -1338,6 +1360,87 @@ mod tests { ); } + /// A fetch that comes back EMPTY for one unit must NOT block a later fetch + /// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent` + /// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B + /// be asked for after unit A came back dry. + #[test] + fn fetch_dry_does_not_block_a_distinct_later_unit() { + let key_a = [0x5au8; 16]; + let key_b = [0x77u8; 16]; + let unit_a = encrypt_aacs_unit(&key_a); + let unit_b = encrypt_aacs_unit(&key_b); + assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys"); + + struct AltSource { + units: Vec>, + idx: usize, + } + impl SectorSource for AltSource { + fn capacity_sectors(&self) -> u32 { + 6 + } + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> Result { + let bytes = count as usize * 2048; + let u = &self.units[self.idx.min(self.units.len() - 1)]; + buf[..bytes].copy_from_slice(u); + self.idx += 1; + Ok(bytes) + } + } + + // Callback serves key_b only when asked about unit B; nothing for A. + let unit_b_cb = unit_b.clone(); + let calls = Arc::new(Mutex::new(0usize)); + let calls_cb = Arc::clone(&calls); + let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec]| { + *calls_cb.lock().unwrap() += 1; + if samples.iter().any(|s| *s == unit_b_cb) { + vec![key_b] + } else { + vec![] + } + }); + + let mut wrapped = DecryptingSectorSource::new( + AltSource { + units: vec![unit_a, unit_b], + idx: 0, + }, + DecryptKeys::Aacs { + unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front + read_data_key: None, + }, + ) + .with_key_fetch(fetch); + + // Read A: fetch fires, returns nothing → A undecryptable (read errors). + let mut buf = vec![0u8; 3 * 2048]; + let _ = wrapped.read_sectors(0, 3, &mut buf, false); + // Read B: fetch must STILL fire (B's sample isn't in the dry set) and + // recover key_b → B decrypts cleanly. + let mut buf2 = vec![0u8; 3 * 2048]; + wrapped + .read_sectors(3, 3, &mut buf2, false) + .expect("unit B recovers via its own fetch"); + + assert_eq!( + *calls.lock().unwrap(), + 2, + "fetch fired for BOTH units — the dry result for A did not latch off B" + ); + assert!( + !crate::aacs::ts_sync_destroyed(&buf2), + "unit B is decrypted after its on-demand fetch" + ); + } + /// `into_inner` / `inner` / `inner_mut` must hand back the original /// source unchanged. Grounding: the accessor methods. #[test]