diff --git a/src/css/mod.rs b/src/css/mod.rs index 2532d00..f8cc8b3 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -403,14 +403,29 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro 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); + // The re-crack found nothing. Descramble with the CACHED + // key anyway — it is the best available evidence, and this + // is very probably still the right key. + // + // `attack_crib` is a heuristic, not a proof. It finds a + // periodic run in the unscrambled header and predicts that + // the run continues past 0x80. When that prediction does + // not hold, the crib check reports a mismatch even though + // the cached key is correct, and the re-crack from this + // sector then fails BECAUSE the crib was never valid. + // Crib mismatch + crack failure is therefore the signature + // of a crib false positive, not of a stale key. + // + // Round 9 read this as "descrambling with a key we just + // proved stale" and made it Error::DecryptFailed to match + // the AACS path. That was wrong on both counts: the key is + // not proven stale, and 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. Real DVDs hit this constantly; the change made + // Greenland.iso unrippable and was caught by the real-media + // acceptance gate, not by any unit test. + lfsr::descramble_sector(title_key, chunk); } } } @@ -465,63 +480,56 @@ 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. + /// A crib mismatch whose re-crack fails keeps the CACHED key and + /// descrambles with it — it does NOT fail the rip. /// - /// 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. + /// `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 round 9 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 — the change made + /// Greenland.iso unrippable, and 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. #[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). + fn a_crib_false_positive_keeps_the_cached_key_rather_than_failing() { + // Header periodic enough to yield a crib, body random enough that no + // LFSR seed reproduces it — crib mismatch, re-crack fails. let mut sector = [0u8; 2048]; - sector[0x14] = 0x30; // scramble flag bits 4-5 + sector[0x14] = 0x30; 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!(is_scrambled(§or), "fixture must be a scrambled sector"); assert!( stevenson::attack_crib(§or).is_some(), - "fixture must yield a crib, or the stale-key branch is never entered" + "fixture must yield a crib, or the mismatch 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"); + let key_before = [0xAAu8; 5]; + let mut key = key_before; + let out = descramble_region(&mut sector, &mut key) + .expect("a crib false positive must NOT fail the rip"); - assert!( - matches!(err, Error::DecryptFailed), - "must be the same verdict the AACS path gives for an unopenable unit, \ - got {err:?}" - ); + assert_eq!(out, 0, "CSS reports no loss term of its own"); 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" + key, key_before, + "a failed re-crack must leave the cached key in place — it is still \ + the best evidence, and overwriting it would poison every later sector" ); } diff --git a/src/decrypt.rs b/src/decrypt.rs index 1a0b455..c509584 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -1461,7 +1461,7 @@ mod tests { ); } - /// Every scheme answers "there is no key for these bytes" the SAME way. + /// Every scheme that CANNOT prove a key answers the same way. /// /// This is the property `decrypt_span` exists to hold. There used to be two /// top-level decrypt paths — one for CSS and clear media, one for AACS — @@ -1473,6 +1473,14 @@ mod tests { /// Asserting one verdict across the schemes is what makes a future /// divergence a test failure rather than a silent corruption. A per-scheme /// test cannot do that: each would still pass while the two disagreed. + /// + /// CSS is deliberately NOT in this list. Its title key is recovered from + /// the data, sector by sector, by a heuristic that false-positives — a crib + /// mismatch whose re-crack fails means the crib was wrong, not that the key + /// is stale, so the cached key is kept and used. Round 9 folded CSS in here + /// on the reasoning that "no key" should mean one thing everywhere; that + /// made real DVDs unrippable, and the real-media gate caught it. Uniform + /// policy is right for schemes that can PROVE a key wrong. CSS cannot. #[test] fn every_scheme_gives_the_same_verdict_when_no_key_can_be_proven() { use crate::disc::ContentFormat; @@ -1496,27 +1504,10 @@ mod tests { let aacs_unmapped = decrypt_span(&mut buf, &mut aacs_keys, 0, Some(&empty), None) .expect_err("an encrypted unit no range covers cannot be keyed"); - // CSS, a scrambled sector whose crib rejects the cached key and whose - // own re-crack finds nothing. - let mut sector = [0u8; 2048]; - sector[0x14] = 0x30; - for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) { - *b = (i % 4) as u8; - } - for (i, b) in sector.iter_mut().enumerate().skip(0x80) { - *b = ((i * 37 + 11) % 251) as u8; - } - let mut css_keys = DecryptKeys::Css { - title_key: [0xAAu8; 5], - }; - let css = decrypt_span(&mut sector, &mut css_keys, 0, None, None) - .expect_err("a CSS sector with no provable key cannot be descrambled"); - let want = crate::error::Error::DecryptFailed.code(); for (what, e) in [ ("AACS, no map", aacs_no_map), ("AACS, unit outside every range", aacs_unmapped), - ("CSS, re-crack failed", css), ] { assert_eq!( e.code(), diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 8f8e245..d977ded 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -24,34 +24,36 @@ fn decrypt_sectors_with_none_keys_is_noop() { /// Test: decrypt_sectors with CSS keys descrambles sectors. #[test] -fn css_decrypt_of_an_unkeyable_sector_fails_instead_of_emitting_data() { +fn css_decrypt_of_an_uncrackable_sector_still_descrambles() { // 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 + // the supplied key IS checked — 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. + // That combination does NOT fail the rip. `attack_crib` is a heuristic: it + // predicts that a periodic header run continues past 0x80, and when that + // prediction does not hold it reports a mismatch even for a CORRECT key — + // whereupon the re-crack fails because the crib was never valid. Crib + // mismatch plus crack failure is the signature of a crib false positive, + // and the cached key stays the best available evidence. // - // This test previously asserted the scramble flag was cleared, which pinned - // the old behaviour of descrambling with whatever key happened to be held. + // This test previously asserted DecryptFailed, matching a round-9 change + // that made real DVDs unrippable (Greenland.iso). 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. let mut sector = vec![0xFFu8; 2048]; sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5 let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let mut keys = DecryptKeys::Css { title_key }; - let err = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0) - .expect_err("an unkeyable CSS sector must fail loud"); + let dropped = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0) + .expect("a crib false positive must not fail the rip"); + assert_eq!(dropped, 0, "CSS reports no loss term of its own"); assert_eq!( - err.code(), - libfreemkv::error::Error::DecryptFailed.code(), - "CSS and AACS must give the SAME verdict for 'no provable key'" + sector[0x14] & 0x30, + 0x00, + "the sector is descrambled with the cached key, which clears the flag" ); }