mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user