Compare commits

...
17 Commits
Author SHA1 Message Date
Matthew Jackson 8fbf5676a0 v1.0.0-rc.4.3: bump version (unified release) 2026-06-23 15:53:02 -07:00
Matthew Jackson 8ad4e5788c v1.0.0-rc.4.2: bump version (unified release) 2026-06-23 12:41:44 -07:00
Matthew Jackson e5939245be v1.0.0-rc.4.1: bump version (unified release) 2026-06-23 10:46:48 -07:00
Matthew Jackson 140af1d29b ci: cache Rust builds with Swatinem/rust-cache to speed up release + CI 2026-06-23 10:01:37 -07:00
Matthew Jackson 611901404c v1.0.0-rc.4: bump version (unified release) 2026-06-23 09:46:59 -07:00
Matthew Jackson 5b30c5d849 keysources: label() on keydb/online sources; neutral path in the XDG test 2026-06-23 09:09:06 -07:00
Matthew Jackson 2b4014dd24 ci: fast-release — verify Cargo.lock libfreemkv resolves to the release git tag 2026-06-23 08:20:51 -07:00
Matthew Jackson e27e876f49 keydb: require content samples so per-disc UK entries are ciphertext-validated
A keydb can hand out a per-disc terminal Key::Unit (a UK entry keyed on
disc_hash). 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 is only disproved by descrambling
real ciphertext.

KeydbSource inherited the default needs_samples() == false, so on the
autorip auto-resume / mux-worker path (which samples units only when some
source reports needs_samples()) a keydb-only resolve ran with empty
samples and committed a wrong UK as success, muxing undecryptable video
while reporting done. The CLI was unaffected because it always samples.

Override needs_samples() to true on KeydbSource so every consumer samples
encrypted units before resolving and a wrong keydb UK is rejected on all
paths. Regression test asserts the override.
2026-06-23 01:53:36 -07:00
Matthew Jackson d45a22a878 v1.0.0-rc.3.1: online key-service auth header + cross-OS keydb search paths (paths.rs) 2026-06-22 18:09:48 -07:00
Matthew Jackson 9b7df43b83 v1.0.0-rc.3: bump version 2026-06-22 16:19:11 -07:00
Matthew Jackson fe2e1a8a77 keysources: expose host certs through KeySource trait
- KeydbSource implements KeySource::host_certs(), delegating to the
  inherent host_certs() — surfaces the | HC | / | HC2 | certs already
  parsed from keydb.cfg by libfreemkv's parser, so the OEM cert route
  collects them across the keysource layer. No new parsing.
- OnlineSource::host_certs() is a no-op stub: returns empty with zero
  network access (no client fetch, no server endpoint). Online host-cert
  serving is deferred. TODO(owner) marker left in place.

Tests: trait host_certs returns the keydb HC row; empty when keydb
missing; online host_certs is an empty no-op without network.
2026-06-22 11:23:46 -07:00
Matthew Jackson e58ea7c998 v1.0.0-rc.2: bump version (unified release with libfreemkv 1.0.0-rc.2) 2026-06-22 09:17:07 -07:00
Matthew Jackson 4e9c52e686 ci: reword publish note (drop internal-repo reference) 2026-06-22 09:15:17 -07:00
Matthew Jackson 0a351bb873 ci: add Release workflow (verify + test + GitHub release; crates.io publish driven by release.sh) 2026-06-22 08:54:25 -07:00
Matthew Jackson 0139ba6f1b rc2: SSRF parity, bounded DNS, strict hex parse, over-cap MKB error signaling 2026-06-22 08:47:52 -07:00
Matthew Jackson 3957ac70c1 v1.0.0-rc.1
key sources (keydb/online/mapfile), overflow-safe LBA
2026-06-21 21:06:07 -07:00
Matthew Jackson a4e8fbe06c docs: generic wording in keydb comment (drop specific title) 2026-06-07 21:27:06 -07:00
7 changed files with 792 additions and 13 deletions
+60
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@ scratch/
# internal agent context — never publish (leak-guard blocks both)
CLAUDE.md
.claude/
.cargo/config.toml
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
);
},
);
}
}