v1.0.0-rc.1
key sources (keydb/online/mapfile), overflow-safe LBA
This commit is contained in:
@@ -7,3 +7,4 @@ scratch/
|
|||||||
# internal agent context — never publish (leak-guard blocks both)
|
# internal agent context — never publish (leak-guard blocks both)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
.claude/
|
||||||
|
.cargo/config.toml
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "freemkv-keysources"
|
name = "freemkv-keysources"
|
||||||
version = "0.31.0"
|
version = "1.0.0-rc.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
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."
|
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"
|
repository = "https://github.com/freemkv/freemkv-keysources"
|
||||||
@@ -10,11 +11,14 @@ categories = ["multimedia"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||||
libfreemkv = "0.31"
|
libfreemkv = "1.0.0-rc.1"
|
||||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "2", features = ["json"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||||
|
# key material).
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|||||||
+8
-1
@@ -125,7 +125,14 @@ pub fn read_sample_units(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let units_this = CHUNK_UNITS.min(total_units - unit);
|
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 count = (units_this * UNIT_SECTORS) as u16;
|
||||||
let mut buf = vec![0u8; count as usize * 2048];
|
let mut buf = vec![0u8; count as usize * 2048];
|
||||||
// `false` = no recovery retries; the reader is the raw drive/file
|
// `false` = no recovery retries; the reader is the raw drive/file
|
||||||
|
|||||||
+272
-7
@@ -1,5 +1,7 @@
|
|||||||
//! Online key-service source.
|
//! Online key-service source.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -7,6 +9,133 @@ use libfreemkv::{DiscInputs, Key, KeySource};
|
|||||||
|
|
||||||
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
||||||
const TIMEOUT_SECS: u64 = 180;
|
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());
|
||||||
|
}
|
||||||
|
let addrs: Vec<SocketAddr> = (host.as_str(), port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| format!("could not resolve host: {e}"))?
|
||||||
|
.collect();
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub struct OnlineSource {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -61,18 +190,63 @@ impl OnlineSource {
|
|||||||
body["title"] = serde_json::Value::String(label.to_string());
|
body["title"] = serde_json::Value::String(label.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut req = ureq::post(&self.base_url).timeout(Duration::from_secs(TIMEOUT_SECS));
|
// Resolve + SSRF-guard the host just before the POST; pin the
|
||||||
if !self.secret.is_empty() {
|
// validated addresses into a redirect-disabled agent so a DNS
|
||||||
req = req.set("Authorization", &format!("Bearer {}", self.secret));
|
// rebind between config time and fetch time can't redirect the
|
||||||
}
|
// request (and the bearer token) to an internal/metadata host.
|
||||||
let resp = match req.send_json(body) {
|
let pinned = match resolve_and_guard(&self.base_url) {
|
||||||
Ok(r) => r,
|
Ok(addrs) => addrs,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.errored = true;
|
self.errored = true;
|
||||||
return None;
|
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 !self.secret.is_empty() {
|
||||||
|
req = req.set("Authorization", &format!("Bearer {}", self.secret));
|
||||||
|
}
|
||||||
|
// 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,
|
Ok(j) => j,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.errored = true;
|
self.errored = true;
|
||||||
@@ -116,3 +290,94 @@ fn parse_uk(hex: &str) -> Option<[u8; 16]> {
|
|||||||
}
|
}
|
||||||
Some(out)
|
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
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user