Add OnlineSource and MapfileSource
OnlineSource: the remote key-service client (moved out of autorip), posting the disc's Unit_Key_RO.inf + MKB + Volume ID + encrypted content samples to the service and returning the resolved unit key as a terminal Key::Unit candidate. Kept out of libfreemkv so the library stays network-free. Source-internal failures (unreachable / status / parse) are logged and surface as "no candidate" so the next source is tried. MapfileSource: reads a rip mapfile's persisted unit keys (the resume / deferred- mux fast path) and offers them as a Key::Unit candidate. Keyed by mapfile path. DiscInputs gained an app-populated `samples` field for sources that validate against ciphertext server-side (OnlineSource); local sources ignore it.
This commit is contained in:
@@ -12,3 +12,8 @@ categories = ["multimedia"]
|
|||||||
# Path during development; the published release pins a crates.io version. The
|
# Path during development; the published release pins a crates.io version. The
|
||||||
# crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
# crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||||
libfreemkv = { version = "0.27", path = "../libfreemkv" }
|
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"
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ mod tests {
|
|||||||
volume_id: [0u8; 16],
|
volume_id: [0u8; 16],
|
||||||
mkb: Vec::new(),
|
mkb: Vec::new(),
|
||||||
unit_key_ro: Vec::new(),
|
unit_key_ro: Vec::new(),
|
||||||
|
samples: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -5,8 +5,8 @@
|
|||||||
//! implementations that do the lookup:
|
//! implementations that do the lookup:
|
||||||
//!
|
//!
|
||||||
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
|
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
|
||||||
//! - `OnlineSource` — a remote key service (source #2). *(added with the app wiring)*
|
//! - [`OnlineSource`] — a remote key service (source #2).
|
||||||
//! - `MapfileSource` — the persisted unit key from a rip mapfile (source #3).
|
//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3).
|
||||||
//!
|
//!
|
||||||
//! Applications (autorip, the `freemkv` CLI) choose and order the sources from
|
//! 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
|
//! 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`]).
|
//! order and keeps the first that decrypts a sample ([`resolve_first`]).
|
||||||
|
|
||||||
mod keydb;
|
mod keydb;
|
||||||
|
mod mapfile;
|
||||||
|
mod online;
|
||||||
|
|
||||||
pub use keydb::KeydbSource;
|
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
|
// Re-exported for downstream convenience so apps need only depend on this crate
|
||||||
// for the source-side types.
|
// for the source-side types.
|
||||||
|
|||||||
@@ -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<PathBuf>) -> Self {
|
||||||
|
Self { path: path.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySource for MapfileSource {
|
||||||
|
fn resolve(&self, _inputs: &DiscInputs) -> Result<Vec<Key>> {
|
||||||
|
// 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())])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -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<String>, secret: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into(),
|
||||||
|
secret: secret.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySource for OnlineSource {
|
||||||
|
fn resolve(&self, inputs: &DiscInputs) -> Result<Vec<Key>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user