mux: fail iso:// with no usable AACS key instead of muxing garbage

When an AACS-encrypted ISO is muxed with decryption requested (not --raw)
but key resolution yielded no usable key, input() proceeded to mux the
still-encrypted stream — emitting ~100 MB of garbage (no TS syncs, demuxer
emits nothing) and sometimes spinning for tens of minutes.

Add a cheap result-check in resolve::input()'s Iso branch via the pure
predicate aacs_key_missing(raw, has_aacs, keys): when decryption is
requested AND the disc carries AACS state AND decrypt_keys() is None,
return new Error::NoDiscKey { disc_hash } (E7022) before muxing. The
40-hex disc hash is sourced from AacsState::disc_hash. --raw and
non-AACS (unencrypted / CSS) discs are unaffected. Unit-tested.
This commit is contained in:
MattJackson
2026-06-05 05:06:15 -07:00
parent e04d79c593
commit b7405e2d27
2 changed files with 93 additions and 0 deletions
+13
View File
@@ -78,6 +78,7 @@ pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019; pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020; pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021; pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
pub const E_NO_DISC_KEY: u16 = 7022;
// Keydb (8xxx) // Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000; pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -253,6 +254,16 @@ pub enum Error {
/// template (older profile blob, or a drive class without an OEM /// template (older profile blob, or a drive class without an OEM
/// VID path). /// VID path).
VidCdbUnavailable, VidCdbUnavailable,
/// The disc is AACS-encrypted and decryption was requested, but key
/// resolution produced no usable key for it — so muxing would emit
/// undecryptable garbage. Distinct from [`Error::KeydbLoad`] (no keydb
/// file at all): a keydb may be present but lack an entry for this disc.
/// `disc_hash` is the 40-hex SHA1 of `Unit_Key_RO.inf` (no `0x` prefix)
/// so the application can name the disc; empty if the hash wasn't
/// captured at scan.
NoDiscKey {
disc_hash: String,
},
// Keydb (8xxx) // Keydb (8xxx)
KeydbConnect { KeydbConnect {
@@ -345,6 +356,7 @@ impl Error {
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB, Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING, Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE, Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID, Error::KeydbInvalid => E_KEYDB_INVALID,
@@ -471,6 +483,7 @@ impl std::fmt::Display for Error {
Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr), Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size), Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path), Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
Error::NoDiscKey { disc_hash } => write!(f, "E{}: {}", self.code(), disc_hash),
_ => write!(f, "E{}", self.code()), _ => write!(f, "E{}", self.code()),
} }
} }
+80
View File
@@ -172,6 +172,22 @@ pub struct InputOptions {
pub raw: bool, pub raw: bool,
} }
/// Decide whether an ISO mux must abort for lack of a usable AACS key.
///
/// Returns `true` only when ALL hold: decryption is requested (`!raw`), the
/// disc carries AACS state (`has_aacs` — AACS-encrypted, not CSS/unencrypted),
/// and key resolution produced no usable key (`keys` is
/// [`crate::decrypt::DecryptKeys::None`]). In that case muxing would emit
/// undecryptable garbage, so the caller fails fast with [`Error::NoDiscKey`].
///
/// `--raw` (raw=true) always returns `false` — raw intentionally skips
/// decryption and needs no key. A non-AACS disc (`has_aacs=false`) always
/// returns `false`: unencrypted content has `None` keys legitimately, and CSS
/// DVDs resolve to `DecryptKeys::Css{..}` (never `None`).
fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKeys) -> bool {
!raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// Open a PES input stream (produces PES frames). /// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> { pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
@@ -205,6 +221,23 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone())) disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()))
.map_err(|e| -> io::Error { e.into() })?; .map_err(|e| -> io::Error { e.into() })?;
} }
// No-key guard: if decryption is requested (not --raw) and the disc
// is AACS-encrypted but key resolution yielded no usable key, FAIL
// here — muxing an undecryptable stream produces ~100 MB of garbage
// (encrypted m2ts → no TS syncs → demuxer emits nothing). A cheap
// result-check on `decrypt_keys()`; no probe decryption needed.
// CSS (DVD) decrypts from compiled keys (`decrypt_keys()` returns
// `Css{..}`, never `None`), so this gate is AACS-only via `disc.aacs`.
if aacs_key_missing(opts.raw, disc.aacs.is_some(), &disc.decrypt_keys()) {
// Surface the disc hash (40-hex, no `0x` prefix) so the caller
// can name the disc. Empty if scan didn't capture it.
let disc_hash = disc
.aacs
.as_ref()
.map(|a| a.disc_hash.trim_start_matches("0x").to_string())
.unwrap_or_default();
return Err(crate::error::Error::NoDiscKey { disc_hash }.into());
}
if disc.titles.is_empty() { if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into()); return Err(crate::error::Error::NoStreams.into());
} }
@@ -457,3 +490,50 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
pid_to_track, pid_to_track,
)) ))
} }
#[cfg(test)]
mod tests {
use super::aacs_key_missing;
use crate::decrypt::DecryptKeys;
fn aacs_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(1, [0x11u8; 16])],
read_data_key: None,
}
}
fn css_keys() -> DecryptKeys {
DecryptKeys::Css {
title_key: [0u8; 5],
}
}
#[test]
fn encrypted_no_key_aborts() {
// AACS disc, decryption requested, resolver yielded no key → abort.
assert!(aacs_key_missing(false, true, &DecryptKeys::None));
}
#[test]
fn encrypted_with_key_proceeds() {
// AACS disc with a usable key → proceed.
assert!(!aacs_key_missing(false, true, &aacs_keys()));
}
#[test]
fn not_encrypted_proceeds() {
// No AACS state: unencrypted (None keys) and CSS (Css keys) both OK.
assert!(!aacs_key_missing(false, false, &DecryptKeys::None));
assert!(!aacs_key_missing(false, false, &css_keys()));
}
#[test]
fn raw_never_aborts() {
// --raw skips decryption — must never hit the no-key abort, even on an
// AACS disc with no key resolved.
assert!(!aacs_key_missing(true, true, &DecryptKeys::None));
assert!(!aacs_key_missing(true, true, &aacs_keys()));
assert!(!aacs_key_missing(true, false, &DecryptKeys::None));
}
}