Files
libfreemkv/docs/css-mod.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

17 KiB

src/css/mod.rs — extended notes

Overflow detail relocated from doc comments in src/css/mod.rs (kept short there per the comment-guard caps). Function/item names below match the code.

CSS_LOCKED_BAIL

The bus-auth read gate is global (all-or-nothing), so a run this long of 05/6F/03 (CSS-locked) reads means the gate is shut and nothing here is crackable — bail instead of grinding the full 50_000-sector budget. That grind is what made rc5 appear to hang on a wedged USB bridge.

crack_key

Recovers the CSS title key with no keys, by scanning scrambled sectors and running the known-plaintext attack (see the keyless module). The crib comes from the periodic-run detector: 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. Scans up to 50000 sectors across the extents and returns 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.

This convenience form runs to completion (no cancellation) and returns just the key; callers needing an operator-Stop / watchdog cancel, or the three-way CrackOutcome (to distinguish "unencrypted" from "encrypted-but-uncracked"), use crack_key_outcome, which takes a halt token.

CrackOutcome

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):

  • Cracked — a scrambled sector yielded a title key.
  • Unencrypted — NO scrambled sector was seen across the scanned extents (is_scrambled_pack never true): the content is genuinely plaintext, so proceeding without a key is correct.
  • ScrambledUncracked — scrambled sectors WERE seen but no key could be recovered (the recovery 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 instead of falling through to "unencrypted" — the per-title crate::error::Error::CssKeyMissing when it is ONE title's own re-crack that failed (skippable: a sibling VTS may still crack), or the disc-level crate::error::Error::CssNoDiscKey when it is the disc-wide scan (Disc::css_error, every title fails identically).

resolve_dvd_title_key

The SINGLE place every DVD read path obtains a title key, so the file-backed mux highway (crate::build_iso_pipeline) and the live-drive single-pass crate::DiscStream descramble a DVD identically ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled data itself, so a None/MPEG-PS title cracks its own key here, in playback order over extents. Everything else is left untouched:

  • AACS keys (HD-DVD .evo is also MPEG-PS but arrives as Aacs) — no CSS.
  • a title that already carries a key — nothing to resolve.
  • a genuinely clear DVD (no scrambled sector) — stays None, a mux no-op.

A scrambled-but-uncrackable title is a hard crate::error::Error::CssKeyMissing, never a silent scrambled-passthrough mux. That code is the PER-TITLE one (error::is_skippable_title_stub), which is correct here: this function cracks ONE title's own extents, and another VTS on the same disc may still yield its key, so an all-titles rip skips this title and finishes the rest. The whole-disc failure is crate::error::Error::CssNoDiscKey, raised by Disc::ensure_decryptable_keys from the scan's css_error.

crack_key_scan (private)

Tracks a saw_scrambled flag so a scrambled-but-uncracked disc is distinguished from a genuinely-unencrypted one (the crack_key Option wrapper collapses both to None via CrackOutcome::into_state).

descramble_sector

A no-op unless the sector is a scrambled MPEG-2 PS PACK. The pack start code is checked, not just the byte 0x14 flag bits, for the same reason descramble_region checks it: 0x14 only means "scrambling control" inside a pack, and in an IFO it is whatever that format stores there. A real VIDEO_TS.IFO sector holds 0x15 there while starting 00 26 00 00; descrambling it destroyed 1912 of its 2048 bytes, and because that sector carries TT_SRPT the disc enumerated 38 titles while an image decrypted from it enumerated 10, silently, at exit 0.

This function has no callers inside the crate, but the module-level example prescribes it — so the crate's own documented guidance led straight into that defect. Making the guard part of the function, rather than something each caller must remember, is what keeps the safe path the easy one.

descramble_region

Descrambles a whole CSS buffer in place, re-cracking the title key on a VOB region boundary. title_key is a CACHE of the last crack, not a fixed disc key: it changes per VTS/VOB region, so it is validated on every scrambled sector and re-cracked on a miss (the standard on-demand per-region rekey).

This CSS key acquisition is intrinsic to the cipher — CSS has no external key source, the ONLY way to a title key is cracking the data — so it lives with the CSS primitives and runs inside decrypt::decrypt_sectors (a public, self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS key-fetch and FMTS segment-skip use (those consume external inputs).

The clear header (<0x80) is never scrambled, so its periodic crib predicts the plaintext at 0x80. Descramble with the cached key; if the crib fails to reappear the key region changed (or the primed key was wrong) — restore the ciphertext, re-crack from this very sector, and descramble again. A crib-less sector (no periodic run) can be neither validated nor cracked, so it rides the cached key — correct, because it lives in the same region as the nearby crib sector that set the cache.

This section used to document an Error::DecryptFailed for the case where a sector's crib rejects the cached key and the re-crack from that sector also fails. That behaviour was tried and REVERTED: crib mismatch plus crack failure is the signature of a crib FALSE POSITIVE, not of a stale key, and failing there made real discs unrippable. The code now descrambles with the cached key and returns Ok.

has_scramble_flag_bits

Deliberately NOT called is_scrambled. Byte 0x14 only means "scrambling control" inside an MPEG-2 Program Stream pack; in an IFO, UDF or ISO 9660 sector it is whatever that format stores there. Treating the flag alone as proof of scrambling is what destroyed 1912 bytes of a real disc's VIDEO_TS.IFO — the sector carrying TT_SRPT — so the disc enumerated 38 titles and an image decrypted from it enumerated 10, silently, at exit 0.

Callers want is_scrambled_pack. It asks the same question and also requires the pack start code, which every genuinely scrambled VOB sector carries and no IFO sector does.

It stays public only because an integration test asserts the flag extraction directly. It has no production callers, and its previous doc comment claimed one (decrypt::decrypt_sectors) that did not exist — so the name was an invitation and the documentation was an argument for accepting it.

is_scrambled_pack

The HARDENED test the crack scan uses to set its saw_scrambled evidence flag (Fix 3).

has_scramble_flag_bits keys solely on bits 4-5 of byte 0x14. That byte is only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack, which ALWAYS begins with the 32-bit pack-start code 00 00 01 BA at offset 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title) can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting byte 0x14 alone there would flip the scan's saw_scrambled gate and make a genuinely-UNENCRYPTED title report ScrambledUncracked — a false E7023.

Requiring the pack-start signature FIRST means only a sector that is structurally a DVD video pack can be counted as scramble evidence. This does NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real scrambled feature is made of valid PS packs, so its scrambled sectors still pass this check and still drive ScrambledUncracked when no key cracks.

The pack-start check alone is not enough, and the sector's PES stream_id (offset 0x11, the byte after the 14-byte pack header's 00 00 01 PES prefix) is the second load-bearing gate. CSS scrambles ONLY elementary streams — video (0xE0..=0xEF) and private_stream_1 audio/subpicture (0xBD) — and it never touches the clear header, so byte 0x11 is the TRUE stream_id even on a scrambled sector. The MPEG-PS structural packets that are never CSS-scrambled — system_header (0xBB), padding (0xBE) and private_stream_2 (0xBF, DVD PCI/DSI navigation) — must be excluded, because on those the byte at 0x14 is NOT a PES scrambling-control field but raw payload/structure whose bits 4-5 land set by chance.

