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:
+82
-73
@@ -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]
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (variant-0) content. This module owns that classification and nothing else —
|
||||
//! (index-0) content. This module owns that classification and nothing else —
|
||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved variant comes from is a separate concern
|
||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
||||
//! Where the resolved index comes from is a separate concern
|
||||
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||
//! source handed us; when Processing Keys are available it will come from the
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
|
||||
use super::segment::{Segment, variant_segment_for_unit};
|
||||
use super::segment::{Segment, segment_for_unit};
|
||||
use super::types::UnitKey;
|
||||
|
||||
/// What the decode should do with one AACS aligned unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitDisposition {
|
||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||
/// default (variant-0) unit key.
|
||||
/// default (index-0) unit key.
|
||||
Default,
|
||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
||||
/// that variant's key.
|
||||
Variant(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
||||
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||
/// that index's key.
|
||||
Index(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||
/// watermark, so it is not part of our output — drop it.
|
||||
DropForeignVariant(u8),
|
||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
||||
DropForeignIndex(u8),
|
||||
/// Inside a forensic segment but no index key is held (the disc's index
|
||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||
/// as loss. Carries the segment's variant for diagnostics.
|
||||
/// as loss. Carries the segment's index for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic variant from the keys we hold.
|
||||
/// Resolve the disc's single forensic index from the keys we hold.
|
||||
///
|
||||
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
|
||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||
/// index source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.map(|k| k.variant_number)
|
||||
.map(|k| k.index_number)
|
||||
.filter(|&v| v != 0)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||
/// the forensic segment map and the disc's resolved variant (`None` if no
|
||||
/// variant key is held).
|
||||
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||
/// index key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_variant: Option<u8>,
|
||||
disc_index: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
match variant_segment_for_unit(segments, unit_offset) {
|
||||
match segment_for_unit(segments, unit_offset) {
|
||||
// Not in any forensic segment → ordinary content.
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our variant.
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
let seg_variant = seg.variant as u8;
|
||||
match disc_variant {
|
||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
||||
let seg_index = seg.index as u8;
|
||||
match disc_index {
|
||||
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
||||
None => UnitDisposition::ForensicNoKey(seg_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ mod tests {
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (variant, start_spn, end_spn).
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
@@ -95,23 +95,23 @@ mod tests {
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
||||
if variant == 0 {
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::variant(idx, [variant; 16], variant)
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_variant_key() {
|
||||
// Default keys only → no variant resolved.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_variant(&[]), None);
|
||||
// One variant key among defaults → that variant.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct variants (deterministic).
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -130,29 +130,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_variant_decrypts() {
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Variant(7)
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_variant_drops() {
|
||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignVariant(7)
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved a variant → conceal as loss.
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
@@ -164,7 +164,7 @@ mod tests {
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches variant_segment_for_unit's span test).
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
@@ -172,7 +172,7 @@ mod tests {
|
||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Variant(5)
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -29,6 +29,7 @@ pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
@@ -38,7 +39,6 @@ pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
pub mod variant_select;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files.
|
||||
///
|
||||
@@ -119,7 +119,7 @@ mod tests {
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
@@ -147,7 +147,10 @@ mod tests {
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
|
||||
+193
-58
@@ -1,40 +1,49 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
||||
//! forensic-watermark mechanism. The same frames are authored as several
|
||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||
//!
|
||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
||||
//! them with segment keys and select one coherent variant instead of muxing
|
||||
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||
//! module does not deal with. All the index keys belong to one variant, whose
|
||||
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the segments live and which index each carries, so a
|
||||
//! decoder can decrypt them with the matching index key instead of muxing
|
||||
//! unit-key garbage.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
||||
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
||||
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||
//! index keys.
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT the forensic index keys.
|
||||
///
|
||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
||||
/// `true` (today): the forensic segments are skipped as expected loss
|
||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
@@ -45,17 +54,17 @@ pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = true;
|
||||
pub const BYPASS_FMTS_KEY: bool = false;
|
||||
|
||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
||||
/// in the FMTS clip.
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH variant this range is,
|
||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
||||
/// the default/non-forensic content carries no segment record at all.)
|
||||
pub variant: u16,
|
||||
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||
/// content carries no segment record at all.)
|
||||
pub index: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
@@ -109,8 +118,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
||||
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
@@ -118,16 +127,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
||||
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||
}
|
||||
|
||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
||||
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
|
||||
/// order. Returns `None` when the header is malformed, the record size is not
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
@@ -147,12 +156,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
variant,
|
||||
index,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
@@ -160,12 +169,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
|
||||
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
|
||||
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
|
||||
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
|
||||
/// clip.
|
||||
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
|
||||
let mut cum = 0u64;
|
||||
for e in extents {
|
||||
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
|
||||
if clip_byte < cum + len {
|
||||
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
|
||||
return Some(e.start_lba.saturating_add(sector_in_ext));
|
||||
}
|
||||
cum += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||
///
|
||||
/// Each forensic segment's clip-relative source-packet span becomes an absolute
|
||||
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
|
||||
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
|
||||
/// one key across the whole segment decodes the ~40 units of its interleave half
|
||||
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
|
||||
/// demux then drops — yielding one coherent stream. Ranges outside every segment
|
||||
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
|
||||
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
|
||||
///
|
||||
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
|
||||
/// with the Unit-Key index as the default — the same structure the CPS map uses,
|
||||
/// only finer-grained.
|
||||
pub fn fmts_key_ranges(
|
||||
segments: &[Segment],
|
||||
extents: &[crate::disc::Extent],
|
||||
index_to_key_idx: &dyn Fn(u16) -> usize,
|
||||
) -> Vec<(u32, u32, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
for s in segments {
|
||||
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
|
||||
// so a segment almost never crosses an extent boundary — but if the two
|
||||
// ends land in different extents (non-contiguous LBAs), skip rather than
|
||||
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
|
||||
// never a mis-decrypt.
|
||||
let (Some(a), Some(b)) = (
|
||||
clip_byte_to_lba(extents, start_byte),
|
||||
clip_byte_to_lba(extents, end_byte - 1),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if b >= a
|
||||
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
|
||||
{
|
||||
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
||||
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
@@ -181,11 +252,75 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||
use crate::disc::Extent;
|
||||
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||
// LBA 1000 + B/2048.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||
let segs = vec![
|
||||
Segment {
|
||||
index: 5,
|
||||
start_spn: 100,
|
||||
end_spn: 199,
|
||||
},
|
||||
Segment {
|
||||
index: 7,
|
||||
start_spn: 10_000,
|
||||
end_spn: 10_099,
|
||||
},
|
||||
];
|
||||
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||
// → LBA 1009..1019, key index 5.
|
||||
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0);
|
||||
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key");
|
||||
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5");
|
||||
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
0,
|
||||
"segment end is exclusive → Unit Key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
@@ -193,9 +328,9 @@ mod tests {
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].variant, 1);
|
||||
assert_eq!(segs[1].variant, 2);
|
||||
assert_eq!(segs[2].variant, 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
@@ -234,18 +369,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_variant() {
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.variant, 1);
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the index, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
@@ -258,9 +393,9 @@ mod tests {
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,29 +403,29 @@ mod tests {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the variant key,
|
||||
// because part of its ciphertext is variant-encrypted.
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.variant, 7);
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_variant() {
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-16
@@ -57,41 +57,40 @@ pub struct ProcessingKey(pub [u8; 16]);
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic-variant tag.
|
||||
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||
///
|
||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
|
||||
/// decrypts the forensic segments tagged with that same variant in
|
||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
||||
/// most one non-zero value is ever in play for a given rip; the decode
|
||||
/// selects the segments matching it and drops the other variants.
|
||||
pub variant_number: u8,
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||
pub index_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
|
||||
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number: 0,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
||||
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
||||
pub const fn is_default_variant(&self) -> bool {
|
||||
self.variant_number == 0
|
||||
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||
pub const fn is_default_index(&self) -> bool {
|
||||
self.index_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user