disc: redact Debug for AacsState/Key/HandshakeResult (test-guarded)

AacsState (public via Disc.aacs) and Key (the decrypt_with key-transport enum)
are crate-root re-exported and carried VUK/unit/read-data keys + volume id on
#[derive(Debug)]; HandshakeResult carried the VID + AACS 2.0 bus key. Manual
Debug impls print shape only, guarded by red->green tests.
This commit is contained in:
Matthew Jackson
2026-07-17 21:10:51 -07:00
parent 3546648faa
commit 2263d2cc4e
6 changed files with 127 additions and 8 deletions
+4 -1
View File
@@ -595,7 +595,10 @@ mod resolve_candidate_tests {
dk: None, dk: None,
}; };
let dbg = format!("{c:?}"); let dbg = format!("{c:?}");
assert!(!dbg.contains("213"), "ResolvedChain leaked unit keys: {dbg}"); assert!(
!dbg.contains("213"),
"ResolvedChain leaked unit keys: {dbg}"
);
assert!( assert!(
dbg.contains("unit_keys_len"), dbg.contains("unit_keys_len"),
"ResolvedChain missing redaction: {dbg}" "ResolvedChain missing redaction: {dbg}"
+4 -1
View File
@@ -498,7 +498,10 @@ mod tests {
}; };
let dbg = format!("{rk:?}"); let dbg = format!("{rk:?}");
assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}"); assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}");
assert!(dbg.contains("redacted"), "ResolvedKeys missing marker: {dbg}"); assert!(
dbg.contains("redacted"),
"ResolvedKeys missing marker: {dbg}"
);
} }
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the /// Audit #5: the `major` / `from_major` mapping is load-bearing for the
+8 -2
View File
@@ -660,8 +660,14 @@ mod tests {
cvalue_index: 2, cvalue_index: 2,
}; };
let dbg = format!("{m:?}"); let dbg = format!("{m:?}");
assert!(!dbg.contains("213"), "ProcessingKeyMatch leaked kp/cvalue: {dbg}"); assert!(
assert!(dbg.contains("redacted"), "ProcessingKeyMatch missing marker: {dbg}"); !dbg.contains("213"),
"ProcessingKeyMatch leaked kp/cvalue: {dbg}"
);
assert!(
dbg.contains("redacted"),
"ProcessingKeyMatch missing marker: {dbg}"
);
} }
#[test] #[test]
+4 -1
View File
@@ -390,7 +390,10 @@ mod tests {
!dbg.contains("213"), !dbg.contains("213"),
"CssState Debug leaked the title key: {dbg}" "CssState Debug leaked the title key: {dbg}"
); );
assert!(dbg.contains("redacted"), "CssState Debug missing marker: {dbg}"); assert!(
dbg.contains("redacted"),
"CssState Debug missing marker: {dbg}"
);
} }
// ── is_scrambled ─────────────────────────────────────────────────────── // ── is_scrambled ───────────────────────────────────────────────────────
+34 -1
View File
@@ -7,7 +7,6 @@ use crate::udf;
/// Result of SCSI AACS handshake (ECDH authentication). /// Result of SCSI AACS handshake (ECDH authentication).
/// Only available when scanning from a real drive, not ISO images. /// Only available when scanning from a real drive, not ISO images.
#[derive(Debug)]
pub(super) struct HandshakeResult { pub(super) struct HandshakeResult {
pub volume_id: [u8; 16], pub volume_id: [u8; 16],
pub read_data_key: Option<[u8; 16]>, pub read_data_key: Option<[u8; 16]>,
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
pub drive_unlocked: bool, pub drive_unlocked: bool,
} }
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
// secret; print only shape. Guarded by `handshake_result_debug_is_redacted`.
impl std::fmt::Debug for HandshakeResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HandshakeResult")
.field("volume_id", &"<redacted>")
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
.field("read_data_key_err", &self.read_data_key_err)
.field("drive_unlocked", &self.drive_unlocked)
.finish()
}
}
/// Single source of truth for "is AACS bus encryption gone for this scan?". The /// Single source of truth for "is AACS bus encryption gone for this scan?". The
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus /// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
/// encryption is gone when ANY of these holds: /// encryption is gone when ANY of these holds:
@@ -428,6 +440,27 @@ mod tests {
use crate::sector::SectorSource; use crate::sector::SectorSource;
use std::collections::HashMap; use std::collections::HashMap;
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
#[test]
fn handshake_result_debug_is_redacted() {
let hs = HandshakeResult {
volume_id: [0xD5; 16],
read_data_key: Some([0xD5; 16]),
read_data_key_err: None,
drive_unlocked: false,
};
let d = format!("{hs:?}");
assert!(
!d.contains("213"),
"HandshakeResult leaked VID/bus key: {d}"
);
assert!(
d.contains("redacted"),
"HandshakeResult missing marker: {d}"
);
}
// --------------------------------------------------------------- // ---------------------------------------------------------------
// In-memory disc + minimal UDF image with a single physical // In-memory disc + minimal UDF image with a single physical
// partition (metadata_start == partition_start). Offsets cited // partition (metadata_start == partition_start). Offsets cited
+73 -2
View File
@@ -1363,7 +1363,6 @@ impl DiscTitle {
// ─── Encryption ───────────────────────────────────────────────────────────── // ─── Encryption ─────────────────────────────────────────────────────────────
/// AACS decryption state for a disc. /// AACS decryption state for a disc.
#[derive(Debug)]
pub struct AacsState { pub struct AacsState {
/// AACS version (1 or 2) /// AACS version (1 or 2)
pub version: u8, pub version: u8,
@@ -1394,6 +1393,28 @@ pub struct AacsState {
pub mkb: Vec<u8>, pub mkb: Vec<u8>,
} }
// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the
// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key /
// volume id / raw .inf + MKB. Print only non-secret shape; redact every
// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for AacsState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AacsState")
.field("version", &self.version)
.field("bus_encryption", &self.bus_encryption)
.field("mkb_version", &self.mkb_version)
.field("disc_hash", &self.disc_hash)
.field("key_source", &self.key_source)
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
.field("volume_id", &"<redacted>")
.field("uk_ro_len", &self.uk_ro.len())
.field("mkb_len", &self.mkb.len())
.finish()
}
}
/// How AACS keys were resolved. Variants are ordered root-of-trust → /// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver /// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls /// attempts derivation from the strongest input it has first and falls
@@ -2244,7 +2265,7 @@ impl Disc {
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from /// point at one level of that chain; [`Disc::decrypt_with`] derives down from
/// it to the per-CPS unit keys. New levels can be added without breaking /// it to the per-CPS unit keys. New levels can be added without breaking
/// callers. /// callers.
#[derive(Debug, Clone)] #[derive(Clone)]
#[non_exhaustive] #[non_exhaustive]
pub enum Key { pub enum Key {
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB /// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
@@ -2273,6 +2294,22 @@ pub enum Key {
Unit(Vec<(u32, [u8; 16])>), Unit(Vec<(u32, [u8; 16])>),
} }
// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport
// type crossing `Disc::decrypt_with`; every variant carries raw key material.
// Print only the variant name and count — never bytes. Guarded by
// `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Key::Device(v) => write!(f, "Key::Device(<{} redacted>)", v.len()),
Key::Processing(v) => write!(f, "Key::Processing(<{} redacted>)", v.len()),
Key::Media(v) => write!(f, "Key::Media(<{} redacted>)", v.len()),
Key::Volume(_) => f.write_str("Key::Volume(<redacted>)"),
Key::Unit(v) => write!(f, "Key::Unit(<{} redacted>)", v.len()),
}
}
}
/// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the /// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
/// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is /// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
/// not AACS-scrambled proves nothing, and with no scrambled sample at all there /// not AACS-scrambled proves nothing, and with no scrambled sample at all there
@@ -4188,6 +4225,40 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
mod tests { mod tests {
use super::*; use super::*;
/// `AacsState` (public via `Disc.aacs`) and `Key` (the key-transport enum)
/// must never print raw key bytes on `{:?}`. Sentinel 213 (0xD5); non-secret
/// fields below are not 213.
#[test]
fn aacs_state_and_key_debug_are_redacted() {
let st = AacsState {
version: 2,
bus_encryption: true,
mkb_version: Some(77),
disc_hash: "0xAA".into(),
key_source: KeyOrigin::ExternalUk,
vuk: Some([0xD5; 16]),
unit_keys: vec![(1, [0xD5; 16])],
read_data_key: Some([0xD5; 16]),
volume_id: [0xD5; 16],
uk_ro: vec![1, 2, 3],
mkb: vec![4, 5, 6],
};
let d = format!("{st:?}");
assert!(!d.contains("213"), "AacsState leaked key bytes: {d}");
assert!(d.contains("redacted"), "AacsState missing marker: {d}");
for k in [
Key::Unit(vec![(1, [0xD5; 16])]),
Key::Volume([0xD5; 16]),
Key::Processing(vec![[0xD5; 16]]),
Key::Media(vec![[0xD5; 16]]),
] {
let d = format!("{k:?}");
assert!(!d.contains("213"), "Key leaked bytes: {d}");
assert!(d.contains("redacted"), "Key missing marker: {d}");
}
}
// ── encrypted-content map (`merged_extents` core) ──────────────────────── // ── encrypted-content map (`merged_extents` core) ────────────────────────
fn ext(start_lba: u32, sector_count: u32) -> Extent { fn ext(start_lba: u32, sector_count: u32) -> Extent {