fix(css): no provable key is a hard failure, matching AACS
descramble_region descrambled with the key a sector's own crib had just proven stale, whenever the re-crack from that sector also failed. The clear header is not scrambled, so it survives intact: the sector still opens with a valid pack start and passes every structural check the PS demuxer applies. Only the payload is corrupted — exactly where nothing looks. Ok(0) dropped, exit 0. CSS has no external key source. The title key comes only from cracking the data, so on a READABLE sector "no key" is not a missing input, it is recovery failing on bytes we can see. That should never happen, and when it does the answer is not to emit something. Now Error::DecryptFailed — the same verdict the AACS path already gives for a unit no held key opens. Both alternatives to failing are bad data reported as success: descrambled with a rejected key it is garbage behind a valid header, and passed through untouched it is ciphertext where plaintext is meant to be. WHY IT WAS POSSIBLE, which matters more than the fix: There is no single place that owns "what do we do when there is no key". decrypt_sectors_impl looks like the central dispatch, but its AACS arm is a `return Err` stub — AACS decrypts entirely through decrypt_sectors_mapped, a separate top-level path. So CSS decided its own policy inside css/, AACS decided in decrypt.rs and mux/resolve.rs, and nothing held them to the same answer. The asymmetry was not an oversight; it was structurally permitted. How a disc decrypts is one process — resolve a key for this data, apply it, refuse if it cannot be proven. Only the resolve-and-apply step is scheme-specific. Filed as a task: the policy belongs in one orchestrator with the schemes supplying only what genuinely differs. Two tests changed rather than added, both of which pinned the old behaviour: the unit test asserted the sector was descrambled, and the integration test asserted the scramble flag was cleared, which is what descrambling-with-any-key does. Neither established that the result was CORRECT — the fourth bad-test shape.
This commit is contained in:
+92
-3
@@ -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
|
/// 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
|
/// cached key — correct, because it lives in the same region as the nearby crib
|
||||||
/// sector that set the cache.
|
/// 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<usize> {
|
||||||
for chunk in buf.chunks_mut(2048) {
|
for chunk in buf.chunks_mut(2048) {
|
||||||
if chunk.len() < 2048 || !is_scrambled(chunk) {
|
if chunk.len() < 2048 || !is_scrambled(chunk) {
|
||||||
continue;
|
continue;
|
||||||
@@ -381,13 +397,26 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) {
|
|||||||
// Cached key is stale for this region — restore the ciphertext and
|
// Cached key is stale for this region — restore the ciphertext and
|
||||||
// crack this sector's own key.
|
// crack this sector's own key.
|
||||||
chunk.copy_from_slice(&original);
|
chunk.copy_from_slice(&original);
|
||||||
if let Some(fresh) = stevenson::crack_title_key(chunk) {
|
match stevenson::crack_title_key(chunk) {
|
||||||
|
Some(fresh) => {
|
||||||
*title_key = fresh;
|
*title_key = fresh;
|
||||||
}
|
|
||||||
lfsr::descramble_sector(title_key, chunk);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a sector has the CSS scramble flag set.
|
/// Check if a sector has the CSS scramble flag set.
|
||||||
///
|
///
|
||||||
@@ -436,6 +465,66 @@ 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
|
||||||
|
/// 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
|
/// `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);
|
/// `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.
|
/// `crack_span` is non-secret and none of its values are 213.
|
||||||
|
|||||||
+1
-2
@@ -567,8 +567,7 @@ fn decrypt_sectors_impl(
|
|||||||
// `css::descramble_region`), and CSS does not need the post-decrypt
|
// `css::descramble_region`), and CSS does not need the post-decrypt
|
||||||
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
|
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
|
||||||
// consume external inputs a `decrypt_sectors` caller cannot supply).
|
// consume external inputs a `decrypt_sectors` caller cannot supply).
|
||||||
css::descramble_region(buf, title_key);
|
css::descramble_region(buf, title_key)?
|
||||||
0
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(dropped)
|
Ok(dropped)
|
||||||
|
|||||||
+25
-10
@@ -24,20 +24,35 @@ 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 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];
|
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)
|
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
sector[0x14] |= 0x30;
|
|
||||||
|
|
||||||
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later
|
|
||||||
let mut keys = DecryptKeys::Css { title_key };
|
let mut keys = DecryptKeys::Css { title_key };
|
||||||
|
|
||||||
// Descramble (CSS uses same operation for encrypt/decrypt)
|
let err = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0)
|
||||||
libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0).unwrap();
|
.expect_err("an unkeyable CSS sector must fail loud");
|
||||||
|
assert_eq!(
|
||||||
// Flag should be cleared
|
err.code(),
|
||||||
assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared");
|
libfreemkv::error::Error::DecryptFailed.code(),
|
||||||
|
"CSS and AACS must give the SAME verdict for 'no provable key'"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: AACS unit encryption detection works.
|
/// Test: AACS unit encryption detection works.
|
||||||
|
|||||||
Reference in New Issue
Block a user