Files
libfreemkv/src/aacs/content.rs
T
Matthew Jackson 1eb6910bdb Harden mux + decrypt paths; fail-loud on unresolvable keys
mp4 demuxer (untrusted input): bound every allocation sized from a box
field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov
sizes, plus an absolute cap so a sparse file can't inflate file_len);
guard the parse_stsd slice and a zero mdhd timescale; cap track count so
the per-track PID can't overflow; rewrite read_moov to handle size==0 /
size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write
tkhd duration in the movie timescale.

decrypt: resolve_mux_key_map now fails loud on an extent no key can
classify instead of inheriting the previous extent's key, so a keymap
never silently carries a wrong key; the sweep/patch key-fetch recovery
fails loud when a unit is still unresolved after the retry.

AACS: reject inverted forensic segments in both range builders; compare
the forensic index in u16 space so an out-of-range value can't truncate
onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone,
preserving the 30s wedge cooldown for a following hard error.

audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp;
the DTS-HD extension-sync search is bounded to after the core; the MP4
16.16 sample-rate field saturates. demux_sink records the video reference
before the kind filter so audio:// / sub:// keep multi-clip PTS continuity
and the DELAY tag.

Remove a dead error variant and the AACS-unsupported-video code; codec
comments cite the primary format specs; assorted doc/naming fixes and
regression tests throughout.
2026-07-23 12:02:43 -07:00

