Files
libfreemkv/docs/lfsr.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

7.4 KiB

CSS content cipher (src/css/lfsr.rs)

Module overview

The algorithm is the publicly documented CSS stream cipher, described in the open CSS cryptanalysis literature. It is implemented here from that public description; its constants (see [super::tables]) are the cipher's own defined values. Nothing in this file is copied or translated from any particular CSS software.

The cipher uses two table-driven linear-feedback circuits:

  • LFSR1 — a 17-bit register (a 9-bit and an 8-bit half seeded from key[0..2] XOR seed[0..2]), stepped through TAB2/TAB3/TAB5.
  • LFSR0 — a 24-bit feedback register (seeded from key[2..5] XOR seed[2..5]), stepped through a feedback polynomial and TAB4.

Each output byte is the sum-with-carry of the two register outputs. A body byte is recovered as plain = TAB1[cipher] ^ keystream — a TAB1 substitution of the ciphertext byte followed by an XOR with the keystream (so the cipher is deliberately not its own inverse).

descramble_sector

The two feedback registers are seeded directly from title_key XOR sector_seed (bytes 0x54..0x59) — there is no title-key mangling on the content path (that belongs to the disc/title-key hierarchy, not the sector cipher). Only the body, bytes 0x80..0x800, is transformed: body[i] = TAB1[body[i]] ^ (keystream & 0xff).

The scramble flag at byte 0x14 (bits 4-5) marks an encrypted sector. This routine CLEARS that flag after unscrambling, so a descrambled sector reads as sector[0x14] & 0x30 == 0; callers and tests use that to tell it from ciphertext, and re-running descramble on an already-cleared sector is a no-op (the flag guard below skips it). Clearing does not affect the recovered body.

sector.len() < 2048 (the encrypted region 0x80..0x800 not fully present): callers chunk by 2048, so a trailing partial chunk is left untouched. The debug_assert! flags this misuse in debug/test builds; a DVD sector is always exactly 2048 bytes.

scramble_sector (test helper)

Exact inverse of descramble_sector: turn a plaintext sector body into CSS ciphertext under title_key.

Descramble computes plain = TAB1[cipher] ^ (keystream & 0xff), so the inverse is cipher = TAB1_INV[plain ^ (keystream & 0xff)] with the identical keystream. The keystream derivation is the same as descramble_sector; only the final substitution differs. Bytes 0x80..0x800 are rewritten in place; the scramble flag is set to 0x10 so a subsequent descramble runs.

Not on any production read path — it exists so the key-recovery tests (and any caller that needs a known CSS-encrypted sector) can build genuine ciphertext rather than approximating it.

Test notes

descramble_produces_the_reference_css_vector

Regression vector: the deterministic output of the CSS content cipher for a fixed key/seed/body. The value is generated by this implementation and is self-consistent with the scramble/descramble round-trip test — any correct CSS descrambler yields the same bytes, since the cipher is deterministic. Pins the implementation against accidental change.

key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.

css_descramble_inverts_scramble_over_body

The content cipher is NOT a plain XOR involution (it applies TAB1 to the ciphertext: plain = TAB1[cipher] ^ ks). The true inverse is scramble_sector. Scrambling a plaintext body and then descrambling with the same key must reproduce the original body exactly.

css_tab1_is_permutation

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

descramble_treats_high_bits_of_0x14_as_clear

Only bits 4-5 of byte 0x14 are the CSS scramble flag: the code reads sector[0x14] & 0x30 == 0 (bits 6-7, i.e. 0x40/0x80, are masked out by 0x30). A sector with 0x14 == 0x40 or 0x80 must therefore be treated as UNSCRAMBLED and left byte-for-byte unchanged. This guards against a too-wide mask silently "descrambling" (and thus corrupting) clear data.

Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in bits 4-5; the masked value 0 means not scrambled. Mutation: widen the mask 0x30 to 0x70/0xF0 -> 0x40/0x80 would be seen as scrambled and the body would change.

descramble_triggers_on_either_flag_bit

Each individual scramble bit (4 and 5) independently marks the sector as encrypted: 0x10 and 0x20 must both trigger descrambling.

Grounding: (0x10 >> 4) & 3 == 1, (0x20 >> 4) & 3 == 2 — both nonzero. Mutation: change != 0 early-return condition to == 3 -> a sector flagged only 0x10 or 0x20 would be skipped and left scrambled.

descramble_clear_preserves_high_bits_of_0x14

After descrambling, ONLY the two scramble bits are cleared (& 0xCF); bits 6 and 7 of byte 0x14 must be preserved. A sector with 0x14 == 0xF0 becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.

Grounding: code does sector[0x14] &= 0xCF; 0xF0 & 0xCF == 0xC0. Mutation: change &= 0xCF to = 0 or &= 0x0F -> the preserved high bits assert fails.

descramble_leaves_header_and_seed_intact

The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the header) must NOT be modified by the keystream — except byte 0x14 whose flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which live inside the header) must survive untouched, since the descrambler reads them but never writes them.

Grounding: loop is sector.iter_mut().take(2048).skip(128) -> indices 128..2048 only. Mutation: change .skip(128) to .skip(0) -> header bytes (incl. the seed) get XORed and this fails.

descramble_covers_final_body_byte

The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix. With a constant body and constant key, the keystream is non-degenerate enough that the very last sector byte (index 2047) is altered. This guards the loop bound .take(2048) against an off-by-one that would leave the final byte(s) scrambled.

Grounding: encrypted region end is 0x800 == 2048 (exclusive). Mutation: change .take(2048) to .take(2047) -> last byte unchanged, assert fires (this body is all-zero so any keystream XOR shows).

descramble_processes_the_first_sector_of_an_over_long_buffer

The length guard is a FLOOR, not a ceiling: descramble_sector is a no-op below one sector, and processes the FIRST sector of anything at least that long (the loop is .take(2048)). css::descramble_sector is a public entry taking &mut [u8] of any length, so a caller handing it a multi-sector buffer must get its first sector descrambled — a guard that rejected over-long buffers would hand that caller its ciphertext back unchanged, with the scramble flag cleared as if it had worked.

descramble_output_depends_on_title_key

Descramble is keyed by title_key XOR seed: two different title keys produce two different bodies for the same scrambled input. A cipher that ignored the title key (or mixed it in wrongly) would yield identical output — silent wrong-key decryption.

Grounding: per-sector key = title_key[i] ^ sector[0x54+i]. Mutation: in the key array drop the title_key[i] ^ term -> both keys give the same body, assert fires.

descramble_output_depends_on_seed

Descramble is keyed by the sector seed too: same title key, different seed -> different body. Pins that bytes 0x54..0x59 actually feed the keystream (not just the per-sector XOR key).

Mutation: replace seed array reads with a constant -> both seeds give the same body, assert fires.