From 33155eb9bfb12b027dad19e4f52aa31330073005 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:17:23 -0700 Subject: [PATCH] keyproof: data-free unit_key_opens predicate (gated feature) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `aacs::content::unit_key_opens(unit, key, format) -> bool` behind a new non-default `keyproof` feature — one bit (key right/wrong), never plaintext — for a private offline key-resolver (kdb). BD-TS per-packet fast reject, with a decrypt_unit + is_clean fallback for PS/odd units. A not(keyproof)-gated compile_fail door in keys/public_api_cannot_decrypt_aacs pins its absence by default, so the default public API still cannot decrypt AACS (KU §2.2). Also expose test_util::encrypt_unit alongside decrypt_unit for consumer fixtures. Equivalence test pins the fast path to decrypt_unit + is_clean. Version left to the release owner. --- .gitignore | 1 + Cargo.toml | 6 ++ src/aacs/content.rs | 98 ++++++++++++++++++++++ src/aacs/content_tests.rs | 47 +++++++++++ src/keys/public_api_cannot_decrypt_aacs.rs | 14 ++++ src/test_util.rs | 11 ++- 6 files changed, 176 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 61c6efa1..1c32b837 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ CLAUDE.md # cargo-mutants working output: large, machine-specific, never committed mutants.out/ mutants.out.old/ +.cargo/config.toml diff --git a/Cargo.toml b/Cargo.toml index 443a6be3..b9e15ab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,12 @@ rip = [ # `test-util`: `libfreemkv::test_util` fixtures for consumers' tests (dev-dependency # only; never in a release build). Implies the rip tree the fixtures build on. test-util = ["rip"] +# `keyproof`: exposes `aacs::content::unit_key_opens` — a data-free key→content +# ORACLE (one bit, never plaintext) for a PRIVATE offline key-resolver (kdb). +# NOT in `default`, so the open public API still cannot decrypt AACS (KU §2.2; +# the `keys::public_api_cannot_decrypt_aacs` guard pins its absence by default). +# Needs the `rip` crypto tree (`aes`). +keyproof = ["rip"] [dependencies] serde = { version = "1", features = ["derive"], optional = true } diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 0ed524a3..a5ee87da 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -176,6 +176,104 @@ fn is_clean_ts(unit: &[u8]) -> bool { content == 0 || synced >= content.min(KEY_PROOF_PACKETS) } +/// Does `unit_key` open this encrypted AACS aligned `unit`? Returns one bit — +/// key right or wrong — and NEVER plaintext. The single authoritative answer to +/// "did this candidate key prove out against a sample unit", shared by the mux's +/// key selection/verify and by an offline key-resolver (kdb's `/decode`). BD-TS +/// 6144-byte units take the decrypt-less TS-sync fast reject ([`ts_unit_key_opens`], +/// only the proof blocks are touched); Program-Stream and non-standard lengths +/// fall back to the full [`decrypt_unit`] + [`is_clean`]. +/// +/// Gated behind the `keyproof` feature so the DEFAULT public API still cannot +/// decrypt AACS — this is a key→content ORACLE (one bit), not a decryptor, and +/// it is a private-consumer seam, not part of the open surface +/// (KU §2.2; see `keys::public_api_cannot_decrypt_aacs`). +#[cfg(feature = "keyproof")] +pub fn unit_key_opens(unit: &[u8], unit_key: &[u8; 16], 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); + } + // 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) +} + +/// BD-TS fast path of [`unit_key_opens`]: the SAME verdict as `decrypt_unit` + +/// `is_clean(BdTs)`, from one AES block per source packet instead of the whole +/// 383-block CBC chain — the hot path of a 100k-candidate brute. CBC decrypts +/// any block alone (`P[i] = AES_dec(C[i]) ⊕ C[i-1]`); packet heads sit at 192-byte +/// (block-aligned) offsets ≥ 192, so `C[i-1]` is always real ciphertext. Packet 0 +/// is skipped: its sync byte lives in the clear 16-byte seed and proves nothing. +/// `decrypt_unit` zeroes all-zero-ciphertext (padding) packets, so those are +/// skipped here identically. Verdict mirrors [`is_clean_ts`]: +/// `synced >= min(content, KEY_PROOF_PACKETS)`. `#[allow(dead_code)]` so the +/// equivalence test covers it even in a build without `keyproof`. +#[allow(dead_code)] +fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool { + use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; + use aes::{Aes128, Block}; + const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 + const NPKT: usize = ALIGNED_UNIT_LEN / PKT; + debug_assert_eq!(unit.len(), ALIGNED_UNIT_LEN); + let block_at = |o: usize| -> Block { Array::try_from(&unit[o..o + 16]).expect("16-byte block") }; + + // Block key = AES-128E(unit_key, seed) ⊕ seed — `decrypt_unit`'s derivation. + let mut bk = block_at(0); + Aes128::new(&(*unit_key).into()).encrypt_block(&mut bk); + for (b, s) in bk.iter_mut().zip(&unit[..16]) { + *b ^= s; + } + let cipher = Aes128::new(&bk); + // Plaintext of the 16-byte block at `o` (o ≥ 32, so C[i-1] is ciphertext). + let plain = |o: usize| { + let mut b = block_at(o); + cipher.decrypt_block(&mut b); + for (x, c) in b.iter_mut().zip(&unit[o - 16..o]) { + *x ^= c; + } + b + }; + + // Batch the head block of every non-padding packet (the backend pipelines it). + let mut offs = [0usize; NPKT]; + let mut heads = [Block::default(); NPKT]; + let mut n = 0; + for p in 1..NPKT { + let off = p * PKT; + if unit[off..off + PKT].iter().all(|&b| b == 0) { + continue; // padding — decrypt_unit restores it to zeros: not content + } + offs[n] = off; + heads[n] = block_at(off); + n += 1; + } + cipher.decrypt_blocks(&mut heads[..n]); + + let (mut content, mut synced) = (0usize, 0usize); + for (head, &off) in heads[..n].iter().zip(&offs[..n]) { + match head[4] ^ unit[off - 16 + 4] { + TS_SYNC => { + content += 1; + synced += 1; + } + 0 => { + // Sync byte decrypted to 0x00: content only if the rest of the + // payload isn't all zero too. + let first = plain(off); + let nonzero = first[4..].iter().any(|&b| b != 0) + || (1..PKT / 16).any(|j| plain(off + 16 * j).iter().any(|&b| b != 0)); + if nonzero { + content += 1; + } + } + _ => content += 1, + } + } + 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 diff --git a/src/aacs/content_tests.rs b/src/aacs/content_tests.rs index c1e1de92..9d9d3ec3 100644 --- a/src/aacs/content_tests.rs +++ b/src/aacs/content_tests.rs @@ -1254,3 +1254,50 @@ fn ts_packet_total_for_various_lengths() { // 6144 = 32 packets. assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32); } + +// The decrypt-less BD-TS fast path `ts_unit_key_opens` (the brute's hot reject, +// moved down from kdb) must return the EXACT same verdict as the full +// `decrypt_unit` + `is_clean(BdTs)`, for the RIGHT key and for wrong keys alike — +// or `unit_key_opens` would silently disagree with a real decrypt. This pins the +// two together so a change to either side can't desync them. +#[test] +fn ts_unit_key_opens_matches_decrypt_unit_then_is_clean() { + let fmt = crate::disc::ContentFormat::BdTs; + // A content unit: TS sync at each packet's offset 4 + non-zero payload (so no + // packet is all-zero ciphertext padding once encrypted). + let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; + let mut off = 4; + while off < ALIGNED_UNIT_LEN { + clear[off] = TS_SYNC; + for j in 1..(BD_SOURCE_PACKET_BYTES - 4) { + clear[off + j] = (j as u8).wrapping_mul(7).wrapping_add(1); + } + off += BD_SOURCE_PACKET_BYTES; + } + let right = [0x5Au8; 16]; + let mut enc = clear.clone(); + aacs_encrypt_unit(&mut enc, &right); + + let full = |key: &[u8; 16]| -> bool { + let mut u = enc.clone(); + decrypt_unit(&mut u, key); + is_clean(&u, fmt) + }; + + assert!(full(&right), "sanity: the right key opens via the full path"); + assert_eq!( + ts_unit_key_opens(&enc, &right), + full(&right), + "fast path must agree with decrypt+is_clean for the RIGHT key" + ); + for n in 0..64u8 { + let mut wrong = [0u8; 16]; + wrong[0] = n.wrapping_add(1); + wrong[15] = n ^ 0xA5; + assert_eq!( + ts_unit_key_opens(&enc, &wrong), + full(&wrong), + "fast path disagreed with decrypt+is_clean for wrong key {n}" + ); + } +} diff --git a/src/keys/public_api_cannot_decrypt_aacs.rs b/src/keys/public_api_cannot_decrypt_aacs.rs index 0f0f518a..79b4634b 100644 --- a/src/keys/public_api_cannot_decrypt_aacs.rs +++ b/src/keys/public_api_cannot_decrypt_aacs.rs @@ -42,3 +42,17 @@ //! ```compile_fail //! libfreemkv::aacs::content::decrypt_unit(&mut [0u8; 6144], &[0u8; 16]); //! ``` +//! +//! `aacs::content::unit_key_opens` is a `keyproof`-gated key→content oracle, so +//! the DEFAULT public API carries no "did this key open this unit" answer. The +//! door below is itself gated to `not(keyproof)` so it asserts absence only in a +//! build where the oracle is meant to be absent (and does not false-fail under +//! `--features keyproof`, where the symbol is intentionally present). + +/// With `keyproof` OFF, `aacs::content::unit_key_opens` must not exist: +/// ```compile_fail +/// libfreemkv::aacs::content::unit_key_opens( +/// &[0u8; 6144], &[0u8; 16], libfreemkv::disc::ContentFormat::BdTs); +/// ``` +#[cfg(not(feature = "keyproof"))] +pub(crate) mod keyproof_gated_off {} diff --git a/src/test_util.rs b/src/test_util.rs index c5cb8e64..4a4f5b74 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -12,7 +12,7 @@ //! - [`synthetic_bd_clip`]: a muxable clear BD-TS clip (MPEG-2 + two AC-3) for goldens. //! - [`FakeTransport`]: a scripted drive with a state model for Stop tests (stop §5.0). -use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit}; +use crate::aacs::content::ALIGNED_UNIT_LEN; use crate::aacs::mkb::AacsVersion; use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES}; use crate::disc::AacsState; @@ -412,6 +412,15 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { crate::aacs::content::decrypt_unit(unit, unit_key) } +/// Encrypt one 6144-byte aligned unit in place with a CPS unit key — the exact +/// inverse of [`decrypt_unit`]. The test-side door for building ciphertext +/// fixtures, so no consumer test needs the internal one. Returns `false` if the +/// slice is too short to encrypt. +#[must_use] +pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { + crate::aacs::content::encrypt_unit(unit, unit_key) +} + /// Overwrite the first sector of an aligned unit with garbage that keeps the encrypted flag /// (CPI 11₂, KS-5) but not the seed's TS sync at byte 4 (KS-2, KS-4): read damage that no /// key opens, as a drive returning the wrong bytes leaves it.