CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
842 lines
34 KiB
Rust
842 lines
34 KiB
Rust
//! AACS encryption resolution — key derivation, SCSI handshake, VUK lookup.
|
|
|
|
use super::*;
|
|
use crate::error::{Error, Result};
|
|
use crate::sector::SectorSource;
|
|
use crate::udf;
|
|
|
|
/// Result of SCSI AACS handshake (ECDH authentication).
|
|
/// Only available when scanning from a real drive, not ISO images.
|
|
#[derive(Debug)]
|
|
pub(super) struct HandshakeResult {
|
|
pub volume_id: [u8; 16],
|
|
pub read_data_key: Option<[u8; 16]>,
|
|
}
|
|
|
|
impl Disc {
|
|
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
|
|
/// when the drive reports `is_unlocked()` (extended-access state),
|
|
/// and falls back to the cert-based AACS mutual-auth handshake
|
|
/// otherwise.
|
|
///
|
|
/// The OEM path is a single READ_BUFFER CDB built from the drive
|
|
/// profile's `read_vid_cdb` template. The response carries a 3-byte
|
|
/// header (validated against `00 22 00`) followed by the 16-byte
|
|
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
|
|
/// the drive's runtime firmware serves the VID directly when in
|
|
/// extended-access state.
|
|
///
|
|
/// The cert path is the standard AACS spec flow: ECDH key
|
|
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
|
|
/// format 0x80 to retrieve VID under bus-key MAC.
|
|
pub(super) fn read_vid(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> Result<[u8; 16]> {
|
|
if session.is_unlocked() {
|
|
let profile = session
|
|
.drive_profile()
|
|
.ok_or(Error::DriveProfileMissing)?
|
|
.clone();
|
|
return Self::read_vid_oem(session, &profile);
|
|
}
|
|
Self::read_vid_cert(session, opts)
|
|
}
|
|
|
|
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
|
|
/// parses the response.
|
|
///
|
|
/// Response layout (36 bytes):
|
|
/// * [0..3] 3-byte response signature; expected `00 22 00`
|
|
/// * [3] reserved
|
|
/// * [4..20] 16-byte Volume ID
|
|
/// * [20..36] reserved / per-drive padding
|
|
fn read_vid_oem(
|
|
session: &mut crate::drive::Drive,
|
|
profile: &crate::profile::DriveProfile,
|
|
) -> Result<[u8; 16]> {
|
|
const RESPONSE_LEN: usize = 36;
|
|
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
|
|
|
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
|
|
let mut buf = vec![0u8; RESPONSE_LEN];
|
|
let result = session.scsi_execute(
|
|
&cdb,
|
|
crate::scsi::DataDirection::FromDevice,
|
|
&mut buf,
|
|
5_000,
|
|
)?;
|
|
if result.bytes_transferred < RESPONSE_LEN {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "oem_vid_short_response",
|
|
bytes_transferred = result.bytes_transferred,
|
|
"OEM VID CDB returned short response"
|
|
);
|
|
return Err(Error::AacsVidRead);
|
|
}
|
|
if buf[0..3] != EXPECTED_HEADER {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "oem_vid_bad_header",
|
|
header_0 = buf[0],
|
|
header_1 = buf[1],
|
|
header_2 = buf[2],
|
|
"OEM VID response header mismatch"
|
|
);
|
|
return Err(Error::AacsVidRead);
|
|
}
|
|
let mut vid = [0u8; 16];
|
|
vid.copy_from_slice(&buf[4..20]);
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "oem_vid_ok",
|
|
"OEM VID retrieved"
|
|
);
|
|
Ok(vid)
|
|
}
|
|
|
|
/// Cert-based VID retrieval — runs the full AACS mutual-auth
|
|
/// handshake and extracts VID from the bus-key-MAC'd
|
|
/// `REPORT_DISC_STRUCTURE` response.
|
|
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
|
|
match Self::do_handshake_cert(session, opts) {
|
|
(Some(h), _) => Ok(h.volume_id),
|
|
(None, Some(e)) => Err(e),
|
|
(None, None) => Err(Error::AacsVidUnavailable),
|
|
}
|
|
}
|
|
|
|
/// SCSI handshake — drives the VID-acquisition flow and returns
|
|
/// a structured `HandshakeResult` for downstream key resolution.
|
|
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
|
|
/// falls back to cert-based mutual auth otherwise.
|
|
///
|
|
/// The OEM path produces only VID (no bus-key, so no
|
|
/// `read_data_key`); the cert path can produce both. AACS 2.0
|
|
/// content that needs read_data_key for bus decryption requires
|
|
/// the cert path.
|
|
///
|
|
/// Returns `(handshake, error)`:
|
|
/// * `(Some(_), None)` — VID acquired
|
|
/// * `(None, Some(_))` — specific failure mode; only
|
|
/// `AacsHostCertRejected` and `AacsVidUnavailable` are returned
|
|
/// here (the OEM-path `DriveProfileMissing` / `VidCdbUnavailable`
|
|
/// errors are caught internally and fall through to cert auth)
|
|
/// * `(None, None)` — handshake not attempted (no keydb;
|
|
/// resolution will proceed with VID=zero and rely on path 1
|
|
/// disc-hash → VUK lookup)
|
|
pub(super) fn do_handshake(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
|
let t0 = std::time::Instant::now();
|
|
tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin");
|
|
let (result, err) = Self::do_handshake_inner(session, opts);
|
|
tracing::info!(
|
|
target: "freemkv::scan",
|
|
phase = "do_handshake",
|
|
ok = result.is_some(),
|
|
error_code = err.as_ref().map(|e| e.code()),
|
|
elapsed_ms = t0.elapsed().as_millis() as u64,
|
|
"end"
|
|
);
|
|
(result, err)
|
|
}
|
|
|
|
fn do_handshake_inner(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
|
let unlocked = session.is_unlocked();
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_entry",
|
|
unlocked,
|
|
"do_handshake entered"
|
|
);
|
|
|
|
if unlocked {
|
|
// Try OEM VID retrieval first. If the drive's profile
|
|
// doesn't carry the CDB template, or the response is
|
|
// malformed, fall through to cert-based auth.
|
|
match Self::read_vid(session, opts) {
|
|
Ok(volume_id) => {
|
|
return (
|
|
Some(HandshakeResult {
|
|
volume_id,
|
|
read_data_key: None,
|
|
}),
|
|
None,
|
|
);
|
|
}
|
|
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_oem_unavailable",
|
|
"OEM VID path unavailable for this drive; trying cert handshake"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_oem_failed",
|
|
error_code = e.code(),
|
|
"OEM VID retrieval failed; trying cert handshake"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Self::do_handshake_cert(session, opts)
|
|
}
|
|
|
|
/// Cert-based AACS handshake. The legacy auth path; still used as
|
|
/// the fallback when the OEM VID path isn't available or fails.
|
|
fn do_handshake_cert(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
|
use crate::aacs;
|
|
|
|
// Host certs come from the caller's DriveCredentials (e.g. the keydb's
|
|
// host_certs(), sourced app-side) — the library does not load a keydb.
|
|
// Absent ⇒ no cert auth: an unlocked / LibreDrive drive already returned
|
|
// a Volume ID via the OEM path before reaching here, so this is the
|
|
// locked-drive-without-credentials case.
|
|
let host_certs: &[aacs::HostCert] = match &opts.credentials {
|
|
Some(c) if !c.host_certs.is_empty() => &c.host_certs,
|
|
_ => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_no_credentials",
|
|
"no drive credentials supplied; cert handshake skipped"
|
|
);
|
|
return (None, None);
|
|
}
|
|
};
|
|
|
|
let host_cert_count = host_certs.len();
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_start",
|
|
host_cert_count,
|
|
"handshake starting"
|
|
);
|
|
|
|
// Cert-attempt wedge guard. An earlier version fired up to 16
|
|
// AACS authenticate attempts back-to-back with no pause. Each
|
|
// attempt is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc
|
|
// whose host cert isn't in the KEYDB (or one the drive rejects),
|
|
// that's 80-160 SCSI commands hammered at the drive in a few
|
|
// hundred milliseconds — and consumer optical drives can respond
|
|
// by entering a fast-fail firmware wedge state where every
|
|
// subsequent CDB returns ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB
|
|
// (sense 05/24) until power-cycled. Observed live on a UHD scan:
|
|
// KEYDB miss → many cert attempts in a tight loop → wedge →
|
|
// forced power cycle to recover.
|
|
//
|
|
// Defense-in-depth: cap attempts, sleep between, and bail
|
|
// early on the drive's wedge sense so any later regression
|
|
// can't undo the protection silently.
|
|
const MAX_CERT_ATTEMPTS: usize = 3;
|
|
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
|
let mut last_err_code: Option<u16> = None;
|
|
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
|
if idx > 0 {
|
|
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
|
}
|
|
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
|
Ok(mut auth) => {
|
|
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
|
Ok(vid) => vid,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_vid_read_failed",
|
|
cert_index = idx,
|
|
error_code = e.code(),
|
|
"auth ok but volume ID read failed"
|
|
);
|
|
return (None, Some(Error::AacsVidUnavailable));
|
|
}
|
|
};
|
|
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
|
.ok()
|
|
.map(|(rdk, _)| rdk);
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_ok",
|
|
cert_index = idx,
|
|
has_read_data_key = read_data_key.is_some(),
|
|
);
|
|
return (
|
|
Some(HandshakeResult {
|
|
volume_id,
|
|
read_data_key,
|
|
}),
|
|
None,
|
|
);
|
|
}
|
|
Err(e) => {
|
|
last_err_code = Some(e.code());
|
|
// Log the real SCSI sense triple, not `e.code()` —
|
|
// `code()` collapses every ScsiError to the flat
|
|
// E_SCSI_ERROR constant and carries no sense key,
|
|
// so it has no diagnostic value for auth-failure
|
|
// routing.
|
|
let sense = e.scsi_sense();
|
|
// Drive wedge senses (ILLEGAL_REQUEST, sense key
|
|
// 0x05). The drive isn't merely rejecting our
|
|
// cert — it's signalling it won't talk to us
|
|
// anymore. Trying more certs makes the wedge worse,
|
|
// so bail out immediately. NOTE: this must read the
|
|
// sense key off the structured ScsiSense, NOT off
|
|
// `e.code()`; `code()` is a flat constant for every
|
|
// ScsiError so the old `(code >> 8) & 0xFF` guard
|
|
// never matched and was dead code (the very wedge
|
|
// this defense exists to prevent could recur).
|
|
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_wedge_detected",
|
|
cert_index = idx,
|
|
sense_key = sense.map(|s| s.sense_key),
|
|
asc = sense.map(|s| s.asc),
|
|
ascq = sense.map(|s| s.ascq),
|
|
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
|
);
|
|
return (None, Some(Error::AacsHostCertRejected));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_all_certs_failed",
|
|
host_cert_count,
|
|
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
|
last_error_code = last_err_code,
|
|
"all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
|
|
MAX_CERT_ATTEMPTS
|
|
);
|
|
(None, Some(Error::AacsHostCertRejected))
|
|
}
|
|
|
|
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
|
/// metadata), for callers that resolve Unit Keys out-of-band and have
|
|
/// disabled the local keydb. The VID is on-disc content read during the
|
|
/// handshake; preserving it here lets the out-of-band path use it. No keys
|
|
/// are present (`unit_keys` empty, `vuk` None), so the disc reports as
|
|
/// "encrypted, no keys" until the caller re-scans with a resolved Unit Key.
|
|
pub(super) fn resolve_vid_only(
|
|
udf_fs: &udf::UdfFs,
|
|
reader: &mut dyn SectorSource,
|
|
handshake: Option<&HandshakeResult>,
|
|
) -> Result<AacsState> {
|
|
use crate::aacs;
|
|
|
|
let uk_ro_data = udf_fs
|
|
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
|
.map_err(|_| Error::AacsNoKeys)?;
|
|
let dh = aacs::disc_hash(&uk_ro_data);
|
|
|
|
let cc = udf_fs
|
|
.read_file(reader, "/AACS/Content000.cer")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
|
.ok()
|
|
.as_deref()
|
|
.and_then(aacs::parse_content_cert);
|
|
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
|
let version = match cc.as_ref().map(|c| c.version) {
|
|
Some(aacs::AacsVersion::V10) => 1,
|
|
Some(_) => 2,
|
|
None if bus_encryption => 2,
|
|
None => 1,
|
|
};
|
|
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
|
// the real record length (same as `read_aacs_inputs`). Without this the
|
|
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
|
// processing-key `decrypt_with` derivation consume, and which a key
|
|
// source ships to an online service — is the full 128 MiB pad, not the
|
|
// ~few-MB record stream.
|
|
let mut mkb_bytes = udf_fs
|
|
.read_file(reader, "/AACS/MKB_RO.inf")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
|
.ok()
|
|
.unwrap_or_default();
|
|
// Trim to the real record length. truncate is a no-op when n >=
|
|
// len and correctly empties the vec when n == 0 (zeroed/corrupt
|
|
// MKB), so it never leaves the full ~128 MiB zero-pad on
|
|
// AacsState.mkb.
|
|
let n = aacs::mkb_content_len(&mkb_bytes);
|
|
mkb_bytes.truncate(n);
|
|
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
|
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "scan_aacs_vid_only",
|
|
disc_hash = %aacs::disc_hash_hex(&dh),
|
|
version,
|
|
bus_encryption,
|
|
has_vid = handshake.is_some(),
|
|
"keydb disabled — carrying VID only, keys resolved out-of-band"
|
|
);
|
|
|
|
Ok(AacsState {
|
|
version,
|
|
bus_encryption,
|
|
mkb_version: mkb_ver,
|
|
disc_hash: aacs::disc_hash_hex(&dh),
|
|
key_source: KeyOrigin::ExternalUk,
|
|
vuk: None,
|
|
unit_keys: vec![],
|
|
read_data_key: handshake.and_then(|h| h.read_data_key),
|
|
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
|
uk_ro: uk_ro_data,
|
|
mkb: mkb_bytes,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::aacs;
|
|
use crate::sector::SectorSource;
|
|
use std::collections::HashMap;
|
|
|
|
// ---------------------------------------------------------------
|
|
// In-memory disc + minimal UDF image with a single physical
|
|
// partition (metadata_start == partition_start). Offsets cited
|
|
// against udf.rs::read_filesystem / ECMA-167.
|
|
// ---------------------------------------------------------------
|
|
|
|
const PART_START: u32 = 4000;
|
|
|
|
struct MemDisc {
|
|
sectors: HashMap<u32, [u8; 2048]>,
|
|
}
|
|
impl MemDisc {
|
|
fn new() -> Self {
|
|
Self {
|
|
sectors: HashMap::new(),
|
|
}
|
|
}
|
|
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
|
self.sectors.insert(lba, data);
|
|
}
|
|
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
|
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
|
let mut s = [0u8; 2048];
|
|
s[..chunk.len()].copy_from_slice(chunk);
|
|
self.put(lba + i as u32, s);
|
|
}
|
|
}
|
|
}
|
|
impl SectorSource for MemDisc {
|
|
fn read_sectors(
|
|
&mut self,
|
|
lba: u32,
|
|
count: u16,
|
|
buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
let need = count as usize * 2048;
|
|
for i in 0..count as u32 {
|
|
let off = i as usize * 2048;
|
|
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
|
buf[off..off + 2048].copy_from_slice(&s);
|
|
}
|
|
Ok(need)
|
|
}
|
|
}
|
|
|
|
/// Extended File Entry ICB (tag 266) with one Short AD.
|
|
fn build_file_icb(size: u32, data_lba: u32) -> [u8; 2048] {
|
|
let mut s = [0u8; 2048];
|
|
s[0..2].copy_from_slice(&266u16.to_le_bytes());
|
|
s[56..64].copy_from_slice(&(size as u64).to_le_bytes());
|
|
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
|
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
|
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
|
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
|
s
|
|
}
|
|
|
|
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
|
let start = buf.len();
|
|
let name_field: Vec<u8> = if is_parent {
|
|
Vec::new()
|
|
} else {
|
|
let mut v = vec![0x08u8];
|
|
v.extend_from_slice(name.as_bytes());
|
|
v
|
|
};
|
|
let mut fid = vec![0u8; 38];
|
|
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
|
let mut fc = 0u8;
|
|
if is_dir {
|
|
fc |= 0x02;
|
|
}
|
|
if is_parent {
|
|
fc |= 0x08;
|
|
}
|
|
fid[18] = fc;
|
|
fid[19] = name_field.len() as u8;
|
|
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
|
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
|
buf.extend_from_slice(&fid);
|
|
buf.extend_from_slice(&name_field);
|
|
let used = buf.len() - start;
|
|
buf.resize(start + ((used + 3) & !3), 0);
|
|
}
|
|
|
|
struct AacsFile {
|
|
name: &'static str,
|
|
icb_lba: u32,
|
|
data_lba: u32,
|
|
contents: Vec<u8>,
|
|
}
|
|
|
|
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
|
let mut avdp = [0u8; 2048];
|
|
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
|
disc.put(256, avdp);
|
|
let mut pd = [0u8; 2048];
|
|
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
|
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
|
disc.put(32, pd);
|
|
let mut lvd = [0u8; 2048];
|
|
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
|
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
|
disc.put(33, lvd);
|
|
let mut td = [0u8; 2048];
|
|
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
|
disc.put(34, td);
|
|
let mut fsd = [0u8; 2048];
|
|
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
|
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
|
disc.put(PART_START, fsd);
|
|
}
|
|
|
|
/// Build a UDF tree with a single /AACS directory holding the given
|
|
/// files. Returns the navigable UdfFs over `disc`.
|
|
fn build_aacs_fs(disc: &mut MemDisc, files: &[AacsFile]) -> udf::UdfFs {
|
|
let mut aacs_fids = Vec::new();
|
|
push_fid(&mut aacs_fids, "", 50, true, true);
|
|
for f in files {
|
|
push_fid(&mut aacs_fids, f.name, f.icb_lba, false, false);
|
|
disc.put(
|
|
PART_START + f.icb_lba,
|
|
build_file_icb(f.contents.len() as u32, f.data_lba),
|
|
);
|
|
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
|
}
|
|
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
|
disc.put_bytes(PART_START + 51, &aacs_fids);
|
|
// Root referencing AACS.
|
|
let mut root_fids = Vec::new();
|
|
push_fid(&mut root_fids, "", 10, true, true);
|
|
push_fid(&mut root_fids, "AACS", 50, true, false);
|
|
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
|
disc.put_bytes(PART_START + 11, &root_fids);
|
|
build_udf_skeleton(disc, 10);
|
|
udf::read_filesystem(disc).expect("fs")
|
|
}
|
|
|
|
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
|
/// bus_encryption bit0@1, cc_id@2..8 (aacs/keys.rs parse_content_cert).
|
|
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
|
let mut v = vec![0u8; 8];
|
|
v[0] = cert_type;
|
|
v[1] = if bus_encryption { 0x01 } else { 0x00 };
|
|
v
|
|
}
|
|
|
|
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
|
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
|
/// record then trailing zero padding. mkb_content_len walks records
|
|
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
|
|
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
|
let mut v = Vec::new();
|
|
// Type 0x10 record, length 16 (>= 12 so version is read).
|
|
v.push(0x10);
|
|
v.extend_from_slice(&[0x00, 0x00, 0x10]); // rec_len = 16 (3-byte BE)
|
|
v.extend_from_slice(&[0u8; 4]); // bytes 4..8 reserved
|
|
v.extend_from_slice(&version.to_be_bytes()); // version @ rec+8
|
|
v.extend_from_slice(&[0u8; 4]); // pad record body to 16
|
|
debug_assert_eq!(v.len(), 16);
|
|
// Trailing zero padding (the "fixed-region" allocation).
|
|
v.resize(pad_to, 0);
|
|
v
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Tests: resolve_vid_only
|
|
// ---------------------------------------------------------------
|
|
|
|
/// Missing Unit_Key_RO.inf (and its DUPLICATE) → Error::AacsNoKeys
|
|
/// (encrypt.rs `.map_err(|_| Error::AacsNoKeys)`). Never panics.
|
|
#[test]
|
|
fn resolve_vid_only_missing_unit_key_ro_errors() {
|
|
let mut disc = MemDisc::new();
|
|
// AACS dir exists but has no Unit_Key_RO.inf.
|
|
let udf = build_aacs_fs(&mut disc, &[]);
|
|
let err = Disc::resolve_vid_only(&udf, &mut disc, None)
|
|
.expect_err("missing Unit_Key_RO must error");
|
|
assert!(matches!(err, Error::AacsNoKeys));
|
|
}
|
|
|
|
/// A V10 content cert (type 0x00, bus_encryption off) → version 1,
|
|
/// bus_encryption false (encrypt.rs version match: Some(V10) → 1).
|
|
#[test]
|
|
fn resolve_vid_only_v10_cert_sets_version_1() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[
|
|
AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
},
|
|
AacsFile {
|
|
name: "Content000.cer",
|
|
icb_lba: 62,
|
|
data_lba: 6000,
|
|
contents: build_content_cert(0x00, false),
|
|
},
|
|
],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
assert_eq!(st.version, 1, "V10 cert → AACS version 1");
|
|
assert!(!st.bus_encryption);
|
|
assert_eq!(st.key_source, KeyOrigin::ExternalUk);
|
|
assert!(st.unit_keys.is_empty(), "vid-only resolves no keys");
|
|
assert!(st.vuk.is_none());
|
|
}
|
|
|
|
/// A V20 content cert (type != 0x00) → version 2 (encrypt.rs Some(_) → 2).
|
|
#[test]
|
|
fn resolve_vid_only_v20_cert_sets_version_2() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[
|
|
AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
},
|
|
AacsFile {
|
|
name: "Content000.cer",
|
|
icb_lba: 62,
|
|
data_lba: 6000,
|
|
contents: build_content_cert(0x01, true),
|
|
},
|
|
],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
assert_eq!(st.version, 2, "V20 cert → AACS version 2");
|
|
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
|
|
}
|
|
|
|
/// No content cert at all but bus_encryption can't be read → version
|
|
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
|
|
#[test]
|
|
fn resolve_vid_only_no_cert_defaults_version_1() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
}],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
assert_eq!(st.version, 1, "no cert → default version 1");
|
|
assert!(!st.bus_encryption);
|
|
}
|
|
|
|
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
|
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
|
|
/// disc_hash must match independently computing it over the same bytes.
|
|
#[test]
|
|
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
|
let mut disc = MemDisc::new();
|
|
let uk = vec![0x42u8; 100];
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: uk.clone(),
|
|
}],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
|
|
assert_eq!(st.disc_hash, expected);
|
|
assert!(st.disc_hash.starts_with("0x"));
|
|
// uk_ro must be stashed verbatim for the external resolver.
|
|
assert_eq!(st.uk_ro, uk);
|
|
}
|
|
|
|
/// The MKB is trimmed to its real record length, NOT left as the full
|
|
/// fixed-region zero-pad (encrypt.rs `mkb_bytes.truncate(mkb_content_len)`).
|
|
/// A 16-byte record + 5000 bytes of padding must trim to 16.
|
|
#[test]
|
|
fn resolve_vid_only_trims_mkb_padding() {
|
|
let mut disc = MemDisc::new();
|
|
let mkb = build_mkb(77, 5000); // record + 4984 pad bytes
|
|
assert_eq!(mkb.len(), 5000);
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[
|
|
AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
},
|
|
AacsFile {
|
|
name: "MKB_RO.inf",
|
|
icb_lba: 62,
|
|
data_lba: 7000,
|
|
contents: mkb.clone(),
|
|
},
|
|
],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
// Real record stream is the single 16-byte type-0x10 record.
|
|
assert_eq!(
|
|
st.mkb.len(),
|
|
aacs::mkb_content_len(&mkb),
|
|
"MKB must be trimmed to record-stream length, not the zero-pad"
|
|
);
|
|
assert_eq!(st.mkb.len(), 16);
|
|
// Version comes from the type-0x10 record body @ offset 8.
|
|
assert_eq!(st.mkb_version, Some(77));
|
|
}
|
|
|
|
/// With no MKB file present, mkb is empty and mkb_version is None
|
|
/// (encrypt.rs `.unwrap_or_default()` → empty Vec; mkb_version(&[]) None).
|
|
#[test]
|
|
fn resolve_vid_only_no_mkb_is_empty() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
}],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
assert!(st.mkb.is_empty());
|
|
assert_eq!(st.mkb_version, None);
|
|
}
|
|
|
|
/// A supplied handshake's volume_id and read_data_key propagate onto the
|
|
/// AacsState (encrypt.rs `handshake.map(|h| h.volume_id)` /
|
|
/// `handshake.and_then(|h| h.read_data_key)`).
|
|
#[test]
|
|
fn resolve_vid_only_propagates_handshake_vid_and_rdk() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
}],
|
|
);
|
|
let vid = [0x11u8; 16];
|
|
let rdk = [0x22u8; 16];
|
|
let hs = HandshakeResult {
|
|
volume_id: vid,
|
|
read_data_key: Some(rdk),
|
|
};
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
|
|
assert_eq!(st.volume_id, vid);
|
|
assert_eq!(st.read_data_key, Some(rdk));
|
|
}
|
|
|
|
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
|
|
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
|
|
#[test]
|
|
fn resolve_vid_only_no_handshake_zero_vid() {
|
|
let mut disc = MemDisc::new();
|
|
let udf = build_aacs_fs(
|
|
&mut disc,
|
|
&[AacsFile {
|
|
name: "Unit_Key_RO.inf",
|
|
icb_lba: 60,
|
|
data_lba: 5000,
|
|
contents: vec![0xAB; 32],
|
|
}],
|
|
);
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
|
assert_eq!(st.volume_id, [0u8; 16]);
|
|
assert_eq!(st.read_data_key, None);
|
|
}
|
|
|
|
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
|
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
|
/// This is the damaged-primary recovery path real discs rely on.
|
|
#[test]
|
|
fn resolve_vid_only_falls_back_to_duplicate_unit_key_ro() {
|
|
let mut disc = MemDisc::new();
|
|
// Build AACS dir with a DUPLICATE subdir holding Unit_Key_RO.inf.
|
|
let uk = vec![0x55u8; 48];
|
|
let mut dup_fids = Vec::new();
|
|
push_fid(&mut dup_fids, "", 70, true, true);
|
|
push_fid(&mut dup_fids, "Unit_Key_RO.inf", 72, false, false);
|
|
disc.put(PART_START + 72, build_file_icb(uk.len() as u32, 9000));
|
|
disc.put_bytes(PART_START + 9000, &uk);
|
|
disc.put(PART_START + 70, build_file_icb(dup_fids.len() as u32, 71));
|
|
disc.put_bytes(PART_START + 71, &dup_fids);
|
|
// AACS dir: only a DUPLICATE subdir (no primary Unit_Key_RO.inf).
|
|
let mut aacs_fids = Vec::new();
|
|
push_fid(&mut aacs_fids, "", 50, true, true);
|
|
push_fid(&mut aacs_fids, "DUPLICATE", 70, true, false);
|
|
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
|
disc.put_bytes(PART_START + 51, &aacs_fids);
|
|
let mut root_fids = Vec::new();
|
|
push_fid(&mut root_fids, "", 10, true, true);
|
|
push_fid(&mut root_fids, "AACS", 50, true, false);
|
|
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
|
disc.put_bytes(PART_START + 11, &root_fids);
|
|
build_udf_skeleton(&mut disc, 10);
|
|
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
|
|
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("DUPLICATE fallback");
|
|
// disc_hash must be computed over the DUPLICATE bytes.
|
|
assert_eq!(
|
|
st.disc_hash,
|
|
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
|
|
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
|
);
|
|
assert_eq!(st.uk_ro, uk);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Tests: read_vid_oem (response parsing). The OEM path issues a
|
|
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
|
// fixture a real Drive, but the response-shape contract (3-byte
|
|
// signature 00 22 00, VID at [4..20]) is documented and worth a
|
|
// direct guard via a fake transport. Skipped here because Drive
|
|
// construction requires a live transport; the parsing branches are
|
|
// exercised through `read_vid_oem`'s callers in integration.
|
|
// ---------------------------------------------------------------
|
|
}
|