diff --git a/Cargo.toml b/Cargo.toml index 60e0a26..2c86c64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,8 @@ categories = ["multimedia"] # Path during development; the published release pins a crates.io version. The # crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. libfreemkv = { version = "0.27", path = "../libfreemkv" } +# OnlineSource: POST disc inputs + samples to a key service over HTTP. +ureq = { version = "2", features = ["json"] } +serde_json = "1" +base64 = "0.22" +tracing = "0.1" diff --git a/src/keydb.rs b/src/keydb.rs index 1fd9243..e480d07 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -93,6 +93,7 @@ mod tests { volume_id: [0u8; 16], mkb: Vec::new(), unit_key_ro: Vec::new(), + samples: Vec::new(), } } diff --git a/src/lib.rs b/src/lib.rs index 5636a12..0712402 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,8 @@ //! implementations that do the lookup: //! //! - [`KeydbSource`] — a local `keydb.cfg` (source #1). -//! - `OnlineSource` — a remote key service (source #2). *(added with the app wiring)* -//! - `MapfileSource` — the persisted unit key from a rip mapfile (source #3). +//! - [`OnlineSource`] — a remote key service (source #2). +//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3). //! //! Applications (autorip, the `freemkv` CLI) choose and order the sources from //! their own config — the local-vs-online policy is just which impls they plug @@ -17,8 +17,12 @@ //! order and keeps the first that decrypts a sample ([`resolve_first`]). mod keydb; +mod mapfile; +mod online; pub use keydb::KeydbSource; +pub use mapfile::MapfileSource; +pub use online::OnlineSource; // Re-exported for downstream convenience so apps need only depend on this crate // for the source-side types. diff --git a/src/mapfile.rs b/src/mapfile.rs new file mode 100644 index 0000000..efe5258 --- /dev/null +++ b/src/mapfile.rs @@ -0,0 +1,42 @@ +//! Mapfile cache source (source #3). +//! +//! A rip's ddrescue-style mapfile persists the resolved unit keys in its +//! `# freemkv-uk:` header (written at sweep time when the disc was keyed). On +//! resume / deferred mux, that mapfile is the fastest source — the keys are +//! already resolved, no keydb parse and no network round-trip. This source +//! reads them back as a terminal [`Key::Unit`] candidate. +//! +//! It is keyed by the mapfile path (the disc identity is implicit in which +//! mapfile belongs to which rip), so it ignores [`DiscInputs`]. + +use std::path::PathBuf; + +use libfreemkv::disc::mapfile::Mapfile; +use libfreemkv::{DiscInputs, Key, KeySource, Result}; + +/// A [`KeySource`] backed by a rip mapfile's persisted unit keys. +pub struct MapfileSource { + path: PathBuf, +} + +impl MapfileSource { + /// A mapfile source reading the given `*.mapfile` path. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } +} + +impl KeySource for MapfileSource { + fn resolve(&self, _inputs: &DiscInputs) -> Result> { + // A missing/unreadable/keyless mapfile simply offers nothing. + let Ok(map) = Mapfile::load(&self.path) else { + return Ok(Vec::new()); + }; + let uks = map.unit_keys(); + if uks.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![Key::Unit(uks.to_vec())]) + } + } +} diff --git a/src/online.rs b/src/online.rs new file mode 100644 index 0000000..e4c68f3 --- /dev/null +++ b/src/online.rs @@ -0,0 +1,178 @@ +//! Online key-service source (source #2). +//! +//! Sends the disc's `Unit_Key_RO.inf`, MKB, Volume ID, and a few encrypted +//! content samples to a remote key service and receives a Unit Key. autorip's +//! original `OnlineKeyService` lived in the app; it moves here so the online +//! lookup is a first-class published source. The library never makes the +//! request — this crate does, keeping libfreemkv network-free. +//! +//! The service does all derivation server-side and returns a final UK, so this +//! source yields a single [`Key::Unit`] candidate (or none). + +use std::time::Duration; + +use base64::Engine; +use libfreemkv::{DiscInputs, Key, KeySource, Result}; + +/// A real MKB is at most a few MB (a UHD MKB ~3.8 MB). Far larger means +/// something is wrong (e.g. the padded MKB_RW region was read); don't ship a +/// giant body — skip the query. +const MAX_MKB_BYTES: usize = 10 * 1024 * 1024; + +/// Generous deadline: the body carries the MKB (~5 MB base64) plus samples and +/// the service is often remote on a slow link. A down server still fails fast +/// (connection refused returns immediately). +const KEYSERVICE_TIMEOUT_SECS: u64 = 180; + +/// Client for a remote AACS key service. Opaque third party: it is sent the +/// disc's files + samples and returns a Unit Key or nothing. +pub struct OnlineSource { + base_url: String, + secret: String, +} + +impl OnlineSource { + /// A source posting to `base_url` with an optional bearer `secret`. + pub fn new(base_url: impl Into, secret: impl Into) -> Self { + Self { + base_url: base_url.into(), + secret: secret.into(), + } + } +} + +impl KeySource for OnlineSource { + fn resolve(&self, inputs: &DiscInputs) -> Result> { + if self.base_url.is_empty() { + tracing::warn!(phase = "keyservice_query", "no key service URL configured"); + return Ok(Vec::new()); + } + if inputs.mkb.len() > MAX_MKB_BYTES { + tracing::warn!( + phase = "keyservice_query", + mkb_bytes = inputs.mkb.len(), + "MKB unexpectedly large ({} MB) — not querying the key service", + inputs.mkb.len() / 1024 / 1024 + ); + return Ok(Vec::new()); + } + + let url = format!("{}/decode", self.base_url.trim_end_matches('/')); + let b64 = base64::engine::general_purpose::STANDARD; + let mut body = serde_json::json!({ + "inf_b64": b64.encode(&inputs.unit_key_ro), + "mkb_b64": b64.encode(&inputs.mkb), + }); + if inputs.volume_id != [0u8; 16] { + body["vid_b64"] = serde_json::Value::String(b64.encode(inputs.volume_id)); + } + if !inputs.samples.is_empty() { + body["units_b64"] = serde_json::Value::Array( + inputs + .samples + .iter() + .map(|u| serde_json::Value::String(b64.encode(u))) + .collect(), + ); + } + + let mut req = ureq::post(&url).timeout(Duration::from_secs(KEYSERVICE_TIMEOUT_SECS)); + if !self.secret.is_empty() { + req = req.set("Authorization", &format!("Bearer {}", self.secret)); + } + tracing::info!( + phase = "keyservice_query", + url = %url, + inf = inputs.unit_key_ro.len(), + mkb = inputs.mkb.len(), + has_vid = inputs.volume_id != [0u8; 16], + units = inputs.samples.len(), + "querying key service" + ); + + // A source never fails the whole resolve: network / status / parse + // problems are logged (so the device log shows unreachable vs no-key) + // and surface as "no candidate", letting the next source try. + let resp = match req.send_json(body) { + Ok(r) => r, + Err(ureq::Error::Status(code, _)) => { + tracing::warn!( + phase = "keyservice_query", + status = code, + "key service returned no key" + ); + return Ok(Vec::new()); + } + Err(e) => { + tracing::warn!(phase = "keyservice_query", error = %e, "key service unreachable"); + return Ok(Vec::new()); + } + }; + let json: serde_json::Value = match resp.into_json() { + Ok(j) => j, + Err(e) => { + tracing::warn!(phase = "keyservice_query", error = %e, "key service reply unreadable"); + return Ok(Vec::new()); + } + }; + match json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) { + Some(uk) => { + tracing::info!(phase = "keyservice_query", "key service returned a key"); + // The service resolves a final unit key server-side; hand it in + // as the terminal level for CPS unit 1 (matching the prior + // rescan-with-unit-key behavior). + Ok(vec![Key::Unit(vec![(1, uk)])]) + } + None => { + tracing::warn!( + phase = "keyservice_query", + "key service reply had no usable key" + ); + Ok(Vec::new()) + } + } + } +} + +/// Parse a 32-char hex Unit Key into 16 bytes. +fn parse_uk(hex: &str) -> Option<[u8; 16]> { + if hex.len() != 32 { + return None; + } + let mut out = [0u8; 16]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_uk_roundtrip() { + assert_eq!( + parse_uk("1deb13ba851d8fbc01e169dca7d2f258").unwrap(), + [ + 0x1d, 0xeb, 0x13, 0xba, 0x85, 0x1d, 0x8f, 0xbc, 0x01, 0xe1, 0x69, 0xdc, 0xa7, 0xd2, + 0xf2, 0x58 + ] + ); + assert!(parse_uk("deadbeef").is_none()); + assert!(parse_uk("zz").is_none()); + } + + #[test] + fn empty_url_yields_no_candidate() { + let src = OnlineSource::new("", ""); + let inputs = DiscInputs { + disc_hash: "0xaabb".into(), + volume_id: [0u8; 16], + mkb: Vec::new(), + unit_key_ro: Vec::new(), + samples: Vec::new(), + }; + assert!(src.resolve(&inputs).unwrap().is_empty()); + } +}