keysources: fix confirmed audit findings (key-file perms, SSRF, cache lock)

- keydb write_atomic: create the temp file 0600 on Unix (OpenOptionsExt::mode)
  so umask can't leave renamed keydb.cfg key material world-readable.
- online is_blocked_ip: decode 6to4 (2002::/16) and Teredo (2001:0000::/32)
  embedded IPv4 and re-check via SSRF guard; add unit test.
- online resolve_and_guard: cap outstanding DNS-resolver threads (AtomicUsize,
  max 4) so a black-holed keyserver can't leak threads unboundedly.
- keydb cached_db: run the ~62 MiB reparse OUTSIDE self.cache lock, with a
  double-checked re-acquire to install (avoids stalling all workers; keeps the
  one-open-one-identity invariant).
- keydb unit_keys_from: log tracing::warn! on enc_title_keys()/mkb() Err before
  falling back to empty (parity with online.rs).
- keydb unit_keys_from: skip stored unit keys with CPS number 0 (would collide
  with unit 1 at idx 0); add regression test.
- tests: assert full 16-byte key values (online UK/VUK/at-cap paths) and give
  parse_disc_entry distinct M/I/V/U values.
- docs/keydb-format.md: DiscEntry::title is read (to_keydb_cfg + Debug), not
  unused; cross-reference the duplicated MAX_KEYDB_BYTES constants.
