Compare commits

...
4 Commits
Author SHA1 Message Date
Matthew Jackson 4221cd6a86 libfreemkv 0.31.7: expose Processing Key from the DK walk 2026-06-17 13:18:34 -07:00
Matthew Jackson dda4e7482b aacs: unify the SD-walk; derive_media_key_from_dk now exposes the PK
Two byte-identical copies of the subset-difference walk lived in keys.rs
and variants.rs. Consolidate the pure helpers (aesg3, calc_v_mask,
calc_pk_from_dk) into keys.rs (pub(super)); variants.rs imports them.

Add derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(mk, pk)>, which
returns the intermediate Processing Key the walk already computes;
derive_media_key_from_dk becomes a thin wrapper. This lets callers bank
the PK on a DK boil instead of re-deriving it via a second, divergent
walk (the classical-vs-variant cvalues order made that miss silently).

216 AACS tests pass (incl. a new (mk,pk) regression); precommit (1.86) green.
2026-06-17 13:11:03 -07:00
Matthew Jackson f80551f278 udf: read AACS MKB length-aware; honor inline allocation descriptors
read_file rejected the padded ~128 MiB MKB_RO.inf via the 0.31.0
MAX_FILE_BYTES cap, so read_aacs_inputs failed and the online
key-resolve path never contacted the keyserver. Read the MKB's real
record length from its header and read exactly that. Also honor
inline/embedded (AD type 3) files so small AACS .inf files read from
the ICB payload instead of being misparsed as allocation descriptors.

