Make five tests capable of failing, and stop the presence probe unmounting the disc

The worst of the five was a regression suite that never touched the code
it guarded: nine batch-count tests called `safe_batch_count` and
`buggy_batch_count`, both defined in the test file itself. The u16
truncation they exist to prevent could be reintroduced in
sector/prefetched.rs with every one of them green. They now drive the real
producer through the public API, and reinstating the truncation fails five
of the nine. Worth recording that the symptom has changed since the
original fix: the unit-alignment clamp below floors a zero batch at three
sectors, so the bug is now a twenty-fold throughput cliff rather than the
stall it once was.

The MP4 reserve test's only numeric case was dominated by the floor and
the buffer, so BYTES_PER_SAMPLE could be zeroed without failing it. It now
has a case where the per-sample term dominates. The zero-count guard in
FileSectorSource was likewise unfalsifiable — seek-past-EOF and a
zero-length read both succeed — so the test now observes the file cursor.
The AACS media-key ambiguity guard had no test at all; the pool scan is
extracted so the verifier can be injected, because a genuine two-key
collision needs one ciphertext decrypting under two AES-128 keys to
plaintexts sharing a 64-bit magic, which is a 2^64 search and not a
fixture.

macOS implemented the documented cheap, side-effect-free presence probe by
building a full exclusive transport — which force-unmounts the disc. Linux
and Windows issue one TEST UNIT READY with no unmount; macOS was the
outlier. It now walks the IOKit registry for the media object instead.

The C shim's registry reads assumed CoreFoundation types the registry does
not guarantee, so a driver publishing a CFNumber where a CFString was
expected aborted the process from inside public API. Types are checked and
a wrong type treated as absent. The unbounded waitpid on the unmount child
is now a polled deadline, and the last-resort match gained the NULL check
its two siblings already had.

The empty-CDB guard existed only on Linux while a shared helper's comment
claimed all three backends had it. Moved into the helper, so the comment
is now true and macOS and Windows are covered.

One finding was REJECTED with evidence rather than fixed. The TrueHD
buffer-cap test was indeed bogus, but MAX_TRUEHD_BUF turns out to be
unreachable by any input: the parser only retains data when the buffer is
shorter than the declared AU, and that declaration is twelve bits, so the
worst case is 8189 bytes against a 256 KiB cap. An exhaustive sweep over
all 65536 AU headers confirmed it. The fixture now sits at the reachable
ceiling and asserts that instead. The cap itself is left in place as
defence, unreachable by construction, matching how the AC-3 resync guard
was handled earlier in this audit.

