libfreemkv 1.0.0-rc.4

Open source raw disc access library for optical drives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
//! CSS (Content Scramble System) — DVD disc encryption.
//!
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
//!
//! The title key is recovered keylessly: [`crack_key`] runs the Stevenson
//! known-plaintext attack (see the [`stevenson`] module) on the scrambled
//! data, needing no player keys, disc-key crack, or external key file.
//! Sectors are then decrypted with [`descramble_sector`].
//!
//! Usage:
//! ```rust,ignore
//! if let Some(state) = css::crack_key(reader, extents, batch) {
//!     css::descramble_sector(&state, &mut sector);
//! }
//! ```

pub mod auth;
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;

use crate::disc::Extent;
use crate::sector::SectorSource;

/// CSS decryption state for a DVD title.
#[derive(Debug, Clone)]
pub struct CssState {
    /// 5-byte CSS title key (from SCSI auth or the crack fallback).
    pub title_key: [u8; 5],
    /// LBA half-open span `[start, end)` of the extent set this key was
    /// cracked from. CSS title keys are per-VTS: a key cracked from one
    /// VTS does NOT descramble a title living in a different VTS. The mux
    /// path checks whether the title being opened overlaps this span; if
    /// not, it re-cracks from that title's own extents. `None` for keys
    /// of unknown provenance (e.g. test fixtures) — treated as "applies
    /// everywhere" for backward compatibility.
    pub crack_span: Option<(u32, u32)>,
}

/// Recover the CSS title key with no keys, by scanning scrambled sectors and
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
///
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
/// constant fill); the attack assumes that run continues across the 0x80
/// boundary into the encrypted region, giving the known plaintext the 2^16
/// LFSR recovery needs. We scan up to 50000 scrambled sectors across the
/// extents and return the first sector that yields a key — no player keys, no
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
/// disc images alike.
pub fn crack_key(
    reader: &mut dyn SectorSource,
    extents: &[Extent],
    batch_sectors: u16,
) -> Option<CssState> {
    crack_key_halt(reader, extents, batch_sectors, None)
}

/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
/// `Option<CssState>` conflated (and which caused a silent-failure bug:
/// scrambled-but-uncracked content was treated as "unencrypted" and muxed as
/// plaintext garbage at exit 0):
///
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
///   scanned extents (`is_scrambled` never true): the content is genuinely
///   plaintext, so proceeding without a key is correct.
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
///   key could be recovered (the Stevenson attack found no crackable crib, or
///   the scrambled region was unreadable). The content is encrypted; muxing it
///   as plaintext would emit garbage, so callers MUST surface a hard error
///   ([`crate::error::Error::CssKeyMissing`]) instead of falling through to
///   "unencrypted".
#[derive(Debug, Clone)]
pub enum CrackOutcome {
    Cracked(CssState),
    Unencrypted,
    ScrambledUncracked,
}

impl CrackOutcome {
    /// The cracked `CssState`, if any. `None` for `Unencrypted` /
    /// `ScrambledUncracked`. Lets the `Option`-returning wrappers stay thin.
    pub fn into_state(self) -> Option<CssState> {
        match self {
            CrackOutcome::Cracked(s) => Some(s),
            _ => None,
        }
    }

    /// True when scrambled sectors were seen but no key was recovered — the
    /// case callers must surface as a hard error instead of "unencrypted".
    pub fn is_scrambled_uncracked(&self) -> bool {
        matches!(self, CrackOutcome::ScrambledUncracked)
    }
}

/// [`crack_key`] returning the full [`CrackOutcome`] (Cracked / Unencrypted /
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
/// "encrypted but uncrackable" — the latter must become a hard error, never a
/// silent fall-through to plaintext.
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)
}

/// [`crack_key`] with an optional cooperative-cancellation token.
///
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
/// drive hitting bad sectors can take a long time. This variant polls `halt`
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
/// scan-level watchdog can interrupt the scan, and emits a
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
/// visible in the log.
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()
}

