mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
17
Commits
v0.31.0
...
v1.0.0-rc.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fbf5676a0 | ||
|
|
8ad4e5788c | ||
|
|
e5939245be | ||
|
|
140af1d29b | ||
|
|
611901404c | ||
|
|
5b30c5d849 | ||
|
|
2b4014dd24 | ||
|
|
e27e876f49 | ||
|
|
d45a22a878 | ||
|
|
9b7df43b83 | ||
|
|
fe2e1a8a77 | ||
|
|
e58ea7c998 | ||
|
|
4e9c52e686 | ||
|
|
0a351bb873 | ||
|
|
0139ba6f1b | ||
|
|
3957ac70c1 | ||
|
|
a4e8fbe06c |
@@ -0,0 +1,60 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
if [ "$CARGO_VER" != "${{ github.ref_name }}" ]; then
|
||||
echo "::error::Cargo.toml says $CARGO_VER but tag is ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version match: $CARGO_VER"
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# release job does NOT `needs:` this job. The tag decision was already gated by
|
||||
# the local precommit (same Rust 1.86, same commit).
|
||||
#
|
||||
# freemkv-keysources is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner). Its
|
||||
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch is
|
||||
# gitignored and absent on the runner), so libfreemkv must already be published
|
||||
# at this version — the release script enforces that ordering before tagging.
|
||||
test:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: the crates.io publish is driven by the maintainer's release tooling
|
||||
# from local credentials, not from CI — this repo has no CARGO_REGISTRY_TOKEN
|
||||
# secret. release.sh runs `cargo publish --no-verify` locally. CI here only
|
||||
# verifies the version/tag match, runs tests (tripwire), and cuts the GitHub
|
||||
# Release. The binaries git-tag-pin keysources, so they don't wait on the
|
||||
# crates.io publish — it serves external consumers in parallel.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release is cut as soon as the version
|
||||
# check passes, in parallel with the test tripwire.
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
@@ -7,3 +7,4 @@ scratch/
|
||||
# internal agent context — never publish (leak-guard blocks both)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.cargo/config.toml
|
||||
|
||||
+6
-2
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "0.31.0"
|
||||
version = "1.0.0-rc.4.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation."
|
||||
repository = "https://github.com/freemkv/freemkv-keysources"
|
||||
@@ -10,11 +11,14 @@ categories = ["multimedia"]
|
||||
|
||||
[dependencies]
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
libfreemkv = "0.31"
|
||||
libfreemkv = "1.0.0-rc.4.3"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||
# key material).
|
||||
tracing = "0.1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
+80
-1
@@ -98,6 +98,33 @@ impl KeydbSource {
|
||||
}
|
||||
|
||||
impl KeySource for KeydbSource {
|
||||
/// Expose the keydb's host certs through the trait — the OEM/AACS cert-auth
|
||||
/// route collects them across every source via this method. Delegates to the
|
||||
/// inherent [`KeydbSource::host_certs`] (same `| HC |`/`| HC2 |` rows parsed
|
||||
/// by libfreemkv's keydb parser); no new parsing.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
KeydbSource::host_certs(self)
|
||||
}
|
||||
|
||||
/// The keydb can hand out a per-disc **terminal** `Key::Unit` (a UK entry
|
||||
/// keyed on `disc_hash` alone — see `candidates_from`). Unlike a derived key
|
||||
/// (Device/Processing/Media/Volume), a terminal UK is applied as-is by
|
||||
/// `Disc::decrypt_with`: it is NOT re-derived through the MKB-verified AACS
|
||||
/// resolver, so a UK entry whose hash matches the disc but whose key bytes
|
||||
/// are wrong would commit and mux undecryptable video as "success". The only
|
||||
/// thing that disproves a wrong UK is descrambling real ciphertext, so this
|
||||
/// source requires content samples — without them `decrypt_with` skips
|
||||
/// validation and the wrong UK is taken. Returning `true` makes every
|
||||
/// consumer (autorip resume/mux-worker AND the CLI) sample units before
|
||||
/// resolving, so a keydb UK is ciphertext-validated on every path.
|
||||
fn needs_samples(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"keydb"
|
||||
}
|
||||
|
||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
// On the first ask, parse the keydb once and build the ordered candidate
|
||||
// list; later asks just advance the cursor. A missing/unreadable keydb
|
||||
@@ -175,7 +202,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn per_disc_uk_ranks_before_vuk() {
|
||||
// An entry with BOTH a UK and a VUK (the Being There shape) must hand the
|
||||
// An entry with BOTH a UK and a VUK (the dual-key shape) must hand the
|
||||
// terminal UK out first, so a stale/wrong VUK never pre-empts a good UK.
|
||||
let mut entries = HashMap::new();
|
||||
let mut e = entry_with_vuk("0xaabb", [0x11u8; 16]);
|
||||
@@ -224,6 +251,22 @@ mod tests {
|
||||
assert!(KeydbSource::candidates_from(&db, &inputs("0xaabb")).is_empty());
|
||||
}
|
||||
|
||||
/// Regression: a keydb can hand out a per-disc terminal `Key::Unit` that
|
||||
/// `Disc::decrypt_with` applies WITHOUT re-deriving through the MKB-verified
|
||||
/// AACS resolver. The only thing that disproves a wrong UK is descrambling
|
||||
/// ciphertext, so the source MUST request content samples — otherwise the
|
||||
/// autorip resume/mux-worker path (which only samples when some source
|
||||
/// reports `needs_samples()`) resolves with empty samples and commits a
|
||||
/// wrong UK as success. Was `false` (inherited default); must be `true`.
|
||||
#[test]
|
||||
fn keydb_source_needs_samples() {
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
assert!(
|
||||
src.needs_samples(),
|
||||
"keydb emits terminal Key::Unit entries that need ciphertext validation"
|
||||
);
|
||||
}
|
||||
|
||||
/// No keydb (or a LibreDrive deployment) → no host credentials, not an
|
||||
/// error. (The positive parse is NOT tested here — it would require host
|
||||
/// key material, which must never appear in code.)
|
||||
@@ -235,4 +278,40 @@ mod tests {
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// The KeySource TRAIT method exposes the keydb's host cert(s) — this is the
|
||||
/// path the OEM/AACS cert-auth route collects certs through. A keydb with a
|
||||
/// `| HC |` row must surface a HostCert via `KeySource::host_certs`, so the
|
||||
/// handshake (which iterates `opts.key_sources[..].host_certs()`) finds it.
|
||||
/// Placeholder all-zero material (never a real key) — same convention as
|
||||
/// libfreemkv's own `parse_host_cert` test.
|
||||
#[test]
|
||||
fn trait_host_certs_returns_keydb_hc_row() {
|
||||
let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("keydb.cfg");
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
std::fs::write(&path, line).unwrap();
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
// Consult through the TRAIT, exactly as the OEM route does.
|
||||
let certs = KeySource::host_certs(&src);
|
||||
assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row");
|
||||
assert_eq!(certs[0].certificate.len(), 92);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Zero certs from a (missing) keydb through the TRAIT method — the OEM route
|
||||
/// sees an empty vec here and, with no other source supplying a cert, fails
|
||||
/// gracefully with `AacsNoHostCert` rather than panicking.
|
||||
#[test]
|
||||
fn trait_host_certs_empty_when_keydb_missing() {
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
assert!(KeySource::host_certs(&src).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -23,10 +23,12 @@
|
||||
mod keydb;
|
||||
mod mapfile;
|
||||
mod online;
|
||||
mod paths;
|
||||
|
||||
pub use keydb::KeydbSource;
|
||||
pub use mapfile::MapfileSource;
|
||||
pub use online::OnlineSource;
|
||||
pub use online::{OnlineSource, validate_keyserver_url};
|
||||
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
|
||||
|
||||
// Re-exported for downstream convenience so apps need only depend on this crate
|
||||
// for the source-side types.
|
||||
@@ -125,7 +127,14 @@ pub fn read_sample_units(
|
||||
break;
|
||||
}
|
||||
let units_this = CHUNK_UNITS.min(total_units - unit);
|
||||
let lba = ext.start_lba + unit * UNIT_SECTORS;
|
||||
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
|
||||
// extents; a malformed extent near u32::MAX would otherwise panic
|
||||
// (debug) or wrap to a wrong LBA (release). Matches the hardened
|
||||
// pattern in mux/disc.rs and verify.rs; an over-capacity LBA then
|
||||
// fails cleanly via the read_sectors().is_err() break below.
|
||||
let lba = ext
|
||||
.start_lba
|
||||
.saturating_add(unit.saturating_mul(UNIT_SECTORS));
|
||||
let count = (units_this * UNIT_SECTORS) as u16;
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `false` = no recovery retries; the reader is the raw drive/file
|
||||
|
||||
+410
-8
@@ -1,5 +1,7 @@
|
||||
//! Online key-service source.
|
||||
|
||||
use std::io::Read;
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
@@ -7,6 +9,169 @@ use libfreemkv::{DiscInputs, Key, KeySource};
|
||||
|
||||
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
||||
const TIMEOUT_SECS: u64 = 180;
|
||||
/// Hard cap on the key-service response body. A real unit-key reply is a few
|
||||
/// hundred bytes; bound the read so a malicious/compromised server can't drive
|
||||
/// the client to OOM with an unbounded body.
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
// ── SSRF guard ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The keyserver URL is operator-supplied and the bearer token is confidential.
|
||||
// After validate_keyserver_url checked the host at config time, an attacker
|
||||
// who controls the keyserver's DNS can rebind it to 169.254.169.254 (cloud
|
||||
// metadata) or an RFC1918 host in the window between validation and the
|
||||
// actual POST, exfiltrating the key material and the Authorization token.
|
||||
//
|
||||
// Defence: resolve the host once just before the POST, reject any blocked IP,
|
||||
// and pin the ureq connection to those validated addresses so a subsequent DNS
|
||||
// flip cannot redirect the request. Use redirects(0) so a public URL can't
|
||||
// 30x-redirect to an internal host.
|
||||
|
||||
/// True when `ip` must never be the target of an outbound key-service POST.
|
||||
/// Blocks loopback, link-local (incl. 169.254.0.0/16 cloud metadata), all
|
||||
/// RFC1918 private ranges, carrier-grade NAT, multicast, unspecified, and
|
||||
/// IPv4-mapped equivalents for all of the above.
|
||||
fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local() // 169.254.0.0/16, incl. 169.254.169.254
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
// Carrier-grade NAT 100.64.0.0/10.
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40)
|
||||
// "This network" 0.0.0.0/8.
|
||||
|| v4.octets()[0] == 0
|
||||
// Class E reserved 240.0.0.0/4.
|
||||
|| v4.octets()[0] >= 240
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
// Unique-local fc00::/7.
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|
||||
// Link-local fe80::/10.
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x,
|
||||
// deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
|
||||
// both forms; re-check the embedded address as IPv4.
|
||||
|| v6
|
||||
.to_ipv4()
|
||||
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
|
||||
== Some(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `url`'s host and validate every resulting address against the SSRF
|
||||
/// guard. Returns the pinned socket addresses (for use with a custom ureq
|
||||
/// resolver) on success, or an error message on rejection.
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
let rest = if let Some(r) = url.strip_prefix("https://") {
|
||||
(r, 443u16)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
(r, 80u16)
|
||||
} else {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
};
|
||||
let (authority, default_port) = rest;
|
||||
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
}
|
||||
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
||||
match stripped.split_once(']') {
|
||||
Some((h, after)) => {
|
||||
let p = after
|
||||
.strip_prefix(':')
|
||||
.map(|s| s.parse::<u16>().map_err(|_| "invalid port".to_string()))
|
||||
.transpose()?
|
||||
.unwrap_or(default_port);
|
||||
(h.to_string(), p)
|
||||
}
|
||||
None => return Err("malformed IPv6 host".into()),
|
||||
}
|
||||
} else if let Some((h, p)) = authority.rsplit_once(':') {
|
||||
match p.parse::<u16>() {
|
||||
Ok(p) => (h.to_string(), p),
|
||||
Err(_) => (authority.to_string(), default_port),
|
||||
}
|
||||
} else {
|
||||
(authority.to_string(), default_port)
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
}
|
||||
// `to_socket_addrs` is a BLOCKING DNS lookup that can hang for the OS
|
||||
// resolver timeout (tens of seconds) and freeze the rip thread that called
|
||||
// query(). Run it on a spawned thread and join with a bounded deadline;
|
||||
// on timeout return Err so query() yields None and the rip proceeds
|
||||
// (mirrors the bounded-resolve in autorip/libfreemkv).
|
||||
let addrs: Vec<SocketAddr> = {
|
||||
use std::sync::mpsc;
|
||||
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
let host = host.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let res = (host.as_str(), port)
|
||||
.to_socket_addrs()
|
||||
.map(|it| it.collect::<Vec<SocketAddr>>());
|
||||
// Receiver may be gone after the timeout — ignore the send error.
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
Ok(Err(e)) => return Err(format!("could not resolve host: {e}")),
|
||||
Err(_) => return Err("DNS resolution timed out".into()),
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return Err("host did not resolve to any address".into());
|
||||
}
|
||||
for a in &addrs {
|
||||
if is_blocked_ip(&a.ip()) {
|
||||
return Err(format!(
|
||||
"refusing to connect to non-public address {} (SSRF guard)",
|
||||
a.ip()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
/// Validate a key-service base URL before it is handed to [`OnlineSource`].
|
||||
/// Requires an `http(s)` scheme, extracts the host, and rejects any host that
|
||||
/// is — or resolves to — a loopback / link-local (incl. the 169.254.169.254
|
||||
/// cloud-metadata endpoint) / RFC1918 / ULA / other non-public address (SSRF /
|
||||
/// metadata-exfiltration guard). Returns `Ok(())` on success so a caller can
|
||||
/// gate `OnlineSource` construction; the error string explains the rejection.
|
||||
///
|
||||
/// This is the *config-time* check. [`OnlineSource`] independently re-resolves
|
||||
/// and re-guards the host immediately before each POST (and pins the validated
|
||||
/// addresses), so a DNS rebind between this check and the request can't redirect
|
||||
/// the key material. The two share the SAME `is_blocked_ip` classifier and the
|
||||
/// SAME bounded-resolve, so their verdicts never diverge — the reason this lives
|
||||
/// here, in the key-source crate, rather than being re-rolled per application.
|
||||
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||
resolve_and_guard(url).map(|_| ())
|
||||
}
|
||||
|
||||
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
||||
/// 30x-redirect to an internal host) and pins DNS resolution to `pinned`
|
||||
/// (the addresses already validated by [`resolve_and_guard`]).
|
||||
fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.redirects(0)
|
||||
.timeout_connect(Duration::from_secs(10))
|
||||
.timeout_read(Duration::from_secs(TIMEOUT_SECS))
|
||||
.resolver(move |_netloc: &str| Ok(pinned.clone()))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub struct OnlineSource {
|
||||
base_url: String,
|
||||
@@ -34,7 +199,15 @@ impl OnlineSource {
|
||||
/// The single server-resolved UK for this disc, or `None`. Runs exactly the
|
||||
/// one network round-trip; `next_key` gates it to one call per session.
|
||||
fn query(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
if self.base_url.is_empty() || inputs.mkb.len() > MAX_MKB_BYTES {
|
||||
// No configured service: a clean None ("no service"), not an error.
|
||||
if self.base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// An over-cap MKB is a real failure to resolve THIS disc, not "no
|
||||
// service" — flag it so the caller reports it distinctly (and a later
|
||||
// ask doesn't conflate it with a missing key).
|
||||
if inputs.mkb.len() > MAX_MKB_BYTES {
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
@@ -61,18 +234,63 @@ impl OnlineSource {
|
||||
body["title"] = serde_json::Value::String(label.to_string());
|
||||
}
|
||||
}
|
||||
let mut req = ureq::post(&self.base_url).timeout(Duration::from_secs(TIMEOUT_SECS));
|
||||
if !self.secret.is_empty() {
|
||||
req = req.set("Authorization", &format!("Bearer {}", self.secret));
|
||||
}
|
||||
let resp = match req.send_json(body) {
|
||||
Ok(r) => r,
|
||||
// Resolve + SSRF-guard the host just before the POST; pin the
|
||||
// validated addresses into a redirect-disabled agent so a DNS
|
||||
// rebind between config time and fetch time can't redirect the
|
||||
// request (and the bearer token) to an internal/metadata host.
|
||||
let pinned = match resolve_and_guard(&self.base_url) {
|
||||
Ok(addrs) => addrs,
|
||||
Err(_) => {
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let json: serde_json::Value = match resp.into_json() {
|
||||
let agent = hardened_agent(pinned);
|
||||
let mut req = agent.post(&self.base_url);
|
||||
if let Some(value) = bearer_header(&self.secret) {
|
||||
req = req.set("Authorization", &value);
|
||||
}
|
||||
// Begin/end around the keyserver round-trip — a slow or unresponsive
|
||||
// service is the suspected DVD-scan hang. The agent is built with a
|
||||
// 10s connect + bounded read timeout (see `hardened_agent`), so this
|
||||
// call can never block forever; we log the timing so a slow round-trip
|
||||
// is visible. SECURITY: never log `body` — it carries base64 key
|
||||
// material.
|
||||
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
|
||||
let post_t0 = std::time::Instant::now();
|
||||
let resp = match req.send_json(body) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"keyserver request failed (timeout, network, or HTTP error)"
|
||||
);
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
);
|
||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.into_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
|| buf.len() > MAX_RESPONSE_BYTES
|
||||
{
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
self.errored = true;
|
||||
@@ -101,18 +319,202 @@ impl KeySource for OnlineSource {
|
||||
true
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"online"
|
||||
}
|
||||
|
||||
fn errored(&self) -> bool {
|
||||
self.errored
|
||||
}
|
||||
|
||||
fn host_certs(&self) -> Vec<libfreemkv::aacs::HostCert> {
|
||||
// NO-OP STUB. The online service does not serve host certs today: there
|
||||
// is no client-side fetch and no server-side endpoint for them. Returning
|
||||
// empty makes the OEM cert route fall back to whatever other source
|
||||
// (e.g. the keydb) supplies — and fail gracefully if none does. No
|
||||
// network is touched here.
|
||||
// TODO(owner): online host-cert serving — design when 0x83 cert is recovered
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Authorization` header value for a key-service request, or `None` when no
|
||||
/// secret/token is configured (the request then goes out unauthenticated). The
|
||||
/// token — passed as `--key-auth` on the CLI or `keyserver_secret` in autorip —
|
||||
/// is sent verbatim as an HTTP Bearer credential.
|
||||
fn bearer_header(secret: &str) -> Option<String> {
|
||||
if secret.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("Bearer {secret}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uk(hex: &str) -> Option<[u8; 16]> {
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
// Reject any non-hex byte up front. `u8::from_str_radix` on a 2-char
|
||||
// window otherwise accepts sign prefixes (e.g. "+5", "-A"), letting a
|
||||
// signed/whitespace-tainted string slip through as a valid key.
|
||||
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
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::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
// ── is_blocked_ip ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ssrf_guard_blocks_loopback_private_and_metadata() {
|
||||
// Loopback.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
// RFC1918 private ranges.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
// Cloud-metadata anycast (link-local 169.254.0.0/16).
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
169, 254, 169, 254
|
||||
))));
|
||||
// Carrier-grade NAT 100.64.0.0/10 and "this network" 0.0.0.0/8.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
|
||||
// IPv6 loopback, ULA fc00::/7, link-local fe80::/10.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
// IPv4-mapped loopback ::ffff:127.0.0.1 must also be blocked.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(
|
||||
Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()
|
||||
)));
|
||||
// IPv4-compatible loopback ::127.0.0.1 (= ::7f00:1, deprecated RFC
|
||||
// 4291 §2.5.5.1) — to_ipv4_mapped() misses this form; to_ipv4() catches
|
||||
// both mapped and compatible.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0, 0, 0, 0, 0, 0, 0x7f00, 0x0001
|
||||
))));
|
||||
// Class E reserved 240.0.0.0/4.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
255, 255, 255, 254
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_guard_allows_public_ips() {
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
|
||||
// Public IPv6 (Cloudflare DNS 2606:4700:4700::1111).
|
||||
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111
|
||||
))));
|
||||
}
|
||||
|
||||
/// The online source serves NO host certs today (no fetch, no endpoint).
|
||||
/// `host_certs()` must return empty WITHOUT touching the network, so the OEM
|
||||
/// route falls back to whatever else (the keydb) supplies. Uses a non-empty
|
||||
/// base URL to prove the empty result isn't merely "no service configured" —
|
||||
/// it's the deliberate no-op stub.
|
||||
#[test]
|
||||
fn host_certs_is_noop_empty_no_network() {
|
||||
let src = OnlineSource::new("http://example.invalid/keys", "secret");
|
||||
assert!(
|
||||
KeySource::host_certs(&src).is_empty(),
|
||||
"online host_certs must be an empty no-op (no network)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── resolve_and_guard ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_and_guard_rejects_internal_literals() {
|
||||
// Numeric literals resolve without DNS — must still be rejected.
|
||||
assert!(resolve_and_guard("http://127.0.0.1/keys").is_err());
|
||||
assert!(resolve_and_guard("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(resolve_and_guard(&format!("http://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
|
||||
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err());
|
||||
assert!(resolve_and_guard("http://[::1]:9000/keys").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_and_guard_rejects_bad_scheme() {
|
||||
assert!(resolve_and_guard("ftp://example.com/keys").is_err());
|
||||
assert!(resolve_and_guard("file:///etc/passwd").is_err());
|
||||
assert!(resolve_and_guard("not a url").is_err());
|
||||
assert!(resolve_and_guard("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_and_guard_accepts_public_literal() {
|
||||
// Public numeric hosts resolve without DNS — must be accepted.
|
||||
let addrs = resolve_and_guard("https://8.8.8.8/keys").expect("public IP must be accepted");
|
||||
assert!(!addrs.is_empty());
|
||||
assert_eq!(addrs[0].port(), 443);
|
||||
|
||||
let addrs =
|
||||
resolve_and_guard("http://1.1.1.1:8080/keys").expect("public IP with port accepted");
|
||||
assert!(!addrs.is_empty());
|
||||
assert_eq!(addrs[0].port(), 8080);
|
||||
}
|
||||
|
||||
// ── bearer_header ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bearer_header_formats_token_and_omits_when_empty() {
|
||||
// A configured token becomes a Bearer credential, sent verbatim.
|
||||
assert_eq!(
|
||||
bearer_header("s3cr3t-token"),
|
||||
Some("Bearer s3cr3t-token".to_string())
|
||||
);
|
||||
// No token → no Authorization header (request goes out unauthenticated).
|
||||
assert_eq!(bearer_header(""), None);
|
||||
}
|
||||
|
||||
// ── validate_keyserver_url ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_keyserver_url_rejects_internal_and_bad_scheme() {
|
||||
// Mirrors resolve_and_guard: the public wrapper rejects the same hosts.
|
||||
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
||||
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(validate_keyserver_url(&format!("http://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
|
||||
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
|
||||
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
|
||||
assert!(validate_keyserver_url("").is_err());
|
||||
// A public literal IP passes (no DNS needed, deterministic).
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
}
|
||||
|
||||
/// Finding #9 regression: parse_uk must reject any non-hex byte up front so
|
||||
/// sign prefixes / whitespace can't slip through the windowed 2-char parse
|
||||
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
|
||||
#[test]
|
||||
fn parse_uk_rejects_non_hex_bytes() {
|
||||
// Valid 32-char hex parses.
|
||||
assert_eq!(
|
||||
parse_uk("000102030405060708090a0b0c0d0e0f"),
|
||||
Some([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
);
|
||||
// Sign-prefixed window: "+5" / "-A" would parse via from_str_radix.
|
||||
assert!(parse_uk("+5000102030405060708090a0b0c0d0e").is_none());
|
||||
assert!(parse_uk("-A000102030405060708090a0b0c0d0e").is_none());
|
||||
// Embedded whitespace.
|
||||
assert!(parse_uk("00 0102030405060708090a0b0c0d0e0f").is_none());
|
||||
// Wrong length is still rejected.
|
||||
assert!(parse_uk("00").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
//! Where the `keydb.cfg` lives, per OS.
|
||||
//!
|
||||
//! Key-path policy belongs with the key sources (this crate), not the library:
|
||||
//! libfreemkv is handed a path and reads it. The CLI/app asks here for the
|
||||
//! ordered list of locations to *search* (first existing wins) and for the
|
||||
//! single *default* location to write to (e.g. `update-keys`/save).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//!
|
||||
//! - **Windows**: `%APPDATA%\freemkv\keydb.cfg` FIRST (the idiomatic per-user
|
||||
//! roaming config dir), then the legacy `%USERPROFILE%\.config\freemkv\keydb.cfg`
|
||||
//! for back-compat with installs that predate this fix.
|
||||
//! - **Linux / macOS**: `$XDG_CONFIG_HOME/freemkv/keydb.cfg` (if `XDG_CONFIG_HOME`
|
||||
//! is set), then `$HOME/.config/freemkv/keydb.cfg` — the long-standing default,
|
||||
//! unchanged so existing users keep working.
|
||||
//!
|
||||
//! Pure `std::env` — `%APPDATA%`, `%USERPROFILE%`, `$HOME`, `$XDG_CONFIG_HOME`
|
||||
//! are all environment variables, so no `dirs`-style crate is pulled in.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The keydb filename plus its `freemkv` subdir, joined onto a base dir.
|
||||
fn keydb_under(base: PathBuf) -> PathBuf {
|
||||
base.join("freemkv").join("keydb.cfg")
|
||||
}
|
||||
|
||||
/// The ordered list of `keydb.cfg` locations to search, most-idiomatic first.
|
||||
///
|
||||
/// The caller picks the first path that exists on disk (see
|
||||
/// [`existing_keydb_path`]); for writing a freshly-downloaded keydb, use
|
||||
/// [`default_keydb_path`] (the first entry — the canonical location).
|
||||
///
|
||||
/// Always returns at least one entry on a normally-configured system; returns
|
||||
/// an empty list only if none of the relevant env vars are set.
|
||||
pub fn keydb_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if cfg!(windows) {
|
||||
// Idiomatic Windows location first.
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
if !appdata.is_empty() {
|
||||
paths.push(keydb_under(PathBuf::from(appdata)));
|
||||
}
|
||||
}
|
||||
// Legacy XDG-style dotfolder under the user profile, for back-compat.
|
||||
if let Ok(profile) = std::env::var("USERPROFILE") {
|
||||
if !profile.is_empty() {
|
||||
paths.push(
|
||||
PathBuf::from(profile)
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Honour XDG_CONFIG_HOME if the user set it, then the historical default.
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
paths.push(keydb_under(PathBuf::from(xdg)));
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
paths.push(
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// The first search path that exists on disk, if any.
|
||||
///
|
||||
/// Use this to LOCATE an existing keydb for reading. Falls back to `None` when
|
||||
/// no candidate file exists (the caller then surfaces "no KEYDB.cfg found").
|
||||
pub fn existing_keydb_path() -> Option<PathBuf> {
|
||||
keydb_search_paths().into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
/// The canonical default location to WRITE the keydb to (e.g. after a download).
|
||||
///
|
||||
/// This is the first (most idiomatic) entry of [`keydb_search_paths`]:
|
||||
/// `%APPDATA%\freemkv\keydb.cfg` on Windows, `~/.config/freemkv/keydb.cfg`
|
||||
/// elsewhere. Returns `None` only when the relevant env vars are unset.
|
||||
pub fn default_keydb_path() -> Option<PathBuf> {
|
||||
keydb_search_paths().into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serialize env-mutating tests: they share the process environment.
|
||||
fn lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
M.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Build the search list under an explicit env, restoring the prior env
|
||||
/// afterwards. Avoids depending on the host's real HOME/APPDATA.
|
||||
fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
|
||||
let _g = lock();
|
||||
let keys = ["APPDATA", "USERPROFILE", "HOME", "XDG_CONFIG_HOME"];
|
||||
let saved: Vec<(&str, Option<String>)> =
|
||||
keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
|
||||
// Clear all, then apply the requested overrides.
|
||||
for k in keys {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
for (k, v) in vars {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
}
|
||||
}
|
||||
f();
|
||||
// Restore.
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_prefers_appdata_then_legacy_userprofile() {
|
||||
with_env(
|
||||
&[
|
||||
("APPDATA", Some(r"C:\Users\matt\AppData\Roaming")),
|
||||
("USERPROFILE", Some(r"C:\Users\matt")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "APPDATA + legacy USERPROFILE");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from(r"C:\Users\matt\AppData\Roaming")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"APPDATA location must be searched first on Windows"
|
||||
);
|
||||
assert_eq!(
|
||||
paths[1],
|
||||
PathBuf::from(r"C:\Users\matt")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"legacy .config dotfolder is the back-compat fallback"
|
||||
);
|
||||
assert_eq!(default_keydb_path(), Some(paths[0].clone()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_falls_back_to_legacy_when_appdata_unset() {
|
||||
with_env(
|
||||
&[("APPDATA", None), ("USERPROFILE", Some(r"C:\Users\matt"))],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 1, "only the legacy USERPROFILE path");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from(r"C:\Users\matt")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn unix_default_is_home_dotconfig() {
|
||||
with_env(
|
||||
&[("HOME", Some("/u/me")), ("XDG_CONFIG_HOME", None)],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 1, "just the $HOME/.config default");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/u/me")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"Linux/macOS default must remain ~/.config/freemkv/keydb.cfg"
|
||||
);
|
||||
assert_eq!(default_keydb_path(), Some(paths[0].clone()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn unix_honours_xdg_config_home_first() {
|
||||
with_env(
|
||||
&[
|
||||
("XDG_CONFIG_HOME", Some("/u/me/.cfg")),
|
||||
("HOME", Some("/u/me")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "XDG dir + $HOME/.config fallback");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/u/me/.cfg")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"XDG_CONFIG_HOME, when set, is searched before ~/.config"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user