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:
+54
-46
@@ -403,14 +403,29 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro
|
|||||||
lfsr::descramble_sector(title_key, chunk);
|
lfsr::descramble_sector(title_key, chunk);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// No provable key. `chunk` already holds the restored
|
// The re-crack found nothing. Descramble with the CACHED
|
||||||
// ciphertext; fail rather than emit it descrambled with a
|
// key anyway — it is the best available evidence, and this
|
||||||
// key this sector's own crib just rejected.
|
// is very probably still the right key.
|
||||||
tracing::error!(
|
//
|
||||||
target: "css",
|
// `attack_crib` is a heuristic, not a proof. It finds a
|
||||||
"css: cached title key stale and re-crack failed on a readable sector"
|
// periodic run in the unscrambled header and predicts that
|
||||||
);
|
// the run continues past 0x80. When that prediction does
|
||||||
return Err(crate::error::Error::DecryptFailed);
|
// 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 super::*;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// A sector whose cached key is provably stale and whose own re-crack fails
|
/// A crib mismatch whose re-crack fails keeps the CACHED key and
|
||||||
/// must FAIL, not emit data.
|
/// descrambles with it — it does NOT fail the rip.
|
||||||
///
|
///
|
||||||
/// The clear header (`<0x80`) is not scrambled, so it survives a wrong-key
|
/// `attack_crib` is a heuristic: it finds a periodic run in the
|
||||||
/// descramble intact: the sector still opens with a valid pack start and
|
/// unscrambled header and predicts the run continues past 0x80. When that
|
||||||
/// passes every structural check the PS demuxer applies. Only the PES
|
/// prediction does not hold, the crib reports a mismatch even though the
|
||||||
/// payload is corrupted, which is exactly where nothing looks. Leaving it
|
/// cached key is correct, and the re-crack then fails BECAUSE the crib was
|
||||||
/// CSS has no external key source, so on a readable sector this is recovery
|
/// never valid. So this combination is the signature of a crib false
|
||||||
/// failing on data we can see — the same condition AACS treats as
|
/// positive, not of a stale key, and the cached key remains the best
|
||||||
/// `DecryptFailed` rather than applying a neighbouring unit's key.
|
/// 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]
|
#[test]
|
||||||
fn a_sector_with_no_provable_key_fails_instead_of_emitting_data() {
|
fn a_crib_false_positive_keeps_the_cached_key_rather_than_failing() {
|
||||||
// Header periodic enough to yield a crib, so the cached key IS validated
|
// Header periodic enough to yield a crib, body random enough that no
|
||||||
// (a crib-less sector rides the cache by design and is not this case).
|
// LFSR seed reproduces it — crib mismatch, re-crack fails.
|
||||||
let mut sector = [0u8; 2048];
|
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) {
|
for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) {
|
||||||
*b = (i % 4) as u8;
|
*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) {
|
for (i, b) in sector.iter_mut().enumerate().skip(0x80) {
|
||||||
*b = ((i * 37 + 11) % 251) as u8;
|
*b = ((i * 37 + 11) % 251) as u8;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(is_scrambled(§or), "fixture must be a scrambled sector");
|
||||||
is_scrambled(§or),
|
|
||||||
"fixture must actually be a scrambled sector, or descramble_region \
|
|
||||||
skips it and this test proves nothing"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
stevenson::attack_crib(§or).is_some(),
|
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!(
|
assert!(
|
||||||
stevenson::crack_title_key(§or).is_none(),
|
stevenson::crack_title_key(§or).is_none(),
|
||||||
"fixture must be uncrackable, or the failure branch is never entered"
|
"fixture must be uncrackable, or the failure branch is never entered"
|
||||||
);
|
);
|
||||||
|
|
||||||
let before = sector;
|
let key_before = [0xAAu8; 5];
|
||||||
let mut key = [0xAAu8; 5];
|
let mut key = key_before;
|
||||||
let err = descramble_region(&mut sector, &mut key)
|
let out = descramble_region(&mut sector, &mut key)
|
||||||
.expect_err("an unprovable key must fail, not emit data");
|
.expect("a crib false positive must NOT fail the rip");
|
||||||
|
|
||||||
assert!(
|
assert_eq!(out, 0, "CSS reports no loss term of its own");
|
||||||
matches!(err, Error::DecryptFailed),
|
|
||||||
"must be the same verdict the AACS path gives for an unopenable unit, \
|
|
||||||
got {err:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sector, before,
|
key, key_before,
|
||||||
"the sector must be left untouched; descrambling it with the stale key \
|
"a failed re-crack must leave the cached key in place — it is still \
|
||||||
would leave the clear header intact and corrupt only the payload, \
|
the best evidence, and overwriting it would poison every later sector"
|
||||||
which passes every structural check downstream"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
key, [0xAAu8; 5],
|
|
||||||
"a failed re-crack must not overwrite the cached key"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-18
@@ -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
|
/// 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 —
|
/// 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
|
/// Asserting one verdict across the schemes is what makes a future
|
||||||
/// divergence a test failure rather than a silent corruption. A per-scheme
|
/// divergence a test failure rather than a silent corruption. A per-scheme
|
||||||
/// test cannot do that: each would still pass while the two disagreed.
|
/// 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]
|
#[test]
|
||||||
fn every_scheme_gives_the_same_verdict_when_no_key_can_be_proven() {
|
fn every_scheme_gives_the_same_verdict_when_no_key_can_be_proven() {
|
||||||
use crate::disc::ContentFormat;
|
use crate::disc::ContentFormat;
|
||||||
@@ -1496,27 +1504,10 @@ mod tests {
|
|||||||
let aacs_unmapped = decrypt_span(&mut buf, &mut aacs_keys, 0, Some(&empty), None)
|
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");
|
.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();
|
let want = crate::error::Error::DecryptFailed.code();
|
||||||
for (what, e) in [
|
for (what, e) in [
|
||||||
("AACS, no map", aacs_no_map),
|
("AACS, no map", aacs_no_map),
|
||||||
("AACS, unit outside every range", aacs_unmapped),
|
("AACS, unit outside every range", aacs_unmapped),
|
||||||
("CSS, re-crack failed", css),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
e.code(),
|
e.code(),
|
||||||
|
|||||||
+18
-16
@@ -24,34 +24,36 @@ fn decrypt_sectors_with_none_keys_is_noop() {
|
|||||||
|
|
||||||
/// Test: decrypt_sectors with CSS keys descrambles sectors.
|
/// Test: decrypt_sectors with CSS keys descrambles sectors.
|
||||||
#[test]
|
#[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
|
// 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
|
// one, so the crib check rejects it and the re-crack from this synthetic
|
||||||
// body finds nothing.
|
// body finds nothing.
|
||||||
//
|
//
|
||||||
// CSS has no external key source: the title key comes only from cracking
|
// That combination does NOT fail the rip. `attack_crib` is a heuristic: it
|
||||||
// the data. So "no key" on a readable sector is recovery failing on bytes
|
// predicts that a periodic header run continues past 0x80, and when that
|
||||||
// we can see, not a missing input — the same condition AACS answers with
|
// prediction does not hold it reports a mismatch even for a CORRECT key —
|
||||||
// DecryptFailed rather than applying a neighbouring unit's key. Emitting
|
// whereupon the re-crack fails because the crib was never valid. Crib
|
||||||
// the sector either way is bad data reported as success: descrambled with
|
// mismatch plus crack failure is the signature of a crib false positive,
|
||||||
// the rejected key it is garbage behind an intact clear header, and passed
|
// and the cached key stays the best available evidence.
|
||||||
// through untouched it is ciphertext where plaintext is meant to be.
|
|
||||||
//
|
//
|
||||||
// This test previously asserted the scramble flag was cleared, which pinned
|
// This test previously asserted DecryptFailed, matching a round-9 change
|
||||||
// the old behaviour of descrambling with whatever key happened to be held.
|
// 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];
|
let mut sector = vec![0xFFu8; 2048];
|
||||||
sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5
|
sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5
|
||||||
|
|
||||||
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
let mut keys = DecryptKeys::Css { title_key };
|
let mut keys = DecryptKeys::Css { title_key };
|
||||||
|
|
||||||
let err = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0)
|
let dropped = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0)
|
||||||
.expect_err("an unkeyable CSS sector must fail loud");
|
.expect("a crib false positive must not fail the rip");
|
||||||
|
assert_eq!(dropped, 0, "CSS reports no loss term of its own");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.code(),
|
sector[0x14] & 0x30,
|
||||||
libfreemkv::error::Error::DecryptFailed.code(),
|
0x00,
|
||||||
"CSS and AACS must give the SAME verdict for 'no provable key'"
|
"the sector is descrambled with the cached key, which clears the flag"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user