/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
/// `Option` wrappers collapse both to `None`).
fn crack_key_scan(
    reader: &mut dyn SectorSource,
    extents: &[Extent],
    batch_sectors: u16,
    halt: Option<&crate::halt::Halt>,
) -> CrackOutcome {
    // Batch the reads: a live optical drive at 1 sector/read is glacial, and the
    // crack only needs to FIND one scrambled sector whose 0x80 plaintext matches
    // a known PES header. `batch_sectors` MUST be sized to the source — a drive
    // rejects a READ(10) larger than its per-command max (DVD = 16) and
    // `Drive::read` does not chunk, so an over-large batch fails every read and
    // scans nothing. Callers pass `detect_max_batch_sectors(device_path)` for a
    // live drive, a file-safe value for an image, or 1 to force per-sector.
    let batch = (batch_sectors.max(1)) as u32;
    // Record the LBA span the key is being cracked from so the per-title mux
    // path can tell whether a later title lives in the same VTS (overlaps the
    // span → key applies) or a different one (→ re-crack). Half-open [min,max).
    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");
    // Track whether ANY scrambled sector was observed. If we exhaust the scan
    // budget having seen scrambled data but never recovered a key, the content
    // is encrypted-but-uncrackable — a HARD failure the caller must surface,
    // NOT silently treat as unencrypted (which would mux scrambled MPEG as
    // plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`.
    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 {
            // Cooperative cancellation — poll once per batch, the same cadence
            // sweep/patch use, so a Stop / watchdog can interrupt the scan.
            if let Some(h) = halt {
                if h.is_cancelled() {
                    break 'outer;
                }
            }
            // Liveness beacon: a long scan over a damaged disc stays visible.
            // The heartbeat is time-throttled; only when it actually beats do
            // we emit the crack-specific context (tried/lba/extent_idx).
            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;
                        }
                    }
                }
                // A failed batch (bad sectors) still counts toward the budget so a
                // damaged region can't loop forever; skip ahead by the batch.
                Err(_) => tried += n,
            }
            i += n;
        }
    }

    // Budget exhausted / extents walked with no key recovered. Distinguish the
    // two indistinguishable-in-`Option` cases: if scrambled sectors were seen
    // (case b: crack failed; case c: scrambled but the crackable region was
    // unreadable), this is encrypted-but-uncracked — a hard failure. Only a
    // scan that NEVER saw a scrambled sector is genuinely unencrypted (case a).
    if saw_scrambled {
        CrackOutcome::ScrambledUncracked
    } else {
        CrackOutcome::Unencrypted
    }
}

/// Descramble a single CSS-encrypted sector in place.
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
    lfsr::descramble_sector(&state.title_key, sector);
}

