diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 67cb15d..c5f6912 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -179,54 +179,33 @@ impl Disc { session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> (Option, Option) { - use crate::aacs::{self, KeyDb}; + use crate::aacs; - let keydb_path = match opts.resolve_keydb() { - Some(p) => p, - None => { + // 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_keydb", - "no KEYDB found in search paths; handshake skipped" + phase = "handshake_no_credentials", + "no drive credentials supplied; cert handshake skipped" ); return (None, None); } }; - let keydb = match KeyDb::load(&keydb_path) { - Ok(db) => db, - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_keydb_load_failed", - io_error_kind = ?e.kind(), - keydb = %keydb_path.display(), - "KEYDB load failed; handshake skipped" - ); - return ( - None, - Some(Error::KeydbLoad { - path: keydb_path.display().to_string(), - }), - ); - } - }; - let host_cert_count = keydb.host_certs.len(); + let host_cert_count = host_certs.len(); tracing::warn!( target: "freemkv::disc", phase = "handshake_start", host_cert_count, - keydb = %keydb_path.display(), "handshake starting" ); - if host_cert_count == 0 { - // No host certs in keydb -> cert auth cannot proceed. - // Surface as RawReadUnsupported so the caller knows - // neither path is available on this configuration. - return (None, Some(Error::AacsRawReadUnsupported)); - } - // v0.25.7 wedge fix. Pre-0.25.7 this loop 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 @@ -246,7 +225,7 @@ impl Disc { const MAX_CERT_ATTEMPTS: usize = 3; const PER_CERT_BACKOFF_MS: u64 = 1000; let mut last_err_code: Option = None; - for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() { + 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)); } @@ -317,227 +296,6 @@ impl Disc { (None, Some(Error::AacsHostCertRejected)) } - /// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none. - /// - /// Reads AACS files from UDF (via SectorSource), resolves keys through - /// whatever path works: KEYDB VUK lookup, media key derivation, processing - /// keys, device keys. Uses handshake result (volume ID, bus key) if available. - pub(super) fn resolve_encryption( - udf_fs: &udf::UdfFs, - reader: &mut dyn SectorSource, - keydb_path: &std::path::Path, - handshake: Option<&HandshakeResult>, - ) -> Result { - use crate::aacs::{self, KeyDb}; - use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme}; - - let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { - path: keydb_path.display().to_string(), - })?; - - // Read AACS files from disc/image via UDF - 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)?; - - // Log the disc hash so we can confirm whether it's present in KEYDB - // when key resolution fails. The disc hash is SHA-1 of the full - // Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by. - let dh = crate::aacs::disc_hash(&uk_ro_data); - let dh_hex = crate::aacs::disc_hash_hex(&dh); - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_disc_hash", - disc_hash = %dh_hex, - uk_ro_len = uk_ro_data.len(), - "disc hash computed (compare with keydb.cfg entries)" - ); - - let cc_data = udf_fs - .read_file(reader, "/AACS/Content000.cer") - .or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer")) - .ok(); - - let mkb_data = udf_fs - .read_file(reader, "/AACS/MKB_RW.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf")) - .ok(); - let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version); - - let mkb_first_64_hex = mkb_data - .as_deref() - .map(|m| { - m.iter() - .take(64) - .map(|b| format!("{b:02x}")) - .collect::() - }) - .unwrap_or_default(); - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_mkb_info", - mkb_present = mkb_data.is_some(), - mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0), - mkb_version = ?mkb_ver, - mkb_first_64 = %mkb_first_64_hex, - keydb_disc_count = keydb.disc_entries.len(), - keydb_dk_count = keydb.device_keys.len(), - keydb_pk_count = keydb.processing_keys.len(), - "AACS resolution inputs" - ); - - // Use handshake volume ID if available, otherwise zeros - // (KEYDB VUK lookup by disc hash works without volume ID; - // paths 2/3/4 in `resolve_keys` short-circuit on the zero - // sentinel and don't waste cycles trying to derive against - // garbage input). - let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]); - let vid_available = volume_id != [0u8; 16]; - let read_data_key = handshake.and_then(|h| h.read_data_key); - - // Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key. - // - // Distinguish "we had every input and still missed" from "we - // never had VID so the derivation paths couldn't run." The - // former points at a stale keydb / unsupported MKB; the - // latter points at a failed handshake upstream. Path 1 - // (disc-hash lookup) ran without VID and missed -> disc isn't - // in the keydb. If the caller has a handshake-failure reason - // it overrides this in `scan_with`. - let miss_error = if vid_available { - Error::AacsMkUnavailable - } else { - Error::AacsVukNotInKeydb - }; - - // Build a probe + context and let the dispatcher pick V10 / V20 - // / V21. CSS is impossible here (this function is only called - // when /AACS exists), so we don't populate the DVD probe sector - // or a CSS context. - let probe = DrmProbe { - dvd_sample_sector: None, - content_cert: cc_data.as_deref(), - mkb: mkb_data.as_deref(), - }; - let scheme = match DrmScheme::detect(&probe) { - Some(s) => s, - None => return Err(miss_error), - }; - let providers: &[&dyn aacs::KeyProvider] = &[&keydb]; - let aacs_ctx = aacs::ResolveContext { - unit_key_ro: &uk_ro_data, - content_cert: cc_data.as_deref(), - volume_id: &volume_id, - providers, - mkb: mkb_data.as_deref(), - }; - let mut ctx = DrmContext { - aacs: Some(aacs_ctx), - css: None, - }; - let resolved = match scheme.load(&mut ctx) { - Some(ResolvedScheme::Aacs(r)) => r, - // Resolution against /AACS inputs can only produce AACS - // keys. Either the dispatcher returned None (load failed) - // or — structurally impossible here — a CSS state. Both - // surface as the upstream miss-error. - _ => return Err(miss_error), - }; - - Ok(AacsState { - version: match resolved.version { - aacs::AacsVersion::V10 => 1, - aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2, - }, - bus_encryption: resolved.bus_encryption, - mkb_version: mkb_ver, - disc_hash: aacs::disc_hash_hex(&resolved.disc_hash), - key_source: match resolved.key_source { - 1 => KeyOrigin::DeviceKey, - 2 => KeyOrigin::ProcessingKey, - 3 => KeyOrigin::KeyDbDerived, - 4 => KeyOrigin::KeyDb, - 5 => KeyOrigin::KeyDbUnitKeys, - _ => KeyOrigin::KeyDb, - }, - vuk: resolved.vuk, - unit_keys: resolved.unit_keys, - read_data_key, - volume_id, - // Stash the AACS inputs so a later out-of-band `Disc::decrypt_with` - // (caller-resolved Key → derive down) can run without re-reading - // the disc. The borrows in `aacs_ctx` ended when `scheme.load` - // returned, so these buffers are free to move here. - uk_ro: uk_ro_data, - mkb: mkb_data.unwrap_or_default(), - }) - } - - /// Resolve encryption from a caller-supplied Unit Key (the external key service - /// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc - /// hash + version/bus-encryption flags, then use `unit_key` directly as - /// CPS unit 1's decryption key. The handshake (if any) still supplies the - /// volume ID and AACS 2.0 read-data key for bus decryption. - pub(super) fn resolve_encryption_static( - udf_fs: &udf::UdfFs, - reader: &mut dyn SectorSource, - unit_key: [u8; 16], - handshake: Option<&HandshakeResult>, - ) -> Result { - 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, - }; - - let mkb_ver = udf_fs - .read_file(reader, "/AACS/MKB_RW.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf")) - .ok() - .as_deref() - .and_then(aacs::mkb_version); - - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_external_uk", - disc_hash = %aacs::disc_hash_hex(&dh), - version, - bus_encryption, - "using caller-supplied unit key" - ); - - 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![(1, unit_key)], - 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: Vec::new(), - mkb: Vec::new(), - }) - } - /// 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 diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 881018c..b935521 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -939,59 +939,31 @@ impl KeyOrigin { // ─── Disc scanning ────────────────────────────────────────────────────────── -/// Standard KEYDB.cfg search locations (compatible with libaacs). -const KEYDB_SEARCH_PATHS: &[&str] = &[ - ".config/aacs/KEYDB.cfg", // libaacs standard path - ".config/freemkv/keydb.cfg", // freemkv download path -]; -const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg"; - -/// Options for disc scanning. -#[derive(Default)] -pub struct ScanOptions { - /// Path to KEYDB.cfg for AACS key lookup. - /// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/). - pub keydb_path: Option, - /// Caller-supplied Unit Key — an alternative to keydb lookup. When set, - /// libfreemkv skips keydb lookup and all derivation and uses this key - /// directly to decrypt; it takes precedence over `keydb_path`. The caller - /// obtains the key however it likes; libfreemkv stays free of any network - /// dependency. - pub unit_key: Option<[u8; 16]>, - /// Disable KEYDB entirely: skip both the explicit `keydb_path` and the - /// standard-location search, so no keydb is loaded for this scan. A caller - /// that resolves keys out-of-band (e.g. a remote key service) sets this so - /// a keydb that merely happens to sit in a default location does not shadow - /// the out-of-band path. `unit_key` still takes precedence over everything. - pub disable_keydb: bool, +/// AACS host credentials for the live-drive authenticated handshake. +/// +/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM +/// Volume-ID path and needs none, and an ISO scan has no handshake at all. The +/// caller supplies the host cert(s) from wherever it likes — today the keydb's +/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key +/// source: a locked drive needs the cert to unlock even when the decryption key +/// comes from an online service. +#[derive(Default, Clone)] +pub struct DriveCredentials { + /// Host certificate(s) + private key(s) for the SCSI AACS handshake. + pub host_certs: Vec, } -impl ScanOptions { - /// Resolve KEYDB path: explicit path first, then standard locations. - /// Returns `None` when `disable_keydb` is set — no keydb is consulted. - fn resolve_keydb(&self) -> Option { - if self.disable_keydb { - return None; - } - if let Some(p) = &self.keydb_path { - if p.exists() { - return Some(p.clone()); - } - } - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { - for relative in KEYDB_SEARCH_PATHS { - let p = std::path::PathBuf::from(&home).join(relative); - if p.exists() { - return Some(p); - } - } - } - let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH); - if p.exists() { - return Some(p); - } - None - } +/// Options for disc scanning. +/// +/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key +/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The +/// only scan input is the optional drive credentials for the live-drive +/// authenticated handshake. +#[derive(Default)] +pub struct ScanOptions { + /// Host credentials for the live-drive AACS handshake. `None` for an + /// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans. + pub credentials: Option, } /// Quick disc identification — name, format, capacity. No title/stream parsing. @@ -1233,7 +1205,7 @@ impl Disc { capacity: u32, handshake: Option, handshake_error: Option, - opts: &ScanOptions, + _opts: &ScanOptions, udf_fs: udf::UdfFs, ) -> Result { // 2. Resolve encryption (AACS, CSS, or none) @@ -1242,69 +1214,16 @@ impl Disc { let (aacs, aacs_error) = if !encrypted { (None, None) - } else if let Some(unit_key) = opts.unit_key { - // Second key source: caller supplied the Unit Key directly - // (external Unit Key). Skip keydb entirely. - match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) { - Ok(state) => (Some(state), None), - Err(e) => (None, Some(e)), - } - } else if opts.disable_keydb { - // Keydb disabled: keys are resolved out-of-band. Still capture the - // VID (read during the handshake) so the out-of-band path has it; - // carry no keys (disc reports "encrypted, no keys" until re-scanned - // with a resolved Unit Key). + } else { + // Lookup-free: capture the disc's AACS inputs (MKB, VID, + // Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key + // from a key source and applies it via `Disc::decrypt_with`. The + // disc reports "encrypted, no keys" until then. match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) { Ok(state) => (Some(state), None), - Err(e) => (None, Some(e)), - } - } else { - match opts.resolve_keydb() { - Some(keydb_path) => { - match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()) - { - Ok(state) => (Some(state), None), - Err(e) => { - // When the handshake itself failed AND resolution - // bottomed out at "no keys", surface the upstream - // handshake failure — it's more actionable than - // the generic AacsNoKeys. - let final_err = match (&e, handshake_error.as_ref()) { - ( - Error::AacsNoKeys - | Error::AacsVukNotInKeydb - | Error::AacsVidUnavailable, - Some(_), - ) => handshake_error.unwrap(), - _ => e, - }; - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_resolve_failed", - error_code = final_err.code(), - keydb = %keydb_path.display(), - handshake_ok = handshake.is_some(), - "AACS key resolution failed" - ); - (None, Some(final_err)) - } - } - } - None => { - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_no_keydb", - "encrypted disc but no KEYDB found in search paths" - ); - // Sentinel path string lets autorip's message switch - // distinguish "no keydb found anywhere" from "keydb at - // failed to parse". - let final_err = - handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad { - path: String::from(""), - }); - (None, Some(final_err)) - } + // A handshake failure (no VID) is more actionable than the + // generic capture error, so surface it when present. + Err(e) => (None, Some(handshake_error.unwrap_or(e))), } }; diff --git a/src/lib.rs b/src/lib.rs index 5878dc5..1254359 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,9 +171,9 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr // prefix at the crate root to keep both addressable. pub use disc::{ AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity, - Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeyOrigin, - LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, - Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage, + Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, FrameRate, HdrFormat, Key, + KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, + ScanOptions, Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage, }; pub use keysource::{DiscInputs, KeySource}; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index ab67b5d..3a25f91 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -162,10 +162,11 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { /// Options for opening an input stream. #[derive(Default)] pub struct InputOptions { - pub keydb_path: Option, - /// Caller-supplied Unit Key (external Unit Key) — the second, mutually - /// exclusive key source. Takes precedence over `keydb_path`. - pub unit_key: Option<[u8; 16]>, + /// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc + /// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the + /// caller has no key. The library does no lookup — a key source resolves + /// these and the caller passes them here. + pub unit_keys: Vec<(u32, [u8; 16])>, pub title_index: Option, /// Skip decryption — return raw encrypted bytes. pub raw: bool, @@ -184,11 +185,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result { validate_file_path(path, "iso")?; - let scan_opts = crate::disc::ScanOptions { - keydb_path: opts.keydb_path.as_ref().map(Into::into), - unit_key: opts.unit_key, - ..Default::default() - }; // FileSectorSource is the sole file-backed sector source. // It carries the platform-tuned SEQUENTIAL fadvise hint // (so the kernel readahead window widens) and the periodic @@ -196,8 +192,17 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; + let mut disc = crate::disc::Disc::scan_image( + &mut reader, + capacity, + &crate::disc::ScanOptions::default(), + ) + .map_err(|e| -> io::Error { e.into() })?; + // Apply the caller-resolved keys (lookup-free); decrypt_keys() then + // yields them for the stream below. + if !opts.unit_keys.is_empty() { + let _ = disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone())); + } if disc.titles.is_empty() { return Err(crate::error::Error::NoStreams.into()); } diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 6e832e1..efb6966 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -126,31 +126,19 @@ fn disc_title_total_sectors() { // ── ScanOptions tests ────────────────────────────────────────────────────── #[test] -fn scan_options_default() { +fn scan_options_default_has_no_credentials() { + // Lookup-free: the only scan input is the optional live-drive credentials. let opts = ScanOptions::default(); - assert!(opts.keydb_path.is_none()); + assert!(opts.credentials.is_none()); } #[test] -fn scan_options_with_keydb() { +fn scan_options_with_credentials() { let opts = ScanOptions { - keydb_path: Some(("/tmp/KEYDB.cfg").into()), - ..Default::default() + credentials: Some(libfreemkv::DriveCredentials::default()), }; - assert_eq!( - opts.keydb_path.as_ref().unwrap().to_str().unwrap(), - "/tmp/KEYDB.cfg" - ); -} - -#[test] -fn scan_options_with_keydb_pathbuf() { - let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg"); - let opts = ScanOptions { - keydb_path: Some(path.clone()), - ..Default::default() - }; - assert_eq!(opts.keydb_path.unwrap(), path); + assert!(opts.credentials.is_some()); + assert!(opts.credentials.unwrap().host_certs.is_empty()); } // ── detect_format integration tests ─────────────────────────────────────── @@ -511,21 +499,16 @@ fn batch_count_max_batch_sizes() { } #[test] -fn resolve_encryption_no_keydb() { - // A UDF image with /AACS directory but no keydb path -> aacs is None +fn scan_encrypted_resolves_no_keys() { + // A UDF image with an /AACS directory: the lookup-free scan detects + // encryption and captures inputs, but resolves NO key on its own — a + // caller applies one later via Disc::decrypt_with. let mut reader = MockSectorReader::new(); build_udf_with_aacs_dir(&mut reader); - // No keydb configured and no standard keydb on the system - let opts = ScanOptions { - keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()), - ..Default::default() - }; - let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap(); + let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap(); - // The disc detects encryption but can't resolve keys without a keydb - assert!( - disc.aacs.is_none(), - "aacs should be None when keydb is unavailable" - ); + // No unit keys without an external key (the mock has no Unit_Key_RO.inf to + // capture, so the keyless state isn't even built) — either way, no keys. + assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None)); }