FMTS: resolve the index key map from one forensic keyserver query

The keyserver protocol now returns all 32 index keys as an array for a
forensic content sample (and a single-element array for plain content).
resolve_fmts_key_map sends one forensic batch and maps array element i to
segment index i+1, replacing the per-index blind-probe collection loop
that repeatedly hit the key service. Segment/index parsing and the
aligned-unit content classification are reworked to support this:

- rename variant_select -> index_select (per-index, not per-variant)
- content classification moves to is_clean(buf, ContentFormat) so the
  unit selector emits only units the key service accepts
- segment.rs: parse IndividualSegment.tbl index tags + SPN ranges,
  build contiguous LBA key ranges from the resolved 32-key array
- decrypt/decorator plumbing for the resolved per-index keys

Fail loud (FmtsKeyMissing) when the forensic query returns < 32 keys or
any segment index stays unresolved.
This commit is contained in:
Matthew Jackson
2026-07-16 19:41:44 -07:00
parent ccb7cafc68
commit edc60582ec
14 changed files with 1087 additions and 289 deletions
+82 -73
View File
@@ -53,31 +53,6 @@ const TS_SYNC: u8 = 0x47;
// ── Content decryption ──────────────────────────────────────────────────────
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196,
/// 388, … (one per 192-byte source packet).
///
/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
/// byte-indistinguishable. So this answers only *"does this unit look like valid
/// clear TS, or not"*, nothing about encryption.
///
/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
/// the caller, because it needs context this function lacks:
/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
/// (see [`crate::decrypt::decrypt_sectors_in_content`] and
/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
/// primitive separates an encrypted/garbled unit (destroyed) from a clear
/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem
/// bytes is what produced the first-2 GB false-positive this split fixes.
///
/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or
/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably.
pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}
/// HD-DVD `.evo` (MPEG-2 Program Stream) AACS-encrypted-unit flag offset & mask.
///
/// BD/UHD/FMTS flag encryption with the Copy Permission Indicator in the top 2
@@ -144,12 +119,13 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b
///
/// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary.
pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
use crate::disc::ContentFormat;
aacs_unit_encrypted(unit, format)
&& match format {
ContentFormat::BdTs => ts_sync_destroyed(unit),
ContentFormat::MpegPs => !is_clean_ps(unit),
}
// "Still needs the key applied" = flagged encrypted AND not yet structurally
// clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof
// floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second
// threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED
// unit as still-scrambled, so the mux re-sampled it to the key service every
// batch (the storm) and could re-apply the key over already-clear bytes.
aacs_unit_encrypted(unit, format) && !is_clean(unit, format)
}
/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47`
@@ -241,10 +217,6 @@ pub fn ts_packet_total(unit: &[u8]) -> usize {
unit.len() / BD_SOURCE_PACKET_BYTES
}
fn ts_syncs_intact(unit: &[u8]) -> bool {
ts_sync_count(unit) > ts_packet_total(unit) / 2
}
/// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): a pure structural
/// check that a unit is valid MPEG-2 PS — every 2048-byte pack begins with the
/// pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans three packs.
@@ -421,7 +393,10 @@ mod tests {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert!(!ts_sync_destroyed(&unit));
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
assert!(
!aacs_unit_encrypted(&unit, ts),
"byte-0 CPI clear ⇒ not flagged encrypted"
@@ -448,54 +423,70 @@ mod tests {
}
#[test]
fn scramble_detection_at_16_32_boundary() {
// With 32 stride positions the majority threshold is
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
// NOT be over-counted into the "scrambled" bucket by an inflated
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
// at the real midpoint.
let set_syncs = |n: usize| {
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
fn is_clean_min4_proof_floor() {
// ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED
// (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build
// NON-ZERO payloads (real content, not padding) so every packet counts
// toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped).
let unit_with = |synced: usize| {
let mut unit: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 7 + 1) as u8) | 1)
.collect();
// Scrub any accidental 0x47 at a sync position, then place exactly
// `synced` real syncs in packets 1.. (skip packet 0).
let mut off = BD_SOURCE_PACKET_BYTES + 4;
let mut placed = 0;
while off < ALIGNED_UNIT_LEN && placed < n {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
while off < ALIGNED_UNIT_LEN {
unit[off] = if placed < synced { TS_SYNC } else { 0x46 };
placed += 1;
off += BD_SOURCE_PACKET_BYTES;
}
unit
};
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
// Exactly half intact → classified scrambled (16 > 16 is false).
assert!(ts_sync_destroyed(&set_syncs(16)));
// One past half → classified clear.
assert!(!ts_sync_destroyed(&set_syncs(17)));
// E = 31 content packets (all non-zero) → threshold min(31,4) = 4.
assert!(
!crate::aacs::content::is_clean(&unit_with(3), crate::disc::ContentFormat::BdTs),
"3 synced of a well-populated unit is below the proof floor → not clean"
);
assert!(
crate::aacs::content::is_clean(&unit_with(4), crate::disc::ContentFormat::BdTs),
"4 synced proves the key opened it, even with many bad-encoded packets"
);
// The old >50% majority would have called `unit_with(4)` scrambled (4/31
// < half) — that false-flag was the mux key-server storm. min(E,4) fixes it.
}
#[test]
fn scramble_detection_extremes() {
// Detection semantics for the clear-cut cases must be preserved:
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
// syncs (fully scrambled body) IS scrambled.
// A fully-clear unit (every packet synced) is clean; a fully-scrambled
// unit (non-zero ciphertext, NO syncs) is not. (An all-zero buffer is
// empty padding — E==0 — which `is_clean` treats as clean, NOT scrambled.)
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert_eq!(ts_sync_count(&clear), 32);
assert!(
!ts_sync_destroyed(&clear),
"fully-clear unit → not scrambled"
crate::aacs::content::is_clean(&clear, crate::disc::ContentFormat::BdTs),
"fully-clear unit → clean"
);
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_sync_count(&scrambled), 0);
assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
// Real scrambled ciphertext: non-zero everywhere, no 0x47 at any sync slot.
let mut scrambled: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 13 + 3) as u8) | 1)
.collect();
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
if scrambled[off] == TS_SYNC {
scrambled[off] = 0x46;
}
off += BD_SOURCE_PACKET_BYTES;
}
assert!(
!crate::aacs::content::is_clean(&scrambled, crate::disc::ContentFormat::BdTs),
"non-zero body with no syncs → scrambled"
);
}
#[test]
@@ -574,9 +565,15 @@ mod tests {
// Now plain contains encrypted data. Decrypt it.
let mut unit = plain;
assert!(ts_sync_destroyed(&unit));
assert!(!crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
decrypt_unit(&mut unit, &unit_key);
assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
)); // decrypted: TS syncs restored
// Verify TS sync bytes
let mut count = 0;
@@ -1151,14 +1148,17 @@ mod tests {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key);
assert!(
ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled"
);
decrypt_unit(&mut unit, &unit_key);
// All 32 stride positions carry sync after decrypt.
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
assert!(!ts_sync_destroyed(&unit));
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
}
#[test]
@@ -1291,13 +1291,22 @@ mod tests {
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
// shorter is reported NOT scrambled (so the decrypt gate skips it)
// rather than indexing past the end.
assert!(!ts_sync_destroyed(&[]));
assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
assert!(crate::aacs::content::is_clean(
&[],
crate::disc::ContentFormat::BdTs
));
assert!(crate::aacs::content::is_clean(
&vec![0u8; ALIGNED_UNIT_LEN - 1],
crate::disc::ContentFormat::BdTs
));
// A scrambled-looking buffer that is one byte short is still "not
// scrambled" by the length guard.
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
almost[4] = 0x00; // no syncs
assert!(!ts_sync_destroyed(&almost));
assert!(crate::aacs::content::is_clean(
&almost,
crate::disc::ContentFormat::BdTs
));
}
#[test]