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.