disc: reject partial CPS-unit key coverage in AACS validation gate

aligned_unit_keys_validate accepted a unit-key set as soon as ONE
scrambled sample decrypted. On a multi-CPS-unit disc a set covering
CPS unit 0 but not CPS unit 1 therefore passed: decrypt_with committed
it, the sweep proceeded, and CPS-unit-1 sectors passed through as raw
encrypted bytes into the ISO/MKV with no error surfaced anywhere.

Require every scrambled sample to be descrambled by some unit key.
A sample no key covers now fails the gate, so an incomplete set is
rejected (AacsKeyRejected) and the caller falls through to the next
candidate, ultimately surfacing a key error instead of silently
writing ciphertext. Wholly-wrong-key rejection is unchanged.

Add a regression test for the partial-coverage case.
This commit is contained in:
Matthew Jackson
2026-06-23 01:43:36 -07:00
parent f2c2ff0eb3
commit 24ed1d1d19
+80 -10
View File
@@ -1821,14 +1821,26 @@ pub enum Key {
Unit(Vec<(u32, [u8; 16])>), Unit(Vec<(u32, [u8; 16])>),
} }
/// True if `unit_keys` can de-scramble at least one of the supplied content /// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
/// `samples` — the validation gate for [`Disc::decrypt_with`]. Conservative: a /// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
/// sample that is not AACS-scrambled proves nothing, and with no scrambled /// not AACS-scrambled proves nothing, and with no scrambled sample at all there
/// sample at all there is nothing to disprove against, so it returns `true` /// is nothing to disprove against, so it returns `true` (accept).
/// (accept). It only returns `false` when a scrambled sample exists and NO unit ///
/// key restores it to clear MPEG-TS — i.e. the key is provably wrong for this /// It returns `false` when ANY scrambled sample cannot be restored to clear
/// disc. Reuses the ecosystem's single `is_aacs_scrambled` predicate and the /// MPEG-TS by ANY unit key in the set. That covers two distinct failure shapes:
/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt. /// 1. a wholly wrong key (a keydb VK that does not match this disc) — no
/// sample decrypts; and
/// 2. a *partially* applicable key set on a multi-CPS-unit disc — the resolved
/// keys cover CPS unit 0 but not CPS unit 1. Accepting on the first sample
/// that decrypts (the old behaviour) would commit such a set, after which
/// CPS-unit-1 sectors pass through as raw encrypted bytes into the ISO/MKV
/// with no error surfaced anywhere. Requiring every scrambled sample to
/// decrypt rejects the incomplete set so the caller falls through to the
/// next candidate (and ultimately surfaces a key error rather than silently
/// writing ciphertext).
///
/// Reuses the ecosystem's single `is_aacs_scrambled` predicate and the full
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate( fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])], unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>, read_data_key: Option<&[u8; 16]>,
@@ -1850,18 +1862,26 @@ fn aligned_unit_keys_validate(
let total = (scrambled.len() as u64) * (unit_keys.len() as u64); let total = (scrambled.len() as u64) * (unit_keys.len() as u64);
let mut tried = 0u64; let mut tried = 0u64;
let mut hb = crate::progress::Heartbeat::new("scan_key_trial"); let mut hb = crate::progress::Heartbeat::new("scan_key_trial");
// Every scrambled sample must be covered by SOME unit key. A single sample
// that no key descrambles means the key set is incomplete (wrong key, or a
// CPS unit left uncovered) — reject so the wrong/partial set never commits.
for sample in scrambled { for sample in scrambled {
let mut covered = false;
for (_, k) in unit_keys { for (_, k) in unit_keys {
// Pure-CPU inner loop: only consult the clock every 256 trials. // Pure-CPU inner loop: only consult the clock every 256 trials.
hb.tick_cpu(tried, total); hb.tick_cpu(tried, total);
tried += 1; tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]); probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
if decrypt_unit_full(&mut probe, k, read_data_key) { if decrypt_unit_full(&mut probe, k, read_data_key) {
return true; covered = true;
break;
} }
} }
if !covered {
return false;
}
} }
false true
} }
impl Disc { impl Disc {
@@ -3895,6 +3915,56 @@ mod tests {
assert!(!super::aligned_unit_keys_validate(&[], None, &[enc])); assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
} }
#[test]
fn unit_key_validation_rejects_partial_cps_unit_coverage() {
// Regression: a multi-CPS-unit disc. CPS unit 0's body is scrambled
// under uk0; CPS unit 1's body under uk1. A resolved key set that
// covers only CPS unit 0 used to pass validation (the old gate accepted
// on the FIRST sample any key decrypted), committing an incomplete set —
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
// ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered.
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
let uk0 = [0x11u8; 16];
let uk1 = [0x22u8; 16];
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
assert!(is_aacs_scrambled(&sample0));
assert!(is_aacs_scrambled(&sample1));
let samples = vec![sample0.clone(), sample1.clone()];
// Partial key set (CPS unit 0 only) against samples from BOTH units ->
// reject. This is the bug fix: previously this returned true.
assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)],
None,
&samples
));
// Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)],
None,
&samples
));
// Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)],
None,
&samples
));
}
/// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc /// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc
/// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS /// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the /// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the