mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
1
Commits
v1.8.0-rc1
...
v1.7.0-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424aeadccd |
Generated
+5
-5
@@ -306,7 +306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.6.14"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
@@ -319,7 +319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.6.14"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -534,7 +534,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.14"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -1271,8 +1271,8 @@ dependencies = [
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.6.14"
|
||||
version = "1.7.0"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.6.14"
|
||||
version = "1.7.0"
|
||||
|
||||
@@ -10,10 +10,12 @@ revision of this comment cited a `candidates_from` function — no such
|
||||
function has ever existed in this crate; the real consumer is
|
||||
`KeydbSource::unit_keys_from` (keydb.rs), which reads `unit_keys`, `vuk`,
|
||||
`media_key` AND `vid` (`vid` is load-bearing for the MK+VID derivation the
|
||||
kat_c / kat_d KATs pin — it is NOT unused). The genuinely unused items —
|
||||
`empty`, `find_vuk`, `DiscEntry::title` — are part of the faithful copy and
|
||||
are retained rather than pruned; allow dead_code so the byte-for-byte copy
|
||||
compiles clean without diverging from the libfreemkv original.
|
||||
kat_c / kat_d KATs pin — it is NOT unused). `DiscEntry::title` is likewise
|
||||
read — by `to_keydb_cfg` and the redacting `Debug` impl — so it is not
|
||||
unused either. The genuinely unused items — `empty` and `find_vuk` — are
|
||||
part of the faithful copy and are retained rather than pruned; allow
|
||||
dead_code so the byte-for-byte copy compiles clean without diverging from
|
||||
the libfreemkv original.
|
||||
|
||||
## ParseStats — what `KeyDb::parse` threw away
|
||||
|
||||
|
||||
+86
-11
@@ -21,9 +21,9 @@ use libfreemkv::{Error, KeySource};
|
||||
|
||||
use crate::keydb_format::KeyDb;
|
||||
|
||||
// Upper bound on decompressed keydb size: caps a decompression bomb (a tiny
|
||||
// zip/gz could otherwise inflate to GiB and OOM the daily refresh thread).
|
||||
// The public UHD keydb is ~62 MiB and growing; 128 MiB leaves headroom.
|
||||
// Upper bound on decompressed keydb size (decompression-bomb cap): a tiny
|
||||
// zip/gz could otherwise inflate to GiB and OOM the refresh thread. Mirrors
|
||||
// keydb_format::MAX_KEYDB_BYTES (the on-disk load cap); keep the two equal.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
||||
@@ -174,20 +174,36 @@ impl KeydbSource {
|
||||
let file = std::fs::File::open(&self.path)?;
|
||||
let stamp = FileStamp::of(&file.metadata()?);
|
||||
let stamped_at = std::time::SystemTime::now();
|
||||
// Fast path: a settled cache hit under a brief lock (stamp compare +
|
||||
// Arc clone). Re-emit the rejection summary on EVERY hit — a hit skips
|
||||
// `KeyDb::parse`, else a corrupt keydb warns once then serves silently.
|
||||
{
|
||||
let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
}
|
||||
// Miss: parse the ~62 MiB file OUTSIDE the lock so a reparse can't stall
|
||||
// every other worker. Stamp (fstat) and bytes still come from the ONE
|
||||
// open handle, preserving the one-open-one-identity invariant.
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
let db = Arc::new(db);
|
||||
// Re-acquire and double-check: a peer may have installed the same
|
||||
// settled stamp while we parsed — adopt theirs and drop our redundant
|
||||
// parse rather than racing a lost update.
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
// Re-emit the rejection summary on EVERY hit, not just the parse
|
||||
// that produced it (a hit skips `KeyDb::parse`) — otherwise a
|
||||
// corrupt keydb warns once, then serves silently. See docs/keydb.md#cached-db-reemit.
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
self.emit_parse_stats(&stats);
|
||||
let db = Arc::new(db);
|
||||
self.parses.fetch_add(1, Ordering::Relaxed);
|
||||
*guard = Some(CacheEntry {
|
||||
stamp,
|
||||
@@ -346,13 +362,30 @@ impl KeydbSource {
|
||||
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
||||
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
||||
for (num, key) in &entry.unit_keys {
|
||||
keys.push(UnitKey::new(num.saturating_sub(1), *key));
|
||||
// A valid CPS unit number is >= 1; num 0 would collide with unit 1
|
||||
// at idx 0 (num - 1), so skip it rather than mis-map two units.
|
||||
if *num == 0 {
|
||||
continue;
|
||||
}
|
||||
keys.push(UnitKey::new(num - 1, *key));
|
||||
}
|
||||
|
||||
// The disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
|
||||
// the scan captured none, in which case only the stored list (1)
|
||||
// contributes.
|
||||
let enc_title_keys = ctx.enc_title_keys().unwrap_or(&[]);
|
||||
let enc_title_keys = match ctx.enc_title_keys() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: surface the read failure, then fall back to
|
||||
// empty (only the stored unit-key list can contribute).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc encrypted title keys unreadable; deriving without them"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
if !enc_title_keys.is_empty() {
|
||||
// VUK path, else MK path (stored/PK/DK) → VUK. See
|
||||
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules.
|
||||
@@ -360,7 +393,19 @@ impl KeydbSource {
|
||||
uks_from_vuk(&vuk, enc_title_keys)
|
||||
} else {
|
||||
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
let mkb = match ctx.mkb() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: log the failure, then fall back to
|
||||
// empty (PK/DK derivation may then find no Media Key).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc MKB unreadable; media-key derivation may fail"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
let mk: Option<MediaKey> = entry
|
||||
.media_key
|
||||
.map(MediaKey)
|
||||
@@ -443,6 +488,20 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
|
||||
))
|
||||
};
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
// The temp file is renamed onto keydb.cfg, which holds AACS key
|
||||
// material and the host private key/cert — create it 0600 on Unix so
|
||||
// umask can't leave the keys world-readable. Non-unix keeps create().
|
||||
#[cfg(unix)]
|
||||
let mut f = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
@@ -642,6 +701,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be
|
||||
// skipped, not mapped to idx 0 where `num - 1` would collide with unit 1.
|
||||
#[test]
|
||||
fn stored_unit_key_with_cps_number_zero_is_skipped() {
|
||||
let mut e = blank_entry(HASH);
|
||||
e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])];
|
||||
let db = db_with(e, Vec::new());
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None));
|
||||
let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
|
||||
assert!(
|
||||
!keys.contains(&[0xEEu8; 16]),
|
||||
"a unit-0 key must be dropped, never mapped onto idx 0"
|
||||
);
|
||||
assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]);
|
||||
}
|
||||
|
||||
// Orphan-unit completeness (the real keydb bug): a PARTIAL stored list
|
||||
// plus a VUK that boils BOTH declared units must return BOTH, not shadow
|
||||
// the VUK with the partial list. See docs/keydb.md#test-orphan-union.
|
||||
|
||||
+15
-10
@@ -17,9 +17,9 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
|
||||
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
|
||||
pub type NumberedUnitKey = (u32, [u8; 16]);
|
||||
|
||||
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load`. The
|
||||
// public UHD keydb is ~62 MiB and growing; 128 MiB bounds worst-case
|
||||
// allocation from a hostile or corrupt file.
|
||||
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load` (the
|
||||
// public UHD keydb is ~62 MiB; 128 MiB bounds a hostile/corrupt file).
|
||||
// Mirror of keydb.rs's MAX_KEYDB_BYTES decompression cap; keep them equal.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Upper bound on parsed disc entries. The real public keydb carries
|
||||
@@ -1017,18 +1017,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
// All-zero placeholders — synthetic; no real key material in code.
|
||||
// Synthetic placeholders, DISTINCT per field so a field mix-up (M read
|
||||
// as V, I as U, …) is caught — no real key material in code.
|
||||
let z40 = "00".repeat(20);
|
||||
let z32 = "00".repeat(16);
|
||||
let mk = "11".repeat(16);
|
||||
let id = "22".repeat(16);
|
||||
let vuk = "33".repeat(16);
|
||||
let uk = "44".repeat(16);
|
||||
let line = format!(
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77"
|
||||
);
|
||||
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
assert_eq!(entry.unit_keys[0].0, 1);
|
||||
assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field");
|
||||
assert_eq!(entry.vid, Some([0x22u8; 16]), "I field");
|
||||
assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field");
|
||||
assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field");
|
||||
assert_eq!(entry.mkb_version, Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+82
-5
@@ -56,13 +56,24 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
|| v4.octets()[0] >= 240
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
let seg = v6.segments();
|
||||
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo
|
||||
// (2001:0000::/32) embeds the client IPv4 in the last two segments,
|
||||
// each XOR 0xffff. Both must be re-checked as their embedded IPv4.
|
||||
let sixtofour = (seg[0] == 0x2002)
|
||||
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
|
||||
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
|
||||
std::net::Ipv4Addr::from(
|
||||
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
|
||||
)
|
||||
});
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
// Unique-local fc00::/7.
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|
||||
|| (seg[0] & 0xfe00) == 0xfc00
|
||||
// Link-local fe80::/10.
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
|| (seg[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.
|
||||
@@ -70,6 +81,8 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
.to_ipv4()
|
||||
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
|
||||
== Some(true)
|
||||
|| sixtofour.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|
||||
|| teredo.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,8 +153,21 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
// resolver timeout and freeze the calling rip thread, so run it on a
|
||||
// spawned thread with a bounded deadline (mirrors autorip/libfreemkv).
|
||||
let addrs: Vec<SocketAddr> = {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// Each resolver thread can hang for the OS timeout and is never joined,
|
||||
// so a black-holed keyserver leaks one thread+stack per attempt. Cap the
|
||||
// outstanding ones; over the cap, report the host unreachable instead.
|
||||
const MAX_DNS_THREADS: usize = 4;
|
||||
static DNS_THREADS: AtomicUsize = AtomicUsize::new(0);
|
||||
if DNS_THREADS.fetch_add(1, Ordering::SeqCst) >= MAX_DNS_THREADS {
|
||||
DNS_THREADS.fetch_sub(1, Ordering::SeqCst);
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
"too many concurrent DNS resolutions in flight".into(),
|
||||
));
|
||||
}
|
||||
let host = host.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
@@ -150,6 +176,9 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
.map(|it| it.collect::<Vec<SocketAddr>>());
|
||||
// Receiver may be gone after the timeout — ignore the send error.
|
||||
let _ = tx.send(res);
|
||||
// Decrement only when the (possibly long-hung) lookup actually
|
||||
// returns, so the cap reflects threads truly in flight.
|
||||
DNS_THREADS.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
@@ -648,6 +677,30 @@ mod tests {
|
||||
))));
|
||||
}
|
||||
|
||||
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
|
||||
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
|
||||
// internal target slips through the tunnel.
|
||||
#[test]
|
||||
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
|
||||
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
|
||||
))));
|
||||
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
|
||||
))));
|
||||
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
|
||||
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
|
||||
))));
|
||||
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
|
||||
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_guard_allows_public_ips() {
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
@@ -996,8 +1049,16 @@ mod tests {
|
||||
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);
|
||||
// Full 16 bytes of each key, not just byte 0 — a byte-transposition bug
|
||||
// would pass a first-byte-only check.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
);
|
||||
assert_eq!(
|
||||
keys[1].key,
|
||||
[15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Backward-compatible form: `"UK"` as a bare hex STRING (not an array)
|
||||
@@ -1012,7 +1073,11 @@ mod tests {
|
||||
.expect("a string UK must still resolve");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
// Full 16-byte key, not just byte 0.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
);
|
||||
}
|
||||
|
||||
// A malformed `"UK"` STRING falls through to the genuine-miss path,
|
||||
@@ -1064,6 +1129,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
// Full-byte KAT: the derived keys must equal the local VUK boil over
|
||||
// the disc's encrypted title keys (the same primitive the code calls).
|
||||
let vuk = parse_uk(vuk_hex).unwrap();
|
||||
let expected = crate::uks_from_vuk(&vuk, &[[0x11u8; 16], [0x22u8; 16]]);
|
||||
assert_eq!(keys[0].key, expected[0].key);
|
||||
assert_eq!(keys[1].key, expected[1].key);
|
||||
}
|
||||
|
||||
/// The service answered correctly with a VUK, but the DISC's encrypted
|
||||
@@ -1236,6 +1307,12 @@ mod tests {
|
||||
let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1)
|
||||
.expect("a reply exactly at the cap is legal and must be read");
|
||||
assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive");
|
||||
// The surviving key must be the planted UK bytes, not merely present.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"the at-cap key must be the exact planted UK"
|
||||
);
|
||||
}
|
||||
|
||||
// ── A bad port is CONFIG, not an outage ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user