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
|
/// 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
@@ -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
|
/// "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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user