This is the DETECTION defect a decrypted HD-DVD tripped: an HD-DVD .evo is MPEG-PS exactly like a DVD .vob, and its RDI navigation packs are private_stream_2 (0xBF) whose payload byte at 0x14 routinely has bits 4-5 set. With no 0x11 gate those nav packs flipped the crack scan's saw_scrambled flag on a disc that carries no CSS at all; the crack then found no key (there is none) and the scan returned ScrambledUncracked, hard-failing a perfectly good HD-DVD with CssKeyMissing — E7023. Excluding 0xBB/0xBE/0xBF at 0x11 makes the evidence gate match what the crack itself can act on (the recovery only recovers a key from a scrambled ES pack), so a decrypted HD-DVD now scans to Unencrypted and muxes cleanly.

This does NOT weaken the genuine "encrypted but uncrackable" hard-fail on a real DVD: a scrambled DVD feature is made of video (0xE0..) and private_stream_1 (0xBD) packs, none of which are excluded, so its scrambled sectors still set saw_scrambled and still drive ScrambledUncracked when no key cracks. Byte 0x11 is in the clear header, so a scrambled DVD pack can never masquerade as 0xBB/0xBE/0xBF.

The DESCRAMBLE path gates on this same function — descramble_sector and descramble_region both call it, not the raw flag test — because the raw test does not merely mis-skip a sector there: it descrambles one that was never scrambled and destroys it. The measured case is written up above under descramble_region: a VIDEO_TS.IFO sector holding 0x15 at offset 0x14 lost 1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38 titles became 10 — silently, at exit 0. One gate, both paths.

Tests (mod tests)