Release 0.31.6.
2026-06-08 19:20:41 -07:00
Matthew Jackson 41a6d89cd1 libfreemkv 0.31.5: do not force monotonic block timestamps on video
B-frame video PTS is legitimately non-monotonic in decode/storage order; the
audio-oriented monotonic nudge was clobbering it to prev+1ms, which decoders
flagged as non-monotonic DTS (thousands per title). Apply the nudge to
audio/subtitle only; video keeps its true PES PTS. + regression test.
2026-06-08 09:00:35 -07:00
8 changed files with 454 additions and 135 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## 0.31.7 (2026-06-17)
### Added
- `derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(media_key, processing_key)>`:
expose the intermediate Processing Key the subset-difference walk already
computes, so callers can record the full key lineage on a Device-Key walk.
### Changed
- Unify the subset-difference walk: the duplicate `aesg3`/`calc_v_mask`/
`calc_pk_from_dk` copies are consolidated to one definition. No behavior change.
## 0.31.6 (2026-06-09)
### Fixed
- UDF `read_file`: the AACS Media Key Block (`MKB_RO.inf`) is allocated to a
fixed ~128 MiB on UHD discs and zero-padded, but the real record stream is
only a few MiB. 0.31.0 added a 64 MiB `MAX_FILE_BYTES` cap on `read_file`,
which rejected the padded MKB outright — so `read_aacs_inputs` failed and the
online key-resolve path reported "could not read this disc's key files" and
**never contacted the keyserver** (a regression on every disc whose MKB
exceeds 64 MiB). The MKB is now read length-aware: a bounded prefix is read,
the real record length is found via the MKB header, and exactly that is
returned — never the padding, and never tripping the cap. Validated
end-to-end against a real UHD ISO (MKB reads and trims to its record length).
- UDF `read_file`: honor inline/embedded allocation descriptors (ICB Tag flags
low bits == 3). Tiny files (some AACS `*.inf` key files) store their data
embedded directly in the ICB with no out-of-line extents; these are now read
from the ICB payload instead of misparsing the embedded bytes as allocation
descriptors (which could hard-error since 0.31.0).
## 0.31.5 (2026-06-08)
### Fixed
- MKV mux: stop forcing strictly-monotonic block timestamps on the **video**
track. The monotonic nudge (added for audio PES that collide on a
millisecond) was clobbering B-frame video PTS — which are legitimately
non-monotonic in decode/storage order — to prev+1ms. A `copy` remux
preserved the wrong value, but decoding derived DTS from the HEVC POC and
found them colliding, emitting thousands of "non monotonically increasing
dts" warnings per title. Video now keeps its true PES PTS (Matroska
SimpleBlock permits non-monotonic block timestamps); audio/subtitle tracks
still get the nudge. Verified: a re-mux drops the warning count to zero.
## 0.31.4 (2026-06-08)
Test cleanup — no runtime changes. Removed 144 unit tests flagged in adversarial
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.31.4"
version = "0.31.7"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+114 -5
View File
@@ -611,7 +611,10 @@ const AESG3_SEED: [u8; 16] = [
/// AACS-G3: derive a subkey from a parent key.
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
///
/// Shared with [`super::variants`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(super) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
@@ -621,8 +624,8 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
out
}
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
/// Compute v_mask from a UV value. Shared with [`super::variants`].
pub(super) fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
@@ -631,7 +634,13 @@ fn calc_v_mask(uv: u32) -> u32 {
}
/// Derive processing key from device key using subset-difference tree traversal.
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
/// Shared with [`super::variants`].
pub(super) fn calc_pk_from_dk(
dk: &[u8; 16],
uv: u32,
v_mask: u32,
dev_key_v_mask: u32,
) -> [u8; 16] {
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
let mut left_child = aesg3(dk, 0);
let mut pk = aesg3(dk, 1);
@@ -675,7 +684,26 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
}
/// Derive Media Key from MKB using device keys (subset-difference tree).
///
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
}
/// Derive both the Media Key and the intermediate Processing Key from an
/// MKB using device keys (subset-difference tree).
///
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
/// returns the Processing Key `Kp` derived at the matching subset-difference
/// node — the value `calc_pk_from_dk` produces immediately before it
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
/// that walks a uv slot whose Processing Key validates against the MKB.
pub fn derive_media_key_and_pk_from_dk(
mkb: &[u8],
device_keys: &[DeviceKey],
) -> Option<([u8; 16], [u8; 16])> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
@@ -735,7 +763,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some(mk);
return Some((mk, pk));
}
}
}
@@ -2741,4 +2769,85 @@ mod tests {
let mkb = [0x05, 0x00, 0x00, 0x04]; // type 0x05, no body
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
}
#[test]
fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() {
// Regression: a classical DK boil must yield the intermediate
// Processing Key, not just the Media Key. The key service banks the
// PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it
// recovered the MK here but lost the PK silently.
//
// Build a minimal classical MKB (no 0x82/0x83) with:
// - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002
// - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk
// - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad)
// and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask ==
// v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1).
use super::super::decrypt::aes_ecb_encrypt as enc;
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
// Expected Processing Key for the no-op walk.
let expected_pk = aesg3(&dk_bytes, 1);
// Plant a known Media Key.
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
// uv (big-endian) = 0x00000002; validate XORs uv into mk[12..16].
let uv_bytes: [u8; 4] = [0x00, 0x00, 0x00, 0x02];
// cvalue C = AES-E(Kp, mk_raw) where mk_raw = mk with the uv XOR
// pre-undone, so the validate step XORs uv back in and recovers mk.
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv_bytes[a];
}
let cvalue = enc(&expected_pk, &mk_raw);
// mk_dv = AES-E(mk, magic || pad); validate decrypts it under mk and
// checks the leading 8 bytes against the verify magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Assemble the MKB. Type/Version (0x10) header first.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// 0x04 Subset-Difference: body = u_mask_shift(0x03) || uv(4 bytes).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03]);
mkb.extend_from_slice(&uv_bytes);
// 0x05 cvalues: one 16-byte cvalue (mkb_find_cvalues prefers 0x05).
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&cvalue);
// 0x86 Verify Media Key: mk_dv.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
// The new `_and_pk_` form returns BOTH the MK and the intermediate PK.
let dks = [dk];
let (got_mk, got_pk) = derive_media_key_and_pk_from_dk(&mkb, &dks)
.expect("classical DK boil must derive (mk, pk)");
assert_eq!(got_mk, mk, "recovered Media Key must match the planted MK");
assert_eq!(
got_pk, expected_pk,
"returned Processing Key must equal aesg3(dk, 1) for the no-op walk"
);
// And the thin wrapper must still return just the MK.
assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(mk));
}
}
+4 -4
View File
@@ -31,10 +31,10 @@ pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked,
derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
trim_mkb,
derive_media_key_and_pk_from_dk, derive_media_key_from_dk, derive_media_key_from_pk,
derive_media_key_from_pk_walked, derive_vuk, disc_hash, disc_hash_hex, mkb_content_len,
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21, trim_mkb,
};
pub use provider::KeyProvider;
pub use variants::{
+34 -67
View File
@@ -177,66 +177,11 @@ fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// Bound the walk to the 32-level depth of a u32 subset-difference tree.
// `current_v_mask` advances via an arithmetic `>> 1` which sign-extends, so
// a disc-supplied v_mask coarser than dev_key_v_mask would otherwise drive
// current_v_mask up to 0xFFFF_FFFF and spin forever — a crafted MKB must
// not hang the rip thread (this runs before the KCD placeholder gate).
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
// on) are shared with the classical walk in [`super::keys`] — a single
// definition keeps the variant SD tree byte-identical to the classical one.
// (`aesg3` itself is imported separately in the test module.)
use super::keys::{calc_pk_from_dk, calc_v_mask};
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
@@ -271,6 +216,27 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
///
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
/// separate on purpose and select MKB records in DELIBERATELY different
/// order:
///
/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the
/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the
/// small `0x07` Explicit-Subset-Difference record carries the
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
/// unified to one order — each is correct for its own MKB shape.
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
/// because the variant chain also reads `0x82`/`0x83`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
/// Consequence: do NOT route the classical DK path through this function
/// — on a classical MKB the `0x07`-first selection picks the wrong (or
/// missing) cvalue and the magic check fails, so it returns `None`.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
@@ -544,6 +510,10 @@ pub fn derive_media_key_variant(
#[cfg(test)]
mod tests {
use super::*;
// These three live in `super::keys` now (consolidated SD-walk helpers);
// `use super::*` does not re-export the parent module's private `use`
// imports, so pull them in directly for the tests below.
use super::super::keys::{aesg3, calc_pk_from_dk};
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -708,7 +678,7 @@ mod tests {
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// times — Kp = aesg3(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
@@ -731,12 +701,12 @@ mod tests {
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
// no-op — Kp = aesg3(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
let kp = aesg3(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
@@ -1002,10 +972,7 @@ mod tests {
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
assert_eq!(m.uv, 2, "matched the planted uv");
assert_eq!(
m.kp, planted_kp,
"Kp equals aesg3_step(dk,1) for the no-op walk"
);
assert_eq!(m.kp, planted_kp, "Kp equals aesg3(dk,1) for the no-op walk");
assert_eq!(m.cvalue_index, 0);
}
+34 -9
View File
@@ -1307,15 +1307,40 @@ impl Disc {
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let mkb = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Trim trailing padding to the real MKB content length, never zeroing
// an unrecognised MKB (see `crate::aacs::trim_mkb` — restores the
// pre-0.31.0 guard so the online key service never receives an empty
// MKB).
Ok((inf, crate::aacs::trim_mkb(mkb)))
let mkb = Self::read_mkb_content(reader, udf_fs)?;
Ok((inf, mkb))
}
/// Read the AACS MKB's real record stream — NOT its zero padding.
///
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
/// zero-padded; the actual record stream is a few MiB. We read a bounded
/// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
/// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
/// report "could not read this disc's key files" without ever contacting
/// the keyserver.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, "/AACS/MKB_RO.inf", want)
.or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the records may run
// past the prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
return Ok(crate::aacs::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
}
/// Read a disc's AACS key-input files from an ISO image: returns
+73 -6
View File
@@ -270,6 +270,26 @@ fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
}
}
/// Per-track block timestamp. The strictly-monotonic nudge is applied to
/// AUDIO/SUBTITLE tracks only; VIDEO (track 0) is returned UNCHANGED.
///
/// With B-frames, a video frame's presentation PTS is legitimately
/// non-monotonic in decode/storage order (a B-frame sits between its anchors,
/// below the frame stored just before it). Forcing it strictly-increasing
/// clobbers those PTS to prev+1ms — a `copy` remux preserves the (wrong) value,
/// but a decoder derives DTS from the HEVC POC and finds them colliding
/// ("non monotonically increasing dts", thousands per title). Matroska
/// SimpleBlock permits non-monotonic block timestamps (signed block-relative
/// offsets), so video keeps its true PES PTS; only no-reorder tracks (audio,
/// subtitles), where a same-millisecond collision IS a real defect, get nudged.
fn block_ts(track_idx: usize, prev: Option<i64>, pts_ms: i64) -> i64 {
if track_idx == 0 {
pts_ms
} else {
monotonic_ts(prev, pts_ms)
}
}
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
/// returning the buffer and the used length. Track numbers are small (1-based,
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
@@ -550,12 +570,19 @@ impl<W: Write + Seek> MkvMuxer<W> {
// kept keyframe are clamped to t=0 rather than corrupting the timeline.
let pts_ms = (raw_ms - base).max(0);
// Enforce strictly-monotonic per-track block timestamps. Some audio PES
// PTS truncate to the same millisecond as the previous frame (or, rarely,
// tick back 1ms), which surfaces as "non-monotonic DTS" and is rejected
// by ffmpeg/strict players. Nudge to prev+1ms — sub-frame, inaudible,
// and A/V sync is unaffected at millisecond granularity.
let pts_ms = monotonic_ts(self.last_pts_ms.get(&track_idx).copied(), pts_ms);
// Strictly-monotonic block timestamps — AUDIO/SUBTITLE ONLY. Some audio
// PES PTS truncate to the same millisecond as the previous frame (or
// tick back 1ms); nudge those to prev+1ms (sub-frame, inaudible).
//
// VIDEO (track 0) is EXEMPT: with B-frames, presentation PTS is
// legitimately non-monotonic in decode/storage order (a B-frame's PTS
// sits between its anchors, below the frame stored before it). Forcing
// it strictly-increasing clobbers those PTS to prev+1ms, which a `copy`
// remux preserves but a decoder rejects — it derives DTS from the HEVC
// POC and finds them colliding ("non monotonically increasing dts").
// Matroska SimpleBlock permits non-monotonic block timestamps (negative
// block-relative offsets), so leave the true PES PTS intact for video.
let pts_ms = block_ts(track_idx, self.last_pts_ms.get(&track_idx).copied(), pts_ms);
let needs_new_cluster = !self.cluster_open
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
@@ -974,6 +1001,46 @@ mod tests {
assert_eq!(out, [1000, 1001, 1002, 1003, 1032, 1033, 1064]);
}
#[test]
fn block_ts_exempts_video_from_monotonic_nudge() {
// VIDEO (track 0) keeps its true PTS even when non-monotonic in storage
// order — a B-frame whose presentation PTS sits below the frame stored
// before it must NOT be nudged to prev+1ms (that clobbering is what
// produced the "non monotonically increasing dts" flood on decode).
assert_eq!(
block_ts(0, Some(1040), 1000),
1000,
"video B-frame PTS preserved"
);
assert_eq!(
block_ts(0, Some(1000), 1000),
1000,
"video dup-ms PTS preserved"
);
// A realistic decode-order GOP (I, then B-frames dipping below it):
// every value passes through untouched for video.
let gop = [1000i64, 960, 920, 1080, 1040];
let mut prev = None;
let out: Vec<i64> = gop
.iter()
.map(|&p| {
let t = block_ts(0, prev, p);
prev = Some(t);
t
})
.collect();
assert_eq!(out, gop, "video timestamps must be left exactly as-is");
// AUDIO/SUBTITLE (track != 0) still get the strictly-monotonic nudge —
// a same-ms collision there is a real defect.
assert_eq!(block_ts(1, Some(1000), 1000), 1001, "audio dup-ms nudged");
assert_eq!(
block_ts(2, Some(1001), 1000),
1002,
"subtitle back-tick nudged"
);
}
#[test]
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
+148 -43
View File
@@ -21,11 +21,19 @@
use crate::error::{Error, Result};
use crate::sector::SectorSource;
/// Upper bound on a single metadata file read (`read_file`). BD-ROM
/// metadata files (.mpls/.clpi/.inf/.bdmv) are a few KiB to tens of MiB;
/// 64 MiB is a generous ceiling. Caps the allocation so a crafted ICB
/// info_length / extent length cannot force a huge zeroed reservation
/// before any data is read.
/// Upper bound on a single UNBOUNDED metadata file read (`read_file`).
/// BD-ROM metadata files (.mpls/.clpi/.bdmv/.inf) are a few KiB to a few
/// MiB; 64 MiB is a generous ceiling that bounds the allocation a crafted
/// ICB info_length / extent length can force.
///
/// The one legitimately huge file — the AACS `MKB_RO.inf`, allocated to a
/// fixed ~128 MiB and zero-padded — is NOT read through the unbounded path:
/// `read_aacs_inputs_from_reader` reads a bounded prefix via
/// `read_file_prefix` and trims to the real record length, so it never
/// trips this cap (and never reads 100+ MiB of padding). A 0.31.0
/// regression added this cap and read the MKB unbounded, so the cap
/// rejected it → `read_aacs_inputs` failed → autorip reported "could not
/// read this disc's key files" and never contacted the keyserver.
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
/// Upper bound on a single directory's on-disc data. Real BD-ROM
@@ -135,6 +143,35 @@ impl UdfFs {
/// Read a file by path, returning its raw bytes.
/// Reads all data extents sector by sector from disc — no buffering.
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, None)
}
/// Read at most `max_bytes` of a file (rounded up to a whole sector),
/// stopping early rather than reading the whole file.
///
/// Used to read only the real, record-length portion of the AACS
/// `MKB_RO.inf` — allocated to a fixed ~128 MiB and zero-padded — instead
/// of reading 100+ MiB of padding (and tripping `MAX_FILE_BYTES`). The
/// caller trims the returned prefix to the MKB record length.
pub fn read_file_prefix(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, Some(max_bytes))
}
/// Shared implementation of [`read_file`] / [`read_file_prefix`]. When
/// `max_bytes` is `Some`, reads at most that many bytes and the whole-file
/// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is
/// not applied (the read is already bounded by `max_bytes`).
fn read_file_limited(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: Option<usize>,
) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
@@ -166,16 +203,34 @@ impl UdfFs {
path: path.to_string(),
})?;
// Read ALL the file's data extents. Multi-extent files (fragmented or
// split across dual layers) would otherwise be silently truncated to
// the first extent, since the buffer is sized to entry.size and
// truncate() can't grow it.
// `max_bytes == None` => read the whole file; `Some(n)` => read at most
// n bytes (rounded up to a sector) and skip the anti-DoS caps below.
let limit = max_bytes.unwrap_or(usize::MAX);
// Tiny files (notably the AACS `*.inf` key files) may store their data
// embedded inline in the ICB (AD type 3), with no out-of-line extents.
// Honor that before the extent path, which would otherwise misparse the
// embedded bytes as allocation descriptors and (since 0.31.0) hard-error
// on the resulting bogus extent length.
if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? {
let want = (entry.size as usize).min(limit);
if inline.len() > want {
inline.truncate(want);
}
return Ok(inline);
}
// Read the file's data extents. Multi-extent files (fragmented or split
// across dual layers) would otherwise be silently truncated to the
// first extent, since the buffer is sized to entry.size and truncate()
// can't grow it.
let extents = self.read_icb_extents(reader, entry.meta_lba)?;
// Reject an oversized declared total before allocating: entry.size is
// a raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation across its extents.
if entry.size > MAX_FILE_BYTES {
// Reject an oversized declared total before allocating: entry.size is a
// raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a
// bounded read is limited by `limit` regardless of the declared size.
if max_bytes.is_none() && entry.size > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
@@ -183,33 +238,30 @@ impl UdfFs {
});
}
// Read ALL the file's data extents. File DATA is in the physical
// partition (partition_start + lba), NOT the metadata partition: ICBs
// are in metadata, data is in physical.
let mut data = Vec::with_capacity(entry.size as usize);
// Read the file DATA from the physical partition (partition_start +
// lba), NOT the metadata partition: ICBs are in metadata, data is in
// physical. Pre-allocate to the smaller of declared size and the
// requested prefix (capped so a bogus entry.size can't reserve GiB).
let cap_hint = (entry.size as usize)
.min(limit)
.min(MAX_FILE_BYTES as usize);
let mut data = Vec::with_capacity(cap_hint);
let mut sector = [0u8; 2048];
for (data_lba, data_len) in extents {
// Cumulative guard: entry.size and each per-extent data_len are
// capped individually above, but a crafted ICB can chain many
// small extents (read_icb_extents follows type-3 chains up to
// MAX_AD_BLOCKS) whose running total grows `data` into GiB. Reject
// once the accumulated bytes would exceed MAX_FILE_BYTES.
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
// data_len is the disc-controlled 30-bit extent length; reject an
// oversized extent before reading so a crafted ICB can't grow the
// buffer past MAX_FILE_BYTES.
if data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
'extents: for (data_lba, data_len) in extents {
if max_bytes.is_none() {
// Anti-DoS guards for the unbounded path: a crafted ICB can
// chain many extents whose running total grows `data` into GiB,
// or declare a single oversized extent. (Skipped when bounded —
// `limit` already caps the read.)
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES
|| data_len as u64 > MAX_FILE_BYTES
{
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
}
let abs_start = self
.partition_start
@@ -221,6 +273,9 @@ impl UdfFs {
})?;
let sector_count = (data_len as u64).div_ceil(2048) as u32;
for i in 0..sector_count {
if data.len() >= limit {
break 'extents;
}
let abs = abs_start.checked_add(i).ok_or(Error::DiscRead {
sector: abs_start as u64,
status: None,
@@ -231,10 +286,12 @@ impl UdfFs {
}
}
// Trim to the real file size; if extents under-covered the file (e.g.
// sparse), leave what we have rather than over-reporting.
if data.len() > entry.size as usize {
data.truncate(entry.size as usize);
// Trim to the real file size, or to the requested prefix — whichever is
// smaller. If extents under-covered the file (e.g. sparse), leave what
// we have rather than over-reporting.
let trim_to = (entry.size as usize).min(limit);
if data.len() > trim_to {
data.truncate(trim_to);
}
Ok(data)
}
@@ -382,6 +439,54 @@ impl UdfFs {
})
}
/// If this ICB stores its file data INLINE (embedded — ICB Tag flags low
/// 3 bits == 3) rather than via out-of-line extents, return the embedded
/// bytes. Tiny files such as the AACS `*.inf` key files are routinely
/// embedded directly in the ICB; `read_icb_extents` finds no real extents
/// for them (it would misparse the embedded payload as allocation
/// descriptors), so `read_file` must read the inline payload here. Returns
/// `Ok(None)` for the normal extent-backed case.
///
/// (Regression guard: 0.31.0 added a per-extent `MAX_FILE_BYTES` cap that
/// turned the misparsed-embedded case into a hard error, which surfaced as
/// autorip "could not read this disc's key files" on discs whose AACS
/// `.inf` files are ICB-embedded — the keyserver was then never called.)
fn read_inline_data(
&self,
reader: &mut dyn SectorSource,
meta_lba: u32,
) -> Result<Option<Vec<u8>>> {
let icb_abs = self.meta_to_abs(meta_lba)?;
let mut icb = [0u8; 2048];
read_sector(reader, icb_abs, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
let (ad_offset, l_ad) = match tag {
// Extended File Entry (266) / standard File Entry (261): the
// allocation-descriptors field (which, for embedded files, holds
// the data itself) begins after the extended attributes.
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
(216 + l_ea, l_ad)
}
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
(176 + l_ea, l_ad)
}
_ => return Ok(None),
};
// ICB Tag flags: u16 at absolute offset 34, low 3 bits select the AD
// type. 3 == data embedded inline in the ICB.
let icb_flags = u16::from_le_bytes([icb[34], icb[35]]);
if (icb_flags & 0x07) != 3 {
return Ok(None);
}
let start = ad_offset.min(icb.len());
let end = ad_offset.saturating_add(l_ad).min(icb.len());
Ok(Some(icb[start..end].to_vec()))
}
/// Read ALL allocation extents for a file from its ICB.
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)