AACS pipeline reshape + TrueHD metadata + central consts + clippy/fmt clean
- AACS: delete in-lib keydb parser (Step 3); boil-down primitives (mk_from_dk/vuk_from_mk/uk_from_vuk) + newtypes; KeySource->get_uk(ctx)+ ResolveCtx; Unlocker->unlock()->Result<Vid,UnlockError> + AacsCertUnlocker; OEM bus-key gate (AacsBusKeyUnavailable); structured ResolutionTrace (Step 4). - TrueHD: sample-rate from major-sync, Atmos label, 44.1k AU duration. - consts: central media/format constants module; 17 duplicate const-defs centralized (sector/TS-packet/source-packet); mpls stream-entry + category codes named. - clippy --all-targets -D warnings clean (1.86); fmt clean; 2199 lib tests.
This commit is contained in:
+2
-2
@@ -583,7 +583,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// One STN stream entry: stream_entry (len(1)=3, type(1)=0x01, pid(2))
|
||||
/// + stream_attributes (len(1) + coding_type(1) + payload). Matches
|
||||
/// plus stream_attributes (len(1) + coding_type(1) + payload). Matches
|
||||
/// the mpls.rs test builders.
|
||||
fn se_video(pid: u16, coding_type: u8) -> Vec<u8> {
|
||||
let mut out = vec![3u8, 0x01];
|
||||
@@ -962,7 +962,7 @@ mod tests {
|
||||
|
||||
/// A clip whose .clpi is missing contributes NO size and NO extent
|
||||
/// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)`
|
||||
/// + `if let Ok(clip_info)` blocks), but the Clip entry is still
|
||||
/// and `if let Ok(clip_info)` blocks), but the Clip entry is still
|
||||
/// recorded with packet count 0. Never panics on the missing read.
|
||||
#[test]
|
||||
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
|
||||
|
||||
+371
-175
@@ -13,6 +13,171 @@ pub(super) struct HandshakeResult {
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
|
||||
/// the external firmware [`crate::unlock::Unlocker`]s.
|
||||
///
|
||||
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
|
||||
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
|
||||
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
|
||||
/// for external firmware unlockers (and keeps their unit tests trivially
|
||||
/// fakeable). So the firmware path stays transport-level and registry-routed,
|
||||
/// while this cert path is an in-tree Drive-level peer invoked directly by
|
||||
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
|
||||
/// [`crate::unlock::UnlockError`] taxonomy.
|
||||
struct AacsCertUnlocker<'a> {
|
||||
opts: &'a ScanOptions,
|
||||
}
|
||||
|
||||
impl AacsCertUnlocker<'_> {
|
||||
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
||||
/// host certs from the key sources + credentials, try each (wedge-guarded),
|
||||
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
|
||||
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
|
||||
/// no-VID outcome.
|
||||
fn authenticate(
|
||||
&self,
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
|
||||
use crate::aacs;
|
||||
use crate::unlock::UnlockError;
|
||||
|
||||
// MKB generation (best-effort) — forwarded to each source's
|
||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||
// (the default impl ignores it). A read failure leaves it `None`.
|
||||
let mkb_gen = aacs::read_mkb_from_drive(session)
|
||||
.ok()
|
||||
.and_then(|m| aacs::mkb_version(&m));
|
||||
|
||||
// Host certs are keysource-served, never compiled in — unioned from the
|
||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
|
||||
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
|
||||
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_host_cert",
|
||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
||||
);
|
||||
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
|
||||
}
|
||||
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 — 80-160 SCSI
|
||||
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
|
||||
// consumer optical drives into a fast-fail firmware wedge (every CDB
|
||||
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
|
||||
// attempts, sleep between, bail early on the drive's wedge sense.
|
||||
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 Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
};
|
||||
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 Ok(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
// Read the wedge sense off the structured ScsiSense, NOT
|
||||
// `e.code()` (a flat constant for every ScsiError). On
|
||||
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
|
||||
// — trying more certs worsens the wedge, so bail immediately.
|
||||
let sense = e.scsi_sense();
|
||||
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 Err(UnlockError::HandshakeRejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_cert_rejected",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
|
||||
);
|
||||
Err(UnlockError::HandshakeRejected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
|
||||
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
|
||||
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
|
||||
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
|
||||
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
||||
use crate::unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
|
||||
path: "<no host cert>".into(),
|
||||
},
|
||||
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
|
||||
UnlockError::HandshakeRejected
|
||||
| UnlockError::CertRevoked { .. }
|
||||
| UnlockError::FirmwareNotUnlockable
|
||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
|
||||
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
|
||||
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
||||
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||
/// a structured `HandshakeResult` for downstream key resolution.
|
||||
@@ -71,13 +236,16 @@ impl Disc {
|
||||
/// are keysource-served, never compiled in; this is the one place the OEM
|
||||
/// cert route gathers them. An empty result is the graceful no-cert signal
|
||||
/// (the caller turns it into [`Error::AacsNoHostCert`]).
|
||||
fn collect_host_certs(opts: &ScanOptions) -> Vec<crate::aacs::HostCert> {
|
||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||
/// generation-appropriate certs (the default ignores it).
|
||||
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
for src in &opts.key_sources {
|
||||
host_certs.extend(src.host_certs());
|
||||
host_certs.extend(src.host_certs(mkb));
|
||||
}
|
||||
host_certs
|
||||
}
|
||||
@@ -86,184 +254,51 @@ impl Disc {
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
use crate::aacs;
|
||||
|
||||
// OEM VID shortcut. Resolve the SAME unlocker that would unlock this
|
||||
// drive and ask it for the VID via its OEM mechanism. Cloning the
|
||||
// DriveId first releases the immutable borrow before we hand the
|
||||
// mutable transport to the registry.
|
||||
let drive_id = session.drive_id.clone();
|
||||
match crate::unlock::unlocker_read_volume_id(session.scsi_mut(), &drive_id) {
|
||||
Ok(Some(volume_id)) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_ok",
|
||||
"Got the disc's Volume ID from the drive unlocker; skipping the AACS host-certificate handshake."
|
||||
);
|
||||
return (
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_none",
|
||||
"Drive unlocker has no Volume ID for this disc; trying the AACS host-certificate handshake next."
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_failed",
|
||||
error_code = e.code(),
|
||||
"Drive unlocker errored while reading the Volume ID; trying the AACS host-certificate handshake next."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No VID from the unlocker → try the cert handshake (host certs are
|
||||
// served by the key sources). Even on a firmware-unlocked drive we try
|
||||
// it: the drive may still honour a cert. If it doesn't, that is not a
|
||||
// failure here — no VID is fine for a keydb VK/UK, and the wedge guard
|
||||
// bounds the attempts. We log the outcome and continue.
|
||||
|
||||
// Host certs are keysource-served, never compiled in. Collect them from
|
||||
// BOTH places the caller may carry them:
|
||||
// 1. the explicit `DriveCredentials` (certs the app pre-extracted), and
|
||||
// 2. the key-source layer (`KeySource::host_certs()` across every
|
||||
// registered source — the keydb source exposes its `| HC |`/`| HC2 |`
|
||||
// rows here; an online source whose cert-serving isn't yet designed
|
||||
// contributes none).
|
||||
// The two are unioned so either wiring works. With ZERO certs from any
|
||||
// source the OEM cert route cannot run: we fail GRACEFULLY with
|
||||
// `AacsNoHostCert` (no panic, no generic failure). Resolution then
|
||||
// proceeds with VID=zero and relies on the path-1 disc-hash → VUK lookup,
|
||||
// which drops the error when it hits.
|
||||
let host_certs = Self::collect_host_certs(opts);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
||||
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
||||
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
||||
// decouples the VID from the host cert + HRL. It yields no
|
||||
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||
// by the bus-key gate in `resolve_vid_only`.
|
||||
if let Some(volume_id) = session.oem_vid() {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_host_cert",
|
||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run. Continuing without a Volume ID; a key source may still supply this disc's key."
|
||||
phase = "oem_vid_ok",
|
||||
"Volume ID supplied by the drive unlocker at init; skipping the AACS host-certificate handshake."
|
||||
);
|
||||
return (
|
||||
None,
|
||||
Some(Error::AacsNoHostCert {
|
||||
path: "<no host cert>".into(),
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let host_certs: &[aacs::HostCert] = &host_certs;
|
||||
|
||||
let host_cert_count = host_certs.len();
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
"handshake starting"
|
||||
phase = "oem_vid_none",
|
||||
"No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)."
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Cert path: the in-tree `AacsCertUnlocker` peer absorbs the host-cert
|
||||
// mutual-auth. It collects host certs from the key sources + credentials,
|
||||
// runs `aacs_authenticate` per cert (wedge-guarded), and on success reads
|
||||
// the VID + read_data_key. Its `UnlockError` is folded back to the same
|
||||
// `Error` variants this function has always surfaced, so `scan_with`'s
|
||||
// error rendering and the path-1 disc-hash → VUK fallback are unchanged.
|
||||
let unlocker = AacsCertUnlocker { opts };
|
||||
match unlocker.authenticate(session) {
|
||||
Ok(hs) => (Some(hs), None),
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "cert_handshake_outcome",
|
||||
outcome = ?cert_unlock_outcome(&e),
|
||||
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
||||
);
|
||||
(None, Some(unlock_error_to_error(e)))
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_cert_rejected",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"The drive rejected the AACS host certificate, so no Volume ID was obtained. Continuing; a key source may still supply this disc's key."
|
||||
);
|
||||
(None, Some(Error::AacsHostCertRejected))
|
||||
}
|
||||
|
||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
||||
@@ -298,6 +333,29 @@ impl Disc {
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
|
||||
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) still carries bus encryption on
|
||||
// its sectors; descrambling needs the `read_data_key` (bus key), which
|
||||
// ONLY the AACS host-certificate cert-auth handshake produces. A
|
||||
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
|
||||
// alone does NOT remove bus encryption — so if a handshake ran (live
|
||||
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
|
||||
// bytes would decrypt to garbage. Fail loudly here instead.
|
||||
//
|
||||
// Gated on `handshake.is_some()` so the two preserved cases never
|
||||
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
|
||||
// and have already had bus encryption removed at read time; (2) AACS 1.0
|
||||
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
|
||||
// skipped (its `read_data_key` is legitimately absent).
|
||||
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "bus_key_unavailable",
|
||||
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
|
||||
);
|
||||
return Err(Error::AacsBusKeyUnavailable);
|
||||
}
|
||||
// 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/
|
||||
@@ -712,6 +770,90 @@ mod tests {
|
||||
assert_eq!(st.read_data_key, Some(rdk));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// OEM bus-key gate: a bus-encrypted disc scanned on a LIVE drive
|
||||
// (handshake present) with no read_data_key must HARD-ERROR
|
||||
// (AacsBusKeyUnavailable) rather than silently yield garbage. The
|
||||
// three non-regressing cases must still succeed.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn disc_with_cert(cert_type: u8, bus_encryption: bool) -> (MemDisc, udf::UdfFs) {
|
||||
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(cert_type, bus_encryption),
|
||||
},
|
||||
],
|
||||
);
|
||||
(disc, udf)
|
||||
}
|
||||
|
||||
/// Live-drive (handshake Some) + bus_encryption cert + NO read_data_key
|
||||
/// → AacsBusKeyUnavailable. This is the wrong-keys guard: a VID-only/OEM
|
||||
/// unlock cannot remove bus encryption.
|
||||
#[test]
|
||||
fn resolve_vid_only_bus_encrypted_live_drive_without_rdk_errors() {
|
||||
let (mut disc, udf) = disc_with_cert(0x01, true);
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
};
|
||||
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
||||
.expect_err("bus-encrypted disc with no bus key must hard-error");
|
||||
assert!(matches!(err, Error::AacsBusKeyUnavailable));
|
||||
}
|
||||
|
||||
/// Live-drive + bus_encryption cert + read_data_key PRESENT → Ok (the cert
|
||||
/// handshake produced the bus key, as required).
|
||||
#[test]
|
||||
fn resolve_vid_only_bus_encrypted_live_drive_with_rdk_ok() {
|
||||
let (mut disc, udf) = disc_with_cert(0x01, true);
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: Some([0x22u8; 16]),
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
|
||||
assert!(st.bus_encryption);
|
||||
assert_eq!(st.read_data_key, Some([0x22u8; 16]));
|
||||
}
|
||||
|
||||
/// ISO scan (handshake None) of a bus_encryption disc → Ok. Bus encryption
|
||||
/// was already removed at read time; the gate must NOT fire without a
|
||||
/// handshake (no UHD-ISO-mux regression).
|
||||
#[test]
|
||||
fn resolve_vid_only_bus_encrypted_iso_no_handshake_ok() {
|
||||
let (mut disc, udf) = disc_with_cert(0x01, true);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("ISO bus disc → ok");
|
||||
assert!(st.bus_encryption);
|
||||
assert_eq!(st.read_data_key, None);
|
||||
}
|
||||
|
||||
/// AACS 1.0 BD (V10 cert, bus_encryption off) on a live drive with NO
|
||||
/// read_data_key → Ok. read_data_key is legitimately absent for AACS 1.0;
|
||||
/// the gate must NOT fire when bus_encryption is false.
|
||||
#[test]
|
||||
fn resolve_vid_only_aacs10_live_drive_without_rdk_ok() {
|
||||
let (mut disc, udf) = disc_with_cert(0x00, false);
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
|
||||
assert!(!st.bus_encryption);
|
||||
assert_eq!(st.read_data_key, None);
|
||||
}
|
||||
|
||||
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
|
||||
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
|
||||
#[test]
|
||||
@@ -798,10 +940,13 @@ mod tests {
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::HostCert>);
|
||||
impl crate::KeySource for CertSource {
|
||||
fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option<crate::disc::Key> {
|
||||
None
|
||||
fn get_uk(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self) -> Vec<aacs::HostCert> {
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
@@ -809,7 +954,7 @@ mod tests {
|
||||
#[test]
|
||||
fn collect_host_certs_empty_when_no_credentials_no_sources() {
|
||||
let opts = ScanOptions::default();
|
||||
assert!(Disc::collect_host_certs(&opts).is_empty());
|
||||
assert!(Disc::collect_host_certs(&opts, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -820,7 +965,7 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let certs = Disc::collect_host_certs(&opts);
|
||||
let certs = Disc::collect_host_certs(&opts, None);
|
||||
assert_eq!(certs.len(), 1);
|
||||
assert_eq!(certs[0].private_key, [1u8; 20]);
|
||||
}
|
||||
@@ -831,7 +976,7 @@ mod tests {
|
||||
key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))],
|
||||
..Default::default()
|
||||
};
|
||||
let certs = Disc::collect_host_certs(&opts);
|
||||
let certs = Disc::collect_host_certs(&opts, None);
|
||||
assert_eq!(certs.len(), 1);
|
||||
assert_eq!(certs[0].private_key, [2u8; 20]);
|
||||
}
|
||||
@@ -851,11 +996,62 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts)
|
||||
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts, None)
|
||||
.iter()
|
||||
.map(|c| c.private_key[0])
|
||||
.collect();
|
||||
tags.sort_unstable();
|
||||
assert_eq!(tags, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// AacsCertUnlocker outcome mapping: UnlockError → Error (preserving
|
||||
// the legacy do_handshake_cert surface) and → UnlockOutcome (the
|
||||
// structured trace step). No English in either.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unlock_error_maps_to_legacy_error_variants() {
|
||||
use crate::unlock::UnlockError;
|
||||
// No host cert keeps the AacsNoHostCert sentinel path.
|
||||
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
|
||||
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::VidUnavailable),
|
||||
Error::AacsVidUnavailable
|
||||
));
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::HandshakeRejected),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
|
||||
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::VidUnavailable),
|
||||
UnlockOutcome::VidUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::HandshakeRejected),
|
||||
UnlockOutcome::HandshakeRejected
|
||||
);
|
||||
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::Scsi(4000)),
|
||||
UnlockOutcome::HandshakeRejected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -24,7 +24,7 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const SECTOR_LEN: usize = 2048;
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
||||
/// multiples of this so the decrypt step always sees whole units.
|
||||
const AACS_UNIT_SECTORS: u32 = 3;
|
||||
@@ -282,7 +282,7 @@ impl Disc {
|
||||
for &(abs_lba, byte_len) in &pf.extents {
|
||||
extents.push(crate::disc::Extent {
|
||||
start_lba: abs_lba,
|
||||
sector_count: (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32,
|
||||
sector_count: (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,7 @@ fn extract_one_file<S: SectorSource>(
|
||||
}
|
||||
|
||||
let mut written: u64 = 0;
|
||||
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_LEN];
|
||||
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES];
|
||||
'extents: for &(abs_lba, byte_len) in &pf.extents {
|
||||
if written >= pf.size {
|
||||
break;
|
||||
@@ -467,7 +467,7 @@ fn extract_one_file<S: SectorSource>(
|
||||
// the per-extent re-anchoring in the mux read paths
|
||||
// (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None.
|
||||
dec.set_unit_base(abs_lba);
|
||||
let sectors = (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32;
|
||||
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32;
|
||||
let mut sector_off: u32 = 0;
|
||||
while sector_off < sectors {
|
||||
let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS);
|
||||
@@ -486,7 +486,7 @@ fn extract_one_file<S: SectorSource>(
|
||||
batch -= batch % AACS_UNIT_SECTORS;
|
||||
}
|
||||
let lba = abs_lba + sector_off;
|
||||
let want = batch as usize * SECTOR_LEN;
|
||||
let want = batch as usize * SECTOR_BYTES;
|
||||
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
|
||||
let chunk_bytes = want as u64;
|
||||
// Clip the chunk to the remaining file size on the final extent.
|
||||
@@ -1000,11 +1000,11 @@ mod tests {
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
||||
// ad_type 0 = Short AD (icb flags low 3 bits at offset 34).
|
||||
s[34..36].copy_from_slice(&0u16.to_le_bytes());
|
||||
let size = sectors_each * SECTOR_LEN as u32 * 2;
|
||||
let size = sectors_each * SECTOR_BYTES as u32 * 2;
|
||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
||||
s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs
|
||||
let ext_len = sectors_each * SECTOR_LEN as u32; // bytes, type-0 recorded
|
||||
let ext_len = sectors_each * SECTOR_BYTES as u32; // bytes, type-0 recorded
|
||||
// AD #0
|
||||
s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
|
||||
|
||||
+64
-18
@@ -370,8 +370,12 @@ pub enum SampleRate {
|
||||
S44_1,
|
||||
/// 48 kHz — standard BD/DVD/UHD audio
|
||||
S48,
|
||||
/// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||||
S88_2,
|
||||
/// 96 kHz — high-res BD audio
|
||||
S96,
|
||||
/// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||||
S176_4,
|
||||
/// 192 kHz — highest BD audio (LPCM)
|
||||
S192,
|
||||
/// 48/96 kHz combo (secondary audio resampled)
|
||||
@@ -432,16 +436,26 @@ pub struct Extent {
|
||||
pub sector_count: u32,
|
||||
}
|
||||
|
||||
/// Correct the channel count of a title's TrueHD audio streams by probing the
|
||||
/// first decrypted access units. The MPLS `audio_format` field declares the
|
||||
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is
|
||||
/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is
|
||||
/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded
|
||||
/// window of the title's first extent. Also regenerates the stream's codec
|
||||
/// label when it was the basic descriptor for the (now corrected) count —
|
||||
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
|
||||
/// Correct a title's TrueHD audio-stream metadata by probing the first
|
||||
/// decrypted access units — channel count, real sample rate, and Atmos
|
||||
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
|
||||
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
|
||||
/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED
|
||||
/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not
|
||||
/// scan). Reads a bounded window of the title's first extent.
|
||||
///
|
||||
/// Corrections, each individually guarded so a malformed field never writes a
|
||||
/// wrong header:
|
||||
/// - **Channels**: from the presentation channel masks (as before).
|
||||
/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an
|
||||
/// unknown rate or no major sync.
|
||||
/// - **Atmos**: when a 4th substream is detected AND the stream still carries
|
||||
/// the basic descriptor label, the label is promoted to the Atmos form;
|
||||
/// richer editorial labels (e.g. an existing "Dolby Atmos") are left intact.
|
||||
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
|
||||
use crate::mux::codec::truehd::truehd_channels_from_stream;
|
||||
use crate::mux::codec::truehd::{
|
||||
truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream,
|
||||
};
|
||||
|
||||
let pids: Vec<u16> = title
|
||||
.streams
|
||||
@@ -496,18 +510,44 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut
|
||||
let Some(payload) = payloads.get(&a.pid) else {
|
||||
continue;
|
||||
};
|
||||
let Some(count) = truehd_channels_from_stream(payload) else {
|
||||
// One major-sync read yields channels, sample rate and the Atmos signal.
|
||||
let Some(info) = truehd_sync_info_from_stream(payload) else {
|
||||
continue;
|
||||
};
|
||||
let new_ch = AudioChannels::from_count(count);
|
||||
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whether the label is still the plain descriptor (no richer editorial
|
||||
// label). Captured against the CURRENT channels before any correction so
|
||||
// a label promotion only happens when nothing editorial is present.
|
||||
let was_basic =
|
||||
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
|
||||
a.channels = new_ch;
|
||||
|
||||
// (1) Channels — only when the major sync resolves a different layout.
|
||||
if let Some(count) = truehd_channels(info.format_info) {
|
||||
let new_ch = AudioChannels::from_count(count);
|
||||
if new_ch != AudioChannels::Unknown && new_ch != a.channels {
|
||||
a.channels = new_ch;
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Sample rate — whitelisted rates only; an unknown nibble or a rate
|
||||
// that maps to no enum variant leaves the container value untouched
|
||||
// (never write a wrong SamplingFrequency).
|
||||
if let Some(hz) = truehd_sample_rate_hz(info.format_info) {
|
||||
let new_sr = SampleRate::from_hz(hz);
|
||||
if new_sr != SampleRate::Unknown && new_sr != a.sample_rate {
|
||||
a.sample_rate = new_sr;
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Label — refresh to the corrected channels; promote to the Atmos
|
||||
// form only when the stream carried the basic descriptor (no editorial
|
||||
// Atmos already) AND a 4th substream was positively detected.
|
||||
if was_basic {
|
||||
a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
|
||||
a.label = if info.is_atmos == Some(true) {
|
||||
crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary)
|
||||
} else {
|
||||
crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -840,7 +880,9 @@ impl SampleRate {
|
||||
match self {
|
||||
SampleRate::S44_1 => 44100.0,
|
||||
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
|
||||
SampleRate::S88_2 => 88200.0,
|
||||
SampleRate::S96 => 96000.0,
|
||||
SampleRate::S176_4 => 176400.0,
|
||||
SampleRate::S192 => 192000.0,
|
||||
SampleRate::Unknown => 48000.0,
|
||||
}
|
||||
@@ -851,7 +893,9 @@ impl SampleRate {
|
||||
match hz {
|
||||
44100 => SampleRate::S44_1,
|
||||
48000 => SampleRate::S48,
|
||||
88200 => SampleRate::S88_2,
|
||||
96000 => SampleRate::S96,
|
||||
176400 => SampleRate::S176_4,
|
||||
192000 => SampleRate::S192,
|
||||
_ => SampleRate::Unknown,
|
||||
}
|
||||
@@ -1037,7 +1081,9 @@ enum_str!(
|
||||
[
|
||||
("44.1kHz", SampleRate::S44_1),
|
||||
("48kHz", SampleRate::S48),
|
||||
("88.2kHz", SampleRate::S88_2),
|
||||
("96kHz", SampleRate::S96),
|
||||
("176.4kHz", SampleRate::S176_4),
|
||||
("192kHz", SampleRate::S192),
|
||||
("48/96kHz", SampleRate::S48_96),
|
||||
("48/192kHz", SampleRate::S48_192),
|
||||
@@ -1894,7 +1940,7 @@ pub enum Key {
|
||||
/// VUK → unit keys. A source hands in its FULL device-key set, because
|
||||
/// choosing which one applies *is* the MKB walk (derivation), and all
|
||||
/// derivation lives here — never in a source.
|
||||
Device(Vec<crate::aacs::keydb::DeviceKey>),
|
||||
Device(Vec<crate::aacs::types::DeviceKey>),
|
||||
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
|
||||
/// → media key → VUK → unit keys.
|
||||
Processing(Vec<[u8; 16]>),
|
||||
@@ -5023,7 +5069,7 @@ mod tests {
|
||||
let good = mf.ranges_with(&[SectorStatus::Finished]);
|
||||
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
|
||||
let disc_bytes = sectors as u64 * 2048;
|
||||
const SEC: u64 = 2048;
|
||||
const SEC: u64 = crate::consts::SECTOR_BYTES as u64;
|
||||
|
||||
// The first failing batch starts at LBA 320; everything before it read
|
||||
// cleanly and must be Finished.
|
||||
|
||||
+1
-1
@@ -2588,7 +2588,7 @@ mod tests {
|
||||
range_sectors: 1,
|
||||
};
|
||||
|
||||
let timed_out = check_range_watchdog(&mut state, &frame, &*shared);
|
||||
let timed_out = check_range_watchdog(&mut state, &frame, &shared);
|
||||
assert!(!timed_out, "range must not time out immediately");
|
||||
// With correct initialization bytes_good_now == range_bytes_good,
|
||||
// so the `bytes_good_now > range_bytes_good` branch does NOT fire
|
||||
|
||||
Reference in New Issue
Block a user