1.2.0: unify hex parsing (one parser) + robust encrypted-unit sampling

- One workspace hex parser (libfreemkv::hex): the keydb / online / mapfile
  parsers had divergent prefix handling (0x vs 0X vs none) — a key written with
  a prefix one parser didn't expect was silently dropped. All three now call it.
- read_encrypted_units probes several points spread across each extent instead
  of only the midpoint-forward window, so a late-starting / sparse encrypted
  body still yields samples (empty samples make decrypt_with skip wrong-key
  validation). A read error at one probe no longer abandons the extent.
This commit is contained in:
Matthew Jackson
2026-06-28 22:12:06 -07:00
parent cad5929afe
commit 4d6f5c0a98
4 changed files with 211 additions and 39 deletions
+3 -29
View File
@@ -651,35 +651,9 @@ impl Drop for Mapfile {
/// caller treats a bad VID comment as simply absent rather than an /// caller treats a bad VID comment as simply absent rather than an
/// error, so a corrupt header never fails a mapfile load. /// error, so a corrupt header never fails a mapfile load.
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> { fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
let s = s.strip_prefix("0x").unwrap_or(s); // The one workspace hex parser (accepts an optional `0x`/`0X` prefix,
// Parse on bytes, not on the &str: slicing a &str by byte index // byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics).
// (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte crate::hex::parse_hex_fixed::<16>(s)
// 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<u8> {
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,
}
} }
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns /// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
+106
View File
@@ -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<Vec<u8>> {
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<const N: usize>(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<u8> {
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![]));
}
}
+101 -10
View File
@@ -344,8 +344,9 @@ pub fn key_fetch(
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME /// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear /// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a /// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
/// clear unit proves nothing, so this collects only scrambled ones, sampling the /// clear unit proves nothing, so this collects only scrambled ones — probing
/// largest extent at its midpoint forward. /// 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( pub fn read_encrypted_units(
reader: &mut dyn crate::sector::SectorSource, reader: &mut dyn crate::sector::SectorSource,
title: &crate::disc::DiscTitle, title: &crate::disc::DiscTitle,
@@ -353,7 +354,12 @@ pub fn read_encrypted_units(
) -> Vec<Vec<u8>> { ) -> Vec<Vec<u8>> {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed}; use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint // 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<u8>> = Vec::new(); let mut out: Vec<Vec<u8>> = Vec::new();
for ext in &title.extents { for ext in &title.extents {
@@ -361,25 +367,30 @@ pub fn read_encrypted_units(
if total_units == 0 { if total_units == 0 {
continue; continue;
} }
let mut unit = total_units / 2; // midpoint (past the clear nav at the head) for p in 1..=PROBES_PER_EXTENT {
for _ in 0..MAX_CHUNKS_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 { if unit >= total_units {
break; continue;
} }
let units_this = CHUNK_UNITS.min(total_units - unit); let units_this = CHUNK_UNITS.min(total_units - unit);
// Saturate: start_lba comes from attacker-controlled UDF/MPLS // Saturate: start_lba comes from attacker-controlled UDF/MPLS
// extents; a malformed extent near u32::MAX would otherwise panic // extents; a malformed extent near u32::MAX would otherwise panic
// (debug) or wrap to a wrong LBA (release). An over-capacity LBA then // (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 let lba = ext
.start_lba .start_lba
.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); .saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
let count = (units_this * ALIGNED_UNIT_SECTORS) as u16; let count = (units_this * ALIGNED_UNIT_SECTORS) as u16;
let mut buf = vec![0u8; count as usize * 2048]; let mut buf = vec![0u8; count as usize * 2048];
// `false` = no recovery retries; the reader is the raw drive/file // `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() { if reader.read_sectors(lba, count, &mut buf, false).is_err() {
break; continue;
} }
for i in 0..units_this as usize { for i in 0..units_this as usize {
let o = i * ALIGNED_UNIT_LEN; let o = i * ALIGNED_UNIT_LEN;
@@ -394,7 +405,6 @@ pub fn read_encrypted_units(
} }
} }
} }
unit += units_this;
} }
} }
out out
@@ -625,4 +635,85 @@ mod tests {
); );
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); 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<usize> {
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");
}
}
} }
+1
View File
@@ -111,6 +111,7 @@ pub mod dvdnav;
pub mod error; pub mod error;
pub mod event; pub mod event;
pub mod halt; pub mod halt;
pub mod hex;
pub(crate) mod identity; pub(crate) mod identity;
pub(crate) mod ifo; pub(crate) mod ifo;
pub mod io; pub mod io;