Cover the dir:// input door, and correct the CSS scramble-gate docs
Three round-1 audit findings on v1.6.0..HEAD.
Tests: `mux::resolve::input("dir://…")` — the door the CLI rips a folder
through — was never driven with a real folder. The only dir:// input test
uses a missing path, which fails in DirImage::open long before
`image_input`, so dropping the `is_folder` argument (and with it
`session::apply_folder_encryption_verdict`) passed the whole suite. Two
tests now close it, stated as a DIFFERENTIAL between the two doors,
because that is the invariant the shared function exists to hold: a
clear folder that kept its AACS/ directory must scan the same and select
the same extents through `scan_dir` and through `dir://`, and a
scrambled one must be refused through both with E9063. Proven red
against a mutated guard (both fail with E7022, the tree-shape verdict
the probe overrides) and green with it restored.
Docs: css/mod.rs documented `is_scrambled`, a function this line renamed
to `has_scramble_flag_bits`. Eleven stale sites, two of them broken
rustdoc links. Two were not stale names but false statements — they said
the descramble loop keeps the looser raw-flag test, when
`descramble_sector` and `descramble_region` both gate on
`is_scrambled_pack`; that parenthetical is rewritten to say what the
code does and why (the measured VIDEO_TS.IFO case: 38 titles became 10,
silently, at exit 0). The one mention that must stay is the sentence
explaining why the name was rejected.
Constants: io::image_writer and dirimage::encode each re-declared the
2048-byte sector that consts::SECTOR_BYTES already exports; both now
alias it. Note `BATCH_SECTORS = 2048` in image_writer is a different
quantity (sectors per batch, not bytes) and is deliberately left alone.
Bare 2048 literals elsewhere in the crate are out of scope here.
This commit is contained in:
+22
-19
@@ -85,7 +85,7 @@ pub fn crack_key(
|
|||||||
///
|
///
|
||||||
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
|
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
|
||||||
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
|
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
|
||||||
/// scanned extents (`is_scrambled` never true): the content is genuinely
|
/// scanned extents (`is_scrambled_pack` never true): the content is genuinely
|
||||||
/// plaintext, so proceeding without a key is correct.
|
/// plaintext, so proceeding without a key is correct.
|
||||||
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
|
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
|
||||||
/// key could be recovered (the Stevenson attack found no crackable crib, or
|
/// key could be recovered (the Stevenson attack found no crackable crib, or
|
||||||
@@ -515,7 +515,7 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
|||||||
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test
|
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test
|
||||||
/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3).
|
/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3).
|
||||||
///
|
///
|
||||||
/// [`is_scrambled`] keys solely on bits 4-5 of byte 0x14. That single byte is
|
/// [`has_scramble_flag_bits`] keys solely on bits 4-5 of byte 0x14. That byte is
|
||||||
/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack,
|
/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack,
|
||||||
/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset
|
/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset
|
||||||
/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title)
|
/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title)
|
||||||
@@ -527,12 +527,15 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
|||||||
/// structurally a DVD video pack can be counted as scramble evidence. This does
|
/// structurally a DVD video pack can be counted as scramble evidence. This does
|
||||||
/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real
|
/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real
|
||||||
/// scrambled feature is made of valid PS packs, so its scrambled sectors still
|
/// scrambled feature is made of valid PS packs, so its scrambled sectors still
|
||||||
/// pass this check and still drive `ScrambledUncracked` when no key cracks. (The
|
/// pass this check and still drive `ScrambledUncracked` when no key cracks.
|
||||||
/// descramble loop keeps the looser [`is_scrambled`]: by the time it runs we
|
///
|
||||||
/// already know the title is CSS, and it only needs to skip interleaved clear
|
/// The DESCRAMBLE path gates on this same function — [`descramble_sector`] and
|
||||||
/// NAV packs — a wrongly-skipped or wrongly-included sector there is recoverable
|
/// [`descramble_region`] both call it, not the raw flag test — because the raw
|
||||||
/// per-sector, whereas a false scramble verdict in the scan poisons the whole
|
/// test does not merely mis-skip a sector there: it descrambles one that was
|
||||||
/// title's outcome.)
|
/// never scrambled and destroys it. The measured case is written up on
|
||||||
|
/// [`descramble_region`]: a `VIDEO_TS.IFO` sector holding 0x15 at offset 0x14
|
||||||
|
/// lost 1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38
|
||||||
|
/// titles became 10 — silently, at exit 0. One gate, both paths.
|
||||||
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
|
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
|
||||||
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
|
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
|
||||||
}
|
}
|
||||||
@@ -618,9 +621,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── is_scrambled ───────────────────────────────────────────────────────
|
// ── has_scramble_flag_bits ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
/// has_scramble_flag_bits returns false for any buffer shorter than one sector,
|
||||||
/// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
|
/// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
|
||||||
/// length guard is short-circuited before the flag read.
|
/// length guard is short-circuited before the flag read.
|
||||||
///
|
///
|
||||||
@@ -630,13 +633,13 @@ mod tests {
|
|||||||
/// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
|
/// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
|
||||||
/// 20-byte slice; this test catches it.
|
/// 20-byte slice; this test catches it.
|
||||||
#[test]
|
#[test]
|
||||||
fn is_scrambled_short_buffer_is_false_no_panic() {
|
fn has_scramble_flag_bits_short_buffer_is_false_no_panic() {
|
||||||
assert!(!has_scramble_flag_bits(&[]));
|
assert!(!has_scramble_flag_bits(&[]));
|
||||||
assert!(!has_scramble_flag_bits(&[0u8; 20])); // shorter than 0x14+1 even
|
assert!(!has_scramble_flag_bits(&[0u8; 20])); // shorter than 0x14+1 even
|
||||||
assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector
|
assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector
|
||||||
}
|
}
|
||||||
|
|
||||||
/// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field).
|
/// has_scramble_flag_bits keys on bits 4-5 of byte 0x14 (the CSS scramble field).
|
||||||
/// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
|
/// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
|
||||||
/// high-bit-only values 0x40/0x80 are clear.
|
/// high-bit-only values 0x40/0x80 are clear.
|
||||||
///
|
///
|
||||||
@@ -644,7 +647,7 @@ mod tests {
|
|||||||
/// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
|
/// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
|
||||||
/// assert fails.
|
/// assert fails.
|
||||||
#[test]
|
#[test]
|
||||||
fn is_scrambled_uses_bits_4_5_only() {
|
fn has_scramble_flag_bits_uses_bits_4_5_only() {
|
||||||
let mut s = vec![0u8; 2048];
|
let mut s = vec![0u8; 2048];
|
||||||
for (flag, expected) in [
|
for (flag, expected) in [
|
||||||
(0x00u8, false),
|
(0x00u8, false),
|
||||||
@@ -665,14 +668,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the
|
/// has_scramble_flag_bits accepts exactly 2048 bytes as the minimum (boundary at the
|
||||||
/// inclusive value 2048).
|
/// inclusive value 2048).
|
||||||
///
|
///
|
||||||
/// Grounding: `sector.len() >= 2048`.
|
/// Grounding: `sector.len() >= 2048`.
|
||||||
/// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
|
/// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
|
||||||
/// sector reports false; this fails.
|
/// sector reports false; this fails.
|
||||||
#[test]
|
#[test]
|
||||||
fn is_scrambled_exact_sector_length_accepted() {
|
fn has_scramble_flag_bits_exact_sector_length_accepted() {
|
||||||
let mut s = vec![0u8; 2048];
|
let mut s = vec![0u8; 2048];
|
||||||
s[0x14] = 0x30;
|
s[0x14] = 0x30;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -686,8 +689,8 @@ mod tests {
|
|||||||
/// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14
|
/// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14
|
||||||
/// but lacks the pack-start is NOT counted as scramble evidence — without
|
/// but lacks the pack-start is NOT counted as scramble evidence — without
|
||||||
/// this the scan flips `saw_scrambled` and a genuinely unencrypted title
|
/// this the scan flips `saw_scrambled` and a genuinely unencrypted title
|
||||||
/// reports `ScrambledUncracked` (the false E7023). The looser `is_scrambled`
|
/// reports `ScrambledUncracked` (the false E7023). The raw flag test
|
||||||
/// (descramble gate) still reads the same sector as flagged.
|
/// `has_scramble_flag_bits` still reads the same sector as flagged.
|
||||||
///
|
///
|
||||||
/// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`.
|
/// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`.
|
||||||
/// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a
|
/// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a
|
||||||
@@ -814,7 +817,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Real DVD video sectors always open with the MPEG-PS
|
// Real DVD video sectors always open with the MPEG-PS
|
||||||
// pack-start code; `is_scrambled` (Fix 3) requires it
|
// pack-start code; `is_scrambled_pack` (Fix 3) requires it
|
||||||
// before trusting the 0x14 scramble bits, so the fixture
|
// before trusting the 0x14 scramble bits, so the fixture
|
||||||
// must include it for a `flag_byte` of 0x30 to register
|
// must include it for a `flag_byte` of 0x30 to register
|
||||||
// as scrambled.
|
// as scrambled.
|
||||||
@@ -1123,7 +1126,7 @@ mod tests {
|
|||||||
/// budget). With a small failing extent, every sector is attempted and the
|
/// budget). With a small failing extent, every sector is attempted and the
|
||||||
/// function returns None.
|
/// function returns None.
|
||||||
///
|
///
|
||||||
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` —
|
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled_pack(...)` —
|
||||||
/// an Err simply falls through to `i += 1`.
|
/// an Err simply falls through to `i += 1`.
|
||||||
/// Mutation: change the read-error handling to `reader.read_sectors(...)?`
|
/// Mutation: change the read-error handling to `reader.read_sectors(...)?`
|
||||||
/// (propagate) -> crack_key would stop after the first error and read only
|
/// (propagate) -> crack_key would stop after the first error and read only
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ use super::layout::{DirNode, Layout};
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Logical block / sector size. Fixed for every optical profile this crate reads.
|
/// Logical block / sector size. Fixed for every optical profile this crate
|
||||||
pub(super) const SECTOR: usize = 2048;
|
/// reads, and the same quantity as [`crate::consts::SECTOR_BYTES`] — aliased
|
||||||
|
/// rather than re-declared so the two cannot drift apart. The short name is
|
||||||
|
/// kept because it appears in ~25 extent and offset expressions across
|
||||||
|
/// `dirimage`, where the longer one would bury the arithmetic.
|
||||||
|
pub(super) use crate::consts::SECTOR_BYTES as SECTOR;
|
||||||
|
|
||||||
/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which
|
/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which
|
||||||
/// is what UDF revisions up to and including 2.00 require.
|
/// is what UDF revisions up to and including 2.00 require.
|
||||||
|
|||||||
@@ -650,6 +650,83 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() {
|
|||||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
|
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The OTHER door: `dir://` through the PES input path ─────────────────────
|
||||||
|
//
|
||||||
|
// `session::scan_dir` (covered above) and `mux::resolve::input("dir://…")` are
|
||||||
|
// two doors into the same folder, and they once disagreed: a folder that ripped
|
||||||
|
// fine through one failed through the other, because only `scan_dir` re-judged
|
||||||
|
// the tree-shape encryption verdict from CONTENT. The fix was to share
|
||||||
|
// `session::apply_folder_encryption_verdict` between them — see its doc
|
||||||
|
// comment, which names this exact failure. Nothing tested the second door, so
|
||||||
|
// dropping the `is_folder` argument at `mux::resolve`'s call site restored the
|
||||||
|
// bug silently.
|
||||||
|
|
||||||
|
/// The two doors must AGREE. Same folder, same verdict, same extents.
|
||||||
|
///
|
||||||
|
/// Stated as a differential rather than a bare `is_ok()` so it cannot go
|
||||||
|
/// vacuous: if the fixture ever stops producing an AACS state, a one-sided
|
||||||
|
/// `Ok` assertion would still pass while guarding nothing, whereas "both doors
|
||||||
|
/// see the same title" is the invariant the shared function actually exists to
|
||||||
|
/// hold.
|
||||||
|
#[test]
|
||||||
|
fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
|
||||||
|
// `s` MUST outlive `stream`: the pipeline's producer thread is still
|
||||||
|
// reading the folder until the stream is dropped, and `Scratch::drop`
|
||||||
|
// removes the directory out from under it.
|
||||||
|
let s = playable_bdmv("dirdoor", false);
|
||||||
|
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||||
|
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||||
|
|
||||||
|
// Door 1 — the scan path.
|
||||||
|
let (disc, _reader) =
|
||||||
|
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!disc.encrypted,
|
||||||
|
"door 1 must judge the clear folder decrypted"
|
||||||
|
);
|
||||||
|
let scanned_extents = disc.titles[0].extents.clone();
|
||||||
|
assert!(
|
||||||
|
!scanned_extents.is_empty(),
|
||||||
|
"fixture must have real extents"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Door 2 — the PES input path the CLI actually rips through.
|
||||||
|
let url = format!("dir://{}", s.path().display());
|
||||||
|
let stream = crate::input(&url, &crate::InputOptions::default())
|
||||||
|
.expect("dir:// input must open a clear folder, exactly as scan_dir does");
|
||||||
|
assert_eq!(
|
||||||
|
stream.info().extents,
|
||||||
|
scanned_extents,
|
||||||
|
"the two doors selected different titles from the same folder"
|
||||||
|
);
|
||||||
|
drop(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other verdict, through the same door: a folder whose content units are
|
||||||
|
/// really scrambled is refused with the TYPED code, not muxed into garbage.
|
||||||
|
///
|
||||||
|
/// This is the load-bearing half. `E_DIR_IMAGE_ENCRYPTED` is produced at
|
||||||
|
/// exactly one site in the crate (`session::apply_folder_encryption_verdict`),
|
||||||
|
/// reachable from here only through the `is_folder` argument — so this test
|
||||||
|
/// cannot pass if that argument is dropped, whatever else changes.
|
||||||
|
#[test]
|
||||||
|
fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() {
|
||||||
|
let s = playable_bdmv("dirdoorenc", true);
|
||||||
|
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||||
|
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||||
|
|
||||||
|
let url = format!("dir://{}", s.path().display());
|
||||||
|
let err = match crate::input(&url, &crate::InputOptions::default()) {
|
||||||
|
Ok(_) => panic!("a scrambled folder must not open as a PES source"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
crate::error::error_code(&err),
|
||||||
|
Some(crate::error::E_DIR_IMAGE_ENCRYPTED),
|
||||||
|
"expected the typed dir-source-encrypted code, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── External oracle ─────────────────────────────────────────────────────────
|
// ── External oracle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Write a synthesized image to a real file and ask the OS to mount it.
|
/// Write a synthesized image to a real file and ask the OS to mount it.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
//!
|
//!
|
||||||
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
|
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
|
||||||
|
|
||||||
|
use crate::consts::SECTOR_BYTES;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::halt::Halt;
|
use crate::halt::Halt;
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
@@ -33,9 +34,6 @@ use std::path::Path;
|
|||||||
/// a notable allocation and cancellation stays responsive.
|
/// a notable allocation and cancellation stays responsive.
|
||||||
const BATCH_SECTORS: u32 = 2048;
|
const BATCH_SECTORS: u32 = 2048;
|
||||||
|
|
||||||
/// Bytes per sector. Fixed for every medium this crate reads.
|
|
||||||
const SECTOR_BYTES: usize = 2048;
|
|
||||||
|
|
||||||
/// Write `total_sectors` sectors from `reader` to `dest`.
|
/// Write `total_sectors` sectors from `reader` to `dest`.
|
||||||
///
|
///
|
||||||
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
|
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
|
||||||
|
|||||||
Reference in New Issue
Block a user