keyproof: answer only for a provable encrypted BD-TS unit; differential tests

unit_key_opens is false for other formats, units that are short, clear, or carry
fewer than KEY_PROOF_PACKETS content packets (before, an empty slice or an
all-zero unit opened under any key). The fast path returns packet counts, and a
seeded differential pins them to decrypt_unit's over padding, zero-sync and
corrupted packets; it fails if either skip branch is removed. The compile_fail
door names E0425.
This commit is contained in:
Matthew Jackson
2026-10-07 11:09:36 -07:00
parent 1a17ad6c14
commit 1810161256
4 changed files with 142 additions and 21 deletions
+1
View File
@@ -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.
+34 -20
View File
@@ -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
+106
View File
@@ -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<u8> {
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"
);
}
+1 -1
View File
@@ -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);
/// ```