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.
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 throughTAB2/TAB3/TAB5. - LFSR0 — a 24-bit feedback register (seeded from
key[2..5] XOR seed[2..5]), stepped through a feedback polynomial andTAB4.
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.