Detail relocated from test doc comments:

  • a_crib_false_positive_keeps_the_cached_key_rather_than_failing: attack_crib is a heuristic — it finds a periodic run in the unscrambled header and predicts the run continues past 0x80. When that prediction does not hold, the crib reports a mismatch even though the cached key is correct, and the re-crack then fails BECAUSE the crib was never valid. So this combination is the signature of a crib false positive, not of a stale key, and the cached key remains the best available evidence. This test exists because a prior round read the same code as "descrambling with a key we just proved stale" and made it DecryptFailed to match the AACS path; real DVDs hit this constantly, and the change made a real disc unrippable (no unit test caught it — the real-media acceptance gate did). CSS is not AACS: an AACS unit key either opens a unit or does not, whereas a CSS title key is recovered from data whose recoverability varies sector by sector.

  • has_scramble_flag_bits_short_buffer_is_false_no_panic: grounding — sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0 short-circuits so a short buffer never reads index 0x14. Mutation: swapping operand order would panic indexing a 20-byte slice; this test catches it.

  • has_scramble_flag_bits_uses_bits_4_5_only: grounding — (sector[0x14] >> 4) & 0x03. Mutation: widening the mask to & 0x0F makes 0x40 report scrambled; the 0x40 assert catches it.

  • has_scramble_flag_bits_exact_sector_length_accepted: grounding — sector.len() >= 2048. Mutation: >= 2048 → > 2048 makes an exact 2048-byte scrambled sector report false; this catches it.

  • is_scrambled_pack_requires_pack_start_signature: Fix 3 hardening — is_scrambled_pack requires BOTH the pack-start code and the 0x14 bits, so a clear/nav-only stub with stray 0x14 bits isn't scramble evidence (else a genuinely unencrypted title reports the false E7023).

  • is_scrambled_pack_excludes_nav_and_structural_stream_ids: detection fix — is_scrambled_pack must exclude system_header/padding/private_stream_2 by stream_id at 0x11, since CSS never scrambles those and byte 0x14 there is raw payload, not a scrambling-control field. This is the exact decrypted-HD-DVD defect (an .evo RDI pack is 0xBF with pack-start and 0x14 bits set); the video/private_stream_1 cases prove the exclusion doesn't weaken real-DVD detection.

  • MockSource fields lock_all, crackable, short_read, stream_id: model a CSS-locked drive (05/6F/03 on every read), a designated crackable LBA serving a full synthetic scrambled sector, a short-read count (the recovery: true short-read contract, Some(0) being the degenerate spin-guard case), and the PES stream_id written at 0x11 (used to model an HD-DVD .evo RDI nav pack via PRIVATE_STREAM_2).

  • crackable_sector: builds a crackable scrambled sector whose cleartext header (0x59..0x80) carries a periodic run continuing across the 0x80 boundary, mirroring the synth_periodic_sector fixture in the keyless tests but built from the crate-internal scramble_sector.

  • a_source_that_returns_zero_sectors_still_obeys_the_scan_budget: tried increments only per inspected sector inside for s in 0..usable; an Ok(0) never runs that loop, so advance is forced to 1 to keep the cursor moving and the extent's full declared length is walked. Mutation: deleting the tried charge in the usable == 0 arm goes red at 60,000 reads for a misbehaving/adversarial source.

  • crack_key_caps_total_tries_at_50000: grounding — max_tries = 50_000, tried += 1 before the read, loop guard tried < max_tries. Mutation: widening the cap, or removing the increment, both go red.

  • crack_key_budget_is_shared_across_extents: grounding — tried is declared outside the extent loop with a shared if tried >= max_tries break. Mutation: moving the declaration inside the loop would give each extent its own budget (80,000 total instead of 50,000).

  • crack_key_scans_from_extent_start_lba: grounding — reader.read_sectors(ext.start_lba + i, ...). Mutation: dropping ext.start_lba + would start LBAs at 0 instead of the extent's start.

  • crack_key_continues_past_read_errors: grounding — a read Err falls through to the next sector rather than aborting. Mutation: propagating the error with ? would stop the scan after the first failure.

  • crack_key_empty_extent_reads_nothing / crack_key_no_extents_is_none: grounding — while i < ext.sector_count / for ext in extents are no-ops on empty input; an off-by-one (<=) or a pre-loop read would break these.

  • crack_outcome_reaches_cracked_with_span: audit gap "MockSource never yields a crackable sector" — drives the full crack_key_scan over a synthetic ISO to exercise the Cracked branch and crack_span recording end-to-end, previously untested.

  • is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two: a prior round introduced the three-way split precisely because conflating the cases made an uncrackable disc exit 0 with garbage output; earlier tests only asserted the TRUE direction, so a predicate answering "yes" unconditionally was indistinguishable from a correct one. All three outcomes here come from real crack_key_outcome scans, not hand-built enum values.

  • resolve_dvd_title_key_* tests: resolve_dvd_title_key is the SINGLE shared per-title CSS step both read paths (build_iso_pipeline multi-pass and DiscStream::new single-pass) call, so these pin its full contract at the shared boundary — crack success, hard-fail on scrambled-uncrackable (never leaving keys as None, which would mux scrambled bytes as plaintext), the decrypted-HD-DVD RDI-pack regression (end to end), --raw passthrough, the AACS-keys-untouched gate, the clear-DVD no-op, and the halt-cancellation-surfaces-as-Halted fix (not a truncated-scan verdict).

  • all_locked_synthetic_iso_yields_css_key_missing_signal: audit §2/§5 #7 — pins that an all-locked synthetic ISO across MULTIPLE extents produces ScrambledUncracked, the exact signal disc/mod.rs converts into disc.css_error = Some(Error::CssKeyMissing).

  • recrack_succeeds_on_other_vts_extents: audit gap "success path missing" — the prior re-crack test only covered the locked→None path; this proves a key cracked for one VTS is genuinely re-derived (not reused) for another.