diff --git a/src/aacs/content.rs b/src/aacs/content.rs index d2226d0..a27aa52 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -6,11 +6,7 @@ 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; +use super::crypto::{AACS_IV, aes_cbc_decrypt, aes_ecb_encrypt}; // ── AACS constants ────────────────────────────────────────────────────────── @@ -326,19 +322,35 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { } } -/// Test-only inverse of [`decrypt_unit`]: encrypt a clear aligned unit under -/// `unit_key` and set the CPI-encrypted flag (top 2 bits of byte 0) so the unit -/// reads as encrypted under [`aacs_unit_encrypted`]. Exposed `pub(crate)` for -/// cross-module mux tests (the mux `driver.rs` builds a genuinely-AACS-encrypted -/// fixture to prove the live/session decrypt path installs its key map). Uses -/// only the module-scope primitives so it stays in lock-step with `decrypt_unit`. -#[cfg(test)] -pub(crate) fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) { +/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of +/// [`decrypt_unit`], for authoring an encrypted disc image (and for building +/// genuinely-encrypted read-path fixtures). +/// +/// PURE, on the same terms as `decrypt_unit`: it applies the key and nothing else. +/// It does NOT set the encrypted flag, because where that flag lives is +/// container-specific (CPI bits in byte 0 for BD-TS, elsewhere for HD-DVD-PS) and +/// keeping it out is what lets this stay container-agnostic. The only guard is the +/// length check, since the crypto is defined only over a whole 6144-byte unit. +/// +/// **Set the encrypted flag BEFORE calling, never after.** Bytes 0..16 are the key +/// seed and are left in plaintext, so the Block Key derives from them: mutating any +/// header byte after encrypting changes the seed a decryptor will derive from and +/// silently yields garbage. The caller's order must be flag, then encrypt. +/// +/// Block Key = AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC **encrypt** bytes +/// 16..6144 under the AACS IV — the forward direction of the same construction +/// `decrypt_unit` documents, sharing its module-scope primitives so the two cannot +/// drift apart. +/// +/// Note one deliberate asymmetry: `decrypt_unit` restores all-zero-on-disc source +/// padding packets to zero. This does not, and need not — an all-zero plaintext +/// packet encrypts to ciphertext that is not all-zero, so it is not mistaken for +/// padding on the way back and the round trip is still exact. Authoring that wants +/// true source-zero padding leaves those packets unencrypted instead. +pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { if unit.len() < ALIGNED_UNIT_LEN { return; } - // Set CPI bits BEFORE key derivation so the recovered plaintext header matches. - unit[0] |= 0xC0; let mut header = [0u8; 16]; header.copy_from_slice(&unit[..16]); let derived = aes_ecb_encrypt(unit_key, &header); @@ -382,6 +394,73 @@ mod tests { use super::*; use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly + /// [`encrypt_unit`] is the exact inverse of [`decrypt_unit`]: whatever an + /// authoring caller encrypts, the read path must recover byte-for-byte. + /// + /// Mutation: drop the trailing `⊕ header` from either function's Block Key + /// derivation, or swap `AACS_IV` for zeroes in one of them, and the two stop + /// agreeing -> this fails. + #[test] + fn encrypt_unit_is_the_exact_inverse_of_decrypt_unit() { + let key = [0x3Cu8; 16]; + // Content with no all-zero packets: every byte position exercised. + let mut clear: Vec = (0..ALIGNED_UNIT_LEN) + .map(|i| (i * 7 % 251 + 1) as u8) + .collect(); + // The encrypted flag belongs to the caller and must be set BEFORE the + // crypto, since bytes 0..16 are the key seed. + clear[0] |= 0xC0; + + let mut unit = clear.clone(); + encrypt_unit(&mut unit, &key); + assert_ne!( + unit[16..], + clear[16..], + "the payload must actually be enciphered" + ); + assert_eq!( + unit[..16], + clear[..16], + "the 16-byte seed stays plaintext on disc" + ); + + decrypt_unit(&mut unit, &key); + assert_eq!(unit, clear, "round trip must be byte-exact"); + } + + /// The documented padding asymmetry actually holds: `decrypt_unit` restores + /// all-zero-ON-DISC packets to zero, but an all-zero PLAINTEXT packet enciphers + /// to non-zero bytes, so it is not mistaken for padding and still round-trips. + /// This is the one place the two functions are deliberately not symmetric, so + /// the claim is worth pinning rather than asserting in prose alone. + #[test] + fn encrypt_unit_round_trips_all_zero_plaintext_packets() { + let key = [0xA5u8; 16]; + let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; + clear[0] |= 0xC0; // flag before crypto + // Give packet 0 some content; leave every later packet entirely zero. + for (i, b) in clear[16..192].iter_mut().enumerate() { + *b = (i % 255 + 1) as u8; + } + + let mut unit = clear.clone(); + encrypt_unit(&mut unit, &key); + // No later packet may encipher to all-zero, or decrypt would treat it as + // source padding and the asymmetry would bite. + for p in 1..ALIGNED_UNIT_LEN / BD_SOURCE_PACKET_BYTES { + let off = p * BD_SOURCE_PACKET_BYTES; + assert!( + unit[off..off + BD_SOURCE_PACKET_BYTES] + .iter() + .any(|&b| b != 0), + "packet {p} enciphered to all-zero, which decrypt reads as padding" + ); + } + + decrypt_unit(&mut unit, &key); + assert_eq!(unit, clear, "zero-payload packets must round trip exactly"); + } + #[test] fn test_aes_ecb_roundtrip() { let key = [ @@ -652,7 +731,8 @@ mod tests { fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { // Delegate to the module-scope `pub(crate)` helper (the single encrypt // implementation, shared with the mux `driver.rs` decrypt test). - super::aacs_encrypt_unit_for_test(unit, unit_key); + unit[0] |= 0xC0; + super::encrypt_unit(unit, unit_key); } /// Build a clear aligned unit with TS sync bytes at offset 4 + k*192. diff --git a/src/decrypt.rs b/src/decrypt.rs index 2cef802..e792c92 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -744,6 +744,12 @@ mod tests { /// A CLEAR trailing partial (encrypted flag NOT set) is a legitimate content /// tail and must pass through, never trip the guard above. + /// + /// "Passes through" means byte-for-byte unchanged, not merely `Ok`. Asserting + /// only `is_ok()` let a mutant that corrupts the clear partial while still + /// returning `Ok` pass — which is the whole failure this test names. + /// Mutation: XOR any byte of the tail before returning -> the snapshot + /// comparison fails. #[test] fn aacs_clear_trailing_partial_passes_through() { let keys = DecryptKeys::Aacs { @@ -755,8 +761,14 @@ mod tests { let mut tail = clear_ts_region(4096); tail[0] &= 0x3F; // ensure the CPI bits are clear buf.extend_from_slice(&tail); + let snapshot = buf.clone(); let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]); - assert!(decrypt_sectors_mapped(&mut buf, &keys, 0, &map).is_ok()); + decrypt_sectors_mapped(&mut buf, &keys, 0, &map) + .expect("a clear trailing partial is legitimate content"); + assert_eq!( + buf, snapshot, + "a clear trailing partial must pass through byte-for-byte, not just return Ok" + ); } // ── DecryptKeys::None and is_encrypted ───────────────────────────────── @@ -1048,34 +1060,12 @@ mod tests { // ── Multi-CPS-unit key selection ────────────────────────────────────── - /// Encrypt an aligned unit with the AACS algorithm run in reverse so that - /// `aacs::content::decrypt_unit` with the same key recovers the plaintext. Mirrors - /// the `aacs_encrypt_unit` helper in `aacs::content::tests`. + /// Encrypt an aligned unit so `aacs::content::decrypt_unit` with the same key + /// recovers the plaintext, flagging it encrypted first (bytes 0..16 are the key + /// seed, so the flag must be set before the crypto runs). fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; - // CPI bits on byte 0 so the unit reads as encrypted; set before deriving - // the per-unit key so the recovered plaintext header matches. unit[0] |= 0xC0; - let header: [u8; 16] = unit[..16].try_into().unwrap(); - let derived = crate::aacs::crypto::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::crypto::AACS_IV; - let num_blocks = (aacs::content::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]); - } + aacs::content::encrypt_unit(unit, unit_key); } /// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 3dd0d9c..a6e6fed 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1052,43 +1052,12 @@ mod tests { s } - /// Build a clear 6144-byte AACS unit (TS syncs at the 192-byte BD-TS - /// stride) then encrypt it under `unit_key` so `aacs::content::decrypt_unit` - /// recovers it cleanly (zero decrypt loss). Mirrors the encrypt helper in - /// `sector/decrypting.rs` tests. `tag` distinguishes two units' payloads. + /// Encrypt the clear unit from `clear_aacs_unit(tag)` under `unit_key` so + /// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss). + /// `tag` distinguishes two units' payloads. fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; - let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; - let mut off = 4; - while off < unit.len() { - unit[off] = 0x47; // TS sync - if off + 1 < unit.len() { - unit[off + 1] = tag; // payload marker so the two extents differ - } - off += 192; - } - // Flag encrypted via CPI bits (byte 0) before key derivation. - unit[0] |= 0xC0; - let header: [u8; 16] = unit[..16].try_into().unwrap(); - let derived = crate::aacs::crypto::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::crypto::AACS_IV; - let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16; - for i in 0..blocks { - let o = 16 + i * 16; - for j in 0..16 { - unit[o + j] ^= prev[j]; - } - let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]); - cipher.encrypt_block(&mut blk); - unit[o..o + 16].copy_from_slice(&blk); - prev.copy_from_slice(&unit[o..o + 16]); - } + let mut unit = clear_aacs_unit(tag); + crate::aacs::content::encrypt_unit(&mut unit, unit_key); unit } diff --git a/src/mux/driver.rs b/src/mux/driver.rs index dc1c50e..6763991 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -1523,7 +1523,9 @@ mod tests { let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es)); let mut unit = vec![0u8; 3 * 2048]; // one 6144-byte aligned unit unit[..192].copy_from_slice(&pkt); - crate::aacs::content::aacs_encrypt_unit_for_test(&mut unit, unit_key); + // Flag encrypted BEFORE encrypting: bytes 0..16 are the key seed. + unit[0] |= 0xC0; + crate::aacs::content::encrypt_unit(&mut unit, unit_key); unit } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 4820ad0..ac19776 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -2640,7 +2640,9 @@ mod tests { } off += 192; } - crate::aacs::content::aacs_encrypt_unit_for_test(&mut u, key); + // Flag encrypted BEFORE encrypting: bytes 0..16 are the key seed. + u[0] |= 0xC0; + crate::aacs::content::encrypt_unit(&mut u, key); u } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index a833e82..fcc4467 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -738,12 +738,13 @@ mod tests { assert_eq!(n, 2048, "CSS reads must not be unit-alignment gated"); } - /// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then - /// encrypt it under `unit_key` so `aacs::content::decrypt_unit` recovers it. Mirrors - /// the encrypt helper in `crate::decrypt`'s tests. - fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; + /// The clear 6144-byte AACS unit that [`encrypt_aacs_unit`] encrypts: all + /// zeroes except a TS sync `0x47` at the BD-TS stride (offset 4, then every + /// 192 bytes) and the CPI bits on byte 0. + /// + /// Exposed separately so a decrypt test can assert byte-exact recovery of the + /// known plaintext instead of only spot-checking the sync bytes. + fn clear_aacs_unit() -> Vec { let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; let mut off = 4; while off < unit.len() { @@ -752,25 +753,14 @@ mod tests { } // CPI bits on byte 0 so it reads as encrypted; set before key derivation. unit[0] |= 0xC0; - let header: [u8; 16] = unit[..16].try_into().unwrap(); - let derived = crate::aacs::crypto::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::crypto::AACS_IV; - let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16; - for i in 0..blocks { - let o = 16 + i * 16; - for j in 0..16 { - unit[o + j] ^= prev[j]; - } - let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]); - cipher.encrypt_block(&mut blk); - unit[o..o + 16].copy_from_slice(&blk); - prev.copy_from_slice(&unit[o..o + 16]); - } + unit + } + + /// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then + /// encrypt it under `unit_key` so `aacs::content::decrypt_unit` recovers it. + fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec { + let mut unit = clear_aacs_unit(); + crate::aacs::content::encrypt_unit(&mut unit, unit_key); unit } @@ -827,12 +817,14 @@ mod tests { let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; let n = dec.read_sectors(0, 3, &mut buf, false).unwrap(); assert_eq!(n, crate::aacs::content::ALIGNED_UNIT_LEN); - // Decrypted: the TS sync 0x47 reappears at the BD-TS stride (offset 4, then - // every 192 bytes). If the map/keys were wrong the bytes would stay - // ciphertext and these syncs would be absent. - for off in (4..crate::aacs::content::ALIGNED_UNIT_LEN).step_by(192) { - assert_eq!(buf[off], 0x47, "TS sync recovered at offset {off}"); - } + // The plaintext is fully known, so assert byte-exact recovery rather than + // spot-checking the TS syncs: checking only 0x47 at the 192-byte stride let + // corruption anywhere in the other 6112 bytes pass undetected. + assert_eq!( + buf, + clear_aacs_unit(), + "the decrypted unit must equal the known plaintext byte-for-byte" + ); } /// An AACS decorator built WITHOUT a key map must fail loud on the first unit —