fix(css): revert the hard-fail — it made real DVDs unrippable

I broke DVD ripping earlier today and the real-media acceptance gate
caught it on its first full run. Greenland.iso failed with E7013
"Decryption failed"; reverting only this change made it rip clean in 8
seconds. That is a regression I introduced, not a pre-existing defect.

WHAT I GOT WRONG.

Round 9's crypto lens reported that descramble_region "descrambles with
a key it just proved wrong" when the crib check rejects the cached key
and the re-crack also fails. I agreed, and made it Error::DecryptFailed
to match the AACS path, on the reasoning that CSS has no external key
source so a failed crack on a readable sector should never happen.

The premise was wrong. `attack_crib` is a HEURISTIC, not a proof: 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 for a CORRECT key — and the re-crack then fails
BECAUSE the crib was never valid. So crib mismatch plus crack failure is
the signature of a crib false positive, not of a stale key. The cached
key is not proven wrong; it remains the best available evidence, and on
a real DVD it is very probably right. Real discs hit this constantly.

The deeper error was treating "no key" as one thing across schemes. An
AACS unit key either opens a unit or it does not — the Verify-Media-Key
relation decides it, and a wrong key is provable. A CSS title key is
recovered from the data itself by an attack whose success varies sector
by sector, so "the crack failed here" says something about THIS SECTOR's
plaintext, not about the key. Unifying the policy was right for the
schemes that can prove a key wrong. CSS cannot, and I folded it in
anyway.

decrypt_span keeps its shape and the cross-scheme test keeps its two
AACS arms, with CSS now explicitly excluded and the reason stated.

Three tests asserted the wrong behaviour and are corrected, including
one I rewrote earlier today to pin exactly this. Every one of them
passed the whole time the code was broken — because none of them had
ever seen a real disc.

The lesson is the one I kept stating and then did not act on: 3,013 unit
tests, ~400 mutants killed and nine audit rounds did not catch this, and
one acceptance run did. Synthetic media cannot reproduce what a real
disc does.
This commit is contained in:
Matthew Jackson
2026-07-30 22:07:32 -07:00
parent 42591c77fc
commit 7d48d820e5
3 changed files with 81 additions and 80 deletions
+9 -18
View File
@@ -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(),