This commit is contained in:
Matthew Jackson
2026-09-05 11:19:03 -07:00
parent 707c8bdc57
commit 424aeadccd
5 changed files with 194 additions and 35 deletions
Generated
+5 -5
View File
@@ -306,7 +306,7 @@ dependencies = [
[[package]] [[package]]
name = "freemkv-keysources" name = "freemkv-keysources"
version = "1.6.14" version = "1.7.0"
dependencies = [ dependencies = [
"base64", "base64",
"flate2", "flate2",
@@ -319,7 +319,7 @@ dependencies = [
[[package]] [[package]]
name = "freemkv-unlock" name = "freemkv-unlock"
version = "1.6.14" version = "1.7.0"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
@@ -534,7 +534,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libfreemkv" name = "libfreemkv"
version = "1.6.14" version = "1.7.0"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
@@ -1271,8 +1271,8 @@ dependencies = [
[[patch.unused]] [[patch.unused]]
name = "freemkv-engine" name = "freemkv-engine"
version = "1.6.14" version = "1.7.0"
[[patch.unused]] [[patch.unused]]
name = "freemkv-i18n" name = "freemkv-i18n"
version = "1.6.14" version = "1.7.0"
+6 -4
View File
@@ -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 function has ever existed in this crate; the real consumer is
`KeydbSource::unit_keys_from` (keydb.rs), which reads `unit_keys`, `vuk`, `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 `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 — kat_c / kat_d KATs pin — it is NOT unused). `DiscEntry::title` is likewise
`empty`, `find_vuk`, `DiscEntry::title` — are part of the faithful copy and read — by `to_keydb_cfg` and the redacting `Debug` impl — so it is not
are retained rather than pruned; allow dead_code so the byte-for-byte copy unused either. The genuinely unused items — `empty` and `find_vuk` — are
compiles clean without diverging from the libfreemkv original. 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 ## ParseStats — what `KeyDb::parse` threw away
+86 -11
View File
@@ -21,9 +21,9 @@ use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb; use crate::keydb_format::KeyDb;
// Upper bound on decompressed keydb size: caps a decompression bomb (a tiny // Upper bound on decompressed keydb size (decompression-bomb cap): a tiny
// zip/gz could otherwise inflate to GiB and OOM the daily refresh thread). // zip/gz could otherwise inflate to GiB and OOM the refresh thread. Mirrors
// The public UHD keydb is ~62 MiB and growing; 128 MiB leaves headroom. // keydb_format::MAX_KEYDB_BYTES (the on-disk load cap); keep the two equal.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024; const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Result of a KEYDB save/update -- path written, entry count, and byte size. /// 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 file = std::fs::File::open(&self.path)?;
let stamp = FileStamp::of(&file.metadata()?); let stamp = FileStamp::of(&file.metadata()?);
let stamped_at = std::time::SystemTime::now(); 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()); let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.as_ref() if let Some(entry) = guard.as_ref()
&& entry.stamp == stamp && entry.stamp == stamp
&& entry.is_settled(self.mtime_granularity) && 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); self.emit_parse_stats(&entry.stats);
return Ok(entry.db.clone()); return Ok(entry.db.clone());
} }
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
self.emit_parse_stats(&stats); self.emit_parse_stats(&stats);
let db = Arc::new(db);
self.parses.fetch_add(1, Ordering::Relaxed); self.parses.fetch_add(1, Ordering::Relaxed);
*guard = Some(CacheEntry { *guard = Some(CacheEntry {
stamp, stamp,
@@ -346,13 +362,30 @@ impl KeydbSource {
// 1. Terminal Unit Keys stored in the entry — directly usable, no // 1. Terminal Unit Keys stored in the entry — directly usable, no
// derivation. Preserve the keydb's CPS numbering (idx = num - 1). // derivation. Preserve the keydb's CPS numbering (idx = num - 1).
for (num, key) in &entry.unit_keys { 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 disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
// the scan captured none, in which case only the stored list (1) // the scan captured none, in which case only the stored list (1)
// contributes. // 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() { if !enc_title_keys.is_empty() {
// VUK path, else MK path (stored/PK/DK) → VUK. See // VUK path, else MK path (stored/PK/DK) → VUK. See
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules. // 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) uks_from_vuk(&vuk, enc_title_keys)
} else { } else {
let vid = ctx.vid().or_else(|| entry.vid.map(Vid)); 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 let mk: Option<MediaKey> = entry
.media_key .media_key
.map(MediaKey) .map(MediaKey)
@@ -443,6 +488,20 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
)) ))
}; };
let write_result = (|| -> std::io::Result<()> { 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)?; let mut f = std::fs::File::create(&tmp)?;
f.write_all(text.as_bytes())?; f.write_all(text.as_bytes())?;
f.sync_all()?; 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 // Orphan-unit completeness (the real keydb bug): a PARTIAL stored list
// plus a VUK that boils BOTH declared units must return BOTH, not shadow // 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. // the VUK with the partial list. See docs/keydb.md#test-orphan-union.
+15 -10
View File
@@ -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. /// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
pub type NumberedUnitKey = (u32, [u8; 16]); pub type NumberedUnitKey = (u32, [u8; 16]);
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load`. The // 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 // public UHD keydb is ~62 MiB; 128 MiB bounds a hostile/corrupt file).
// allocation from a hostile or corrupt file. // Mirror of keydb.rs's MAX_KEYDB_BYTES decompression cap; keep them equal.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024; const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries /// Upper bound on parsed disc entries. The real public keydb carries
@@ -1017,18 +1017,23 @@ mod tests {
#[test] #[test]
fn test_parse_disc_entry() { 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 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!( 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(); let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
assert!(entry.media_key.is_some()); assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field");
assert!(entry.vuk.is_some()); assert_eq!(entry.vid, Some([0x22u8; 16]), "I field");
assert_eq!(entry.unit_keys.len(), 1); assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field");
assert_eq!(entry.unit_keys[0].0, 1); assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field");
assert_eq!(entry.mkb_version, Some(77));
} }
#[test] #[test]
+82 -5
View File
@@ -56,13 +56,24 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|| v4.octets()[0] >= 240 || v4.octets()[0] >= 240
} }
IpAddr::V6(v6) => { 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_loopback()
|| v6.is_unspecified() || v6.is_unspecified()
|| v6.is_multicast() || v6.is_multicast()
// Unique-local fc00::/7. // Unique-local fc00::/7.
|| (v6.segments()[0] & 0xfe00) == 0xfc00 || (seg[0] & 0xfe00) == 0xfc00
// Link-local fe80::/10. // 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, // 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 // deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
// both forms; re-check the embedded address as IPv4. // both forms; re-check the embedded address as IPv4.
@@ -70,6 +81,8 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
.to_ipv4() .to_ipv4()
.map(|m| is_blocked_ip(&IpAddr::V4(m))) .map(|m| is_blocked_ip(&IpAddr::V4(m)))
== Some(true) == 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 // resolver timeout and freeze the calling rip thread, so run it on a
// spawned thread with a bounded deadline (mirrors autorip/libfreemkv). // spawned thread with a bounded deadline (mirrors autorip/libfreemkv).
let addrs: Vec<SocketAddr> = { let addrs: Vec<SocketAddr> = {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc; use std::sync::mpsc;
const DNS_TIMEOUT: Duration = Duration::from_secs(10); 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 host = host.clone();
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
std::thread::spawn(move || { 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>>()); .map(|it| it.collect::<Vec<SocketAddr>>());
// Receiver may be gone after the timeout — ignore the send error. // Receiver may be gone after the timeout — ignore the send error.
let _ = tx.send(res); 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) { match rx.recv_timeout(DNS_TIMEOUT) {
Ok(Ok(addrs)) => addrs, 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] #[test]
fn ssrf_guard_allows_public_ips() { 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(8, 8, 8, 8))));
@@ -996,8 +1049,16 @@ mod tests {
assert_eq!(keys.len(), 2); assert_eq!(keys.len(), 2);
assert_eq!(keys[0].idx, 0); assert_eq!(keys[0].idx, 0);
assert_eq!(keys[1].idx, 1); assert_eq!(keys[1].idx, 1);
assert_eq!(keys[0].key[0], 0x00); // Full 16 bytes of each key, not just byte 0 — a byte-transposition bug
assert_eq!(keys[1].key[0], 0x0f); // 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) /// 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"); .expect("a string UK must still resolve");
assert_eq!(keys.len(), 1); assert_eq!(keys.len(), 1);
assert_eq!(keys[0].idx, 0); 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, // 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[0].idx, 0);
assert_eq!(keys[1].idx, 1); 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 /// 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) let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1)
.expect("a reply exactly at the cap is legal and must be read"); .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"); 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 ──────────────────────────────── // ── A bad port is CONFIG, not an outage ────────────────────────────────