verify: post-read decrypt-verify gate + libaacs-strict verify + audit fixes

Post-read verify gate (new src/disc/verify.rs): UnitVerifier buffers/aligns the disc-absolute read stream into clip-file 6144-byte units, then makes one decryptability() decision per unit (CPI gate -> held keys -> key_fetch -> strict TS). POST_READ_VERIFY const kill-switch; fail-safe contract (only ever downgrades units it is confident are undecryptable; every doubt skips). Hooked into Disc::sweep (producer observes ciphertext -> WorkItem::MarkBad after the Good, FIFO-ordered) and Disc::patch (post-loop reverify_iso reads recovered units whole from the patched ISO). extract::clip_layouts enumerates AACS clips for the gate.

Standards-correct AACS verify: aacs::unit_is_clean_ts is a strict port of libaacs _verify_ts (all 32 TS syncs, not a majority vote); decrypt_unit accepts a key only on it; the majority verify_ts is removed. Deleted the Disc::verify_clips post-pass bolt-on (its primitive is absorbed by the read-path gate).

libaacs/DVD audit fixes: content-cert bus_encryption flag now read from bit 7 (was bit 0 - defeated the bus-key fail-loud gate); cc_id read from offset 14; title_cps_unit range-validated + 1->0 index-converted per libaacs. Corrected attack_crib ("functionally-equivalent" not "exact" port) and read_disc_key (READ DVD STRUCTURE 0xAD, not REPORT KEY) doc comments.

