diff --git a/Cargo.toml b/Cargo.toml index cf2e600..152dcd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/lib.rs b/src/lib.rs index 03d2101..1309412 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. diff --git a/src/online.rs b/src/online.rs index d501261..f794af5 100644 --- a/src/online.rs +++ b/src/online.rs @@ -144,6 +144,23 @@ fn resolve_and_guard(url: &str) -> Result, 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 { + 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.). diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..da44101 --- /dev/null +++ b/src/paths.rs @@ -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 { + 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 { + 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 { + 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)> = + 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" + ); + }, + ); + } +}