CSS crypto tests + DVD pipeline fully wired

- CSS roundtrip tests: decrypt_key determinism, descramble XOR roundtrip
- CSS table verification: TAB1 is permutation, TAB4 is bit-reversal involution
- DVD scan pipeline confirmed: scan_dvd_titles, CSS crack, ContentReader descramble
- 229 tests, all passing
This commit is contained in:
MattJackson
2026-04-11 17:14:58 +00:00
parent 59e6916702
commit fd4c88e969
5 changed files with 326 additions and 25 deletions
+138
View File
@@ -194,4 +194,142 @@ mod tests {
assert_ne!(result, key);
assert_ne!(result, [0u8; 5]);
}
/// Test 1: css_decrypt_key_roundtrip
///
/// decrypt_key is not a simple encrypt/decrypt pair — it is a one-way mangling
/// function. However, we can verify consistency: calling it twice with the same
/// parameters produces the same output, and varying the invert byte changes
/// the LFSR0 contribution predictably.
#[test]
fn css_decrypt_key_roundtrip() {
let keys: &[[u8; 5]] = &[
[0x12, 0x34, 0x56, 0x78, 0x9A],
[0x00, 0x00, 0x00, 0x00, 0x00],
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
];
let crypted_inputs: &[[u8; 5]] = &[
[0x11, 0x22, 0x33, 0x44, 0x55],
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
[0x00, 0x00, 0x00, 0x00, 0x00],
];
for key in keys {
for crypted in crypted_inputs {
// decrypt_key with invert=0x00 and invert=0xFF should give different results
let r0 = decrypt_key(0x00, key, crypted);
let rff = decrypt_key(0xFF, key, crypted);
// The two results differ because the invert byte XORs the LFSR0 output
// They should not be equal (except by extreme coincidence)
// More importantly, both should be deterministic
let r0_again = decrypt_key(0x00, key, crypted);
let rff_again = decrypt_key(0xFF, key, crypted);
assert_eq!(r0, r0_again, "decrypt_key(0x00) not deterministic");
assert_eq!(rff, rff_again, "decrypt_key(0xFF) not deterministic");
// With different invert values, the keystream differs
assert_ne!(r0, rff, "invert=0x00 and 0xFF gave same result for key {:?}", key);
}
}
}
/// Test 2: css_descramble_produces_valid_mpeg2
///
/// descramble_sector XORs a keystream into bytes 128..2048. Calling it
/// twice with the same key and restored scramble flag should roundtrip,
/// since XOR is its own inverse.
#[test]
fn css_descramble_produces_valid_mpeg2() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
// Build a sector with MPEG-2 pack header and PES header
let mut sector = vec![0x00u8; 2048];
// Pack header at byte 0
sector[0] = 0x00;
sector[1] = 0x00;
sector[2] = 0x01;
sector[3] = 0xBA;
// Scramble flag at byte 0x14
sector[0x14] = 0x30;
// Sector seed at bytes 0x54-0x58
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
// PES header at byte 128
sector[0x80] = 0x00;
sector[0x81] = 0x00;
sector[0x82] = 0x01;
sector[0x83] = 0xE0;
// Fill some content in the encrypted region
for i in 0x84..2048 {
sector[i] = (i & 0xFF) as u8;
}
let original = sector.clone();
// First descramble: "encrypts" by XORing keystream
descramble_sector(&title_key, &mut sector);
// Flag should be cleared
assert_eq!(sector[0x14] & 0x30, 0x00, "scramble flag not cleared after first descramble");
// Encrypted region should differ
assert_ne!(&sector[0x80..0x84], &original[0x80..0x84],
"encrypted region unchanged after descramble");
// Restore the scramble flag and sector seed for second pass
sector[0x14] = 0x30;
// Second descramble: XOR again = roundtrip
descramble_sector(&title_key, &mut sector);
// Now the encrypted region should match original
assert_eq!(&sector[0x80..2048], &original[0x80..2048],
"double descramble did not roundtrip");
}
/// Test 4: css_tab1_relationship
///
/// Verify the structure of TAB1: it is a substitution table used in
/// key mangling. Check that no two inputs map to the same output
/// (TAB1 is a permutation of 0..255).
#[test]
fn css_tab1_is_permutation() {
let mut seen = [false; 256];
for i in 0..256 {
let v = TAB1[i] as usize;
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
seen[v] = true;
}
// Check involution property: TAB1[TAB1[x]] should map back predictably
// TAB1 is not necessarily a strict involution, but we verify the
// composition TAB1[TAB1[x]] is also a permutation
let mut seen2 = [false; 256];
for i in 0..256 {
let v = TAB1[TAB1[i] as usize] as usize;
assert!(!seen2[v], "TAB1[TAB1[x]] maps two inputs to {:#04x}", v);
seen2[v] = true;
}
}
/// Test 5: css_tab4_is_bit_reversal
///
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
#[test]
fn css_tab4_is_bit_reversal() {
for i in 0u16..256 {
let expected = (0..8).fold(0u8, |acc, bit| {
acc | (((i as u8 >> bit) & 1) << (7 - bit))
});
assert_eq!(
TAB4[i as usize], expected,
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
i, TAB4[i as usize], expected
);
}
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
for i in 0..256 {
assert_eq!(
TAB4[TAB4[i] as usize], i as u8,
"TAB4 is not an involution at {:#04x}", i
);
}
}
}
+178 -21
View File
@@ -11,6 +11,7 @@
use crate::clpi;
use crate::drive::DriveSession;
use crate::error::{Error, Result};
use crate::ifo;
use crate::mpls;
use crate::sector::SectorReader;
use crate::speed::DriveSpeed;
@@ -43,6 +44,17 @@ pub struct Disc {
pub css: Option<crate::css::CssState>,
/// Whether this disc requires decryption (AACS or CSS)
pub encrypted: bool,
/// Content format (BD transport stream vs DVD program stream)
pub content_format: ContentFormat,
}
/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
/// DVD MPEG-2 Program Stream (VOB)
MpegPs,
}
/// Disc format.
@@ -97,6 +109,8 @@ pub struct DiscTitle {
pub streams: Vec<Stream>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
/// Content format for this title
pub content_format: ContentFormat,
}
/// A clip reference within a title.
@@ -292,6 +306,7 @@ impl DiscTitle {
clips: Vec::new(),
streams: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
}
}
@@ -550,22 +565,14 @@ impl Disc {
None
};
// 3. Playlists
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
if let Some(title) =
Self::parse_playlist(reader, &udf_fs, &entry.name, &mpls_data)
{
titles.push(title);
}
}
}
}
}
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
(Self::scan_bluray_titles(reader, &udf_fs), ContentFormat::BdTs)
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
(Self::scan_dvd_titles(reader, &udf_fs), ContentFormat::MpegPs)
} else {
(Vec::new(), ContentFormat::BdTs)
};
titles.sort_by(|a, b| {
b.duration_secs
.partial_cmp(&a.duration_secs)
@@ -581,9 +588,8 @@ impl Disc {
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
// 6. CSS detection for DVDs (VIDEO_TS directory = DVD structure)
let is_dvd = udf_fs.find_dir("/VIDEO_TS").is_some();
let css = if is_dvd && !titles.is_empty() {
// 6. CSS detection for DVDs
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
crate::css::crack_key(reader, &titles[0].extents)
} else {
None
@@ -602,6 +608,7 @@ impl Disc {
aacs,
css,
encrypted,
content_format,
})
}
@@ -937,8 +944,139 @@ impl Disc {
clips,
streams,
extents,
content_format: ContentFormat::BdTs,
})
}
/// Scan Blu-ray titles from MPLS playlists.
fn scan_bluray_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> {
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
if let Some(title) =
Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)
{
titles.push(title);
}
}
}
}
}
titles
}
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
fn scan_dvd_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> {
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
Ok(info) => info,
Err(_) => return Vec::new(),
};
let mut titles = Vec::new();
let mut title_number: u16 = 0;
for ts in &dvd_info.title_sets {
// Map DvdVideoAttr to Stream::Video
let video_codec = match ts.video.codec.as_str() {
"mpeg2" => Codec::Mpeg2,
"mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes
_ => Codec::Mpeg2,
};
let video_stream = Stream::Video(VideoStream {
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
codec: video_codec,
resolution: ts.video.resolution.clone(),
frame_rate: match ts.video.standard.as_str() {
"PAL" => "25".to_string(),
_ => "29.97".to_string(),
},
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
});
// Map DvdAudioAttr to Stream::Audio
let audio_streams: Vec<Stream> = ts
.audio_streams
.iter()
.enumerate()
.map(|(i, a)| {
let codec = match a.codec.as_str() {
"ac3" => Codec::Ac3,
"dts" => Codec::Dts,
"lpcm" => Codec::Lpcm,
"mpeg1" | "mpeg2" => Codec::Mpeg2,
_ => Codec::Unknown(0),
};
let channels = match a.channels {
1 => "mono".to_string(),
2 => "stereo".to_string(),
6 => "5.1".to_string(),
8 => "7.1".to_string(),
n => format!("{}ch", n),
};
let sample_rate = match a.sample_rate {
48000 => "48kHz".to_string(),
96000 => "96kHz".to_string(),
sr => format!("{}kHz", sr / 1000),
};
Stream::Audio(AudioStream {
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
codec,
channels,
language: a.language.clone(),
sample_rate,
secondary: false,
label: String::new(),
})
})
.collect();
for dvd_title in &ts.titles {
title_number += 1;
// Build extents from cell sector ranges (absolute = vob_start + cell offset)
let extents: Vec<Extent> = dvd_title
.cells
.iter()
.map(|cell| {
let start = ts.vob_start_sector + cell.first_sector;
let count = cell.last_sector.saturating_sub(cell.first_sector) + 1;
Extent {
start_lba: start,
sector_count: count,
}
})
.collect();
let size_bytes: u64 = extents
.iter()
.map(|e| e.sector_count as u64 * 2048)
.sum();
let mut streams = vec![video_stream.clone()];
streams.extend(audio_streams.iter().cloned());
titles.push(DiscTitle {
playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number),
playlist_id: title_number,
duration_secs: dvd_title.duration_secs,
size_bytes,
clips: Vec::new(),
streams,
extents,
content_format: ContentFormat::MpegPs,
});
}
}
titles
}
}
// ─── Decrypted reader ──────────────────────────────────────────────────────
@@ -954,6 +1092,8 @@ impl Disc {
pub struct ContentReader<'a> {
session: &'a mut DriveSession,
aacs: Option<&'a AacsState>,
css: Option<&'a crate::css::CssState>,
content_format: ContentFormat,
extents: Vec<Extent>,
current_extent: usize,
current_offset: u32,
@@ -1003,6 +1143,8 @@ impl Disc {
Ok(ContentReader {
session,
aacs: self.aacs.as_ref(),
css: self.css.as_ref(),
content_format: title.content_format,
extents: title.extents.clone(),
current_extent: 0,
current_offset: 0,
@@ -1109,6 +1251,7 @@ impl<'a> ContentReader<'a> {
// Decrypt all units in the buffer in-place
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
if let Some(aacs) = &self.aacs {
// AACS unit decryption (BD/UHD)
let uk = aacs
.unit_keys
.get(self.unit_key_idx)
@@ -1124,11 +1267,25 @@ impl<'a> ContentReader<'a> {
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
}
}
}
let total_bytes = self.buf_len * unit_len;
self.buf_pos = self.buf_len; // mark fully consumed
self.buf_pos = self.buf_len;
Ok(Some(&self.read_buf[..total_bytes]))
} else if let Some(css) = &self.css {
// CSS per-sector descrambling (DVD)
let total_bytes = self.buf_len * unit_len;
for chunk in self.read_buf[..total_bytes].chunks_mut(2048) {
crate::css::lfsr::descramble_sector(&css.title_key, chunk);
}
self.buf_pos = self.buf_len;
Ok(Some(&self.read_buf[..total_bytes]))
} else {
// No encryption
let total_bytes = self.buf_len * unit_len;
self.buf_pos = self.buf_len;
Ok(Some(&self.read_buf[..total_bytes]))
}
}
/// Decrypt a single aligned unit in-place if needed.
+3 -2
View File
@@ -94,8 +94,9 @@ pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
pub use disc::{
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentReader, Disc, DiscFormat, DiscTitle,
Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream, VideoStream,
AacsState, AudioStream, Clip, Codec, ColorSpace, ContentFormat, ContentReader, Disc,
DiscFormat, DiscTitle, Extent, HdrFormat, KeySource, ScanOptions, Stream, SubtitleStream,
VideoStream,
};
pub use mux::DiscOptions;
pub use mux::DiscStream;
+1
View File
@@ -180,6 +180,7 @@ impl M2tsMeta {
clips: Vec::new(),
streams,
extents: Vec::new(),
content_format: crate::disc::ContentFormat::BdTs,
}
}
}
+4
View File
@@ -48,6 +48,7 @@ fn sample_disc_title() -> DiscTitle {
}),
],
extents: Vec::new(),
content_format: ContentFormat::BdTs,
}
}
@@ -446,6 +447,7 @@ fn meta_codec_roundtrip() {
clips: Vec::new(),
streams,
extents: Vec::new(),
content_format: ContentFormat::BdTs,
};
let meta = M2tsMeta::from_title(&dt);
@@ -478,6 +480,7 @@ fn meta_empty_streams() {
clips: Vec::new(),
streams: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
};
let meta = M2tsMeta::from_title(&dt);
@@ -495,6 +498,7 @@ fn meta_all_stream_types() {
duration_secs: 3600.0,
size_bytes: 0,
clips: Vec::new(),
content_format: ContentFormat::BdTs,
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011,