pub mod auth;
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;
use crate::disc::Extent;
use crate::sector::SectorSource;
const CSS_LOCKED_BAIL: u32 = 64;
#[derive(Debug, Clone)]
pub struct CssState {
pub title_key: [u8; 5],
pub crack_span: Option<(u32, u32)>,
}
pub fn crack_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
) -> Option<CssState> {
crack_key_halt(reader, extents, batch_sectors, None)
}
#[derive(Debug, Clone)]
pub enum CrackOutcome {
Cracked(CssState),
Unencrypted,
ScrambledUncracked,
}
impl CrackOutcome {
pub fn into_state(self) -> Option<CssState> {
match self {
CrackOutcome::Cracked(s) => Some(s),
_ => None,
}
}
pub fn is_scrambled_uncracked(&self) -> bool {
matches!(self, CrackOutcome::ScrambledUncracked)
}
}
pub fn crack_key_outcome(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> CrackOutcome {
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
pub fn crack_key_halt(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> Option<CssState> {
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
}
fn crack_key_scan(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
fail_on_locked: bool,
) -> CrackOutcome {
let batch = (batch_sectors.max(1)) as u32;
let crack_span = extents
.iter()
.filter(|e| e.sector_count > 0)
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
.reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax)));
let mut tried = 0u32;
let max_tries = 50_000u32;
let mut buf = vec![0u8; batch as usize * 2048];
let mut hb = crate::progress::Heartbeat::new("css_crack");
let mut saw_scrambled = false;
let mut saw_locked = false;
let mut consecutive_locked = 0u32;
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
let mut i = 0u32;
while i < ext.sector_count && tried < max_tries {
if let Some(h) = halt {
if h.is_cancelled() {
break 'outer;
}
}
if hb.tick(tried as u64, max_tries as u64) {
tracing::debug!(
target: "freemkv::heartbeat",
phase = "css_crack",
tried,
lba = ext.start_lba + i,
extent_idx,
"scanning"
);
}
let n = (ext.sector_count - i).min(batch);
let want = n as usize * 2048;
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
Ok(_) => {
consecutive_locked = 0;
for s in 0..n as usize {
tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048];
if is_scrambled_pack(sect) {
saw_scrambled = true;
if let Some(key) = stevenson::crack_title_key(sect) {
return CrackOutcome::Cracked(CssState {
title_key: key,
crack_span,
});
}
}
if tried >= max_tries {
break 'outer;
}
}
}
Err(e) => {
tried += n;
if e.scsi_sense().is_some_and(|s| s.is_css_locked()) {
saw_locked = true;
consecutive_locked += 1;
if consecutive_locked >= CSS_LOCKED_BAIL {
break 'outer;
}
} else {
consecutive_locked = 0;
}
}
}
i += n;
}
}
if saw_scrambled || (saw_locked && fail_on_locked) {
CrackOutcome::ScrambledUncracked
} else {
CrackOutcome::Unencrypted
}
}
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
lfsr::descramble_sector(&state.title_key, sector);
}
pub fn is_scrambled(sector: &[u8]) -> bool {
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
}
pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{Error, Result};
#[test]
fn is_scrambled_short_buffer_is_false_no_panic() {
assert!(!is_scrambled(&[]));
assert!(!is_scrambled(&[0u8; 20])); assert!(!is_scrambled(&[0xFFu8; 2047])); }
#[test]
fn is_scrambled_uses_bits_4_5_only() {
let mut s = vec![0u8; 2048];
for (flag, expected) in [
(0x00u8, false),
(0x10, true),
(0x20, true),
(0x30, true),
(0x40, false),
(0x80, false),
(0xC0, false),
(0xFF, true), ] {
s[0x14] = flag;
assert_eq!(
is_scrambled(&s),
expected,
"flag byte {flag:#04x} scramble detection"
);
}
}
#[test]
fn is_scrambled_exact_sector_length_accepted() {
let mut s = vec![0u8; 2048];
s[0x14] = 0x30;
assert!(is_scrambled(&s), "exactly 2048 bytes must be eligible");
}
#[test]
fn is_scrambled_pack_requires_pack_start_signature() {
let mut s = vec![0u8; 2048];
s[0x14] = 0x30; assert!(
!is_scrambled_pack(&s),
"0x14 bits without the MPEG-PS pack-start must NOT count as a scrambled pack"
);
assert!(is_scrambled(&s), "is_scrambled keys on the 0x14 flag alone");
s[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBB]);
assert!(
!is_scrambled_pack(&s),
"a wrong pack-start byte must not qualify"
);
s[0x00..0x04].copy_from_slice(&PACK_START);
assert!(
is_scrambled_pack(&s),
"valid pack-start + 0x14 bits → scrambled pack"
);
}
struct MockSource {
reads: std::cell::RefCell<Vec<u32>>,
flag_byte: u8,
fail_all: bool,
lock_all: bool,
crackable: Option<(u32, Vec<u8>)>,
}
impl MockSource {
fn new(flag_byte: u8) -> Self {
Self {
reads: std::cell::RefCell::new(Vec::new()),
flag_byte,
fail_all: false,
lock_all: false,
crackable: None,
}
}
}
fn crackable_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> Vec<u8> {
const RUN_START: usize = 0x59;
const SEED_OFFSET: usize = 0x54;
let mut plaintext = vec![0u8; 2048];
plaintext[0x00..0x04].copy_from_slice(&PACK_START); plaintext[0x14] = 0x10; let pat: Vec<u8> = (0..period)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
*b = pat[i % period];
}
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
lfsr::scramble_sector(title_key, &mut plaintext);
plaintext
}
impl SectorSource for MockSource {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.reads.borrow_mut().push(lba);
if self.lock_all {
return Err(Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
});
}
if self.fail_all {
return Err(Error::DecryptFailed);
}
let n = count as usize * 2048;
let end = n.min(buf.len());
for b in buf[..end].iter_mut() {
*b = 0;
}
for s in 0..count as u32 {
let sect_lba = lba + s;
let base = s as usize * 2048;
if base + 2048 > end {
break;
}
match &self.crackable {
Some((clba, sector)) if *clba == sect_lba => {
buf[base..base + 2048].copy_from_slice(sector);
}
_ => {
buf[base..base + 4].copy_from_slice(&PACK_START);
buf[base + 0x14] = self.flag_byte;
}
}
}
Ok(n)
}
}
#[test]
fn crack_key_caps_total_tries_at_50000() {
let mut src = MockSource::new(0x00); let extents = [Extent {
start_lba: 0,
sector_count: 200_000,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none(), "clear sectors yield no key");
assert_eq!(
src.reads.borrow().len(),
50_000,
"scan must stop at the 50_000-sector budget"
);
}
#[test]
fn crack_outcome_clear_sectors_is_unencrypted() {
let mut src = MockSource::new(0x00); let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
matches!(outcome, CrackOutcome::Unencrypted),
"no scrambled sector seen → Unencrypted, got {outcome:?}"
);
assert!(crack_key(&mut MockSource::new(0x00), &extents, 1).is_none());
}
#[test]
fn crack_outcome_scrambled_uncracked_is_hard_failure() {
let mut src = MockSource::new(0x30); let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
outcome.is_scrambled_uncracked(),
"scrambled sectors seen but no key → ScrambledUncracked, got {outcome:?}"
);
assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none());
}
#[test]
fn crack_outcome_all_reads_fail_is_unencrypted() {
let mut src = MockSource::new(0x30);
src.fail_all = true; let extents = [Extent {
start_lba: 0,
sector_count: 10,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
matches!(outcome, CrackOutcome::Unencrypted),
"no readable scrambled sector → Unencrypted, got {outcome:?}"
);
}
#[test]
fn crack_outcome_css_locked_initial_is_scrambled_uncracked() {
let mut src = MockSource::new(0x30);
src.lock_all = true; let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
outcome.is_scrambled_uncracked(),
"every read 6F/03 on the initial scan → ScrambledUncracked, got {outcome:?}"
);
}
#[test]
fn crack_key_recrack_locked_is_none_not_hard_fail() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
assert!(crack_key(&mut src, &extents, 1).is_none());
}
#[test]
fn crack_css_locked_scan_early_bails() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 10_000,
}];
let _ = crack_key_outcome(&mut src, &extents, 1, None);
let n = src.reads.borrow().len();
assert!(
n <= (CSS_LOCKED_BAIL as usize) + 1,
"locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}"
);
}
#[test]
fn crack_key_budget_is_shared_across_extents() {
let mut src = MockSource::new(0x00);
let extents = [
Extent {
start_lba: 0,
sector_count: 40_000,
},
Extent {
start_lba: 100_000,
sector_count: 40_000,
},
];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
50_000,
"the 50_000 budget is shared across all extents"
);
}
#[test]
fn crack_key_scans_from_extent_start_lba() {
let mut src = MockSource::new(0x00);
let extents = [Extent {
start_lba: 5_000,
sector_count: 4,
}];
let _ = crack_key(&mut src, &extents, 1);
let reads = src.reads.borrow();
assert_eq!(
&reads[..],
&[5_000, 5_001, 5_002, 5_003],
"sequential scan from start_lba"
);
}
#[test]
fn crack_key_continues_past_read_errors() {
let mut src = MockSource::new(0x30);
src.fail_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 10,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
10,
"read errors must not abort the scan"
);
}
#[test]
fn crack_key_empty_extent_reads_nothing() {
let mut src = MockSource::new(0x30);
let extents = [Extent {
start_lba: 42,
sector_count: 0,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
0,
"zero-sector extent reads nothing"
);
}
#[test]
fn crack_key_no_extents_is_none() {
let mut src = MockSource::new(0x30);
let res = crack_key(&mut src, &[], 1);
assert!(res.is_none());
assert_eq!(src.reads.borrow().len(), 0);
}
#[test]
fn crack_outcome_reaches_cracked_with_span() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let crackable = crackable_sector(&title_key, &seed, 8);
let mut src = MockSource::new(0x00); src.crackable = Some((1003, crackable.clone()));
let extents = [Extent {
start_lba: 1000,
sector_count: 50,
}];
let outcome = crack_key_outcome(&mut src, &extents, 4, None);
let state = match outcome {
CrackOutcome::Cracked(s) => s,
other => panic!("expected Cracked, got {other:?}"),
};
let mut test = crackable.clone();
descramble_sector(&state, &mut test);
let mut plain = crackable;
lfsr::descramble_sector(&title_key, &mut plain);
assert_eq!(
&test[0x80..],
&plain[0x80..],
"recovered key must round-trip the scrambled sector body"
);
assert_eq!(
state.crack_span,
Some((1000, 1050)),
"crack_span must record the extent LBA span for per-VTS routing"
);
}
#[test]
fn all_locked_synthetic_iso_yields_css_key_missing_signal() {
let mut src = MockSource::new(0x30);
src.lock_all = true; let extents = [
Extent {
start_lba: 0,
sector_count: 30,
},
Extent {
start_lba: 5_000,
sector_count: 30,
},
];
let outcome = crack_key_outcome(&mut src, &extents, 16, None);
assert!(
outcome.is_scrambled_uncracked(),
"all-locked ISO → ScrambledUncracked (the css_error=CssKeyMissing \
signal), got {outcome:?}"
);
let mut src2 = MockSource::new(0x30);
src2.lock_all = true;
assert!(crack_key(&mut src2, &extents, 16).is_none());
}
#[test]
fn recrack_succeeds_on_other_vts_extents() {
let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76];
let seed = [0x00, 0xFF, 0x80, 0x7F, 0x01];
let crackable = crackable_sector(&title_key, &seed, 5);
let mut src = MockSource::new(0x00);
src.crackable = Some((9000, crackable));
let other_vts = [Extent {
start_lba: 9000,
sector_count: 20,
}];
let state = crack_key(&mut src, &other_vts, 4).expect("re-crack must recover a key");
assert_eq!(
state.crack_span,
Some((9000, 9020)),
"re-crack span must reflect the OTHER VTS extents, not a reused span"
);
}
}