v1.0.0-rc.3.1: online key-service auth header + cross-OS keydb search paths (paths.rs)
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.0.0-rc.3"
|
||||
version = "1.0.0-rc.3.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
+59
-2
@@ -144,6 +144,23 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
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`]).
|
||||
@@ -230,8 +247,8 @@ impl OnlineSource {
|
||||
};
|
||||
let agent = hardened_agent(pinned);
|
||||
let mut req = agent.post(&self.base_url);
|
||||
if !self.secret.is_empty() {
|
||||
req = req.set("Authorization", &format!("Bearer {}", self.secret));
|
||||
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
|
||||
@@ -317,6 +334,18 @@ impl KeySource for OnlineSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -438,6 +467,34 @@ mod tests {
|
||||
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.).
|
||||
|
||||
+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("/home/matt")), ("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("/home/matt")
|
||||
.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("/home/matt/.cfg")),
|
||||
("HOME", Some("/home/matt")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "XDG dir + $HOME/.config fallback");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/home/matt/.cfg")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"XDG_CONFIG_HOME, when set, is searched before ~/.config"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user