From 30bea123922174fac5093cb202673015fe3ec909 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:10:32 -0700 Subject: [PATCH] fix(css): no provable key is a hard failure, matching AACS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit descramble_region descrambled with the key a sector's own crib had just proven stale, whenever the re-crack from that sector also failed. The clear header is not scrambled, so it survives intact: the sector still opens with a valid pack start and passes every structural check the PS demuxer applies. Only the payload is corrupted — exactly where nothing looks. Ok(0) dropped, exit 0. CSS has no external key source. The title key comes only from cracking the data, so on a READABLE sector "no key" is not a missing input, it is recovery failing on bytes we can see. That should never happen, and when it does the answer is not to emit something. Now Error::DecryptFailed — the same verdict the AACS path already gives for a unit no held key opens. Both alternatives to failing are bad data reported as success: descrambled with a rejected key it is garbage behind a valid header, and passed through untouched it is ciphertext where plaintext is meant to be. WHY IT WAS POSSIBLE, which matters more than the fix: There is no single place that owns "what do we do when there is no key". decrypt_sectors_impl looks like the central dispatch, but its AACS arm is a `return Err` stub — AACS decrypts entirely through decrypt_sectors_mapped, a separate top-level path. So CSS decided its own policy inside css/, AACS decided in decrypt.rs and mux/resolve.rs, and nothing held them to the same answer. The asymmetry was not an oversight; it was structurally permitted. How a disc decrypts is one process — resolve a key for this data, apply it, refuse if it cannot be proven. Only the resolve-and-apply step is scheme-specific. Filed as a task: the policy belongs in one orchestrator with the schemes supplying only what genuinely differs. Two tests changed rather than added, both of which pinned the old behaviour: the unit test asserted the sector was descrambled, and the integration test asserted the scramble flag was cleared, which is what descrambling-with-any-key does. Neither established that the result was CORRECT — the fourth bad-test shape. --- src/css/mod.rs | 97 +++++++++++++++++++++++++++++++++++++-- src/decrypt.rs | 3 +- tests/pass_n_patch_fix.rs | 35 ++++++++++---- 3 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/css/mod.rs b/src/css/mod.rs index 21654de..2532d00 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -361,7 +361,23 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { /// 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. -pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) { +/// +/// # Errors +/// +/// [`Error::DecryptFailed`] when a sector's own crib proves the cached key stale +/// and the re-crack from that same sector also fails. CSS has no external key +/// source — the title key comes only from cracking the data — so on a readable +/// sector this is not a missing input, it is recovery failing on data we can +/// see. Emitting the sector anyway means one of two bad outcomes: descrambled +/// with the key its crib just rejected, which yields garbage behind an intact +/// clear header (valid pack start, passes every structural check the PS demuxer +/// applies, corruption confined to the PES payload where nothing looks); or +/// passed through still scrambled, which is ciphertext delivered where plaintext +/// is meant to be. Both are bad data reported as success. +/// +/// This matches the AACS sibling, which returns [`Error::DecryptFailed`] rather +/// than apply a neighbouring CPS unit's key. +pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { for chunk in buf.chunks_mut(2048) { if chunk.len() < 2048 || !is_scrambled(chunk) { continue; @@ -381,12 +397,25 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) { // Cached key is stale for this region — restore the ciphertext and // crack this sector's own key. chunk.copy_from_slice(&original); - if let Some(fresh) = stevenson::crack_title_key(chunk) { - *title_key = fresh; + match stevenson::crack_title_key(chunk) { + Some(fresh) => { + *title_key = fresh; + lfsr::descramble_sector(title_key, chunk); + } + None => { + // No provable key. `chunk` already holds the restored + // ciphertext; fail rather than emit it descrambled with a + // key this sector's own crib just rejected. + tracing::error!( + target: "css", + "css: cached title key stale and re-crack failed on a readable sector" + ); + return Err(crate::error::Error::DecryptFailed); + } } - lfsr::descramble_sector(title_key, chunk); } } + Ok(0) } /// Check if a sector has the CSS scramble flag set. @@ -436,6 +465,66 @@ mod tests { use super::*; use crate::error::{Error, Result}; + /// A sector whose cached key is provably stale and whose own re-crack fails + /// must FAIL, not emit data. + /// + /// The clear header (`<0x80`) is not scrambled, so it survives a wrong-key + /// descramble intact: the sector still opens with a valid pack start and + /// passes every structural check the PS demuxer applies. Only the PES + /// payload is corrupted, which is exactly where nothing looks. Leaving it + /// CSS has no external key source, so on a readable sector this is recovery + /// failing on data we can see — the same condition AACS treats as + /// `DecryptFailed` rather than applying a neighbouring unit's key. + #[test] + fn a_sector_with_no_provable_key_fails_instead_of_emitting_data() { + // Header periodic enough to yield a crib, so the cached key IS validated + // (a crib-less sector rides the cache by design and is not this case). + let mut sector = [0u8; 2048]; + sector[0x14] = 0x30; // scramble flag bits 4-5 + for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) { + *b = (i % 4) as u8; + } + // Body is random-ish so no LFSR seed reproduces the crib from it: the + // re-crack must fail. + for (i, b) in sector.iter_mut().enumerate().skip(0x80) { + *b = ((i * 37 + 11) % 251) as u8; + } + assert!( + is_scrambled(§or), + "fixture must actually be a scrambled sector, or descramble_region \ + skips it and this test proves nothing" + ); + assert!( + stevenson::attack_crib(§or).is_some(), + "fixture must yield a crib, or the stale-key branch is never entered" + ); + assert!( + stevenson::crack_title_key(§or).is_none(), + "fixture must be uncrackable, or the failure branch is never entered" + ); + + let before = sector; + let mut key = [0xAAu8; 5]; + let err = descramble_region(&mut sector, &mut key) + .expect_err("an unprovable key must fail, not emit data"); + + assert!( + matches!(err, Error::DecryptFailed), + "must be the same verdict the AACS path gives for an unopenable unit, \ + got {err:?}" + ); + assert_eq!( + sector, before, + "the sector must be left untouched; descrambling it with the stale key \ + would leave the clear header intact and corrupt only the payload, \ + which passes every structural check downstream" + ); + assert_eq!( + key, [0xAAu8; 5], + "a failed re-crack must not overwrite the cached key" + ); + } + /// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a /// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5); /// `crack_span` is non-secret and none of its values are 213. diff --git a/src/decrypt.rs b/src/decrypt.rs index e1335f8..b9155ad 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -567,8 +567,7 @@ fn decrypt_sectors_impl( // `css::descramble_region`), and CSS does not need the post-decrypt // recovery seam that AACS key-fetch / FMTS segment-skip use (those DO // consume external inputs a `decrypt_sectors` caller cannot supply). - css::descramble_region(buf, title_key); - 0 + css::descramble_region(buf, title_key)? } }; Ok(dropped) diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 653640c..8f8e245 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -24,20 +24,35 @@ fn decrypt_sectors_with_none_keys_is_noop() { /// Test: decrypt_sectors with CSS keys descrambles sectors. #[test] -fn decrypt_sectors_with_css_keys_works() { +fn css_decrypt_of_an_unkeyable_sector_fails_instead_of_emitting_data() { + // A scrambled sector whose header is uniformly periodic yields a crib, so + // the supplied key IS validated — and this arbitrary key is not the right + // one, so the crib check rejects it and the re-crack from this synthetic + // body finds nothing. + // + // CSS has no external key source: the title key comes only from cracking + // the data. So "no key" on a readable sector is recovery failing on bytes + // we can see, not a missing input — the same condition AACS answers with + // DecryptFailed rather than applying a neighbouring unit's key. Emitting + // the sector either way is bad data reported as success: descrambled with + // the rejected key it is garbage behind an intact clear header, and passed + // through untouched it is ciphertext where plaintext is meant to be. + // + // This test previously asserted the scramble flag was cleared, which pinned + // the old behaviour of descrambling with whatever key happened to be held. let mut sector = vec![0xFFu8; 2048]; + sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5 - // Set CSS scramble flag (bits 4-5 of byte 0x14) - sector[0x14] |= 0x30; - - let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later + let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let mut keys = DecryptKeys::Css { title_key }; - // Descramble (CSS uses same operation for encrypt/decrypt) - libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0).unwrap(); - - // Flag should be cleared - assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared"); + let err = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0) + .expect_err("an unkeyable CSS sector must fail loud"); + assert_eq!( + err.code(), + libfreemkv::error::Error::DecryptFailed.code(), + "CSS and AACS must give the SAME verdict for 'no provable key'" + ); } /// Test: AACS unit encryption detection works.