Second pass over src/aacs and src/css. No production change; the only
non-test edits are two fixture bytes and one test rename.
Five latent panics on untrusted data, every guard correct and none
tested — so each was free to be deleted:
variant.rs:224 a 0x04 record not a multiple of 5 indexes p_uv[0..4]
off a one-byte tail
variant.rs:269 a 0x0c record shorter than the 0x04 slot count
slices past the cvalue table
stevenson.rs:177 short sector read -> index 138 into a 129-byte slice
stevenson.rs:208 a crib longer than the 1920-byte encrypted region
-> index 2058 into 2048
stevenson.rs:272 a header periodic all the way to offset 0 ->
subtract with overflow
That last one is reachable from ORDINARY DVD data — constant or padding
bytes are periodic. Verified on HEAD: widening the guard to <= 0x80
passes all 64 css tests unmutated.
media_key_variant_from_kp had only a soft-correction test, so every
step past that early return was unexecuted. The new two-slot fixture
puts the covering slot at index 1, so the uvs[1 + 5*idx] and
cvalues[idx*16] strides stop multiplying by zero.
derive.rs:319 + -> - confirmed killable, as the first pass predicted:
p == 0 makes (p-1)..32 underflow. Every prior fixture used a uv whose
lowest set bit was 4, 10 or 11, so trailing_zeros() was never 0.
One fixture bug caught and fixed rather than papered over: a |= mutant
first SURVIVED because mk[14]'s 0x04 bit happened to be set, making OR
and XOR agree. The byte is now clear and an assert_eq! pins it, so the
fixture cannot drift back into agreeing with the mutation it exists to
catch.
walk_mkb_be24_high_byte_is_honored renamed to
walk_mkb_be24_middle_byte_is_honored. Its 0x00_0110 length exercises
the << 8 term only, which is why << 16 -> >> 16 survived it. The name
was the lie; both framings are worth having, and the comment now points
at the genuine high-byte test at 0x01_0004.
derive.rs 146:32 and 154:30 stay untested, now with a proof rather than
a judgement: bit_pos == -1 requires current_v_mask == 0xFFFF_FFFF, and
calc_v_mask can never return that — its loop condition holds at
!v_mask == 0, so it always shifts at least once. Both branches are
reachable only after the walk has gone non-convergent and is heading
for the bounded exit, where the return value is undefined. Termination
is already pinned.
Equivalents proven by observing green, including six more OR/XOR pairs
on provably disjoint bit fields, and the two KEY_CORRECTION_DATA sites
where the constant is the documented all-zero placeholder so x ^ 0 ==
x | 0. Those become killable only if a real per-licensee KCD is wired
in.
A partial confirmation sweep (138 of 415 mutants before the box
saturated) found 135 caught, one timeout that is itself a detection,
and exactly one survivor — the KEY_CORRECTION_DATA equivalent above.
406 lines
17 KiB
Rust
406 lines
17 KiB
Rust
//! AACS decryption — Volume Unique Key lookup and title key derivation.
|
||
//!
|
||
//! Two paths:
|
||
//! 1. VUK lookup: disc_hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||
//! 2. Full handshake: device_keys + MKB → Media Key → + Volume ID → VUK (fallback)
|
||
//!
|
||
//! KEYDB.cfg format:
|
||
//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||
//! | PK | 0x...
|
||
//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||
//! | HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||
//! 0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||
//!
|
||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||
//!
|
||
//! ## Spec provenance
|
||
//!
|
||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||
//! implements:
|
||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||
|
||
pub mod content;
|
||
pub mod crypto;
|
||
pub mod derive;
|
||
pub mod host_certs;
|
||
pub mod index_select;
|
||
pub mod inf;
|
||
pub mod mkb;
|
||
pub mod provider;
|
||
pub mod resolve;
|
||
pub mod segment;
|
||
pub mod segment_key;
|
||
pub mod trace;
|
||
pub mod types;
|
||
pub mod variant;
|
||
|
||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||
/// discovery.
|
||
///
|
||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||
/// hardcoded.
|
||
///
|
||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||
/// which every reader walks with [`read_first`], first-that-reads. No reader
|
||
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
|
||
/// win; an HD DVD has none of them, so it falls through to the discovered
|
||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||
|
||
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum AacsRole {
|
||
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||
UnitKey,
|
||
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||
Mkb,
|
||
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||
ContentCert,
|
||
}
|
||
|
||
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||
///
|
||
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||
/// disc has no such directory → `None`.
|
||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||
udf.root.entries.iter().find(|e| {
|
||
e.is_dir
|
||
&& e.name.ends_with('!')
|
||
&& e.entries
|
||
.iter()
|
||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||
})
|
||
}
|
||
|
||
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||
/// the other family's entries simply never read.
|
||
///
|
||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||
let mut v: Vec<String> = match role {
|
||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||
}
|
||
.into_iter()
|
||
.map(String::from)
|
||
.collect();
|
||
|
||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||
let d = &dir.name;
|
||
match role {
|
||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||
AacsRole::UnitKey => {
|
||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||
// deterministic try order.
|
||
//
|
||
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||
// name does not match the title's playlist must not be used. The
|
||
// caller resolves this by trying candidates in sorted order and
|
||
// decrypting with the one whose keys verify — correct for a
|
||
// single-playlist disc; a name-matched selection keyed on the
|
||
// active playlist is the precise form for multi-playlist discs.
|
||
let mut names: Vec<&str> = dir
|
||
.entries
|
||
.iter()
|
||
.filter(|e| !e.is_dir)
|
||
.filter(|e| {
|
||
let u = e.name.to_ascii_uppercase();
|
||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||
})
|
||
.map(|e| e.name.as_str())
|
||
.collect();
|
||
names.sort_unstable();
|
||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the
|
||
/// first that reads.
|
||
///
|
||
/// `read` performs the actual per-path read (full file or bounded prefix), so
|
||
/// callers share the same first-present walk regardless of read style. Returns
|
||
/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path
|
||
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||
where
|
||
S: AsRef<str>,
|
||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||
{
|
||
for path in candidates {
|
||
if let Ok(buf) = read(path.as_ref()) {
|
||
return Ok(buf);
|
||
}
|
||
}
|
||
Err(crate::error::Error::AacsNoKeys)
|
||
}
|
||
|
||
// The module structure IS the public API — consumers import from the owning
|
||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||
//
|
||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||
// content-decrypt entry points that downstream key-source crates import through
|
||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||
// lets those crates track the module refactor without a lockstep re-pin.
|
||
pub use content::ALIGNED_UNIT_LEN;
|
||
pub use derive::derive_vuk;
|
||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
//! Surface guards. The public API is the module tree itself (no facade).
|
||
//! Touching one representative item per module keeps these as a
|
||
//! compile-time contract that the module paths stay stable.
|
||
|
||
use super::content::ALIGNED_UNIT_LEN;
|
||
use super::inf::{disc_hash, disc_hash_hex};
|
||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||
use super::variant::is_variant_mkb;
|
||
|
||
#[test]
|
||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size: 3 × 2048 = 6144.
|
||
// Re-exported from decrypt; pin the value here so the public constant
|
||
// and the spec stay in lockstep.
|
||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||
}
|
||
|
||
#[test]
|
||
fn version_strides_are_reexported_and_distinct() {
|
||
// The three AACS generations are part of the public surface, and the
|
||
// V10 (48) vs V20/V21 (64) stride distinction is the load-bearing
|
||
// difference. Confirm the enum re-export is usable and the variants
|
||
// are distinct values.
|
||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||
}
|
||
|
||
#[test]
|
||
fn public_helpers_are_callable_by_module_path() {
|
||
// Touch a representative function from each module so a dropped/renamed
|
||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||
let _ = !crate::aacs::content::is_clean(
|
||
&[0u8; ALIGNED_UNIT_LEN],
|
||
crate::disc::ContentFormat::BdTs,
|
||
);
|
||
let _ = mkb_content_len(&[]);
|
||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||
let _ = super::derive::resolve_candidate(
|
||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||
&[],
|
||
&[],
|
||
None,
|
||
);
|
||
}
|
||
|
||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||
//
|
||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||
|
||
#[test]
|
||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||
use crate::udf::fixture::*;
|
||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||
let mut disc = MemDisc::new();
|
||
let aacs_files = vec![
|
||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||
];
|
||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||
let root = DirSpec {
|
||
name: String::new(),
|
||
icb_lba: 10,
|
||
dir_data_lba: 11,
|
||
files: Vec::new(),
|
||
subdirs: vec![
|
||
DirSpec {
|
||
name: "AAC!".to_string(),
|
||
icb_lba: 20,
|
||
dir_data_lba: 21,
|
||
files: aacs_files,
|
||
subdirs: vec![],
|
||
},
|
||
DirSpec {
|
||
name: "AAC!_BAK".to_string(),
|
||
icb_lba: 30,
|
||
dir_data_lba: 31,
|
||
files: bak_files,
|
||
subdirs: vec![],
|
||
},
|
||
],
|
||
};
|
||
build_udf_skeleton(&mut disc, 10);
|
||
lay_dir(&mut disc, &root);
|
||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||
|
||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||
// AACS dir, never the `_BAK` mirror.
|
||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||
assert_eq!(dir.name, "AAC!");
|
||
|
||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||
assert_eq!(
|
||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||
vec![
|
||
super::PATH_UNIT_KEY_RO.to_string(),
|
||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||
"/AAC!/VTKF090.AACS".to_string(),
|
||
"/AAC!/VTKF100.AACS".to_string(),
|
||
]
|
||
);
|
||
assert_eq!(
|
||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||
.last()
|
||
.unwrap(),
|
||
"/AAC!/MKBROM.AACS"
|
||
);
|
||
assert_eq!(
|
||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||
.last()
|
||
.unwrap(),
|
||
"/AAC!/CONTENT_CERT.AACS"
|
||
);
|
||
}
|
||
|
||
/// The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required —
|
||
/// the discovery is a conjunction, not a disjunction.
|
||
///
|
||
/// The existing fixtures only ever present a directory that satisfies both
|
||
/// (`AAC!` with `MKBROM.AACS`) alongside one that satisfies neither
|
||
/// (`AAC!_BAK` — which contains `MKBROM.AACS` but is ALSO reached only after
|
||
/// the real dir), so either half of the conjunction could be dropped and the
|
||
/// same directory would still be found. Here a directory satisfies the name
|
||
/// half and NOT the contents half: it must not be picked.
|
||
///
|
||
/// If it were, the HD DVD path would resolve `MKBROM.AACS`,
|
||
/// `CONTENT_CERT.AACS` and the title-key file under a directory that holds
|
||
/// none of them — the disc reports "no AACS key files" and never rips.
|
||
#[test]
|
||
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||
use crate::udf::fixture::*;
|
||
let mut disc = MemDisc::new();
|
||
let root = DirSpec {
|
||
name: String::new(),
|
||
icb_lba: 10,
|
||
dir_data_lba: 11,
|
||
files: Vec::new(),
|
||
subdirs: vec![DirSpec {
|
||
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||
// HD DVD AACS directory.
|
||
name: "AAC!".to_string(),
|
||
icb_lba: 20,
|
||
dir_data_lba: 21,
|
||
files: vec![
|
||
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||
],
|
||
subdirs: vec![],
|
||
}],
|
||
};
|
||
build_udf_skeleton(&mut disc, 10);
|
||
lay_dir(&mut disc, &root);
|
||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||
|
||
assert!(
|
||
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||
);
|
||
assert_eq!(
|
||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||
vec![
|
||
super::PATH_UNIT_KEY_RO.to_string(),
|
||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||
],
|
||
"no HD DVD candidates may be appended from a directory that was \
|
||
never identified as the AACS directory"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||
use crate::udf::fixture::*;
|
||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||
// and the candidate list is exactly the static BD/UHD paths.
|
||
let mut disc = MemDisc::new();
|
||
let root = DirSpec {
|
||
name: String::new(),
|
||
icb_lba: 10,
|
||
dir_data_lba: 11,
|
||
files: Vec::new(),
|
||
subdirs: vec![DirSpec {
|
||
name: "AACS".to_string(),
|
||
icb_lba: 20,
|
||
dir_data_lba: 21,
|
||
files: vec![
|
||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||
],
|
||
subdirs: vec![],
|
||
}],
|
||
};
|
||
build_udf_skeleton(&mut disc, 10);
|
||
lay_dir(&mut disc, &root);
|
||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||
|
||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||
assert_eq!(
|
||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||
vec![
|
||
super::PATH_UNIT_KEY_RO.to_string(),
|
||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||
]
|
||
);
|
||
}
|
||
}
|