From 3546648faa973f206d9f88f6b8af52b90accff85 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:03:31 -0700 Subject: [PATCH] aacs: redact Debug for ResolvedChain/ResolvedKeys/ProcessingKeyMatch (test-guarded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These carry raw unit-key / VUK / processing-key bytes on their Debug; manual impls print shape only (unit_keys_len, redacted markers). Each has a red→green test. --- src/aacs/derive.rs | 36 +++++++++++++++++++++++++++++++++++- src/aacs/resolve.rs | 36 +++++++++++++++++++++++++++++++++++- src/aacs/variant.rs | 31 ++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index cc8da51..b0f9c37 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -465,7 +465,7 @@ pub enum KeyCandidate { /// its declared CPS-unit number); the caller runs /// `decrypt_unit` + `is_clean_ts` to find which one actually opens the /// disc. Rungs above the candidate are `None`. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ResolvedChain { pub unit_keys: Vec<(u32, [u8; 16])>, pub vuk: Option, @@ -475,6 +475,21 @@ pub struct ResolvedChain { pub dk: Option, } +// Redacting `Debug`: `unit_keys` holds raw title-key bytes, never printed. The +// other rungs are `types` newtypes that self-redact. Guarded by +// `resolved_chain_debug_is_redacted`. +impl std::fmt::Debug for ResolvedChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolvedChain") + .field("unit_keys_len", &self.unit_keys.len()) + .field("vuk", &self.vuk) + .field("mk", &self.mk) + .field("pk", &self.pk) + .field("dk", &self.dk) + .finish() + } +} + /// Derive the full AACS key chain from a candidate key of ANY ladder rung. /// /// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys: @@ -568,6 +583,25 @@ mod resolve_candidate_tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; + /// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are + /// self-redacting `types` newtypes). `Debug` must not leak the title keys. + #[test] + fn resolved_chain_debug_is_redacted() { + let c = ResolvedChain { + unit_keys: vec![(1, [0xD5; 16])], + vuk: None, + mk: None, + pk: None, + dk: None, + }; + let dbg = format!("{c:?}"); + assert!(!dbg.contains("213"), "ResolvedChain leaked unit keys: {dbg}"); + assert!( + dbg.contains("unit_keys_len"), + "ResolvedChain missing redaction: {dbg}" + ); + } + /// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted /// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n. fn synth_inf(encs: &[[u8; 16]]) -> Vec { diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 48f54c6..a02ccf2 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -13,7 +13,6 @@ use super::mkb::*; // ── Full VUK resolution chain ─────────────────────────────────────────────── /// Result of resolving a disc's VUK. -#[derive(Debug)] pub struct ResolvedKeys { /// Disc hash (SHA1 of Unit_Key_RO.inf) pub disc_hash: [u8; 20], @@ -34,6 +33,23 @@ pub struct ResolvedKeys { pub key_source: u8, } +// Redacting `Debug`: `vuk` and `unit_keys` are raw key bytes, never printed. +// `disc_hash` is the public per-disc identifier (SHA-1 of the .inf), not secret. +// Guarded by `resolved_keys_debug_is_redacted`. +impl std::fmt::Debug for ResolvedKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolvedKeys") + .field("disc_hash", &self.disc_hash) + .field("vuk", &self.vuk.map(|_| "")) + .field("unit_keys_len", &self.unit_keys.len()) + .field("title_cps_unit", &self.title_cps_unit) + .field("version", &self.version) + .field("bus_encryption", &self.bus_encryption) + .field("key_source", &self.key_source) + .finish() + } +} + /// Inputs shared by every classical-path resolver. References only — /// callers retain ownership of all buffers. pub struct ResolveContext<'a> { @@ -467,6 +483,24 @@ mod tests { use super::super::types::*; use super::*; + /// `ResolvedKeys` carries the disc's VUK and unit keys raw; `Debug` must not + /// leak them. Sentinel 213 (0xD5); non-secret fields are not 213. + #[test] + fn resolved_keys_debug_is_redacted() { + let rk = ResolvedKeys { + disc_hash: [0u8; 20], + vuk: Some([0xD5; 16]), + unit_keys: vec![(1, [0xD5; 16])], + title_cps_unit: vec![0], + version: AacsVersion::V21, + bus_encryption: true, + key_source: 1, + }; + let dbg = format!("{rk:?}"); + assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}"); + assert!(dbg.contains("redacted"), "ResolvedKeys missing marker: {dbg}"); + } + /// Audit #5: the `major` / `from_major` mapping is load-bearing for the /// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any /// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte). diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index cdc6b25..a42eff1 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -146,7 +146,7 @@ use super::derive::{calc_pk_from_dk, calc_v_mask}; /// Outcome of a subset-difference walk against an MKB. Carries the /// processing key and the matching `uv` slot — both needed as inputs /// to the variant chain. -#[derive(Debug, Clone, Copy)] +#[derive(Clone, Copy)] pub struct ProcessingKeyMatch { /// Processing Key. pub kp: [u8; 16], @@ -158,6 +158,20 @@ pub struct ProcessingKeyMatch { pub cvalue_index: usize, } +// Redacting `Debug`: `kp` (a Processing Key) and `cvalue` are secret, never +// printed. `uv` / `cvalue_index` are non-secret coordinates. Guarded by +// `processing_key_match_debug_is_redacted`. +impl std::fmt::Debug for ProcessingKeyMatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProcessingKeyMatch") + .field("kp", &"") + .field("uv", &self.uv) + .field("cvalue", &"") + .field("cvalue_index", &self.cvalue_index) + .finish() + } +} + fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { let r = records.iter().find(|r| { (r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2) @@ -635,6 +649,21 @@ mod tests { use super::super::crypto::aesg3; use super::super::derive::calc_pk_from_dk; + /// `ProcessingKeyMatch` carries the Processing Key (`kp`) and `cvalue` raw; + /// `Debug` must redact both. Non-secret `uv`/`cvalue_index` are not 213. + #[test] + fn processing_key_match_debug_is_redacted() { + let m = ProcessingKeyMatch { + kp: [0xD5; 16], + uv: 1, + cvalue: [0xD5; 16], + cvalue_index: 2, + }; + let dbg = format!("{m:?}"); + assert!(!dbg.contains("213"), "ProcessingKeyMatch leaked kp/cvalue: {dbg}"); + assert!(dbg.contains("redacted"), "ProcessingKeyMatch missing marker: {dbg}"); + } + #[test] fn calc_pk_from_dk_terminates_on_nonconvergent_mask() { // Regression for the unbounded-loop hang: pick a (dev_key_v_mask,