pub mod auth;
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;
use crate::disc::Extent;
use crate::sector::SectorSource;
#[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)
}
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).into_state()
}
fn crack_key_scan(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> 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;
'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(_) => {
for s in 0..n as usize {
tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048];
if is_scrambled(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(_) => tried += n,
}
i += n;
}
}
if saw_scrambled {
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
}
#[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");
}
struct MockSource {
reads: std::cell::RefCell<Vec<u32>>,
flag_byte: u8,
fail_all: bool,
}
impl MockSource {
fn new(flag_byte: u8) -> Self {
Self {
reads: std::cell::RefCell::new(Vec::new()),
flag_byte,
fail_all: false,
}
}
}
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.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;
}
if buf.len() > 0x14 {
buf[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_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);
}
}