Also includes accumulated uncommitted work: key-fetch seam and TrueHD/DTS audio fix.
This commit is contained in:
Matthew Jackson
2026-06-28 15:03:52 -07:00
parent f49ef023cf
commit a7bd574c34
23 changed files with 3098 additions and 207 deletions
+209 -43
View File
@@ -101,24 +101,68 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
// ── Content decryption ──────────────────────────────────────────────────────
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196,
/// 388, … (one per 192-byte source packet).
///
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
/// across discs/players.
/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
/// byte-indistinguishable. So this answers only *"does this unit look like valid
/// clear TS, or not"*, nothing about encryption.
///
/// This is the single shared definition of "encrypted" for the whole ecosystem
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
/// service's validation gate all call THIS, so they always agree on what is
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
/// so the decrypt path never double-decrypts and there is no flag to clear.
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
/// the caller, because it needs context this function lacks:
/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
/// (see [`crate::decrypt::decrypt_sectors_in_content`] and
/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
/// primitive separates an encrypted/garbled unit (destroyed) from a clear
/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem
/// bytes is what produced the first-2 GB false-positive this split fixes.
///
/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or
/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably.
pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
/// this is readable WITHOUT a key:
/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
///
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
/// disc-absolute / mis-aligned read makes byte 0 arbitrary mid-stream data, so
/// the CPI bits are meaningless (which is precisely why per-unit verify must run
/// clip-anchored, not in the whole-disc sweep).
pub fn aacs_unit_encrypted(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
}
/// True when an aligned unit is flagged encrypted (CPI set) AND still looks
/// scrambled (TS syncs not yet restored) — i.e. genuine encrypted content that
/// has NOT been decrypted yet.
///
/// [`aacs_unit_encrypted`] is the authoritative spec gate, but the CPI bits live
/// in the plaintext header (bytes `0..16`) which decryption never rewrites, so a
/// successfully decrypted unit still reports CPI-set. Buffer-iterating sites that
/// may run twice over the same `buf` (the post-fetch re-decrypt, sample
/// collection, failure diagnosis) need an IDEMPOTENT "does this still need work?"
/// test, so they compose CPI with the sync-restored check: once a unit decrypts,
/// its syncs come back and it drops out. Single-shot callers that always operate
/// on fresh ciphertext (`decrypt_unit`) gate on [`aacs_unit_encrypted`] alone.
///
/// Like CPI itself this is only meaningful at the clip-FILE-anchored boundary.
pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
@@ -148,9 +192,29 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
ts_sync_count(unit) > ts_packet_total(unit) / 2
}
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
fn verify_ts(unit: &[u8]) -> bool {
ts_syncs_intact(unit)
/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
/// independent of (and not coupled to) `decrypt_unit`.
///
/// It is deliberately stricter than the majority-vote `ts_syncs_intact`
/// scramble *heuristic*: a wrong-key decrypt that coincidentally restores >16
/// syncs passes the majority test and would silently corrupt content, but fails
/// here. Non-mutating — purely a verdict; it neither decrypts nor clears the CPI
/// bits (those stay the concern of the unchanged `decrypt_unit`).
pub fn unit_is_clean_ts(unit: &[u8]) -> bool {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
let mut i = 0;
while i < ALIGNED_UNIT_LEN {
if unit[i + 4] != TS_SYNC {
return false;
}
i += BD_SOURCE_PACKET_BYTES;
}
true
}
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
@@ -170,8 +234,8 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
if !is_aacs_scrambled(unit) {
return true; // not encrypted
if !aacs_unit_encrypted(unit) {
return true; // CPI flag clear → plaintext, pass through untouched
}
// Save original first 16 bytes (they're plaintext TP_extra_header)
@@ -190,8 +254,11 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
// Step 3: Decrypt bytes 16..6143 with AES-CBC
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
verify_ts(unit)
// Decryption restored the TS syncs; accept the key only if the unit is now
// STRICTLY clean MPEG-TS (all 32 syncs) — the standards-correct gate, shared
// with the post-read verify stage. A wrong key that coincidentally restores
// a majority of syncs is rejected here, not silently accepted.
unit_is_clean_ts(unit)
}
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
@@ -206,7 +273,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
/// identical to the slow path.
///
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
/// The caller MUST pass an aligned, already-[`ts_sync_destroyed`] unit
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
/// per-candidate scramble re-scan is intentionally skipped here.
///
@@ -272,7 +339,7 @@ pub enum UnitKeyResult {
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
/// `None` if no key worked (the unit is restored to its original bytes).
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
if !is_aacs_scrambled(unit) {
if !aacs_unit_encrypted(unit) {
return Some(UnitKeyResult::AlreadyClear);
}
@@ -314,7 +381,7 @@ pub fn decrypt_unit_full(
unit_key: &[u8; 16],
read_data_key: Option<&[u8; 16]>,
) -> bool {
if !is_aacs_scrambled(unit) {
if !ts_sync_destroyed(unit) {
return true;
}
if let Some(rdk) = read_data_key {
@@ -386,7 +453,7 @@ mod tests {
off += BD_SOURCE_PACKET_BYTES;
}
let key = [0u8; 16];
assert!(!is_aacs_scrambled(&unit));
assert!(!ts_sync_destroyed(&unit));
assert!(decrypt_unit(&mut unit, &key));
}
@@ -429,9 +496,9 @@ mod tests {
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
// Exactly half intact → classified scrambled (16 > 16 is false).
assert!(is_aacs_scrambled(&set_syncs(16)));
assert!(ts_sync_destroyed(&set_syncs(16)));
// One past half → classified clear.
assert!(!is_aacs_scrambled(&set_syncs(17)));
assert!(!ts_sync_destroyed(&set_syncs(17)));
}
#[test]
@@ -447,13 +514,13 @@ mod tests {
}
assert_eq!(ts_sync_count(&clear), 32);
assert!(
!is_aacs_scrambled(&clear),
!ts_sync_destroyed(&clear),
"fully-clear unit → not scrambled"
);
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_sync_count(&scrambled), 0);
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled");
assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
}
#[test]
@@ -502,8 +569,10 @@ mod tests {
plain[offset] = TS_SYNC;
offset += BD_SOURCE_PACKET_BYTES;
}
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
// Flag the unit encrypted via the CPI bits (byte 0) — the authoritative
// gate `decrypt_unit` now consults. Set before key derivation so the
// recovered plaintext header matches.
plain[0] |= 0xC0;
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
let header: [u8; 16] = plain[..16].try_into().unwrap();
@@ -530,9 +599,9 @@ mod tests {
// Now plain contains encrypted data. Decrypt it.
let mut unit = plain;
assert!(is_aacs_scrambled(&unit));
assert!(ts_sync_destroyed(&unit));
assert!(decrypt_unit(&mut unit, &unit_key));
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored
// Verify TS sync bytes
let mut count = 0;
@@ -557,6 +626,10 @@ mod tests {
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
/// fixed AACS IV.
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
// header the real decrypt recovers matches what we encrypt under.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
@@ -721,14 +794,14 @@ mod tests {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key);
assert!(
is_aacs_scrambled(&unit),
ts_sync_destroyed(&unit),
"encrypted unit must look scrambled"
);
assert!(decrypt_unit(&mut unit, &unit_key));
// All 32 stride positions carry sync after decrypt.
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
assert!(!is_aacs_scrambled(&unit));
assert!(!ts_sync_destroyed(&unit));
}
#[test]
@@ -756,9 +829,10 @@ mod tests {
// left untouched by decrypt (only unit[16..] is CBC-processed).
let unit_key = [0x9Au8; 16];
let mut clear = clear_unit();
// Put a distinctive header so we can confirm it survives.
// Put a distinctive header so we can confirm it survives. Byte 0 carries
// both CPI bits (0xE0) so it is stable under the fixture's `|= 0xC0`.
clear[..16].copy_from_slice(&[
0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xE0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
]);
let header_before: [u8; 16] = clear[..16].try_into().unwrap();
@@ -800,7 +874,7 @@ mod tests {
Some(UnitKeyResult::DecryptedWith(2))
);
assert!(
!is_aacs_scrambled(&unit),
!ts_sync_destroyed(&unit),
"unit must be clear after the hit"
);
}
@@ -819,6 +893,59 @@ mod tests {
assert_eq!(unit, snapshot, "failed try must restore the original bytes");
}
// ── CPI gate: the authoritative encrypted-vs-clear decision ────────────
#[test]
fn cpi_gate_clear_flag_passes_through_even_when_body_looks_scrambled() {
// THE false-fail fix: a unit whose CPI bits are clear (byte 0 & 0xC0 == 0)
// is plaintext by spec, even if its body has no TS syncs (non-TS clear
// data, or a mis-probed body). `decrypt_unit` must pass it through
// untouched and report success — never attempt a decrypt that would fail.
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
// Body looks scrambled (no syncs at the stride) but byte 0 stays 0x00.
for (i, b) in unit.iter_mut().enumerate().skip(16) {
*b = (i as u8).wrapping_mul(31) | 1; // never 0x47 at the sync stride
}
assert!(ts_sync_destroyed(&unit), "body has no syncs");
assert!(!aacs_unit_encrypted(&unit), "CPI clear");
assert!(
!aacs_unit_needs_decrypt(&unit),
"CPI-clear ⇒ no decrypt attempt"
);
let snapshot = unit.clone();
// Any key: passthrough success, bytes untouched (no false DecryptFailed).
assert!(decrypt_unit(&mut unit, &[0xABu8; 16]));
assert_eq!(unit, snapshot, "CPI-clear unit must be left byte-identical");
assert_eq!(
decrypt_unit_try_keys(&mut unit, &[[0xABu8; 16]]),
Some(UnitKeyResult::AlreadyClear),
"CPI-clear unit consumes no key"
);
}
#[test]
fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() {
// A CPI-set encrypted unit decrypts with the right key. CPI lives in the
// plaintext header, so it survives decryption — `aacs_unit_encrypted`
// still reports true afterward, but `aacs_unit_needs_decrypt` flips to
// false (syncs restored), keeping the re-decrypt paths idempotent.
let key = [0x5au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body
assert!(aacs_unit_encrypted(&unit), "CPI set");
assert!(aacs_unit_needs_decrypt(&unit), "flagged + still scrambled");
assert!(decrypt_unit(&mut unit, &key), "right key decrypts");
assert!(
aacs_unit_encrypted(&unit),
"CPI bits live in the preserved header ⇒ still set post-decrypt"
);
assert!(
!aacs_unit_needs_decrypt(&unit),
"syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)"
);
}
// ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
#[test]
@@ -840,6 +967,45 @@ mod tests {
assert!(!decrypt_unit(&mut probe2, &bad));
}
#[test]
fn unit_is_clean_ts_is_strict_all_32_syncs() {
// Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
// packet syncs is required. A fully-synced clear unit passes.
let clear = clear_unit();
assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");
// Drop a SINGLE sync (packet 17 of 32). 31/32 remain, so the majority
// heuristic still passes — that is exactly the silent-corruption hole.
// The strict gate must REJECT it.
let mut one_missing = clear_unit();
one_missing[17 * BD_SOURCE_PACKET_BYTES + 4] = 0x00;
assert!(
ts_syncs_intact(&one_missing),
"majority heuristic still passes one missing sync (the hole)"
);
assert!(
!unit_is_clean_ts(&one_missing),
"strict gate rejects even one missing sync"
);
// A correctly decrypted unit is clean; a wrong-key decrypt is not.
let key = [0x33u8; 16];
let mut enc = clear_unit();
aacs_encrypt_unit(&mut enc, &key);
let mut good = enc.clone();
decrypt_unit(&mut good, &key);
assert!(unit_is_clean_ts(&good), "right-key decrypt yields clean TS");
let mut wrong = enc.clone();
decrypt_unit(&mut wrong, &[0x34u8; 16]);
assert!(
!unit_is_clean_ts(&wrong),
"wrong-key decrypt must fail the strict gate"
);
// Short buffer is never vacuously clean.
assert!(!unit_is_clean_ts(&clear[..ALIGNED_UNIT_LEN - 1]));
}
#[test]
fn unit_key_validates_is_non_mutating() {
// The accelerator must never write its input (it operates on the
@@ -947,25 +1113,25 @@ mod tests {
prev.copy_from_slice(&unit[off..off + 16]);
}
}
assert!(is_aacs_scrambled(&unit));
assert!(ts_sync_destroyed(&unit));
assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
}
// ── is_aacs_scrambled / ts_sync_count edge cases ───────────────────────
// ── ts_sync_destroyed / ts_sync_count edge cases ───────────────────────
#[test]
fn is_aacs_scrambled_false_for_sub_unit_length() {
fn ts_sync_destroyed_false_for_sub_unit_length() {
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
// shorter is reported NOT scrambled (so the decrypt gate skips it)
// rather than indexing past the end.
assert!(!is_aacs_scrambled(&[]));
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
assert!(!ts_sync_destroyed(&[]));
assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
// A scrambled-looking buffer that is one byte short is still "not
// scrambled" by the length guard.
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
almost[4] = 0x00; // no syncs
assert!(!is_aacs_scrambled(&almost));
assert!(!ts_sync_destroyed(&almost));
}
#[test]
+55 -33
View File
@@ -171,21 +171,31 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
return None;
}
// Title → CPS unit mapping
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
// index to a 0-based key index. We mirror that so the stored value is a safe,
// ready-to-use key index rather than a raw 1-based number.
let to_key_idx = |cps: u16| -> u16 {
if cps >= 1 && cps as usize <= num_uk {
cps - 1
} else {
0
}
};
let mut title_cps_unit = Vec::new();
if data.len() >= 26 {
let first_play = u16::from_be_bytes([data[20], data[21]]);
let top_menu = u16::from_be_bytes([data[22], data[23]]);
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
title_cps_unit.push(first_play);
title_cps_unit.push(top_menu);
title_cps_unit.push(to_key_idx(first_play));
title_cps_unit.push(to_key_idx(top_menu));
for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(cps);
title_cps_unit.push(to_key_idx(cps));
}
}
}
@@ -1050,22 +1060,25 @@ pub struct ContentCert {
/// Parse a Content Certificate (ContentXXX.cer) file.
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
if data.len() < 8 {
if data.len() < 20 {
return None;
}
// Content Certificate format:
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
// [1] bus_encryption_enabled (bit 0)
// [2..8] cc_id (6 bytes)
// Content Certificate layout (matches libaacs content_cert.c):
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
let bus_encryption = (data[1] & 0x01) != 0;
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
let bus_encryption = (data[1] >> 7) & 1 == 1;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[2..8]);
cc_id.copy_from_slice(&data[14..20]);
Some(ContentCert {
bus_encryption,
@@ -2314,19 +2327,26 @@ mod tests {
}
#[test]
fn test_content_cert_parse() {
// AACS 1.0 cert
let mut data = vec![0u8; 16];
// AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
// BIT 7 of byte 1, cc_id at bytes 14..20.
let mut data = vec![0u8; 20];
data[0] = 0x00; // AACS 1.0
data[1] = 0x00; // no bus encryption
data[1] = 0x00; // bus_encryption flag (bit 7) clear
data[14..20].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V10);
assert!(!cc.bus_encryption);
// AACS 2.0 with bus encryption
data[0] = 0x01; // AACS 2.0
data[1] = 0x01; // bus encryption enabled
assert_eq!(cc.cc_id, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
// AACS 2.0 with bus encryption: type 0x10, flag is BIT 7 (0x80) of byte 1
// — NOT bit 0. A cert with byte1=0x01 must therefore read as bus-OFF.
data[0] = 0x10; // AACS 2.0
data[1] = 0x80; // bus_encryption_enabled_flag = bit 7
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption);
// Regression guard: bit 0 set, bit 7 clear -> bus OFF (the old bug read this as ON).
data[1] = 0x01;
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
}
// ════════════════════════════════════════════════════════════════════
// Hardening additions
@@ -2613,29 +2633,31 @@ mod tests {
// ── Content Certificate parsing ────────────────────────────────────────
#[test]
fn parse_content_cert_rejects_short_buffer() {
// < 8 bytes → None (cc_id slice [2..8] would index OOB).
assert!(parse_content_cert(&[0x00; 7]).is_none());
// < 20 bytes → None (cc_id slice [14..20] would index OOB).
assert!(parse_content_cert(&[0x00; 19]).is_none());
assert!(parse_content_cert(&[0x00; 20]).is_some());
}
#[test]
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
// [0]=type, [1]=bus-enc bit0, [2..8]=cc_id. Any non-0x00 type → V20.
let mut data = vec![0u8; 8];
data[0] = 0x02; // not 0x00 and not 0x01 → still V20
// libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
// non-0x00 type → V20.
let mut data = vec![0u8; 20];
data[0] = 0x10; // AACS2 type marker → V20
data[1] = 0x00;
data[2..8].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
data[14..20].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20);
assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
assert!(!cc.bus_encryption);
}
#[test]
fn parse_content_cert_bus_encryption_only_reads_bit0() {
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with
// bit0 clear → false. Pins the mask, not a truthiness of the byte.
let mut data = vec![0u8; 8];
data[1] = 0x02; // bit 1 set, bit 0 clear
fn parse_content_cert_bus_encryption_reads_bit7() {
// bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
let mut data = vec![0u8; 20];
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
data[1] = 0x03; // bit 0 set
data[1] = 0x80; // bit 7 set
assert!(parse_content_cert(&data).unwrap().bus_encryption);
}
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
@@ -2732,10 +2754,10 @@ mod tests {
unit_keys: Vec::new(),
}),
};
// Content cert: AACS2 + bus encryption enabled.
let mut cc = vec![0u8; 8];
cc[0] = 0x01;
cc[1] = 0x01;
// Content cert: AACS2 (type 0x10) + bus encryption enabled (bit 7 of byte 1).
let mut cc = vec![0u8; 20];
cc[0] = 0x10;
cc[1] = 0x80;
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
+5 -4
View File
@@ -31,9 +31,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit,
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
ts_sync_count, unit_key_validates,
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ts,
unit_key_validates,
};
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we
@@ -97,7 +98,7 @@ mod tests {
// Touch a representative function from each re-export group so a
// dropped/renamed export fails to compile. These are smoke calls, not
// behavioural assertions (behaviour is covered in each module).
let _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]);
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));