Promote AACS unit encryption to real API, and assert decrypt byte-exactly
encrypt_unit becomes public library API rather than a #[cfg(test)] helper.
Authoring an encrypted disc image is a legitimate use of this crate, and
the capability was already written four times over: a pub(crate) test-only
copy in aacs/content.rs plus three hand-rolled duplicates in decrypt.rs,
sector/decrypting.rs and disc/extract.rs. All four now call one function,
removing ~110 lines of duplicated cipher code that could drift from
decrypt_unit independently.
It mirrors decrypt_unit's purity contract: crypto only, no encrypted-flag
handling, because where that flag lives is container-specific (CPI bits in
byte 0 for BD-TS, elsewhere for HD-DVD-PS). Callers set the flag BEFORE
encrypting — bytes 0..16 are the key seed left in plaintext, so touching a
header byte afterwards changes the key a decryptor derives. That footgun is
documented at the function and at every call site.
Two tests pin it: an exact round trip through both directions, and the one
place the pair is deliberately asymmetric — decrypt_unit restores
all-zero-on-disc packets to zero, and the test proves an all-zero plaintext
packet enciphers to non-zero bytes so it is never mistaken for padding.
That asymmetry was previously only prose.
Two decrypt tests were also weaker than their own names:
* aacs_clear_trailing_partial_passes_through asserted only is_ok(), so a
mutant corrupting the clear partial while returning Ok passed. It now
snapshots the buffer and asserts byte equality, matching the
none_keys_is_noop pattern already in the file.
* aacs_decorator_decrypts_encrypted_unit_via_map checked only that 0x47
reappeared at the 192-byte stride, leaving corruption in the other 6112
bytes undetected. The plaintext is fully known, so it now asserts
byte-exact recovery against it.
Both were verified red first by mutating the production path.
This commit is contained in:
+96
-16
@@ -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<u8> = (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.
|
||||
|
||||
+17
-27
@@ -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
|
||||
|
||||
+5
-36
@@ -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<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+22
-30
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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 —
|
||||
|
||||
Reference in New Issue
Block a user