diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs index 9fe0cc6..c202d14 100644 --- a/src/disc/mapfile.rs +++ b/src/disc/mapfile.rs @@ -651,35 +651,9 @@ impl Drop for Mapfile { /// caller treats a bad VID comment as simply absent rather than an /// error, so a corrupt header never fails a mapfile load. fn parse_vid_hex(s: &str) -> Option<[u8; 16]> { - let s = s.strip_prefix("0x").unwrap_or(s); - // Parse on bytes, not on the &str: slicing a &str by byte index - // (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte - // UTF-8 char. A hand-edited/corrupt `# freemkv-vid:` comment of - // exactly 32 bytes containing a multi-byte char would otherwise - // kill the whole load. ASCII hex is one byte per char, so anything - // non-ASCII is simply rejected here as malformed. - let bytes = s.as_bytes(); - if bytes.len() != 32 { - return None; - } - let mut out = [0u8; 16]; - for (i, b) in out.iter_mut().enumerate() { - let hi = hex_nibble(bytes[i * 2])?; - let lo = hex_nibble(bytes[i * 2 + 1])?; - *b = (hi << 4) | lo; - } - Some(out) -} - -/// Map a single ASCII hex digit byte to its 0-15 value. Returns `None` -/// for any non-hex byte (including any non-ASCII / multi-byte lead byte). -fn hex_nibble(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } + // The one workspace hex parser (accepts an optional `0x`/`0X` prefix, + // byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics). + crate::hex::parse_hex_fixed::<16>(s) } /// Parse a `# freemkv-uk:` value `:<32hex>` into `(cps_unit, key)`. Returns diff --git a/src/hex.rs b/src/hex.rs new file mode 100644 index 0000000..a94e49b --- /dev/null +++ b/src/hex.rs @@ -0,0 +1,106 @@ +//! The single hex → bytes parser for the whole workspace. +//! +//! Key material arrives as hex from three third-party sources — the keydb, an +//! online key service, and the mapfile's `# freemkv-vid:` comment — and each +//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped +//! nothing, one stripped `0x` only). A key written with a prefix one parser +//! didn't expect was silently dropped → "can't decrypt" with no error. This is +//! the one parser they all call, so the prefix/case/validation rules live in +//! exactly one place. +//! +//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a +//! multi-byte UTF-8 scalar must reject as malformed, never panic on a +//! mid-codepoint slice. + +/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix +/// (case-insensitive), then requires an even run of ASCII hex digits. Any +/// non-hex byte, or an odd length, yields `None`. +pub fn parse_hex_bytes(s: &str) -> Option> { + let body = strip_prefix(s.trim()); + let bytes = body.as_bytes(); + // Empty → empty Vec (a legitimately-empty variable-length field); odd length + // is malformed. (`parse_hex_fixed` enforces a concrete length separately.) + if bytes.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 2); + for pair in bytes.chunks_exact(2) { + out.push(byte(pair[0], pair[1])?); + } + Some(out) +} + +/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X` +/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any +/// non-hex byte or a length mismatch. +pub fn parse_hex_fixed(s: &str) -> Option<[u8; N]> { + let body = strip_prefix(s.trim()); + let bytes = body.as_bytes(); + if bytes.len() != 2 * N { + return None; + } + let mut out = [0u8; N]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = byte(bytes[2 * i], bytes[2 * i + 1])?; + } + Some(out) +} + +/// Strip a single leading `0x` / `0X` if present (case-insensitive). +fn strip_prefix(s: &str) -> &str { + s.strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s) +} + +/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional: +/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then +/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail +/// cleanly rather than slipping through `from_str_radix`'s sign handling. +fn byte(hi: u8, lo: u8) -> Option { + let hi = (hi as char).to_digit(16)?; + let lo = (lo as char).to_digit(16)?; + Some((hi * 16 + lo) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_accepts_0x_0x_and_bare_same_result() { + let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00]; + let bare = "0011abcdef429900"; + assert_eq!(parse_hex_fixed::<8>(bare), Some(want)); + assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want)); + // The case that used to be dropped by one parser but not another. + assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want)); + assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want)); + } + + #[test] + fn fixed_rejects_wrong_length_and_non_hex_and_signs() { + assert_eq!(parse_hex_fixed::<16>("00"), None); // too short + assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long + assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex + assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars + } + + #[test] + fn does_not_panic_on_multibyte_of_exact_byte_length() { + // "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed + // parser. Must reject, not panic. + let s = "中".to_string() + &"a".repeat(29); + assert_eq!(s.len(), 32); + assert_eq!(parse_hex_fixed::<16>(&s), None); + } + + #[test] + fn bytes_variable_length_and_odd_rejected() { + assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC])); + assert_eq!(parse_hex_bytes("AABBC"), None); // odd + // Empty (or prefix-only) → empty Vec: a legitimately-empty field. + assert_eq!(parse_hex_bytes(""), Some(vec![])); + assert_eq!(parse_hex_bytes("0x"), Some(vec![])); + } +} diff --git a/src/keysource.rs b/src/keysource.rs index 4911f39..bcdf7d7 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -344,8 +344,9 @@ pub fn key_fetch( /// "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 -/// largest extent at its midpoint forward. +/// clear unit proves nothing, so this collects only scrambled ones — probing +/// several points spread across EACH extent so a title whose encrypted body +/// starts late (or whose midpoint lands in clear nav) still yields samples. pub fn read_encrypted_units( reader: &mut dyn crate::sector::SectorSource, title: &crate::disc::DiscTitle, @@ -353,7 +354,12 @@ pub fn read_encrypted_units( ) -> Vec> { 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 + // Probe several evenly-spaced points across EACH extent rather than only the + // midpoint-and-forward: a title whose encrypted feature starts late, or whose + // midpoint lands in a clear nav stretch, must STILL yield scrambled samples. + // Empty samples make `Disc::decrypt_with` skip wrong-key validation, so a + // real encrypted title returning nothing here is a silent wrong-key hazard. + const PROBES_PER_EXTENT: u32 = 8; let mut out: Vec> = Vec::new(); for ext in &title.extents { @@ -361,25 +367,30 @@ pub fn read_encrypted_units( if total_units == 0 { continue; } - let mut unit = total_units / 2; // midpoint (past the clear nav at the head) - for _ in 0..MAX_CHUNKS_PER_EXTENT { + for p in 1..=PROBES_PER_EXTENT { + // Probe at p/(P+1) of the extent — spreads P points across it while + // skipping the clear nav at the very head. + let unit = ((total_units as u64 * p as u64) / (PROBES_PER_EXTENT as u64 + 1)) as u32; if unit >= total_units { - break; + continue; } let units_this = CHUNK_UNITS.min(total_units - unit); // Saturate: start_lba comes from attacker-controlled UDF/MPLS // extents; a malformed extent near u32::MAX would otherwise panic // (debug) or wrap to a wrong LBA (release). An over-capacity LBA then - // fails cleanly via the read_sectors().is_err() break below. + // fails cleanly via the read_sectors().is_err() skip below. let lba = ext .start_lba .saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); let count = (units_this * ALIGNED_UNIT_SECTORS) as u16; let mut buf = vec![0u8; count as usize * 2048]; // `false` = no recovery retries; the reader is the raw drive/file - // (no decrypt decorator), so these are the on-disc encrypted bytes. + // (no decrypt decorator), so these are the on-disc encrypted bytes. A + // read error at one probe skips THAT probe only — it must not abandon + // the rest of the extent (the old `break` blinded the sampler on a + // single transient miss). if reader.read_sectors(lba, count, &mut buf, false).is_err() { - break; + continue; } for i in 0..units_this as usize { let o = i * ALIGNED_UNIT_LEN; @@ -394,7 +405,6 @@ pub fn read_encrypted_units( } } } - unit += units_this; } } out @@ -625,4 +635,85 @@ mod tests { ); assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); } + + /// #4 regression: encrypted content NOT at the extent midpoint (a late- + /// starting feature, or a midpoint landing in clear nav) must still be + /// sampled — empty samples make `decrypt_with` skip wrong-key validation. + /// The old midpoint-and-forward sampler returned empty; the probe-spread + /// finds the early scrambled band. + #[test] + fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() { + use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed}; + use crate::error::Result; + use crate::sector::SectorSource; + + // Units in the FIRST SIXTH of the extent are scrambled (0xFF → no TS + // sync); everything else (incl. the midpoint) is clear (0x47 syncs). + struct BandSource { + ext_start: u32, + total_units: u32, + } + impl SectorSource for BandSource { + fn capacity_sectors(&self) -> u32 { + self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> Result { + let bytes = count as usize * 2048; + for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() { + if chunk.len() < ALIGNED_UNIT_LEN { + break; + } + let abs_unit = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32; + if abs_unit < self.total_units / 6 { + chunk.fill(0xFF); // scrambled: no TS sync + } else { + chunk.fill(0); + let mut o = 4; + while o < ALIGNED_UNIT_LEN { + chunk[o] = 0x47; // clear TS syncs + o += 192; + } + } + } + Ok(bytes) + } + } + + let total_units = 600u32; + let ext_start = 1000u32; + let mut src = BandSource { + ext_start, + total_units, + }; + let title = crate::disc::DiscTitle { + playlist: String::new(), + playlist_id: 0, + duration_secs: 0.0, + size_bytes: 0, + clips: Vec::new(), + streams: Vec::new(), + chapters: Vec::new(), + extents: vec![crate::disc::Extent { + start_lba: ext_start, + sector_count: total_units * ALIGNED_UNIT_SECTORS, + }], + content_format: crate::disc::ContentFormat::BdTs, + codec_privates: Vec::new(), + }; + + let samples = read_encrypted_units(&mut src, &title, 4); + assert!( + !samples.is_empty(), + "the probe-spread must sample the early scrambled band the midpoint misses" + ); + for s in &samples { + assert!(ts_sync_destroyed(s), "every sample is a scrambled unit"); + } + } } diff --git a/src/lib.rs b/src/lib.rs index aaaffe9..80cd288 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -111,6 +111,7 @@ pub mod dvdnav; pub mod error; pub mod event; pub mod halt; +pub mod hex; pub(crate) mod identity; pub(crate) mod ifo; pub mod io;