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.
110 lines
3.9 KiB
Rust
110 lines
3.9 KiB
Rust
//! Regression tests for Pass N (patch) fix — decrypt key inversion bug.
|
|
//!
|
|
//! Tests that decrypt_sectors is invoked correctly when opts.decrypt=true.
|
|
//! The 2026-05-03 bug at `libfreemkv/src/disc/mod.rs:1938-1942` inverted
|
|
//! the decrypt key arms, causing patch to pass DecryptKeys::None on encrypted discs.
|
|
|
|
use libfreemkv::{aacs, decrypt::DecryptKeys};
|
|
|
|
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
|
|
#[test]
|
|
fn decrypt_sectors_with_none_keys_is_noop() {
|
|
let mut sector = vec![0x42u8; 2048];
|
|
|
|
let mut keys = DecryptKeys::None;
|
|
let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0);
|
|
|
|
assert!(result.is_ok());
|
|
assert_eq!(
|
|
§or[..],
|
|
&[0x42u8; 2048][..],
|
|
"DecryptKeys::None should not modify buffer"
|
|
);
|
|
}
|
|
|
|
/// Test: decrypt_sectors with CSS keys descrambles sectors.
|
|
#[test]
|
|
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 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.
|
|
//
|
|
// 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 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 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!(
|
|
sector[0x14] & 0x30,
|
|
0x00,
|
|
"the sector is descrambled with the cached key, which clears the flag"
|
|
);
|
|
}
|
|
|
|
/// Test: AACS unit encryption detection works.
|
|
#[test]
|
|
fn aacs_encryption_flag_detection() {
|
|
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
|
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
let mut off = 4;
|
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
|
unit[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
|
assert!(aacs::content::is_clean(
|
|
&unit,
|
|
libfreemkv::disc::ContentFormat::BdTs
|
|
));
|
|
|
|
// Flag bits on a synced unit do not make it look encrypted.
|
|
unit[0] = 0xC0;
|
|
unit[7] = 0xC0;
|
|
assert!(aacs::content::is_clean(
|
|
&unit,
|
|
libfreemkv::disc::ContentFormat::BdTs
|
|
));
|
|
|
|
// Scrambled body (syncs gone) → encrypted.
|
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
assert!(!aacs::content::is_clean(
|
|
&scrambled,
|
|
libfreemkv::disc::ContentFormat::BdTs
|
|
));
|
|
}
|
|
|
|
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
|
#[test]
|
|
fn decrypt_keys_is_encrypted_variants() {
|
|
let none = DecryptKeys::None;
|
|
assert!(!none.is_encrypted());
|
|
|
|
let aacs = DecryptKeys::Aacs {
|
|
unit_keys: vec![],
|
|
read_data_key: None,
|
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
|
};
|
|
assert!(aacs.is_encrypted());
|
|
|
|
let css = DecryptKeys::Css {
|
|
title_key: [0u8; 5],
|
|
};
|
|
assert!(css.is_encrypted());
|
|
}
|