diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e085a0d..b2decb3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [1.8.2] — Unreleased +- New non-default `keyproof` feature: `aacs::content::unit_key_opens(unit, key, format)` answers whether a key opens an encrypted BD-TS aligned unit (one bit, never plaintext). It is `false` for anything that proves nothing (other formats, short or clear units, fewer than four content packets); a wrong key passes about once in 10^5 units, so confirm a match on several. `test_util::encrypt_unit` builds ciphertext fixtures. The default build is unchanged. - Key lookup trusts the disc's declared CPS units: samples from each unit instead of 32 reads from every stream file (a 46-file UHD over USB: ~95 s → ~1.5 s). Each source is asked once; a disc no key opens is refused at once. FMTS discs included. - Key evidence reads the filesystem in batches, not one command per sector (~1 s less). - The live scan prefetches only the files it parses (~22 MB less on a UHD) and reads the MKB from a 4 MiB prefix, not 16 MiB; the MKB bytes are unchanged. diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 8163414b..bb696bdc 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -156,6 +156,12 @@ pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool { // Structural "does this unit carry enough valid MPEG-TS to prove a key opened it?" (the mux's // key-selection/verify signal): synced >= min(E, KEY_PROOF_PACKETS) over non-padding packets. fn is_clean_ts(unit: &[u8]) -> bool { + let (content, synced) = ts_packet_counts(unit); + content == 0 || synced >= content.min(KEY_PROOF_PACKETS) +} + +// `(content, synced)`: non-padding packets after packet 0, and those whose sync byte is 0x47. +fn ts_packet_counts(unit: &[u8]) -> (usize, usize) { const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 let limit = ALIGNED_UNIT_LEN.min(unit.len()); let mut content = 0usize; @@ -173,13 +179,14 @@ fn is_clean_ts(unit: &[u8]) -> bool { } off += PKT; } - content == 0 || synced >= content.min(KEY_PROOF_PACKETS) + (content, synced) } -/// Does `unit_key` open this encrypted AACS aligned `unit`? One bit, never plaintext. -/// BD-TS 6144-byte units take the decrypt-less TS-sync check (`ts_unit_key_opens`, only -/// the proof blocks are touched); Program-Stream and other lengths fall back to the full -/// [`decrypt_unit`] + [`is_clean`]. +/// Does `unit_key` open this encrypted BD-TS aligned `unit`? One bit, never plaintext. +/// `true` only for a whole 6144-byte unit flagged encrypted whose plaintext would carry +/// at least `KEY_PROOF_PACKETS` content packets, all synced: anything else proves nothing +/// and is `false` (other formats, short or clear units, too little content). A wrong key +/// passes about once in 10^5 units, so confirm a match on several units. /// /// Behind the `keyproof` feature so the default public API still cannot decrypt AACS: a /// key oracle, not a decryptor (KU §2.2; see `keys::public_api_cannot_decrypt_aacs`). @@ -190,24 +197,31 @@ pub fn unit_key_opens( format: crate::disc::ContentFormat, ) -> bool { use crate::disc::ContentFormat; - if matches!(format, ContentFormat::BdTs) && unit.len() == ALIGNED_UNIT_LEN { - return ts_unit_key_opens(unit, unit_key); + if !matches!(format, ContentFormat::BdTs) + || unit.len() != ALIGNED_UNIT_LEN + || !aacs_unit_encrypted(unit, format) + { + return false; } - // PS / non-standard length: full decrypt into a scratch copy, then structural check. - let mut scratch = unit.to_vec(); - decrypt_unit(&mut scratch, unit_key); - is_clean(&scratch, format) + let (content, synced) = ts_unit_key_proof(unit, unit_key); + content >= KEY_PROOF_PACKETS && synced >= KEY_PROOF_PACKETS } -/// BD-TS fast path of `unit_key_opens`: the same verdict as `decrypt_unit` + -/// `is_clean(BdTs)` from one AES block per source packet. CBC decrypts any block alone +/// [`is_clean_ts`]'s verdict for `decrypt_unit(unit, unit_key)`, from the packet counts of +/// `ts_unit_key_proof`. +#[cfg(test)] +fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool { + let (content, synced) = ts_unit_key_proof(unit, unit_key); + content == 0 || synced >= content.min(KEY_PROOF_PACKETS) +} + +/// The `(content, synced)` counts `ts_packet_counts` would see after `decrypt_unit`, from +/// one AES block per source packet. CBC decrypts any block alone /// (`P[i] = AES_dec(C[i]) ⊕ C[i-1]`), and packet heads sit at block-aligned offsets ≥ 192. /// Packet 0 is skipped (its sync byte is in the clear seed), as are all-zero padding -/// packets, which `decrypt_unit` zeroes. Verdict mirrors [`is_clean_ts`]: -/// `synced >= min(content, KEY_PROOF_PACKETS)`. `dead_code` is allowed so the -/// equivalence test covers it without `keyproof`. -#[allow(dead_code)] -fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool { +/// packets, which `decrypt_unit` zeroes. +#[cfg(any(test, feature = "keyproof"))] +fn ts_unit_key_proof(unit: &[u8], unit_key: &[u8; 16]) -> (usize, usize) { use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; use aes::{Aes128, Block}; const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 @@ -223,7 +237,7 @@ fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool { *b ^= s; } let cipher = Aes128::new(&bk); - // Plaintext of the 16-byte block at `o` (o ≥ 32, so C[i-1] is ciphertext). + // Plaintext of the 16-byte block at `o` (o ≥ 192, so C[i-1] is ciphertext). let plain = |o: usize| { let mut b = block_at(o); cipher.decrypt_block(&mut b); @@ -268,7 +282,7 @@ fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool { _ => content += 1, } } - content == 0 || synced >= content.min(KEY_PROOF_PACKETS) + (content, synced) } /// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride diff --git a/src/aacs/content_tests.rs b/src/aacs/content_tests.rs index 8e855e44..c5ee9279 100644 --- a/src/aacs/content_tests.rs +++ b/src/aacs/content_tests.rs @@ -1301,3 +1301,109 @@ fn ts_unit_key_opens_matches_decrypt_unit_then_is_clean() { ); } } + +fn xorshift(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +/// A flagged, encrypted BD-TS unit whose packets mix synced content, header-only and +/// zero-sync packets, zero-ciphertext padding and stray corruption, as seeded by `state`. +fn mixed_encrypted_unit(state: &mut u64, key: &[u8; 16]) -> Vec { + const PKT: usize = BD_SOURCE_PACKET_BYTES; + let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; + let mut kinds = Vec::new(); + for p in 0..ALIGNED_UNIT_LEN / PKT { + let off = p * PKT; + let kind = xorshift(state) % 5; + kinds.push(kind); + match kind { + 0 | 1 => { + unit[off + 4] = TS_SYNC; + for b in &mut unit[off + 5..off + PKT] { + *b = xorshift(state) as u8; + } + } + 2 => unit[off + 4] = TS_SYNC, + 3 => unit[off + 4 + 1 + (xorshift(state) as usize % (PKT - 5))] = 1, + _ => {} + } + } + aacs_encrypt_unit(&mut unit, key); + for (p, &kind) in kinds.iter().enumerate().skip(1) { + if kind == 4 && xorshift(state).is_multiple_of(2) { + unit[p * PKT..(p + 1) * PKT].fill(0); + } + } + if xorshift(state).is_multiple_of(4) { + let i = 16 + xorshift(state) as usize % (ALIGNED_UNIT_LEN - 16); + unit[i] ^= 1 + xorshift(state) as u8 % 255; + } + unit +} + +// Seeded differential: the fast path's packet counts and verdict match `decrypt_unit`'s over +// padding, header-only and zero-sync packets and corruption, for right and wrong keys. +#[test] +fn ts_unit_key_opens_matches_the_full_path_on_mixed_units() { + let fmt = crate::disc::ContentFormat::BdTs; + let mut state = 0x9E37_79B9_7F4A_7C15u64; + for _ in 0..400 { + let mut right = [0u8; 16]; + right + .iter_mut() + .for_each(|b| *b = xorshift(&mut state) as u8); + let enc = mixed_encrypted_unit(&mut state, &right); + let mut wrong = right; + wrong[xorshift(&mut state) as usize % 16] ^= 0x80; + for key in [right, wrong] { + let mut full = enc.clone(); + decrypt_unit(&mut full, &key); + assert_eq!(ts_unit_key_proof(&enc, &key), ts_packet_counts(&full)); + assert_eq!(ts_unit_key_opens(&enc, &key), is_clean(&full, fmt)); + } + } +} + +#[cfg(feature = "keyproof")] +#[test] +fn unit_key_opens_answers_only_for_a_provable_encrypted_bd_ts_unit() { + use crate::disc::ContentFormat::{BdTs, MpegPs}; + let right = [0x5Au8; 16]; + let mut enc = clear_unit(); + for (i, b) in enc.iter_mut().enumerate() { + if i % BD_SOURCE_PACKET_BYTES > 4 { + *b = (i as u8).wrapping_mul(7) | 1; + } + } + aacs_encrypt_unit(&mut enc, &right); + assert!(unit_key_opens(&enc, &right, BdTs)); + for n in 0..64u8 { + assert!(!unit_key_opens(&enc, &[n; 16], BdTs), "wrong key {n}"); + } + assert!( + !unit_key_opens(&enc, &right, MpegPs), + "only BD-TS is answered" + ); + let mut clear_flag = enc.clone(); + clear_flag[0] &= 0x3F; + assert!(!unit_key_opens(&clear_flag, &right, BdTs), "a clear unit"); + for len in [0, 383, ALIGNED_UNIT_LEN - 1] { + assert!(!unit_key_opens(&enc[..len], &right, BdTs), "{len} bytes"); + } + let mut zero = vec![0u8; ALIGNED_UNIT_LEN]; + zero[0] = 0xC0; + assert!( + !unit_key_opens(&zero, &right, BdTs), + "no content proves nothing" + ); + // Three content packets, the rest zero-ciphertext padding: too little to prove a key. + let mut thin = enc.clone(); + thin[4 * BD_SOURCE_PACKET_BYTES..].fill(0); + assert!( + !unit_key_opens(&thin, &right, BdTs), + "below the proof floor" + ); +} diff --git a/src/keys/public_api_cannot_decrypt_aacs.rs b/src/keys/public_api_cannot_decrypt_aacs.rs index 79b4634b..e4f9759d 100644 --- a/src/keys/public_api_cannot_decrypt_aacs.rs +++ b/src/keys/public_api_cannot_decrypt_aacs.rs @@ -50,7 +50,7 @@ //! `--features keyproof`, where the symbol is intentionally present). /// With `keyproof` OFF, `aacs::content::unit_key_opens` must not exist: -/// ```compile_fail +/// ```compile_fail,E0425 /// libfreemkv::aacs::content::unit_key_opens( /// &[0u8; 6144], &[0u8; 16], libfreemkv::disc::ContentFormat::BdTs); /// ```