1335 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AACS content decryption — aligned-unit / bus decryption and TS verification.
//! The low-level AES primitives it uses live in [`super::crypto`].
#[cfg(test)]
use aes::Aes128;
#[cfg(test)]
use aes::cipher::{KeyInit, generic_array::GenericArray};
use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt};
// Available at module scope for this module's test fixtures (they reference
// `super::AACS_IV` when building CBC ciphertext directly); test-only.
#[cfg(test)]
use super::crypto::AACS_IV;
// ── AACS constants ──────────────────────────────────────────────────────────
/// Size of an AACS aligned unit (3 × 2048-byte sectors). [BD] §3.10.1.
pub const ALIGNED_UNIT_LEN: usize = 6144;
/// An AACS aligned unit spans this many 2048-byte sectors (3).
pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;
/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to
/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`,
/// NOT absolute disc LBA 0).
///
/// AACS aligned units (6144 B / 3 sectors) are anchored at the start of each
/// clip's encrypted region, so a read must begin a whole number of units past
/// that base for `decrypt_sectors` (which anchors units at buffer offset 0) to
/// align the CBC correctly. This is the SINGLE source of truth for the test —
/// the decrypt-on-read gate, the inline and highway mux read paths, and the
/// key-validation sample reader all key off this, never absolute `lba % 3`. A
/// disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate
/// (reject readable units, then report "Decryption failed") on exactly the
/// titles whose clips land off a 3-boundary.
///
/// `lba` is always `>= unit_base` by contract (a read never begins before the
/// extent base it is measured against). `saturating_sub` makes the `lba <
/// unit_base` case well-defined anyway — it clamps the offset to 0, which is a
/// unit boundary — rather than the latent `wrapping_sub` trap where an
/// underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, mis-reports the
/// alignment (e.g. `lba == unit_base - 1` would falsely read as aligned).
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
}
use crate::consts::SECTOR_BYTES;
use crate::consts::BD_SOURCE_PACKET_BYTES;
/// TS sync byte.
const TS_SYNC: u8 = 0x47;
// ── Content decryption ──────────────────────────────────────────────────────
/// HD-DVD `.evo` (MPEG-2 Program Stream) AACS-encrypted-unit flag offset & mask.
///
/// BD/UHD/FMTS flag encryption with the Copy Permission Indicator in the top 2
/// bits of byte 0 (the M2TS `TP_extra_header`). HD-DVD `.evo` is Program Stream —
/// byte 0 is a `00 00 01 BA` pack_start_code, NOT a CPI — so AACS reuses the
/// MPEG-2 `PES_scrambling_control` field instead: pack_header (14 bytes) + PES
/// start-code/`stream_id` (4) + `PES_packet_length` (2) puts the PES flags byte at
/// offset 20, with `PES_scrambling_control` in bits 5-4 (`& 0x30`). Non-zero =
/// encrypted. Derived from BackupHDDVD (`Header[20] & 0x30`) cross-checked against
/// MPEG-2 systems (ISO/IEC 13818-1).
///
/// UNVERIFIED against a real ENCRYPTED HD-DVD disc — none available to confirm
/// byte-exactly. TWO open questions a real disc must settle:
/// 1. A pack carrying an MPEG `system_header` (`00 00 01 BB`) before the PES
/// packet shifts this offset past 20.
/// 2. Whether offset 20 is even readable pre-decrypt. BD keeps only the first 16
/// bytes clear (the seed) and AES-CBC-encrypts 16..6144 — under that model
/// offset 20 is ciphertext. BackupHDDVD reading `Header[20]` pre-decrypt
/// implies HD-DVD instead encrypts PES *payloads* with *clear* pack/PES
/// headers (per-PES model). If so, `decrypt_unit`'s 16-byte-seed model also
/// would not fit HD-DVD and needs its own path. TS is unaffected either way.
const PS_SCRAMBLE_OFF: usize = 20;
const PS_SCRAMBLE_MASK: u8 = 0x30;
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal, per container.
///
/// - `BdTs` (BD / UHD / FMTS Transport Stream): the Copy Permission Indicator in
/// the top 2 bits of byte 0 — the first `TP_extra_header` byte, always left
/// clear (the first 16 bytes of every unit are the unencrypted SEED). [BD]
/// §3.10.2. `(buf[0] & 0xC0) == 0` → clear; non-zero → bytes `16..6144` are
/// AES-CBC encrypted.
/// - `MpegPs` (HD-DVD `.evo`): the MPEG-2 `PES_scrambling_control` flag — see
/// [`PS_SCRAMBLE_OFF`] (UNVERIFIED against a real encrypted disc).
///
/// Readable WITHOUT a key. CRITICAL: only meaningful when `unit` is read at the
/// correct clip-FILE-anchored boundary — a disc-absolute / mis-aligned read makes
/// the flag byte arbitrary mid-stream data (which is why per-unit checks run
/// clip-anchored, not in the whole-disc sweep).
pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
use crate::disc::ContentFormat;
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
match format {
ContentFormat::BdTs => (unit[0] & 0xC0) != 0,
// UNVERIFIED-HDDVD-DECRYPT (1 of 2): the PES_scrambling_control location
// for HD-DVD `.evo` is derived from spec, never confirmed against a real
// ENCRYPTED HD DVD (we only have decrypted rips). If HD-DVD ripping ever
// misbehaves, verify this flag byte/mask against a genuine encrypted unit.
ContentFormat::MpegPs => (unit[PS_SCRAMBLE_OFF] & PS_SCRAMBLE_MASK) != 0,
}
}
/// True when an aligned unit is flagged encrypted AND still looks scrambled
/// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted.
///
/// [`aacs_unit_encrypted`] is the authoritative gate, but the flag lives in the
/// clear header, which decryption never rewrites, so a successfully decrypted unit
/// still reports the flag. 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 the flag with a
/// "structure restored?" check that flips once decrypted: TS syncs come back for
/// `BdTs`; valid `00 00 01 BA` packs come back for `MpegPs`.
///
/// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary.
pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
// "Still needs the key applied" = flagged encrypted AND not yet structurally
// clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof
// floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second
// threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED
// unit as still-scrambled, so the mux re-sampled it to the key service every
// batch (the storm) and could re-apply the key over already-clear bytes.
aacs_unit_encrypted(unit, format) && !is_clean(unit, format)
}
/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47`
/// syncs ≈ 32 bits of MPEG-TS structure ≈ 1-in-4-billion that a wrong key (uniform
/// AES noise, `0x47` at 1/256 per packet) fakes it. It is an ABSOLUTE proof floor,
/// NOT a proportion — a unit the key opened but whose content is bad-encoded
/// (many non-conforming packets) is proven by ANY four good packets, not rejected
/// for the bad ones.
const KEY_PROOF_PACKETS: usize = 4;
/// Structural "did a key open this content unit?" — the pure, NO-CRYPTO signal
/// for key SELECTION (pick the right key among multiple on a multi-CPS disc) and
/// read VERIFY. AACS has no cryptographic "did the key work" answer (no MAC), so
/// a key is proven STRUCTURALLY: its plaintext must look like valid content for
/// the disc's container. This dispatches to the right container check by
/// `format` — BD/UHD/FMTS are Transport Stream ([`is_clean_ts`]); HD-DVD `.evo`
/// is Program Stream ([`is_clean_ps`]). NOT a decryption verdict: a correct key
/// can decrypt structurally-broken content, which is the muxer's concern.
pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
match format {
crate::disc::ContentFormat::BdTs => is_clean_ts(unit),
crate::disc::ContentFormat::MpegPs => is_clean_ps(unit),
}
}
/// Structural "does this unit carry enough valid MPEG-TS to prove a key opened
/// it?" — the Transport-Stream arm of [`is_clean`]. It is
/// NOT a decryption verdict: [`decrypt_unit`] applies a key (that is
/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question. The
/// mux never calls this — TS validity is a muxer concern, never a decrypt result.
///
/// Rule — evidence is ABSOLUTE, scaled to the packets that exist. Over the
/// ENCRYPTED packets (skip packet 0: its `0x47` sits in the clear 16-byte seed, so
/// it reads `0x47` for ANY key and is never evidence), let `E` = non-padding
/// content packets and `synced` = those carrying `0x47`. The key opened the unit
/// iff `E == 0` (nothing encrypted to prove) OR `synced >= min(E, KEY_PROOF_PACKETS)`.
/// * WRONG key → ~0 synced → fails (reaching 4 by chance ≈ 1e-5/unit, and every
/// unit of a clip would have to fluke — astronomically safe).
/// * RIGHT key, bad-encoded content → any 4 good packets pass; the bad ones are
/// the muxer's problem. (This is the false-negative the old 75% PROPORTION
/// caused — a mostly-bad unit the key opened was wrongly rejected.)
/// * `min(E, 4)` handles the end-of-clip fragment TAIL: a unit with only E=1
/// real packet (then source-zero padding) needs just that one to sync, so a
/// sparse-but-valid tail is never false-rejected. Padding (all-zero payload)
/// is excluded throughout.
fn is_clean_ts(unit: &[u8]) -> bool {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut content = 0usize;
let mut synced = 0usize;
// Skip packet 0: its sync byte lives in the clear 16-byte seed (unencrypted),
// so it is `0x47` regardless of the key and proves nothing about decryption.
let mut off = PKT;
while off + PKT <= limit {
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) {
content += 1;
if unit[off + 4] == TS_SYNC {
synced += 1;
}
}
off += PKT;
}
content == 0 || synced >= content.min(KEY_PROOF_PACKETS)
}
/// 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
/// packet; an encrypted unit, or a non-content unit decrypted under a key
/// that doesn't apply, shows ~none.
pub fn ts_sync_count(unit: &[u8]) -> usize {
let mut count = 0;
let mut offset = 4;
while offset < unit.len() {
if unit[offset] == TS_SYNC {
count += 1;
}
offset += BD_SOURCE_PACKET_BYTES;
}
count
}
/// Number of BD-TS packets in the unit — the maximum possible sync count.
pub fn ts_packet_total(unit: &[u8]) -> usize {
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
// `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the
// form `4 + k·192`.
unit.len() / BD_SOURCE_PACKET_BYTES
}
/// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): a pure structural
/// check that a unit is valid MPEG-2 PS — every 2048-byte pack begins with the
/// pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans three packs.
///
/// Like [`is_clean_ts`] this is a structural question, NOT a decryption verdict.
/// Pack 0's start sits in the clear 16-byte seed (present regardless of the key —
/// the freebie `is_clean_ts` skips at packet 0); packs 1 and 2 (offsets 2048 and
/// 4096) are in the encrypted region, so a wrong key garbles them and this returns
/// false (64 bits of discrimination). Validated against real decrypted HD-DVD
/// `.evo` (ANCHORMAN / SHAUN_OF_THE_DEAD): pack starts are exactly 2048-aligned,
/// three per aligned unit, at offsets 0 / 2048 / 4096.
///
/// UNVERIFIED-HDDVD-DECRYPT (2 of 2): the pack STRUCTURE here is confirmed on
/// decrypted rips, but that a real ENCRYPTED `.evo` decrypts to it via the same
/// 6144-byte aligned unit (16-byte seed + AES-CBC over 16..6144) as BD is the
/// unverified assumption — we have no encrypted HD DVD. If HD-DVD decryption
/// yields garbage with a known-good key, the unit granularity is the suspect.
fn is_clean_ps(unit: &[u8]) -> bool {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
// Skip pack 0 (offset 0): its start bytes lie in the clear 16-byte seed —
// present for ANY key (and byte 0 may carry the Blu-ray-style CPI bits), so
// it proves nothing about decryption. This mirrors `is_clean_ts` skipping
// packet 0. Packs 1 and 2 (offsets 2048 / 4096) are in the encrypted region
// and are what discriminate the key (64 bits of proof).
let mut o = SECTOR_BYTES;
while o + 4 <= ALIGNED_UNIT_LEN {
if unit[o..o + 4] != PACK_START {
return false;
}
o += SECTOR_BYTES; // one MPEG-2 PS pack per 2048-byte sector
}
true
}
/// Decrypt one AACS aligned unit (6144 bytes) IN PLACE — PURE crypto that applies
/// `unit_key` and leaves the plaintext. This is decryption and NOTHING else: it
/// makes no verdict about whether the result is clean TS. That is the SEPARATE
/// [`is_clean_ts`] question, which a caller composes only when it needs key
/// SELECTION (multi-CPS discs) or a read VERIFY — because AACS content has no MAC,
/// "did the key decrypt correctly?" is unanswerable by crypto; TS structure is a
/// data-quality signal, not a decrypt verdict.
///
/// PURE: this applies the key UNCONDITIONALLY (any full-length unit). It does NOT
/// check the encrypted-flag — decrypting an already-clear unit would corrupt it,
/// so the CALLER must gate on [`aacs_unit_encrypted`] (which is container-aware)
/// before calling. Lifting that gate out of the crypto keeps this function
/// container-agnostic (the flag's location differs BD-TS vs HD-DVD-PS) and true
/// to "decrypt applies the key and nothing else". The only guard kept here is the
/// length check, since the crypto is defined only over a whole 6144-byte unit.
///
/// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8: encrypt the clear
/// 16-byte seed under the CPS Unit Key, XOR the seed back in — the trailing ⊕seed
/// is load-bearing); then AES-128-CBC decrypt bytes 16..6144 under the AACS IV.
/// Source-zero padding packets (all 192 bytes zero on disc) are restored to zero:
/// their decrypted bytes are AES-noise from decrypting zeros, but the source WAS
/// zero, so writing the true source back is faithful and gives the demux a tidy
/// gap. Content packets are left EXACTLY as decrypted — the decrypt path never
/// rewrites content, so an authored-bad packet passes through verbatim.
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
if unit.len() < ALIGNED_UNIT_LEN {
return;
}
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let npkt = ALIGNED_UNIT_LEN / PKT;
let mut pad = [false; ALIGNED_UNIT_LEN / PKT];
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
let off = p * PKT;
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
}
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
let mut decrypt_key = [0u8; 16];
for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i];
}
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
if is_pad {
let off = p * PKT;
for b in unit[off..off + PKT].iter_mut() {
*b = 0;
}
}
}
}
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
if sector_start + SECTOR_BYTES > unit.len() {
break;
}
// First 16 bytes of each sector are plaintext
aes_cbc_decrypt(
read_data_key,
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
);
}
}
#[cfg(test)]
mod tests {
use super::super::crypto::aes_ecb_decrypt;
use super::*;
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
#[test]
fn test_aes_ecb_roundtrip() {
let key = [
0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
0x0B, 0x0C,
];
let plain = [0x41u8; 16];
let enc = aes_ecb_encrypt(&key, &plain);
let dec = aes_ecb_decrypt(&key, &enc);
assert_eq!(dec, plain);
}
#[test]
fn is_unit_aligned_relative_to_base() {
// Aligned at the base and every 3 sectors above it; misaligned between.
assert!(is_unit_aligned(100, 100), "base itself is aligned");
assert!(is_unit_aligned(103, 100), "one unit past base is aligned");
assert!(is_unit_aligned(106, 100));
assert!(!is_unit_aligned(101, 100));
assert!(!is_unit_aligned(102, 100));
// Non-3-aligned base: alignment is RELATIVE to the base, not absolute.
assert!(is_unit_aligned(101, 101), "non-3-aligned base is aligned");
assert!(is_unit_aligned(104, 101));
assert!(!is_unit_aligned(102, 101));
}
#[test]
fn is_unit_aligned_lba_below_base_is_well_defined() {
// Latent-trap contract (rc.5.2 audit #5): a read never starts before its
// extent base, but if `lba < unit_base` the result must be well-defined,
// NOT the `wrapping_sub` underflow that — because 2^32 ≡ 1 (mod 3) —
// would falsely report alignment. `saturating_sub` clamps to offset 0,
// which is a unit boundary, so any `lba <= unit_base` reads as aligned.
assert!(
is_unit_aligned(99, 100),
"lba just below base must not wrap"
);
assert!(is_unit_aligned(98, 100));
assert!(is_unit_aligned(0, 100));
// The specific wrapping_sub trap value: unit_base - 1. With wrapping_sub
// this is 0xFFFF_FFFF % 3 == 0 → falsely "aligned" by underflow; with
// saturating_sub it is genuinely 0 → aligned, for the right reason.
assert!(is_unit_aligned(u32::MAX, u32::MAX)); // base == lba, trivially aligned
assert!(
is_unit_aligned(0, u32::MAX),
"max base, lba 0 must saturate to 0"
);
}
#[test]
fn clear_unit_is_not_flagged_encrypted() {
// `decrypt_unit` is now PURE (applies the key unconditionally); the
// "leave a clear unit untouched" policy lives at the caller's gate,
// `aacs_unit_encrypted` / `aacs_unit_needs_decrypt`. A clear TS unit
// (byte-0 CPI bits clear, syncs intact) must report NOT-encrypted so the
// caller never hands it to decrypt_unit.
let ts = crate::disc::ContentFormat::BdTs;
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
assert!(
!aacs_unit_encrypted(&unit, ts),
"byte-0 CPI clear ⇒ not flagged encrypted"
);
assert!(
!aacs_unit_needs_decrypt(&unit, ts),
"a clear unit needs no decrypt ⇒ caller skips decrypt_unit"
);
}
#[test]
fn ts_packet_total_no_off_by_one() {
// The maximum sync count is exactly the number of stride
// positions the counting loop visits (offset 4, 196, ...), i.e.
// len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit
// the loop checks offsets 4..=5956 → 32 positions.
let unit = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_packet_total(&unit), 32);
// Confirm the loop visits exactly that many stride positions.
let visited = (4..ALIGNED_UNIT_LEN)
.step_by(BD_SOURCE_PACKET_BYTES)
.count();
assert_eq!(visited, ts_packet_total(&unit));
}
#[test]
fn is_clean_min4_proof_floor() {
// ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED
// (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build
// NON-ZERO payloads (real content, not padding) so every packet counts
// toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped).
let unit_with = |synced: usize| {
let mut unit: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 7 + 1) as u8) | 1)
.collect();
// Scrub any accidental 0x47 at a sync position, then place exactly
// `synced` real syncs in packets 1.. (skip packet 0).
let mut off = BD_SOURCE_PACKET_BYTES + 4;
let mut placed = 0;
while off < ALIGNED_UNIT_LEN {
unit[off] = if placed < synced { TS_SYNC } else { 0x46 };
placed += 1;
off += BD_SOURCE_PACKET_BYTES;
}
unit
};
// E = 31 content packets (all non-zero) → threshold min(31,4) = 4.
assert!(
!crate::aacs::content::is_clean(&unit_with(3), crate::disc::ContentFormat::BdTs),
"3 synced of a well-populated unit is below the proof floor → not clean"
);
assert!(
crate::aacs::content::is_clean(&unit_with(4), crate::disc::ContentFormat::BdTs),
"4 synced proves the key opened it, even with many bad-encoded packets"
);
// The old >50% majority would have called `unit_with(4)` scrambled (4/31
// < half) — that false-flag was the mux key-server storm. min(E,4) fixes it.
}
#[test]
fn scramble_detection_extremes() {
// A fully-clear unit (every packet synced) is clean; a fully-scrambled
// unit (non-zero ciphertext, NO syncs) is not. (An all-zero buffer is
// empty padding — E==0 — which `is_clean` treats as clean, NOT scrambled.)
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert!(
crate::aacs::content::is_clean(&clear, crate::disc::ContentFormat::BdTs),
"fully-clear unit → clean"
);
// Real scrambled ciphertext: non-zero everywhere, no 0x47 at any sync slot.
let mut scrambled: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 13 + 3) as u8) | 1)
.collect();
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
if scrambled[off] == TS_SYNC {
scrambled[off] = 0x46;
}
off += BD_SOURCE_PACKET_BYTES;
}
assert!(
!crate::aacs::content::is_clean(&scrambled, crate::disc::ContentFormat::BdTs),
"non-zero body with no syncs → scrambled"
);
}
#[test]
fn test_aes_cbc_roundtrip() {
let key = [
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let original = vec![0x42u8; 128]; // 8 blocks
let mut data = original.clone();
// Encrypt with CBC manually (forward direction)
fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut prev = super::AACS_IV;
let num_blocks = data.len() / 16;
for i in 0..num_blocks {
let offset = i * 16;
for j in 0..16 {
data[offset + j] ^= prev[j];
}
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
cipher.encrypt_block(&mut block);
data[offset..offset + 16].copy_from_slice(&block);
prev.copy_from_slice(&data[offset..offset + 16]);
}
}
aes_cbc_encrypt(&key, &mut data);
assert_ne!(data, original); // should be different after encrypt
super::aes_cbc_decrypt(&key, &mut data);
assert_eq!(data, original); // should match after roundtrip
}
#[test]
fn test_decrypt_unit_synthetic() {
// Build a fake 6144-byte aligned unit with known TS sync pattern,
// encrypt it with the AACS algorithm, then decrypt and verify.
let unit_key = [0xAAu8; 16];
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
let mut plain = vec![0u8; ALIGNED_UNIT_LEN];
let mut offset = 4;
while offset < ALIGNED_UNIT_LEN {
plain[offset] = TS_SYNC;
offset += BD_SOURCE_PACKET_BYTES;
}
// 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();
let derived = aes_ecb_encrypt(&unit_key, &header);
let mut encrypt_key = [0u8; 16];
for i in 0..16 {
encrypt_key[i] = derived[i] ^ header[i];
}
// CBC encrypt bytes 16..6143
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
let mut prev = AACS_IV;
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
plain[off + j] ^= prev[j];
}
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
cipher.encrypt_block(&mut block);
plain[off..off + 16].copy_from_slice(&block);
prev.copy_from_slice(&plain[off..off + 16]);
}
// Now plain contains encrypted data. Decrypt it.
let mut unit = plain;
assert!(!crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
decrypt_unit(&mut unit, &unit_key);
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
)); // decrypted: TS syncs restored
// Verify TS sync bytes
let mut count = 0;
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
if unit[off] == TS_SYNC {
count += 1;
}
off += BD_SOURCE_PACKET_BYTES;
}
// Assert against the single canonical packet count, not the old
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
assert_eq!(count, ts_packet_total(&unit));
}
// ── Helpers for the hardening tests below ──────────────────────────────
/// Encrypt an aligned unit in place with the AACS unit-decrypt
/// algorithm run in reverse, so [`decrypt_unit`] with the same
/// `unit_key` recovers the plaintext. This is the exact inverse of
/// the production decrypt: derive `decrypt_key = AES-ECB-E(unit_key,
/// 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];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = AACS_IV;
let num_blocks = (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 at offset 4 + k*192.
fn clear_unit() -> Vec<u8> {
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
unit
}
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
//
// A content fragment can end mid-unit, with the disc zero-padding the rest
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
// — its real packets decrypt; the source-zero tail is padding, not content
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
// genuine misread / wrong key). The discriminator is the SOURCE bytes of the
// failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data.
/// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from
/// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a
/// non-zero `fill` models a corrupt/misread tail.
fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec<u8> {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, unit_key);
for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = fill;
}
unit
}
#[test]
fn decryptable_full_content_unit_under_correct_key() {
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
decrypt_unit(&mut unit, &key);
assert_eq!(
ts_sync_count(&unit),
32,
"the right key recovered the plaintext (all 32 syncs restored)"
);
}
#[test]
fn fragment_tail_with_source_zero_pad_is_decryptable() {
// 11 real content packets, then source-zero padding (the Dunkirk shape).
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0x00);
decrypt_unit(&mut unit, &key);
for p in 0..11 {
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"content pkt {p} restored its sync"
);
}
// Padding emitted as clean zeros, not decrypted garbage.
for p in 11..32 {
let off = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[off..off + BD_SOURCE_PACKET_BYTES]
.iter()
.all(|&b| b == 0),
"padding pkt {p} zeroed"
);
}
}
#[test]
fn fragment_tail_with_nonzero_garbage_decrypts_the_real_prefix() {
// Same shape, but the tail is NON-zero garbage. The crypto still recovers
// the 11 real packets; the garbage tail is whatever it is (the muxer drops
// it on sync-loss, and a genuine bad sector is caught by the physical read
// layer / mapfile — never by TS structure). Ground truth: the 11 real
// packets came back.
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0xC3);
decrypt_unit(&mut unit, &key);
for p in 0..11 {
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"real content pkt {p} decrypted"
);
}
}
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
//
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
// that single packet, so the mux concealed ~all of it as NULL and 21/22 good
// video packets were destroyed and tallied as loss ("false corruption").
// The read/decrypt path must instead ACCEPT the unit (the key opened it) and
// pass the defective packet through VERBATIM for the muxer to drop. TS-sync
// conformance is a muxer concern, never a decryption verdict.
/// Build a unit that decrypts to 32 content packets, with `defect_pkts`
/// marked authored-bad (a non-`0x47` at the sync position + non-zero payload
/// so they count as genuine content, not padding), encrypted under `key`.
fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec<u8> {
let mut unit = clear_unit();
for &p in defect_pkts {
let off = p * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // NOT a TS sync
unit[off + 5] = 0xAB; // non-zero payload => real content, not padding
}
aacs_encrypt_unit(&mut unit, key);
unit
}
/// A POST-DECRYPT-looking unit for the key-independent [`is_clean_ts`]: `e`
/// ENCRYPTED content packets carrying non-zero payload, `synced` of them with
/// `0x47`; the rest is source-zero padding. Content is placed at packets 1..
/// because [`is_clean_ts`] SKIPS packet 0 (its sync lives in the clear seed and
/// is never evidence), so `e`/`synced` map directly to what it measures. CPI
/// (encrypted flag) set iff `cpi`.
fn decrypted_shape(e: usize, synced: usize, cpi: bool) -> Vec<u8> {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
for i in 0..e {
let off = (i + 1) * BD_SOURCE_PACKET_BYTES; // packets 1.. (skip seed pkt 0)
u[off + 5] = 0xAB; // non-zero payload => counted as content
u[off + 4] = if i < synced { TS_SYNC } else { 0x80 };
}
if cpi {
u[0] |= 0xC0;
}
u
}
#[test]
fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() {
// 1 defective content packet in an otherwise-perfect unit (the real case).
let key = [0x5Au8; 16];
let mut unit = unit_with_defects(&key, &[17]);
decrypt_unit(&mut unit, &key);
let off = 17 * BD_SOURCE_PACKET_BYTES;
assert_eq!(
unit[off + 4],
0x80,
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
);
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
for p in 0..32 {
if p == 17 {
continue;
}
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"every other packet restored its sync (pkt {p})"
);
}
}
#[test]
fn several_defect_packets_decrypt_the_good_ones() {
// Ground truth: the right key recovers every non-defect packet's sync; the
// authored-bad packets pass through verbatim (the muxer drops them).
let key = [0x33u8; 16];
let defects = [3usize, 9, 17, 24, 30];
let mut unit = unit_with_defects(&key, &defects);
decrypt_unit(&mut unit, &key);
for p in 0..32 {
if defects.contains(&p) {
continue;
}
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"non-defect pkt {p} decrypted"
);
}
}
#[test]
fn wrong_key_does_not_recover_the_plaintext() {
// Ground truth: a wrong key produces bytes that are NOT the plaintext.
let clear = clear_unit();
let mut unit = clear.clone();
aacs_encrypt_unit(&mut unit, &[0x5Au8; 16]);
decrypt_unit(&mut unit, &[0x22u8; 16]);
assert_ne!(unit, clear, "a wrong key does not recover the plaintext");
}
#[test]
fn key_proof_floor_is_four_synced_on_a_full_unit() {
// ABSOLUTE proof floor: >=4 synced ENCRYPTED packets opens a full unit;
// <4 does not — regardless of how many others are bad-encoded.
assert!(
is_clean_ts(&decrypted_shape(31, 4, false)),
"4 synced -> opened"
);
assert!(
!is_clean_ts(&decrypted_shape(31, 3, false)),
"3 synced -> below the proof floor -> not opened"
);
}
#[test]
fn all_padding_unit_is_trivially_opened() {
// CPI set but every packet is source-zero padding: nothing to decrypt.
assert!(is_clean_ts(&decrypted_shape(0, 0, true)));
}
#[test]
fn is_clean_dispatches_by_container_format() {
use crate::disc::ContentFormat;
// Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA`
// at each 2048-byte pack boundary (0/2048/4096) — the layout validated
// against real decrypted ANCHORMAN / SHAUN_OF_THE_DEAD `.evo`. Offset 0 is
// the clear-seed freebie; the packs at 2048/4096 are encrypted and are
// what actually discriminate a key.
let mut ps = vec![0u8; ALIGNED_UNIT_LEN];
for off in [0usize, 2048, 4096] {
ps[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
}
assert!(
is_clean(&ps, ContentFormat::MpegPs),
"clean PS opens for MpegPs"
);
assert!(
!is_clean(&ps, ContentFormat::BdTs),
"PS content has no 0x47 TS syncs -> not clean as TS"
);
// Wrong key garbles the ENCRYPTED packs (2048/4096); offset 0 stays a pack
// start (it is the clear seed) but that alone proves nothing -> rejected.
let mut wrong = ps.clone();
wrong[2048] = 0xFF;
wrong[4096] = 0xFF;
assert!(
!is_clean(&wrong, ContentFormat::MpegPs),
"garbled encrypted packs -> wrong key rejected"
);
// A clean BD Transport-Stream unit opens for BdTs, not MpegPs.
let ts = decrypted_shape(31, 31, false);
assert!(
is_clean(&ts, ContentFormat::BdTs),
"clean TS opens for BdTs"
);
assert!(
!is_clean(&ts, ContentFormat::MpegPs),
"TS content has no `00 00 01 BA` packs -> not clean as PS"
);
}
#[test]
fn ps_container_encrypt_detection_and_idempotency() {
use crate::disc::ContentFormat;
let ps = ContentFormat::MpegPs;
// Base clean PS unit: pack_start_code at each 2048 boundary.
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
for off in [0usize, 2048, 4096] {
clear[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
}
// PES scrambling_control (byte 20, bits 5-4) == 0 → not encrypted.
assert!(
!aacs_unit_encrypted(&clear, ps),
"scrambling_control clear ⇒ not encrypted"
);
// Encrypted + still-scrambled: flag set, packs 1/2 garbled (would be
// ciphertext on a real disc) → is_clean_ps false → needs decrypt.
let mut enc = clear.clone();
enc[PS_SCRAMBLE_OFF] |= 0x10; // PES_scrambling_control = 01
enc[2048] = 0xFF;
enc[4096] = 0xFF;
assert!(
aacs_unit_encrypted(&enc, ps),
"scrambling_control set ⇒ encrypted"
);
assert!(
aacs_unit_needs_decrypt(&enc, ps),
"flagged + packs not restored ⇒ needs decrypt"
);
// Decrypted: the flag survives (it is in the preserved header) but packs
// are valid → needs_decrypt flips false (idempotent re-decrypt).
let mut dec = clear.clone();
dec[PS_SCRAMBLE_OFF] |= 0x10;
assert!(
aacs_unit_encrypted(&dec, ps),
"flag survives decryption (header preserved)"
);
assert!(
!aacs_unit_needs_decrypt(&dec, ps),
"valid packs restored ⇒ no re-decrypt (idempotent)"
);
}
#[test]
fn sparse_tail_needs_all_present_packets_min_e_4() {
// End-of-clip fragment tail: `min(E,4)` scales to the packets that exist,
// so a sparse unit needs ALL of its (few) encrypted packets — a wrong key
// gives 0, so this is a strong proof with no wrong-key hole, and a valid
// 1-packet tail is never false-rejected.
assert!(
is_clean_ts(&decrypted_shape(1, 1, false)),
"E=1: the one packet syncs -> open"
);
assert!(
!is_clean_ts(&decrypted_shape(1, 0, false)),
"E=1: 0 synced (wrong key) -> not"
);
assert!(
is_clean_ts(&decrypted_shape(3, 3, false)),
"E=3: all 3 sync -> open"
);
assert!(
!is_clean_ts(&decrypted_shape(3, 2, false)),
"E=3: min(3,4)=3 -> 2 synced is not enough"
);
// The threshold boundary: E=4 needs all 4 (min(4,4)=4); E=5 needs only 4
// of 5 (min(5,4)=4) — the transition from "need all E" to "need exactly 4".
assert!(
is_clean_ts(&decrypted_shape(4, 4, false)),
"E=4: 4/4 -> open"
);
assert!(
!is_clean_ts(&decrypted_shape(4, 3, false)),
"E=4: 3/4 -> below min(4,4)=4"
);
assert!(
is_clean_ts(&decrypted_shape(5, 4, false)),
"E=5: 4/5 -> open (min(5,4)=4, the floor caps at 4)"
);
assert!(
!is_clean_ts(&decrypted_shape(5, 3, false)),
"E=5: 3/5 -> below the 4 floor"
);
}
#[test]
fn fragment_tail_padding_plus_one_defect_still_decrypts() {
// Both tolerances at once: source-zero padding tail EXCLUDED, and the one
// authored-bad packet in the real prefix TOLERATED. 20 real packets
// (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
let off = 5 * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // defect inside the real content
unit[off + 5] = 0xAB;
for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = 0; // source-zero padding tail (packets 20..32)
}
aacs_encrypt_unit(&mut unit, &key);
decrypt_unit(&mut unit, &key);
assert_eq!(
unit[off + 4],
0x80,
"the defect packet passes through verbatim"
);
for p in 20..32 {
let o = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0),
"padding pkt {p} emitted as clean zeros"
);
}
}
#[test]
fn wrong_key_full_unit_does_not_recover_plaintext() {
let clear = clear_unit();
let mut unit = clear.clone();
aacs_encrypt_unit(&mut unit, &[0x11u8; 16]);
decrypt_unit(&mut unit, &[0x22u8; 16]);
assert_ne!(
unit, clear,
"a wrong key on a full content unit does not recover the plaintext"
);
}
#[test]
fn cpi_clear_unit_reports_not_encrypted() {
// CPI-clear (plaintext) unit: the caller's gate `aacs_unit_encrypted`
// reports NOT-encrypted, so it is never handed to the now-pure
// `decrypt_unit` (which would otherwise corrupt it by applying a key).
let unit = clear_unit(); // byte 0 high bits clear
assert!(
!aacs_unit_encrypted(&unit, crate::disc::ContentFormat::BdTs),
"CPI-clear ⇒ caller does not decrypt it"
);
}
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
#[test]
fn aes_ecb_matches_fips197_known_answer() {
// FIPS-197 Appendix C.1 AES-128 KAT:
// key = 000102030405060708090a0b0c0d0e0f
// plaintext = 00112233445566778899aabbccddeeff
// ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a
// This pins the AES primitive against a published vector — a wrong
// cipher (or a key/plaintext byte-order slip) fails it.
let key = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
let pt = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let expected = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
0xC5, 0x5A,
];
assert_eq!(aes_ecb_encrypt(&key, &pt), expected);
// And decrypt is the exact inverse.
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
}
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
#[test]
fn cbc_decrypt_first_block_xors_aacs_iv() {
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
// constant (not zero). Encrypt a single block forward with IV, then
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
// makes the recovered block wrong.
let key = [0x24u8; 16];
let plain = [0x5Au8; 16];
// Forward CBC for one block: C = AES-E(K, P XOR IV).
let mut x = plain;
for j in 0..16 {
x[j] ^= AACS_IV[j];
}
let ct = aes_ecb_encrypt(&key, &x);
let mut buf = ct;
aes_cbc_decrypt(&key, &mut buf);
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
}
// ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ───────────────
#[test]
fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() {
// NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector:
// Key = 2b7e151628aed2a6abf7158809cf4f3c
// IV = 000102030405060708090a0b0c0d0e0f
// CT = 7649abac8119b246cee98e9b12e9197d (block 0)
// 5086cb9b507219ee95db113a917678b2 (block 1)
// 73bed6b8e3c1743b7116e69e22229516 (block 2)
// 3ff1caa1681fac09120eca307586e1a7 (block 3)
// PT = 6bc1bee22e409f96e93d7e117393172a (block 0)
// ae2d8a571e03ac9c9eb76fac45af8e51 (block 1)
// 30c81c46a35ce411e5fbc1191a0a52ef (block 2)
// f69f2445df4f9b17ad2b417be66c3710 (block 3)
//
// `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never
// takes a caller IV), so:
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
// pins the real reverse-order CBC chaining against a published KAT.
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
// XOR AACS_IV — the documented IV substitution. Asserting this exact
// relation pins both the AES decrypt of C[0] AND that block 0 uses
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
let key = [
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
0x4F, 0x3C,
];
let nist_iv = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
// Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's
// 32-nibble-per-line heuristic doesn't flag these published vectors as
// key material (same layout the existing FIPS-197 / CMAC KATs use).
let ciphertext: [u8; 64] = [
0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9,
0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A,
0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16,
0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09,
0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7,
];
let nist_plaintext: [u8; 64] = [
0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93,
0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC,
0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB,
0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17,
0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10,
];
let mut buf = ciphertext;
aes_cbc_decrypt(&key, &mut buf);
// Blocks 1..=3: exact match against the published NIST plaintext.
assert_eq!(
&buf[16..64],
&nist_plaintext[16..64],
"CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext"
);
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
let mut expected_block0 = [0u8; 16];
for i in 0..16 {
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
}
assert_eq!(
&buf[0..16],
&expected_block0,
"block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)"
);
}
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
#[test]
fn decrypt_unit_roundtrip_restores_all_syncs() {
// Encrypt a clear unit, confirm it reads as scrambled, then decrypt
// and confirm every TS sync byte at the 192-byte stride is restored.
let unit_key = [0x37u8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key);
assert!(
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled"
);
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!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
}
#[test]
fn decrypt_unit_wrong_key_does_not_recover_plaintext() {
// A wrong unit key leaves the body scrambled — the plaintext is NOT
// recovered. Grounds the brute-force gate: a bad key must not look right.
let good = [0x11u8; 16];
let bad = [0x22u8; 16];
let clear = clear_unit();
let mut unit = clear.clone();
aacs_encrypt_unit(&mut unit, &good);
decrypt_unit(&mut unit, &bad);
assert_ne!(unit, clear, "a wrong key does not recover the plaintext");
}
#[test]
fn decrypt_unit_ignores_short_unit() {
// unit.len() < ALIGNED_UNIT_LEN → no-op (no panic on the 16.. slice).
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
let before = short.clone();
decrypt_unit(&mut short, &[0u8; 16]);
assert_eq!(short, before, "a short unit is left untouched (no panic)");
}
#[test]
fn decrypt_unit_only_touches_bytes_16_onward() {
// The first 16 bytes are the plaintext TP_extra header and must be
// 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. Byte 0 carries
// both CPI bits (0xE0) so it is stable under the fixture's `|= 0xC0`.
clear[..16].copy_from_slice(&[
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();
let mut unit = clear;
aacs_encrypt_unit(&mut unit, &unit_key);
// Encryption also leaves the header untouched (only 16.. is encrypted).
assert_eq!(&unit[..16], &header_before);
decrypt_unit(&mut unit, &unit_key);
assert_eq!(
&unit[..16],
&header_before,
"header bytes must be preserved"
);
}
// ── CPI gate: the authoritative encrypted-vs-clear decision ────────────
#[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 ts = crate::disc::ContentFormat::BdTs;
let key = [0x5au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body
assert!(aacs_unit_encrypted(&unit, ts), "CPI set");
assert!(
aacs_unit_needs_decrypt(&unit, ts),
"flagged + still scrambled"
);
decrypt_unit(&mut unit, &key);
assert!(
aacs_unit_encrypted(&unit, ts),
"CPI bits live in the preserved header ⇒ still set post-decrypt"
);
assert!(
!aacs_unit_needs_decrypt(&unit, ts),
"syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)"
);
}
// ── bus decryption (AACS 2.0 / UHD) ────────────────────────────────────
#[test]
fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() {
// Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector
// (3 sectors per aligned unit), leaving the first 16 plaintext. Build
// the forward transform, then confirm decrypt_bus inverts it and
// leaves each sector's first 16 bytes untouched.
let rdk = [0x13u8; 16];
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
// Fill with a recognisable pattern.
for (i, b) in unit.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
let plain = unit.clone();
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
let mut prev = AACS_IV;
let body = s + 16;
let end = s + SECTOR_BYTES;
let nblocks = (end - body) / 16;
for i in 0..nblocks {
let off = body + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut blk);
unit[off..off + 16].copy_from_slice(&blk);
prev.copy_from_slice(&unit[off..off + 16]);
}
}
assert_ne!(unit, plain, "forward bus-encrypt must change the body");
decrypt_bus(&mut unit, &rdk);
assert_eq!(
unit, plain,
"decrypt_bus must invert per-sector bus encrypt"
);
// Each sector's first 16 bytes equal the original (never touched).
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
}
}
// ── is_clean / ts_sync_count edge cases ────────────────────────────────
#[test]
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!(crate::aacs::content::is_clean(
&[],
crate::disc::ContentFormat::BdTs
));
assert!(crate::aacs::content::is_clean(
&vec![0u8; ALIGNED_UNIT_LEN - 1],
crate::disc::ContentFormat::BdTs
));
// 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!(crate::aacs::content::is_clean(
&almost,
crate::disc::ContentFormat::BdTs
));
}
#[test]
fn ts_sync_count_only_samples_the_192_byte_stride() {
// A 0x47 placed OFF the stride (e.g. offset 5) must not be counted —
// the detector samples exactly offset 4, 196, 388, ... A mutation that
// scanned every byte would over-count and misclassify scrambled units.
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
unit[5] = TS_SYNC; // off-stride
unit[197] = TS_SYNC; // off-stride
assert_eq!(ts_sync_count(&unit), 0, "off-stride 0x47 must not count");
unit[4] = TS_SYNC; // on-stride
assert_eq!(ts_sync_count(&unit), 1);
}
#[test]
fn ts_packet_total_for_various_lengths() {
// total = len / 192 (BD-TS packet size). Pin a few lengths.
assert_eq!(ts_packet_total(&[0u8; 192]), 1);
assert_eq!(ts_packet_total(&[0u8; 384]), 2);
assert_eq!(ts_packet_total(&[0u8; 191]), 0);
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
}