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:
+3
-29
@@ -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<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,
|
||||
}
|
||||
// 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 `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
|
||||
+106
@@ -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
@@ -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<Vec<u8>> {
|
||||
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<u8>> = 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<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user