keyproof: data-free unit_key_opens predicate (gated feature)

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.
This commit is contained in:
Matthew Jackson
2026-10-07 10:17:23 -07:00
parent c0be981300
commit 33155eb9bf
6 changed files with 176 additions and 1 deletions
+1
View File
@@ -22,3 +22,4 @@ CLAUDE.md
# cargo-mutants working output: large, machine-specific, never committed # cargo-mutants working output: large, machine-specific, never committed
mutants.out/ mutants.out/
mutants.out.old/ mutants.out.old/
.cargo/config.toml
+6
View File
@@ -56,6 +56,12 @@ rip = [
# `test-util`: `libfreemkv::test_util` fixtures for consumers' tests (dev-dependency # `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. # only; never in a release build). Implies the rip tree the fixtures build on.
test-util = ["rip"] 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] [dependencies]
serde = { version = "1", features = ["derive"], optional = true } serde = { version = "1", features = ["derive"], optional = true }
+98
View File
@@ -176,6 +176,104 @@ fn is_clean_ts(unit: &[u8]) -> bool {
content == 0 || synced >= content.min(KEY_PROOF_PACKETS) 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 /// 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 /// (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 /// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
+47
View File
@@ -1254,3 +1254,50 @@ fn ts_packet_total_for_various_lengths() {
// 6144 = 32 packets. // 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32); 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}"
);
}
}
@@ -42,3 +42,17 @@
//! ```compile_fail //! ```compile_fail
//! libfreemkv::aacs::content::decrypt_unit(&mut [0u8; 6144], &[0u8; 16]); //! 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 {}
+10 -1
View File
@@ -12,7 +12,7 @@
//! - [`synthetic_bd_clip`]: a muxable clear BD-TS clip (MPEG-2 + two AC-3) for goldens. //! - [`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). //! - [`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::aacs::mkb::AacsVersion;
use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES}; use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES};
use crate::disc::AacsState; 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) 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 /// 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 /// (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. /// key opens, as a drive returning the wrong bytes leaves it.