Two behaviour changes worth naming: Linux's empty-CDB error becomes
InvalidCdbLength rather than a transport failure, and an unknown device
now reports absent media rather than a not-found error, because the
registry cannot tell an empty drive from a missing one. The latter is a
conflation of the kind this audit has fixed three times; it is recorded
for the next round rather than left silent.
This commit is contained in:
Matthew Jackson
2026-07-30 09:26:56 -07:00
parent b8fa5e74dc
commit 327087c70e
9 changed files with 562 additions and 147 deletions
+100 -16
View File
@@ -379,26 +379,18 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
// One AES-D + magic check per candidate (cheap). mk_dv is hoisted
// out of the loop so the MKB is not re-walked per candidate.
let mks = providers.media_keys();
let mut mk_hits: Vec<[u8; 16]> = Vec::new();
if let Some(mk_dv) = mkb_find_mk_dv(mkb) {
for mk in &mks {
let verifies = aes_ecb_decrypt(mk, &mk_dv)[..8]
== [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if verifies && !mk_hits.contains(mk) {
mk_hits.push(*mk);
if mk_hits.len() > 1 {
break; // ambiguous — bail to avoid a wrong key
}
}
}
}
if mk_hits.len() == 1 {
let vuk = derive_vuk(&mk_hits[0], ctx.volume_id);
let chosen_mk = mkb_find_mk_dv(mkb).and_then(|mk_dv| {
unique_verifying_mk(&mks, |mk| {
aes_ecb_decrypt(mk, &mk_dv)[..8] == MK_VERIFY_MAGIC
})
});
if let Some(mk) = chosen_mk {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
// Same class as path 3 (KEYDB MK → derived VUK).
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), "MK-pool brute: no unique verifying MK");
} else {
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
}
@@ -450,6 +442,42 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
None
}
/// First 8 bytes of the plaintext behind an MKB Verify Media Key record — the
/// AACS "this is the right Km" sentinel (`0123456789ABCDEF`). A candidate MK
/// verifies when AES-128-ECB-D(mk, mk_dv) starts with it.
const MK_VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
/// The MK-pool selection rule of path 2.5, split out of [`resolve_keys_v1`] so
/// the ambiguity guard has a reachable test.
///
/// `verifies` is the MKB check — in production
/// `AES-D(mk, mk_dv)[..8] == MK_VERIFY_MAGIC`. Returns a Media Key only when
/// EXACTLY ONE DISTINCT candidate passes. Duplicates of the same key are one
/// candidate (a pool aggregated across providers routinely repeats a key), but
/// two DIFFERENT keys that both verify mean the pool cannot say which is this
/// disc's Km: picking either derives a wrong VUK, and a wrong VUK decrypts to
/// plausible-looking garbage rather than failing loudly. Bail and let the
/// later hash/VID paths answer instead.
///
/// The predicate is a parameter rather than the inlined AES check because a
/// genuine two-key multi-hit cannot be synthesised: it needs one ciphertext
/// that decrypts under two distinct AES-128 keys to plaintexts sharing a
/// 64-bit prefix — a 2^64 search. Injecting the verifier is the only way the
/// ambiguity branch is reachable from a test at all.
fn unique_verifying_mk(mks: &[[u8; 16]], verifies: impl Fn(&[u8; 16]) -> bool) -> Option<[u8; 16]> {
let mut hits: Vec<[u8; 16]> = Vec::new();
for mk in mks {
if verifies(mk) && !hits.contains(mk) {
hits.push(*mk);
if hits.len() > 1 {
// Ambiguous — bail rather than pick a Media Key.
return None;
}
}
}
hits.first().copied()
}
/// For path 5: cross-reference the disc's `Unit_Key_RO.inf` CPS-unit
/// numbering against the KEYDB entry's pre-decrypted unit keys. Every
/// CPS unit the disc declares must have a matching entry in KEYDB;
@@ -1300,6 +1328,62 @@ mod tests {
"VUK must derive from the verified Km + this disc's VID"
);
}
/// Path 2.5's ambiguity guard: when MORE THAN ONE DISTINCT pooled Media Key
/// verifies against the MKB, the resolver must return no key at all rather
/// than pick one. A wrong Km derives a wrong VUK, and a wrong VUK does not
/// fail loudly — it decrypts the title to garbage that muxes and plays as a
/// corrupt rip.
///
/// The real MKB check cannot be forced into a multi-hit: two distinct
/// AES-128 keys decrypting one `mk_dv` to plaintexts that share the 64-bit
/// verify magic is a 2^64 search, not a fixture. So the rule is tested
/// through `unique_verifying_mk`, whose verifier is a parameter — the same
/// function `resolve_keys_v1` calls, with the same pool semantics.
#[test]
fn mk_pool_ambiguity_bails_rather_than_picking_a_media_key() {
let a = [0xAAu8; 16];
let b = [0xBBu8; 16];
let c = [0xCCu8; 16];
// One verifying candidate → that key.
assert_eq!(
unique_verifying_mk(&[a, b, c], |mk| *mk == b),
Some(b),
"a single verifying MK resolves"
);
// The SAME key repeated across providers is one candidate, not an
// ambiguity — the dedup (`!hits.contains`) must keep this resolvable.
assert_eq!(
unique_verifying_mk(&[b, b, b], |mk| *mk == b),
Some(b),
"duplicates of one key are not ambiguity"
);
// TWO DISTINCT verifying candidates → bail, no key.
assert_eq!(
unique_verifying_mk(&[a, b], |mk| *mk == a || *mk == b),
None,
"two distinct verifying MKs must yield NO key, not the first one"
);
// Ambiguity must still be detected when the second hit is last in the
// pool, i.e. the scan may not stop at the first hit.
assert_eq!(
unique_verifying_mk(&[a, c, [0u8; 16], b], |mk| *mk == a || *mk == b),
None,
"a late second hit is still ambiguous"
);
// Every candidate verifying is the degenerate ambiguous case.
assert_eq!(unique_verifying_mk(&[a, b, c], |_| true), None);
// No candidate verifies → no key (and no panic on an empty pool).
assert_eq!(unique_verifying_mk(&[a, b, c], |_| false), None);
assert_eq!(unique_verifying_mk(&[], |_| true), None);
}
#[test]
fn test_content_cert_parse() {
// AACS 1.0 cert, bus encryption OFF. Content-cert layout: flag in