v1.0.0-rc.1

CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
This commit is contained in:
Matthew Jackson
2026-06-21 21:06:07 -07:00
parent e8bb6225ac
commit 5941c059c6
53 changed files with 7439 additions and 2340 deletions
+191 -24
View File
@@ -165,7 +165,10 @@ impl DecryptKeys {
/// For CSS: processes per 2048-byte sector.
/// For None: no-op.
///
/// `unit_key_idx` selects which AACS unit key to use (0 for most discs).
/// `unit_key_idx` is the initial AACS unit-key hint (0 for most discs). On a
/// multi-CPS-unit disc every key is tried per unit until the TS-sync verify
/// passes; `unit_key_idx` is tried first so single-CPS-unit discs pay zero
/// overhead. An out-of-range `unit_key_idx` is always an error.
///
/// Returns `Err` if decryption was expected but keys are missing or invalid.
/// Never produces silently corrupted output.
@@ -180,12 +183,15 @@ pub fn decrypt_sectors(
unit_keys,
read_data_key,
} => {
let uk = match unit_keys.get(unit_key_idx) {
Some((_, k)) => *k,
None => {
return Err(crate::error::Error::DecryptFailed);
}
};
// Validate that unit_key_idx is in-range before doing anything else.
// This preserves the existing contract: an out-of-range explicit index
// is always an error (tested by `aacs_out_of_range_unit_key_idx_errors`).
if unit_keys.get(unit_key_idx).is_none() {
return Err(crate::error::Error::DecryptFailed);
}
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
@@ -229,18 +235,60 @@ pub fn decrypt_sectors(
let nthreads = decrypt_threads();
let nunits = buf.len() / unit_len;
// Per-unit decrypt closure. The is_aacs_scrambled check reads the
// raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look
// scrambled and trigger a decrypt attempt, so on a verify miss we
// snapshot+restore the original bytes so it survives. See test
// `nav_file_unit_survives_decrypt_attempt`.
// Cache the last successfully-validated key index so that runs of
// units under the same CPS unit hit on the first try. Initialised to
// unit_key_idx (the caller's hint — 0 for almost all discs). An
// AtomicUsize lets the parallel path share it cheaply; relaxed
// ordering is fine because a stale read just causes one extra try,
// never a wrong result (TS-sync verify gates correctness).
let last_key_idx = AtomicUsize::new(unit_key_idx);
// Per-unit decrypt closure. For a scrambled full aligned unit:
// 1. Try the cached key index first (avoids scanning all keys on the
// common case where a disc run uses one CPS unit throughout).
// 2. On miss, try every key in order (multi-CPS-unit discs).
// 3. Accept the first key whose output passes the TS-sync verify.
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
// genuine decrypt failure). See test
// `nav_file_unit_survives_decrypt_attempt`.
//
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) {
let original: Vec<u8> = chunk.to_vec();
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
chunk.copy_from_slice(&original);
if chunk.len() != unit_len || !aacs::is_aacs_scrambled(chunk) {
return;
}
// Save original bytes so we can restore if no key validates.
let original: Vec<u8> = chunk.to_vec();
// Build a bus-decrypted copy to try unit keys against, or work
// in-place when there is no bus layer.
if let Some(ref rdk_key) = rdk {
aacs::decrypt_bus(chunk, rdk_key);
}
// Reorder the key iterator: try the cached hint first, then fall
// back to the full list skipping the hint.
let hint = last_key_idx.load(Ordering::Relaxed);
let try_order =
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
for idx in try_order {
if let Some(key) = raw_keys.get(idx) {
// Work on a per-key copy so a failing attempt doesn't
// clobber the bus-decrypted base we'll retry on.
let mut attempt: Vec<u8> = chunk.to_vec();
if aacs::decrypt_unit(&mut attempt, key) {
chunk.copy_from_slice(&attempt);
last_key_idx.store(idx, Ordering::Relaxed);
return;
}
}
}
// No key validated — restore the original encrypted bytes.
chunk.copy_from_slice(&original);
};
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
@@ -460,19 +508,18 @@ mod tests {
// ── CSS dispatch (DecryptKeys::Css) ────────────────────────────────────
/// Build a CSS-scrambled 2048-byte sector by XORing the descramble
/// keystream over a known plaintext body (the keystream XOR is its own
/// inverse), with the scramble flag restored so decrypt_sectors will
/// re-descramble it back to the plaintext.
/// Build a CSS-scrambled 2048-byte sector by scrambling a known plaintext
/// body with the exact inverse of `descramble_sector`, so decrypt_sectors
/// will descramble it back to the plaintext. The content cipher applies
/// TAB1 to the ciphertext (`plain = TAB1[cipher] ^ ks`), so it is NOT a
/// self-inverse XOR — `scramble_sector` is the true inverse and sets the
/// scramble flag.
fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec<u8>, Vec<u8>) {
let mut sector = vec![body_fill; 2048];
sector[0x14] = 0x30; // scramble flag (bits 4-5)
sector[0x54..0x59].copy_from_slice(seed);
let plaintext = sector.clone();
// First descramble XORs the keystream in (producing "ciphertext"); it
// clears the flag, so restore it for the round-trip via decrypt_sectors.
css::lfsr::descramble_sector(title_key, &mut sector);
sector[0x14] = 0x30;
css::lfsr::scramble_sector(title_key, &mut sector);
(sector, plaintext)
}
@@ -608,6 +655,126 @@ mod tests {
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
}
// ── Multi-CPS-unit key selection ──────────────────────────────────────
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
/// `aacs::decrypt_unit` with the same key recovers the plaintext. Mirrors
/// the `aacs_encrypt_unit` helper in `aacs::decrypt::tests`.
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut block);
unit[off..off + 16].copy_from_slice(&block);
prev.copy_from_slice(&unit[off..off + 16]);
}
}
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
/// (offset 4 + k*192) so `is_aacs_scrambled` reports false and
/// `decrypt_unit` verifies it as clear after decryption.
fn clear_ts_unit() -> Vec<u8> {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
unit
}
/// A unit encrypted under unit_keys[1] (the second CPS unit) on a
/// two-key disc must be correctly decrypted — not left as garbage —
/// when `decrypt_sectors` is called with unit_key_idx=0 (the default).
///
/// Before the fix, `decrypt_one` used only `unit_keys[unit_key_idx]`
/// (i.e. always key 0). On a multi-CPS-unit disc this produced silent
/// garbage for content under key ≥ 1. The fix tries every key and
/// accepts the one whose output passes the TS-sync verify.
///
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
/// garbled bytes that still look scrambled, failing the `!is_aacs_scrambled`
/// assert.
#[test]
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
let key0 = [0x11u8; 16]; // CPS unit 0 key — NOT the correct key for this unit
let key1 = [0x22u8; 16]; // CPS unit 1 key — the correct key
// Build and encrypt a clear unit under key1 (the non-default CPS unit).
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
aacs::is_aacs_scrambled(&unit),
"encrypted unit must look scrambled before decrypt"
);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None,
};
// Call with the default hint (idx 0) — the fix must fall back to key1.
let mut buf = unit;
decrypt_sectors(&mut buf, &keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
!aacs::is_aacs_scrambled(&buf),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
"all TS sync bytes must be restored after decrypting under key1"
);
}
/// Single-key disc: the common case is unaffected — the single key is
/// tried first (via the hint) and validates, so no second-pass overhead.
///
/// Grounding: the `hint = last_key_idx.load(…)` path returns on the first
/// `try_order` iteration. A regression that always tried all keys (instead
/// of accepting the first hit) would still pass this test — correctness is
/// the invariant here, not the performance shortcut.
#[test]
fn aacs_single_key_disc_still_decrypts_correctly() {
let key = [0x55u8; 16];
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
};
let mut buf = unit;
decrypt_sectors(&mut buf, &keys, 0).expect("single-key disc must decrypt");
assert!(
!aacs::is_aacs_scrambled(&buf),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
"all TS sync bytes must be restored for single-key disc"
);
}
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
/// The default (auto) decrypt thread count is always a usable pool size: