0.27.4: lookup-free Key API (Disc::decrypt_with) + E8005 deferred-mux fix
Add libfreemkv::Key (non_exhaustive; Unit wired) + Disc::decrypt_with(Key) — the caller resolves a key from any source and hands it in; the library decrypts with no keydb/network of its own. inject_unit_keys now synthesizes a minimal ExternalUk AACS state when a scan built none (no keydb at scan time), so a disc keyed at sweep but scanned without a keydb still decrypts from its mapfile UK instead of reporting E8005 and deferring the mux forever. Tests cover the synthesize, replace, empty->None, and decrypt_with paths.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.27.3"
|
version = "0.27.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
+195
-1
@@ -1476,6 +1476,28 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A decryption key handed to libfreemkv by the caller.
|
||||||
|
///
|
||||||
|
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
|
||||||
|
/// server, never searches paths. The application resolves a key from whatever
|
||||||
|
/// source it likes (a local keydb, an online key service, a mapfile cache) and
|
||||||
|
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
|
||||||
|
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
|
||||||
|
///
|
||||||
|
/// `#[non_exhaustive]`: AACS is a derivation chain
|
||||||
|
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
|
||||||
|
/// [`Key::Unit`] entry point is wired; the higher entry points
|
||||||
|
/// (device / media / volume key) land as the derivation is lifted out of the
|
||||||
|
/// keydb-coupled scan path. Adding them is non-breaking thanks to
|
||||||
|
/// `#[non_exhaustive]`.
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Key {
|
||||||
|
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
|
||||||
|
/// (keydb / key server) resolved these, or they were cached in the mapfile
|
||||||
|
/// at sweep; libfreemkv decrypts directly with no further derivation.
|
||||||
|
Unit(Vec<(u32, [u8; 16])>),
|
||||||
|
}
|
||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// Get the resolved decryption keys for this disc.
|
/// Get the resolved decryption keys for this disc.
|
||||||
/// Used by disc-to-ISO and other full-disc operations.
|
/// Used by disc-to-ISO and other full-disc operations.
|
||||||
@@ -1506,11 +1528,53 @@ impl Disc {
|
|||||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||||
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
|
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
|
||||||
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
|
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
|
||||||
/// No-op for a disc with no AACS state (unencrypted / non-AACS).
|
///
|
||||||
|
/// If the scan built no AACS state (`self.aacs == None`) — which happens
|
||||||
|
/// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
|
||||||
|
/// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
|
||||||
|
/// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
|
||||||
|
/// decryption key; the keydb is only needed to *derive* it, and that
|
||||||
|
/// derivation already happened at sweep (the UK is in the mapfile). So a UK
|
||||||
|
/// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
|
||||||
|
/// was applied by the drive at read time, so `read_data_key` is unused for
|
||||||
|
/// file-backed mux. Without this, a keyed disc swept without a keydb would
|
||||||
|
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
|
||||||
|
/// remux. No-op for an unencrypted or CSS (DVD) disc.
|
||||||
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
||||||
if let Some(aacs) = self.aacs.as_mut() {
|
if let Some(aacs) = self.aacs.as_mut() {
|
||||||
aacs.unit_keys = keys;
|
aacs.unit_keys = keys;
|
||||||
aacs.key_source = KeySource::ExternalUk;
|
aacs.key_source = KeySource::ExternalUk;
|
||||||
|
} else if self.encrypted && self.css.is_none() {
|
||||||
|
self.aacs = Some(AacsState {
|
||||||
|
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
|
||||||
|
bus_encryption: self.format == DiscFormat::Uhd,
|
||||||
|
mkb_version: None,
|
||||||
|
disc_hash: String::new(),
|
||||||
|
key_source: KeySource::ExternalUk,
|
||||||
|
vuk: None,
|
||||||
|
unit_keys: keys,
|
||||||
|
read_data_key: None,
|
||||||
|
volume_id: [0u8; 16],
|
||||||
|
});
|
||||||
|
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
|
||||||
|
// the decryption key. Clear it so callers don't treat the disc as
|
||||||
|
// keyless on the stale error.
|
||||||
|
self.aacs_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
|
||||||
|
/// decryption state. **Lookup-free**: no keydb, no network — the caller
|
||||||
|
/// (an application, via a key source) does all resolution and hands the key
|
||||||
|
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
|
||||||
|
/// unit keys came from a key source or the mapfile cache, and libfreemkv
|
||||||
|
/// decrypts directly (see [`Self::inject_unit_keys`]).
|
||||||
|
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
|
||||||
|
match key {
|
||||||
|
Key::Unit(keys) => {
|
||||||
|
self.inject_unit_keys(keys);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2715,6 +2779,136 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
|
||||||
|
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
|
||||||
|
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
|
||||||
|
// in the mapfile. At remux the UK is recovered and injected — that MUST
|
||||||
|
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
|
||||||
|
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
|
||||||
|
// forever with "No keys available (E8005)" despite holding the UK.
|
||||||
|
let mut disc = make_test_disc(1000, "UHD");
|
||||||
|
disc.encrypted = true;
|
||||||
|
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
|
||||||
|
path: "<no keydb in search paths>".into(),
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
|
||||||
|
"precondition: encrypted disc with no aacs state => no decrypt keys"
|
||||||
|
);
|
||||||
|
|
||||||
|
let uk = vec![(0u32, [0x11u8; 16])];
|
||||||
|
disc.inject_unit_keys(uk.clone());
|
||||||
|
|
||||||
|
match disc.decrypt_keys() {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs {
|
||||||
|
unit_keys,
|
||||||
|
read_data_key,
|
||||||
|
} => {
|
||||||
|
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
||||||
|
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
disc.aacs_error.is_none(),
|
||||||
|
"stale KeydbLoad must be cleared once a UK is in hand"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disc.aacs.as_ref().unwrap().key_source,
|
||||||
|
KeySource::ExternalUk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||||||
|
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||||||
|
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||||||
|
AacsState {
|
||||||
|
version: 2,
|
||||||
|
bus_encryption: true,
|
||||||
|
mkb_version: None,
|
||||||
|
disc_hash: String::new(),
|
||||||
|
key_source: KeySource::DeviceKey,
|
||||||
|
vuk: None,
|
||||||
|
unit_keys,
|
||||||
|
read_data_key: None,
|
||||||
|
volume_id: [0u8; 16],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
|
||||||
|
// VID-only state (resolved but no Unit Key yet) must read as None, not
|
||||||
|
// an empty-but-usable key set — callers treat it as "keys missing".
|
||||||
|
let mut disc = make_test_disc(1000, "UHD");
|
||||||
|
disc.encrypted = true;
|
||||||
|
disc.aacs = Some(aacs_with(Vec::new()));
|
||||||
|
assert!(matches!(
|
||||||
|
disc.decrypt_keys(),
|
||||||
|
crate::decrypt::DecryptKeys::None
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
|
||||||
|
// When scan DID build an AACS state, decrypt_with must overwrite its
|
||||||
|
// unit keys (not append) and mark the source ExternalUk.
|
||||||
|
let mut disc = make_test_disc(1000, "UHD");
|
||||||
|
disc.encrypted = true;
|
||||||
|
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
|
||||||
|
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
|
||||||
|
disc.decrypt_with(Key::Unit(new.clone())).unwrap();
|
||||||
|
match disc.decrypt_keys() {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||||
|
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Aacs decrypt keys"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
disc.aacs.as_ref().unwrap().key_source,
|
||||||
|
KeySource::ExternalUk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_with_unit_key_yields_decrypt_keys() {
|
||||||
|
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
|
||||||
|
// decrypt_keys() must return usable AACS state (same path as the
|
||||||
|
// deferred-mux resume — autorip resolves the UK and passes it in).
|
||||||
|
let mut disc = make_test_disc(1000, "UHD");
|
||||||
|
disc.encrypted = true;
|
||||||
|
let uk = vec![(0u32, [0x44u8; 16])];
|
||||||
|
disc.decrypt_with(Key::Unit(uk.clone())).unwrap();
|
||||||
|
match disc.decrypt_keys() {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||||
|
assert_eq!(unit_keys, uk);
|
||||||
|
}
|
||||||
|
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
|
||||||
|
// Unencrypted disc: nothing to inject into, stays None.
|
||||||
|
let mut plain = make_test_disc(1000, "PLAIN");
|
||||||
|
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
|
||||||
|
assert!(plain.aacs.is_none());
|
||||||
|
assert!(matches!(
|
||||||
|
plain.decrypt_keys(),
|
||||||
|
crate::decrypt::DecryptKeys::None
|
||||||
|
));
|
||||||
|
|
||||||
|
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
|
||||||
|
let mut dvd = make_test_disc(1000, "DVD");
|
||||||
|
dvd.format = DiscFormat::Dvd;
|
||||||
|
dvd.encrypted = true;
|
||||||
|
dvd.css = Some(crate::css::CssState {
|
||||||
|
title_key: [0u8; 5],
|
||||||
|
});
|
||||||
|
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
|
||||||
|
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sweep_to_dev_null_no_enodev() {
|
fn sweep_to_dev_null_no_enodev() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+3
-3
@@ -170,9 +170,9 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
|||||||
// prefix at the crate root to keep both addressable.
|
// prefix at the crate root to keep both addressable.
|
||||||
pub use disc::{
|
pub use disc::{
|
||||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeySource,
|
||||||
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
|
LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions,
|
||||||
SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Streams ────────────────────────────────────────────────────────────────
|
// ─── Streams ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user