/// Check if a sector has the CSS scramble flag set.
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};

    // ── is_scrambled ───────────────────────────────────────────────────────

    /// is_scrambled returns false for any buffer shorter than one sector,
    /// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
    /// length guard is short-circuited before the flag read.
    ///
    /// Grounding: `sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0` —
    /// `&&` short-circuits so a 20-byte buffer never reads index 0x14.
    /// Mutation: swap the operands so the flag is read first
    /// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
    /// 20-byte slice; this test catches it.
    #[test]
    fn is_scrambled_short_buffer_is_false_no_panic() {
        assert!(!is_scrambled(&[]));
        assert!(!is_scrambled(&[0u8; 20])); // shorter than 0x14+1 even
        assert!(!is_scrambled(&[0xFFu8; 2047])); // one byte short of a sector
    }

    /// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field).
    /// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
    /// high-bit-only values 0x40/0x80 are clear.
    ///
    /// Grounding: `(sector[0x14] >> 4) & 0x03`.
    /// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
    /// assert fails.
    #[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), // bits 4-5 set within 0xFF
        ] {
            s[0x14] = flag;
            assert_eq!(
                is_scrambled(&s),
                expected,
                "flag byte {flag:#04x} scramble detection"
            );
        }
    }

    /// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the
    /// inclusive value 2048).
    ///
    /// Grounding: `sector.len() >= 2048`.
    /// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
    /// sector reports false; this fails.
    #[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");
    }

    // ── crack_key scanning over a mock SectorSource ────────────────────────

    /// Records every (lba, count) read; returns a caller-supplied flag byte at
    /// 0x14 so we can drive scrambled/clear sectors, or an injected error.
    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)
        }
    }

    /// crack_key caps total scanned sectors at 50_000 even when extents are
    /// far larger, and counts EVERY scanned sector (clear ones included)
    /// toward the budget. With one 200_000-sector extent of clear sectors, it
    /// must read exactly 50_000 sectors and return None — never run away.
    ///
    /// Grounding: `let max_tries = 50_000; ... tried += 1` before the read,
    /// loop guard `tried < max_tries`.
    /// Mutation: change `50_000` to `500_000` -> read count exceeds 50_000;
    /// the exact-count assert fails. Removing the `tried += 1` increment ->
    /// would read all 200_000; also fails.
    #[test]
    fn crack_key_caps_total_tries_at_50000() {
        let mut src = MockSource::new(0x00); // clear sectors, never a hit
        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"
        );
    }

    // ── CrackOutcome: scrambled-but-uncracked vs genuinely unencrypted (Fix 6) ─

    /// A scan over CLEAR sectors (scramble flag never set) returns
    /// `Unencrypted` — the content is genuinely plaintext, so proceeding
    /// without a key is correct.
    #[test]
    fn crack_outcome_clear_sectors_is_unencrypted() {
        let mut src = MockSource::new(0x00); // never scrambled
        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:?}"
        );
        // The Option wrapper collapses Unencrypted → None.
        assert!(crack_key(&mut MockSource::new(0x00), &extents, 1).is_none());
    }

    /// THE Fix 6 regression: a scan that SEES scrambled sectors (flag set) but
    /// recovers no key (the mock's zeroed data has no Stevenson crib) must
    /// return `ScrambledUncracked` — a HARD failure — NOT `Unencrypted`. The
    /// old code conflated this with "unencrypted" and muxed scrambled MPEG as
    /// plaintext (garbage at exit 0).
    #[test]
    fn crack_outcome_scrambled_uncracked_is_hard_failure() {
        let mut src = MockSource::new(0x30); // scrambled flag set, no crackable crib
        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:?}"
        );
        // The legacy Option wrapper still collapses this to None (the callers
        // that need the distinction now use crack_key_outcome instead).
        assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none());
    }

    /// Even when every read FAILS, a scan that never managed to observe a
    /// scrambled sector reports `Unencrypted` (we cannot prove encryption from
    /// unreadable data alone — the AACS/keydb paths and the disc-level
    /// `css_error` plumbing cover genuinely unreadable encrypted discs).
    #[test]
    fn crack_outcome_all_reads_fail_is_unencrypted() {
        let mut src = MockSource::new(0x30);
        src.fail_all = true; // no sector is ever inspected
        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:?}"
        );
    }

    /// The budget spans ALL extents, not per-extent: two extents summing past
    /// the cap must still stop at 50_000 total reads.
    ///
    /// Grounding: `tried` is declared outside the `for ext in extents` loop;
    /// `if tried >= max_tries { break }` after each extent.
    /// Mutation: move `let mut tried = 0` inside the extent loop -> each extent
    /// gets its own 50_000 budget; total reads would be 80_000, this fails.
    #[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"
        );
    }

    /// crack_key scans sequentially from each extent's start_lba. The first
    /// reads must be at the extent's start_lba, start_lba+1, ... pinning the
    /// LBA arithmetic `ext.start_lba + i`.
    ///
    /// Grounding: `reader.read_sectors(ext.start_lba + i, 1, ...)`.
    /// Mutation: change `ext.start_lba + i` to just `i` -> the recorded LBAs
    /// would start at 0, not 5000; this fails.
    #[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"
        );
    }

    /// A read error on a sector does NOT abort the scan: crack_key keeps
    /// scanning subsequent sectors (the error sector still counts toward the
    /// budget). With a small failing extent, every sector is attempted and the
    /// function returns None.
    ///
    /// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` —
    /// an Err simply falls through to `i += 1`.
    /// Mutation: change the read-error handling to `reader.read_sectors(...)?`
    /// (propagate) -> crack_key would stop after the first error and read only
    /// 1 sector; this asserts all 10 were attempted.
    #[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"
        );
    }

    /// Empty extents (no sectors) -> crack_key reads nothing and returns None.
    /// A zero-sector extent must not read its start_lba.
    ///
    /// Grounding: `while i < ext.sector_count` with sector_count == 0 never
    /// enters.
    /// Mutation: change `i < ext.sector_count` to `i <= ext.sector_count` ->
    /// one spurious read at start_lba; this asserts zero reads.
    #[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"
        );
    }

    /// No extents at all -> immediate None, zero reads.
    ///
    /// Grounding: `for ext in extents` over an empty slice is a no-op.
    /// Mutation: any change that reads before the loop would break this.
    #[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);
    }
}