mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Merge branch 'fix/key-service-outage-not-missing-key' into dev
This commit is contained in:
+464
-147
@@ -87,34 +87,59 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why [`resolve_and_guard`] rejected a URL. The two halves demand OPPOSITE
|
||||||
|
/// operator actions, so they must not be collapsed: a `Config` rejection is a
|
||||||
|
/// standing misconfiguration that will never fix itself, while `Unreachable` is
|
||||||
|
/// the key service being down right now — exactly the condition this module's
|
||||||
|
/// callers must not report as "this disc has no key".
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GuardFail {
|
||||||
|
/// Malformed URL, bad scheme, or a host that is (or resolves to) a
|
||||||
|
/// non-public address. Operator configuration; retrying changes nothing.
|
||||||
|
Config,
|
||||||
|
/// The host did not resolve — DNS failure or DNS timeout. The service is
|
||||||
|
/// unreachable; nothing is known about this disc's key. Transient.
|
||||||
|
Unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve `url`'s host and validate every resulting address against the SSRF
|
/// Resolve `url`'s host and validate every resulting address against the SSRF
|
||||||
/// guard. Returns the pinned socket addresses (for use with a custom ureq
|
/// guard. Returns the pinned socket addresses (for use with a custom ureq
|
||||||
/// resolver) on success, or an error message on rejection.
|
/// resolver) on success, or the rejection reason plus an explanatory message.
|
||||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
///
|
||||||
|
/// SECURITY: the message names the resolved address on an SSRF rejection and is
|
||||||
|
/// for the CONFIG-time caller only — never log it (see the call site in
|
||||||
|
/// [`OnlineSource::query`]).
|
||||||
|
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
|
||||||
let rest = if let Some(r) = url.strip_prefix("https://") {
|
let rest = if let Some(r) = url.strip_prefix("https://") {
|
||||||
(r, 443u16)
|
(r, 443u16)
|
||||||
} else if let Some(r) = url.strip_prefix("http://") {
|
} else if let Some(r) = url.strip_prefix("http://") {
|
||||||
(r, 80u16)
|
(r, 80u16)
|
||||||
} else {
|
} else {
|
||||||
return Err("URL must start with http:// or https://".into());
|
return Err((
|
||||||
|
GuardFail::Config,
|
||||||
|
"URL must start with http:// or https://".into(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
let (authority, default_port) = rest;
|
let (authority, default_port) = rest;
|
||||||
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
||||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||||
if authority.is_empty() {
|
if authority.is_empty() {
|
||||||
return Err("URL has no host".into());
|
return Err((GuardFail::Config, "URL has no host".into()));
|
||||||
}
|
}
|
||||||
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
||||||
match stripped.split_once(']') {
|
match stripped.split_once(']') {
|
||||||
Some((h, after)) => {
|
Some((h, after)) => {
|
||||||
let p = after
|
let p = after
|
||||||
.strip_prefix(':')
|
.strip_prefix(':')
|
||||||
.map(|s| s.parse::<u16>().map_err(|_| "invalid port".to_string()))
|
.map(|s| {
|
||||||
|
s.parse::<u16>()
|
||||||
|
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))
|
||||||
|
})
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or(default_port);
|
.unwrap_or(default_port);
|
||||||
(h.to_string(), p)
|
(h.to_string(), p)
|
||||||
}
|
}
|
||||||
None => return Err("malformed IPv6 host".into()),
|
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
|
||||||
}
|
}
|
||||||
} else if let Some((h, p)) = authority.rsplit_once(':') {
|
} else if let Some((h, p)) = authority.rsplit_once(':') {
|
||||||
match p.parse::<u16>() {
|
match p.parse::<u16>() {
|
||||||
@@ -125,7 +150,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
|||||||
(authority.to_string(), default_port)
|
(authority.to_string(), default_port)
|
||||||
};
|
};
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
return Err("URL has no host".into());
|
return Err((GuardFail::Config, "URL has no host".into()));
|
||||||
}
|
}
|
||||||
// `to_socket_addrs` is a BLOCKING DNS lookup that can hang for the OS
|
// `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
|
// resolver timeout (tens of seconds) and freeze the rip thread that called
|
||||||
@@ -146,18 +171,29 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
|||||||
});
|
});
|
||||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||||
Ok(Ok(addrs)) => addrs,
|
Ok(Ok(addrs)) => addrs,
|
||||||
Ok(Err(e)) => return Err(format!("could not resolve host: {e}")),
|
Ok(Err(e)) => {
|
||||||
Err(_) => return Err("DNS resolution timed out".into()),
|
return Err((
|
||||||
|
GuardFail::Unreachable,
|
||||||
|
format!("could not resolve host: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => return Err((GuardFail::Unreachable, "DNS resolution timed out".into())),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if addrs.is_empty() {
|
if addrs.is_empty() {
|
||||||
return Err("host did not resolve to any address".into());
|
return Err((
|
||||||
|
GuardFail::Unreachable,
|
||||||
|
"host did not resolve to any address".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
for a in &addrs {
|
for a in &addrs {
|
||||||
if is_blocked_ip(&a.ip()) {
|
if is_blocked_ip(&a.ip()) {
|
||||||
return Err(format!(
|
return Err((
|
||||||
"refusing to connect to non-public address {} (SSRF guard)",
|
GuardFail::Config,
|
||||||
a.ip()
|
format!(
|
||||||
|
"refusing to connect to non-public address {} (SSRF guard)",
|
||||||
|
a.ip()
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +214,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
|||||||
/// SAME bounded-resolve, so their verdicts never diverge — the reason this lives
|
/// 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.
|
/// here, in the key-source crate, rather than being re-rolled per application.
|
||||||
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||||
resolve_and_guard(url).map(|_| ())
|
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
||||||
@@ -206,18 +242,32 @@ impl OnlineSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The server-resolved Unit Keys for this disc, or an empty `Vec`. Runs
|
/// The server-resolved Unit Keys for this disc. Runs exactly one network
|
||||||
/// exactly one network round-trip. The service returns either a terminal
|
/// round-trip. The service returns either a terminal `UK` (used directly) or
|
||||||
/// `UK` (used directly) or a `VUK` (derived to Unit Keys locally via the
|
/// a `VUK` (derived to Unit Keys locally via the disc's encrypted title keys
|
||||||
/// disc's encrypted title keys from `ctx`). Any failure — no service,
|
/// from `ctx`).
|
||||||
/// over-cap MKB, network/parse error, or no key for this disc — yields an
|
///
|
||||||
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
|
/// The return type draws THE distinction this source exists to draw:
|
||||||
/// the resolver's contract (each source's `get_unit_keys` is called once), so no
|
///
|
||||||
/// per-call latch is needed.
|
/// * `Ok(non-empty)` — the service answered with a key.
|
||||||
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
/// * `Ok(empty)` — the service ANSWERED and holds no key for this disc, or
|
||||||
|
/// this source had nothing to ask with (no service configured, a
|
||||||
|
/// misconfigured URL, an over-cap MKB, too few samples). A genuine miss;
|
||||||
|
/// the resolver moves to the next source and `E7022` is the right verdict.
|
||||||
|
/// * `Err(..)` — the service could not answer: unreachable, timed out, DNS
|
||||||
|
/// failed, returned 5xx, rejected the token (401/403), rate-limited (429),
|
||||||
|
/// or replied with something unreadable. NOTHING is known about whether a
|
||||||
|
/// key exists. Collapsing this into `Ok(empty)` is the bug this signature
|
||||||
|
/// fixes: a seven-hour run of HTTP 502s was reported to operators as
|
||||||
|
/// `E7022 No key source has a decryption key for this disc`, and they went
|
||||||
|
/// hunting for a VUK when the correct action was to wait.
|
||||||
|
///
|
||||||
|
/// `&self`: one-shot is the resolver's contract (each source's
|
||||||
|
/// `get_unit_keys` is called once), so no per-call latch is needed.
|
||||||
|
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
// No configured service: nothing to resolve.
|
// No configured service: nothing to resolve.
|
||||||
if self.base_url.is_empty() {
|
if self.base_url.is_empty() {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
// Refuse to transmit over plaintext. The request body always carries
|
// Refuse to transmit over plaintext. The request body always carries
|
||||||
// base64 AACS key material (inf/mkb/units/vid) and, when configured, a
|
// base64 AACS key material (inf/mkb/units/vid) and, when configured, a
|
||||||
@@ -239,7 +289,7 @@ impl OnlineSource {
|
|||||||
"key-service URL is not https:// — refusing to send key material \
|
"key-service URL is not https:// — refusing to send key material \
|
||||||
and credentials in cleartext; skipping the online source"
|
and credentials in cleartext; skipping the online source"
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||||
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
|
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
|
||||||
@@ -252,7 +302,7 @@ impl OnlineSource {
|
|||||||
cap = MAX_MKB_BYTES,
|
cap = MAX_MKB_BYTES,
|
||||||
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
|
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
// Gather encrypted-content samples and prove the minimum by TYPE: a
|
// Gather encrypted-content samples and prove the minimum by TYPE: a
|
||||||
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
|
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
|
||||||
@@ -269,7 +319,7 @@ impl OnlineSource {
|
|||||||
min = MIN_SAMPLE_UNITS,
|
min = MIN_SAMPLE_UNITS,
|
||||||
"too few content samples for a reliable online key request; skipping the online source"
|
"too few content samples for a reliable online key request; skipping the online source"
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let b64 = base64::engine::general_purpose::STANDARD;
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
@@ -303,7 +353,20 @@ impl OnlineSource {
|
|||||||
// request (and the bearer token) to an internal/metadata host.
|
// request (and the bearer token) to an internal/metadata host.
|
||||||
let pinned = match resolve_and_guard(&self.base_url) {
|
let pinned = match resolve_and_guard(&self.base_url) {
|
||||||
Ok(addrs) => addrs,
|
Ok(addrs) => addrs,
|
||||||
Err(_) => {
|
// A host that did not RESOLVE is the service being unreachable, not a
|
||||||
|
// bad URL — DNS failure and DNS timeout are exactly what a key-service
|
||||||
|
// outage looks like from here, and reporting them as a genuine miss is
|
||||||
|
// the same conflation as reporting a 502 that way.
|
||||||
|
Err((GuardFail::Unreachable, _)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::keysource",
|
||||||
|
phase = "keyserver_post",
|
||||||
|
"key-service host did not resolve (DNS failure or timeout); \
|
||||||
|
the service is unreachable, not out of keys"
|
||||||
|
);
|
||||||
|
return Err(Error::KeyServiceUnavailable);
|
||||||
|
}
|
||||||
|
Err((GuardFail::Config, _)) => {
|
||||||
// Log THAT the URL was rejected, never WHY: the guard's message
|
// Log THAT the URL was rejected, never WHY: the guard's message
|
||||||
// names the resolved address, and an internal address must not
|
// names the resolved address, and an internal address must not
|
||||||
// reach a log an operator may paste into a bug report. The
|
// reach a log an operator may paste into a bug report. The
|
||||||
@@ -315,7 +378,7 @@ impl OnlineSource {
|
|||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
"key-service URL failed the address guard; skipping the online source"
|
"key-service URL failed the address guard; skipping the online source"
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let agent = hardened_agent(pinned);
|
let agent = hardened_agent(pinned);
|
||||||
@@ -331,151 +394,215 @@ impl OnlineSource {
|
|||||||
// material.
|
// material.
|
||||||
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
|
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
|
||||||
let post_t0 = std::time::Instant::now();
|
let post_t0 = std::time::Instant::now();
|
||||||
let resp = match req.send_json(body) {
|
let sent = req.send_json(body);
|
||||||
Ok(r) => r,
|
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
|
||||||
Err(e) => {
|
}
|
||||||
// Record the HTTP status when the service actually answered:
|
}
|
||||||
// 401/403 (bad or expired token), 429 (rate limited) and
|
|
||||||
// 5xx (service down) are completely different operator actions,
|
/// Map a key-service HTTP status the client never asked for into the operator
|
||||||
// and collapsing them into one line is why a 502 was read as
|
/// action it implies. THE table this fix exists for — each arm is a different
|
||||||
// "this disc has no key". The status is the service's own
|
/// thing for a person to do, and none of them is "your disc has no key":
|
||||||
// response code — it carries no key material and no address.
|
///
|
||||||
// A transport error has no status; report it as such rather
|
/// * 401 / 403 → the token is wrong or expired. Fix the credentials.
|
||||||
// than inventing one.
|
/// * 429 → rate limited. Back off and retry more slowly.
|
||||||
match e {
|
/// * 5xx → the service is down. Wait; this is the seven-hour-502 case.
|
||||||
ureq::Error::Status(code, _) => tracing::warn!(
|
/// * anything else non-2xx → the service is misbehaving in a way this client
|
||||||
|
/// cannot interpret. Treated as unavailable, deliberately: the genuine miss is
|
||||||
|
/// a **200 with no key in the body**, so a non-2xx status is never evidence
|
||||||
|
/// about this disc, and guessing "no key" from one is the original bug.
|
||||||
|
fn classify_http_status(code: u16) -> Error {
|
||||||
|
match code {
|
||||||
|
401 | 403 => Error::KeyServiceUnauthorized,
|
||||||
|
429 => Error::KeyServiceRateLimited,
|
||||||
|
_ => Error::KeyServiceUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the raw outcome of the key-service POST into this source's answer.
|
||||||
|
///
|
||||||
|
/// Split out of [`OnlineSource::query`] so the reply → verdict mapping is
|
||||||
|
/// testable WITHOUT a network: the SSRF guard in `resolve_and_guard` blocks
|
||||||
|
/// loopback by design, so a stub HTTP server on 127.0.0.1 can never be reached
|
||||||
|
/// through `query`, and this seam is the only honest way to drive "the service
|
||||||
|
/// returned 502" and "the service returned 200 with no entry" through the same
|
||||||
|
/// code the rip uses.
|
||||||
|
///
|
||||||
|
/// `elapsed_ms` is the round-trip time, for the timing logs only.
|
||||||
|
///
|
||||||
|
/// SECURITY: logs status codes, byte counts and static labels only. The request
|
||||||
|
/// body carries base64 AACS key material and the reply carries keys — neither is
|
||||||
|
/// ever logged, and nor is a serde parse error (it quotes the offending input).
|
||||||
|
fn interpret_reply(
|
||||||
|
sent: Result<ureq::Response, ureq::Error>,
|
||||||
|
ctx: &dyn ResolveCtx,
|
||||||
|
elapsed_ms: u64,
|
||||||
|
) -> Result<Vec<UnitKey>, Error> {
|
||||||
|
let resp = match sent {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
// Record the HTTP status when the service actually answered:
|
||||||
|
// 401/403 (bad or expired token), 429 (rate limited) and
|
||||||
|
// 5xx (service down) are completely different operator actions,
|
||||||
|
// and collapsing them into one line is why a 502 was read as
|
||||||
|
// "this disc has no key". The status is the service's own
|
||||||
|
// response code — it carries no key material and no address.
|
||||||
|
// A transport error has no status; report it as such rather
|
||||||
|
// than inventing one.
|
||||||
|
//
|
||||||
|
// Each arm now RETURNS the classified error instead of an empty
|
||||||
|
// vec, so the distinction survives past this function.
|
||||||
|
return Err(match e {
|
||||||
|
ureq::Error::Status(code, _) => {
|
||||||
|
let err = classify_http_status(code);
|
||||||
|
tracing::warn!(
|
||||||
target: "freemkv::keysource",
|
target: "freemkv::keysource",
|
||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
http_status = code,
|
http_status = code,
|
||||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
error_code = err.code(),
|
||||||
"key service returned an HTTP error; no key from online"
|
elapsed_ms,
|
||||||
),
|
"key service returned an HTTP error; no ANSWER from online (not a missing key)"
|
||||||
ureq::Error::Transport(_) => tracing::warn!(
|
);
|
||||||
|
err
|
||||||
|
}
|
||||||
|
ureq::Error::Transport(_) => {
|
||||||
|
tracing::warn!(
|
||||||
target: "freemkv::keysource",
|
target: "freemkv::keysource",
|
||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
elapsed_ms,
|
||||||
"key service unreachable (connect/timeout/TLS); no key from online"
|
"key service unreachable (connect/timeout/TLS); no ANSWER from online (not a missing key)"
|
||||||
),
|
);
|
||||||
|
Error::KeyServiceUnavailable
|
||||||
}
|
}
|
||||||
return Vec::new();
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
target: "freemkv::keysource",
|
||||||
|
phase = "keyserver_post",
|
||||||
|
elapsed_ms,
|
||||||
|
"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
|
||||||
|
{
|
||||||
|
// Length only — never the body, which carries base64 key material.
|
||||||
|
// A truncated / over-cap body is the service failing mid-answer: the
|
||||||
|
// question went unanswered, so this is a source failure, not a miss.
|
||||||
|
tracing::warn!(
|
||||||
target: "freemkv::keysource",
|
target: "freemkv::keysource",
|
||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
cap = MAX_RESPONSE_BYTES,
|
||||||
"end"
|
"key-service reply was unreadable or over the size cap; no ANSWER from online"
|
||||||
);
|
);
|
||||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
return Err(Error::KeyServiceUnavailable);
|
||||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
}
|
||||||
let mut buf = Vec::new();
|
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||||
if resp
|
Ok(j) => j,
|
||||||
.into_reader()
|
Err(_) => {
|
||||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
// Never log the parse error or the payload: a serde message
|
||||||
.read_to_end(&mut buf)
|
// quotes the offending input, which here is key material.
|
||||||
.is_err()
|
// Unparseable is not "no key" — it is the service answering in a
|
||||||
|| buf.len() > MAX_RESPONSE_BYTES
|
// language this client does not speak. Transient / a service bug.
|
||||||
{
|
|
||||||
// Length only — never the body, which carries base64 key material.
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::keysource",
|
target: "freemkv::keysource",
|
||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
cap = MAX_RESPONSE_BYTES,
|
"key-service reply was not valid JSON; no ANSWER from online"
|
||||||
"key-service reply was unreadable or over the size cap; no key from online"
|
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Err(Error::KeyServiceUnavailable);
|
||||||
}
|
}
|
||||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
};
|
||||||
Ok(j) => j,
|
// `UK` is an ARRAY of hex keys (the service always returns an array now,
|
||||||
Err(_) => {
|
// even of one). A single element is the base Unit Key. A full set (one per
|
||||||
// Never log the parse error or the payload: a serde message
|
// forensic index, ordered index 1..N) is returned for a forensic sample.
|
||||||
// quotes the offending input, which here is key material.
|
// Preserve array order and tag each key with its array position, so the
|
||||||
|
// caller can map position → index (element i = index i+1). A bare string is
|
||||||
|
// still accepted for backward compatibility.
|
||||||
|
if let Some(uk) = json.get("UK") {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(s) = uk.as_str() {
|
||||||
|
if let Some(k) = parse_uk(s) {
|
||||||
|
out.push(UnitKey::new(0, k));
|
||||||
|
}
|
||||||
|
} else if let Some(arr) = uk.as_array() {
|
||||||
|
// A forensic set is only usable COMPLETE: the mux trusts any
|
||||||
|
// non-empty result as the whole set and never assumes 32 (see
|
||||||
|
// `get_fmts_indexes`). Skipping an unparseable element would
|
||||||
|
// hand back a short set that silently omits an index, so a
|
||||||
|
// malformed element rejects the whole reply — that is a service
|
||||||
|
// bug, not a usable answer, and therefore a source FAILURE.
|
||||||
|
let mut bad = false;
|
||||||
|
for (i, v) in arr.iter().enumerate() {
|
||||||
|
match v.as_str().and_then(parse_uk) {
|
||||||
|
Some(k) => out.push(UnitKey::new(i as u32, k)),
|
||||||
|
None => {
|
||||||
|
bad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::keysource",
|
target: "freemkv::keysource",
|
||||||
phase = "keyserver_post",
|
phase = "keyserver_post",
|
||||||
"key-service reply was not valid JSON; no key from online"
|
keys = arr.len(),
|
||||||
|
"key-service returned a malformed key in the UK set; rejecting the whole reply"
|
||||||
);
|
);
|
||||||
return Vec::new();
|
return Err(Error::KeyServiceUnavailable);
|
||||||
}
|
|
||||||
};
|
|
||||||
// `UK` is an ARRAY of hex keys (the service always returns an array now,
|
|
||||||
// even of one). A single element is the base Unit Key. A full set (one per
|
|
||||||
// forensic index, ordered index 1..N) is returned for a forensic sample.
|
|
||||||
// Preserve array order and tag each key with its array position, so the
|
|
||||||
// caller can map position → index (element i = index i+1). A bare string is
|
|
||||||
// still accepted for backward compatibility.
|
|
||||||
if let Some(uk) = json.get("UK") {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if let Some(s) = uk.as_str() {
|
|
||||||
if let Some(k) = parse_uk(s) {
|
|
||||||
out.push(UnitKey::new(0, k));
|
|
||||||
}
|
|
||||||
} else if let Some(arr) = uk.as_array() {
|
|
||||||
// A forensic set is only usable COMPLETE: the mux trusts any
|
|
||||||
// non-empty result as the whole set and never assumes 32 (see
|
|
||||||
// `get_fmts_indexes`). Skipping an unparseable element would
|
|
||||||
// hand back a short set that silently omits an index, so a
|
|
||||||
// malformed element rejects the whole reply — that is a service
|
|
||||||
// bug, not a usable answer.
|
|
||||||
let mut bad = false;
|
|
||||||
for (i, v) in arr.iter().enumerate() {
|
|
||||||
match v.as_str().and_then(parse_uk) {
|
|
||||||
Some(k) => out.push(UnitKey::new(i as u32, k)),
|
|
||||||
None => {
|
|
||||||
bad = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bad {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::keysource",
|
|
||||||
phase = "keyserver_post",
|
|
||||||
keys = arr.len(),
|
|
||||||
"key-service returned a malformed key in the UK set; rejecting the whole reply"
|
|
||||||
);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !out.is_empty() {
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A VUK is derived to the terminal keys locally, via the disc's
|
if !out.is_empty() {
|
||||||
// encrypted title keys from the context — the library owns the crypto.
|
return Ok(out);
|
||||||
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
|
||||||
match ctx.enc_title_keys() {
|
|
||||||
Ok(enc) => return uks_from_vuk(&vuk, enc),
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::keysource",
|
|
||||||
phase = "keyserver_post",
|
|
||||||
"key-service returned a VUK but the disc's encrypted title keys \
|
|
||||||
are unreadable; cannot derive unit keys"
|
|
||||||
);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// The genuine miss. Logged distinctly from every failure above so an
|
|
||||||
// operator can tell "the service has no key for this disc" from "the
|
|
||||||
// service did not answer" — collapsing the two is what made a 502 look
|
|
||||||
// like a missing key.
|
|
||||||
tracing::info!(
|
|
||||||
target: "freemkv::keysource",
|
|
||||||
phase = "keyserver_post",
|
|
||||||
"key service has no key for this disc"
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
|
// A VUK is derived to the terminal keys locally, via the disc's
|
||||||
|
// encrypted title keys from the context — the library owns the crypto.
|
||||||
|
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||||
|
match ctx.enc_title_keys() {
|
||||||
|
Ok(enc) => return Ok(uks_from_vuk(&vuk, enc)),
|
||||||
|
Err(_) => {
|
||||||
|
// The SERVICE answered correctly; the DISC's encrypted title keys
|
||||||
|
// are what could not be read. Not a service failure, so not an
|
||||||
|
// `Err` from this source — the disc-side reason is the library's
|
||||||
|
// to report.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::keysource",
|
||||||
|
phase = "keyserver_post",
|
||||||
|
"key-service returned a VUK but the disc's encrypted title keys \
|
||||||
|
are unreadable; cannot derive unit keys"
|
||||||
|
);
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The genuine miss — and the ONLY path that returns `Ok(empty)` from a
|
||||||
|
// completed round-trip. Logged distinctly from every failure above so an
|
||||||
|
// operator can tell "the service has no key for this disc" from "the
|
||||||
|
// service did not answer"; collapsing the two is what made a 502 look like
|
||||||
|
// a missing key. This is the one case where `E7022` is the truth.
|
||||||
|
tracing::info!(
|
||||||
|
target: "freemkv::keysource",
|
||||||
|
phase = "keyserver_post",
|
||||||
|
"key service has no key for this disc"
|
||||||
|
);
|
||||||
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeySource for OnlineSource {
|
impl KeySource for OnlineSource {
|
||||||
/// Base per-CPS-unit Unit Keys: submit the ctx's content samples and take the
|
/// Base per-CPS-unit Unit Keys: submit the ctx's content samples and take the
|
||||||
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
|
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
|
||||||
/// round-trip; any failure yields empty (the resolver tries the next source).
|
/// round-trip. An empty `Ok` means the service ANSWERED and holds no key; an
|
||||||
|
/// `Err` means it could not answer (see [`OnlineSource::query`]). Both let the
|
||||||
|
/// resolver try the next source — the difference is what the operator is told
|
||||||
|
/// when no source has one.
|
||||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
Ok(self.query(ctx))
|
self.query(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
|
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
|
||||||
@@ -486,7 +613,7 @@ impl KeySource for OnlineSource {
|
|||||||
/// reads the reply. The count is whatever the service returns; the mux trusts
|
/// reads the reply. The count is whatever the service returns; the mux trusts
|
||||||
/// any non-empty result as the complete set and never assumes 32.
|
/// any non-empty result as the complete set and never assumes 32.
|
||||||
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
Ok(self.query(ctx))
|
self.query(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
@@ -653,6 +780,196 @@ mod tests {
|
|||||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the reply → verdict mapping (THE defect) ───────────────────────────
|
||||||
|
//
|
||||||
|
// Driven through `interpret_reply`, the seam `query` hands its round-trip
|
||||||
|
// to. A stub HTTP server is NOT usable here: `resolve_and_guard` blocks
|
||||||
|
// loopback by design (SSRF guard), so 127.0.0.1 can never be reached through
|
||||||
|
// `query`, and pointing the test at a public host would make it a network
|
||||||
|
// test. Everything from the response onward is this function.
|
||||||
|
|
||||||
|
/// A `ResolveCtx` that carries nothing — enough for the reply paths that do
|
||||||
|
/// not derive from a VUK.
|
||||||
|
struct BareCtx;
|
||||||
|
impl ResolveCtx for BareCtx {
|
||||||
|
fn disc_hash(&self) -> &str {
|
||||||
|
"0x422EB"
|
||||||
|
}
|
||||||
|
fn title(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn mkb(&self) -> Result<&[u8], Error> {
|
||||||
|
Ok(&[])
|
||||||
|
}
|
||||||
|
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||||
|
Ok(&[])
|
||||||
|
}
|
||||||
|
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(status: u16, body: &str) -> ureq::Response {
|
||||||
|
ureq::Response::new(status, "stub", body).expect("stub response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE regression. The key service returned HTTP 502 for ~seven hours and
|
||||||
|
/// the CLI told operators the disc had no key, so they went hunting for a
|
||||||
|
/// VUK that was never missing. A 5xx must be a source FAILURE, and it must
|
||||||
|
/// be distinguishable from the service answering 200 with no entry.
|
||||||
|
#[test]
|
||||||
|
fn http_5xx_is_a_source_failure_not_a_missing_key() {
|
||||||
|
for status in [500u16, 502, 503, 504] {
|
||||||
|
let out = interpret_reply(
|
||||||
|
Err(ureq::Error::Status(status, reply(status, ""))),
|
||||||
|
&BareCtx,
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out.expect_err("a 5xx must not look like an answer").code(),
|
||||||
|
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||||
|
"HTTP {status} must report the service as unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contrast case, through the SAME function: the service answered,
|
||||||
|
// and genuinely holds nothing for this disc.
|
||||||
|
let miss = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 7)
|
||||||
|
.expect("a 200 with no key is an ANSWER, not a failure");
|
||||||
|
assert!(miss.is_empty(), "no key in the body → no keys out");
|
||||||
|
|
||||||
|
// And the two must not be the same outcome — the whole point.
|
||||||
|
let down = interpret_reply(Err(ureq::Error::Status(502, reply(502, ""))), &BareCtx, 7);
|
||||||
|
assert!(
|
||||||
|
down.is_err(),
|
||||||
|
"502 and 200-with-no-entry must not collapse to the same result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each status the service can return maps to a DIFFERENT operator action:
|
||||||
|
/// fix the token (401/403), back off (429), wait (5xx). A non-2xx status is
|
||||||
|
/// never evidence about this disc, so anything else is "unavailable" too —
|
||||||
|
/// guessing "no key" from a status code is the original bug.
|
||||||
|
#[test]
|
||||||
|
fn http_status_maps_to_the_operator_action() {
|
||||||
|
let cases: &[(u16, u16)] = &[
|
||||||
|
(401, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||||
|
(403, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||||
|
(429, libfreemkv::error::E_KEY_SERVICE_RATE_LIMITED),
|
||||||
|
(500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||||
|
(502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||||
|
(400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||||
|
(404, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||||
|
];
|
||||||
|
for (status, want) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_status(*status).code(),
|
||||||
|
*want,
|
||||||
|
"HTTP {status} classified wrongly"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
classify_http_status(*status).code(),
|
||||||
|
libfreemkv::error::E_NO_DISC_KEY,
|
||||||
|
"no HTTP status may ever mean \"this disc has no key\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transport failure — nothing answered at all — is the same verdict as a
|
||||||
|
/// 5xx and equally not a missing key. Uses a refused connection to
|
||||||
|
/// 127.0.0.1:1 to obtain a REAL `ureq::Error::Transport` (which has no public
|
||||||
|
/// constructor); this touches no network and never reaches `query`, so the
|
||||||
|
/// SSRF guard is not involved.
|
||||||
|
#[test]
|
||||||
|
fn transport_failure_is_a_source_failure() {
|
||||||
|
let sent = ureq::AgentBuilder::new()
|
||||||
|
.timeout_connect(Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.post("http://127.0.0.1:1/")
|
||||||
|
.send_string("{}");
|
||||||
|
assert!(
|
||||||
|
matches!(sent, Err(ureq::Error::Transport(_))),
|
||||||
|
"a refused connection must surface as a ureq transport error"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
interpret_reply(sent, &BareCtx, 3)
|
||||||
|
.expect_err("an unreachable service must not look like an answer")
|
||||||
|
.code(),
|
||||||
|
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reply the client cannot read is the service failing mid-answer, not an
|
||||||
|
/// answer of "no key".
|
||||||
|
#[test]
|
||||||
|
fn unparseable_reply_is_a_source_failure() {
|
||||||
|
assert_eq!(
|
||||||
|
interpret_reply(Ok(reply(200, "<html>gateway error</html>")), &BareCtx, 1)
|
||||||
|
.expect_err("non-JSON is not an answer")
|
||||||
|
.code(),
|
||||||
|
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
// A malformed element in the UK set rejects the whole reply (a partial
|
||||||
|
// forensic set silently omits an index) — also a service fault.
|
||||||
|
assert_eq!(
|
||||||
|
interpret_reply(
|
||||||
|
Ok(reply(
|
||||||
|
200,
|
||||||
|
r#"{"UK":["000102030405060708090a0b0c0d0e0f","zz"]}"#
|
||||||
|
)),
|
||||||
|
&BareCtx,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
.expect_err("a malformed key in the set is not an answer")
|
||||||
|
.code(),
|
||||||
|
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression pin (passes before and after): a service that DOES hold the
|
||||||
|
/// key still resolves it, and array order is preserved as the forensic index
|
||||||
|
/// order. The Result-returning signature must not have changed the happy path.
|
||||||
|
#[test]
|
||||||
|
fn service_with_a_key_still_resolves_it_in_order() {
|
||||||
|
let keys = interpret_reply(
|
||||||
|
Ok(reply(
|
||||||
|
200,
|
||||||
|
r#"{"UK":["000102030405060708090a0b0c0d0e0f","0f0e0d0c0b0a09080706050403020100"]}"#,
|
||||||
|
)),
|
||||||
|
&BareCtx,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("a 200 carrying keys resolves");
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
assert_eq!(keys[0].idx, 0);
|
||||||
|
assert_eq!(keys[1].idx, 1);
|
||||||
|
assert_eq!(keys[0].key[0], 0x00);
|
||||||
|
assert_eq!(keys[1].key[0], 0x0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A URL that fails the ADDRESS guard is operator configuration, not the
|
||||||
|
/// service being down — the two must stay separable, since only one of them
|
||||||
|
/// is worth retrying.
|
||||||
|
#[test]
|
||||||
|
fn address_guard_rejections_are_config_not_unreachable() {
|
||||||
|
for url in [
|
||||||
|
"http://127.0.0.1/keys",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"ftp://example.com/keys",
|
||||||
|
"not a url",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_and_guard(url).expect_err("must be rejected").0,
|
||||||
|
GuardFail::Config,
|
||||||
|
"{url} is a configuration fault, not an outage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Finding #9 regression: parse_uk must reject any non-hex byte up front so
|
/// 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
|
/// sign prefixes / whitespace can't slip through the windowed 2-char parse
|
||||||
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
|
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
|
||||||
|
|||||||
Reference in New Issue
Block a user