Compare commits
6
Commits
ccb7cafc68
...
v1.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93571d9181 | ||
|
|
89af9876ae | ||
|
|
0471e0ca40 | ||
|
|
38207d2272 | ||
|
|
add9d8e0cd | ||
|
|
edc60582ec |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "1.4.2"
|
version = "1.4.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
|||||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.4" }
|
||||||
num-bigint = "0.4"
|
num-bigint = "0.4"
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
|
|||||||
+82
-73
@@ -53,31 +53,6 @@ const TS_SYNC: u8 = 0x47;
|
|||||||
|
|
||||||
// ── Content decryption ──────────────────────────────────────────────────────
|
// ── 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.
|
/// 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
|
/// 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.
|
/// 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 {
|
pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||||
use crate::disc::ContentFormat;
|
// "Still needs the key applied" = flagged encrypted AND not yet structurally
|
||||||
aacs_unit_encrypted(unit, format)
|
// clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof
|
||||||
&& match format {
|
// floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second
|
||||||
ContentFormat::BdTs => ts_sync_destroyed(unit),
|
// threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED
|
||||||
ContentFormat::MpegPs => !is_clean_ps(unit),
|
// 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`
|
/// 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
|
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
|
/// 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
|
/// 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.
|
/// 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;
|
unit[off] = TS_SYNC;
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
assert!(!ts_sync_destroyed(&unit));
|
assert!(crate::aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
!aacs_unit_encrypted(&unit, ts),
|
!aacs_unit_encrypted(&unit, ts),
|
||||||
"byte-0 CPI clear ⇒ not flagged encrypted"
|
"byte-0 CPI clear ⇒ not flagged encrypted"
|
||||||
@@ -448,54 +423,70 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scramble_detection_at_16_32_boundary() {
|
fn is_clean_min4_proof_floor() {
|
||||||
// With 32 stride positions the majority threshold is
|
// ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED
|
||||||
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
|
// (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build
|
||||||
// NOT be over-counted into the "scrambled" bucket by an inflated
|
// NON-ZERO payloads (real content, not padding) so every packet counts
|
||||||
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
|
// toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped).
|
||||||
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
|
let unit_with = |synced: usize| {
|
||||||
// at the real midpoint.
|
let mut unit: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||||
let set_syncs = |n: usize| {
|
.map(|i| ((i * 7 + 1) as u8) | 1)
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
.collect();
|
||||||
let mut off = 4;
|
// 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;
|
let mut placed = 0;
|
||||||
while off < ALIGNED_UNIT_LEN && placed < n {
|
while off < ALIGNED_UNIT_LEN {
|
||||||
unit[off] = TS_SYNC;
|
unit[off] = if placed < synced { TS_SYNC } else { 0x46 };
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
placed += 1;
|
placed += 1;
|
||||||
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
unit
|
unit
|
||||||
};
|
};
|
||||||
|
// E = 31 content packets (all non-zero) → threshold min(31,4) = 4.
|
||||||
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
|
assert!(
|
||||||
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
|
!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"
|
||||||
// Exactly half intact → classified scrambled (16 > 16 is false).
|
);
|
||||||
assert!(ts_sync_destroyed(&set_syncs(16)));
|
assert!(
|
||||||
// One past half → classified clear.
|
crate::aacs::content::is_clean(&unit_with(4), crate::disc::ContentFormat::BdTs),
|
||||||
assert!(!ts_sync_destroyed(&set_syncs(17)));
|
"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]
|
#[test]
|
||||||
fn scramble_detection_extremes() {
|
fn scramble_detection_extremes() {
|
||||||
// Detection semantics for the clear-cut cases must be preserved:
|
// A fully-clear unit (every packet synced) is clean; a fully-scrambled
|
||||||
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
|
// unit (non-zero ciphertext, NO syncs) is not. (An all-zero buffer is
|
||||||
// syncs (fully scrambled body) IS scrambled.
|
// empty padding — E==0 — which `is_clean` treats as clean, NOT scrambled.)
|
||||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
while off < ALIGNED_UNIT_LEN {
|
while off < ALIGNED_UNIT_LEN {
|
||||||
clear[off] = TS_SYNC;
|
clear[off] = TS_SYNC;
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
assert_eq!(ts_sync_count(&clear), 32);
|
|
||||||
assert!(
|
assert!(
|
||||||
!ts_sync_destroyed(&clear),
|
crate::aacs::content::is_clean(&clear, crate::disc::ContentFormat::BdTs),
|
||||||
"fully-clear unit → not scrambled"
|
"fully-clear unit → clean"
|
||||||
);
|
);
|
||||||
|
|
||||||
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
|
// Real scrambled ciphertext: non-zero everywhere, no 0x47 at any sync slot.
|
||||||
assert_eq!(ts_sync_count(&scrambled), 0);
|
let mut scrambled: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||||
assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
|
.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]
|
#[test]
|
||||||
@@ -574,9 +565,15 @@ mod tests {
|
|||||||
|
|
||||||
// Now plain contains encrypted data. Decrypt it.
|
// Now plain contains encrypted data. Decrypt it.
|
||||||
let mut unit = plain;
|
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);
|
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
|
// Verify TS sync bytes
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
@@ -1151,14 +1148,17 @@ mod tests {
|
|||||||
let mut unit = clear_unit();
|
let mut unit = clear_unit();
|
||||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||||
assert!(
|
assert!(
|
||||||
ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled"
|
"encrypted unit must look scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
decrypt_unit(&mut unit, &unit_key);
|
decrypt_unit(&mut unit, &unit_key);
|
||||||
// All 32 stride positions carry sync after decrypt.
|
// All 32 stride positions carry sync after decrypt.
|
||||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
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]
|
#[test]
|
||||||
@@ -1291,13 +1291,22 @@ mod tests {
|
|||||||
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
|
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
|
||||||
// shorter is reported NOT scrambled (so the decrypt gate skips it)
|
// shorter is reported NOT scrambled (so the decrypt gate skips it)
|
||||||
// rather than indexing past the end.
|
// rather than indexing past the end.
|
||||||
assert!(!ts_sync_destroyed(&[]));
|
assert!(crate::aacs::content::is_clean(
|
||||||
assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
|
&[],
|
||||||
|
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
|
// A scrambled-looking buffer that is one byte short is still "not
|
||||||
// scrambled" by the length guard.
|
// scrambled" by the length guard.
|
||||||
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||||
almost[4] = 0x00; // no syncs
|
almost[4] = 0x00; // no syncs
|
||||||
assert!(!ts_sync_destroyed(&almost));
|
assert!(crate::aacs::content::is_clean(
|
||||||
|
&almost,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
//! 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
|
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||||
//!
|
//!
|
||||||
//! Where the resolved variant comes from is a separate concern
|
//! Where the resolved index comes from is a separate concern
|
||||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
//! ([`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
|
//! source handed us; when Processing Keys are available it will come from the
|
||||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
//! 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;
|
use super::types::UnitKey;
|
||||||
|
|
||||||
/// What the decode should do with one AACS aligned unit.
|
/// What the decode should do with one AACS aligned unit.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum UnitDisposition {
|
pub enum UnitDisposition {
|
||||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||||
/// default (variant-0) unit key.
|
/// default (index-0) unit key.
|
||||||
Default,
|
Default,
|
||||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||||
/// that variant's key.
|
/// that index's key.
|
||||||
Variant(u8),
|
Index(u8),
|
||||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||||
/// watermark, so it is not part of our output — drop it.
|
/// watermark, so it is not part of our output — drop it.
|
||||||
DropForeignVariant(u8),
|
DropForeignIndex(u8),
|
||||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
/// 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
|
/// 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),
|
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
|
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
/// index source answered, so forensic segments are not decodable. A disc has
|
||||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
/// 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
|
unit_keys
|
||||||
.iter()
|
.iter()
|
||||||
.map(|k| k.variant_number)
|
.map(|k| k.index_number)
|
||||||
.filter(|&v| v != 0)
|
.filter(|&v| v != 0)
|
||||||
.min()
|
.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
/// 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
|
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||||
/// variant key is held).
|
/// index key is held).
|
||||||
pub fn unit_disposition(
|
pub fn unit_disposition(
|
||||||
unit_offset: u64,
|
unit_offset: u64,
|
||||||
segments: &[Segment],
|
segments: &[Segment],
|
||||||
disc_variant: Option<u8>,
|
disc_index: Option<u8>,
|
||||||
) -> UnitDisposition {
|
) -> UnitDisposition {
|
||||||
match variant_segment_for_unit(segments, unit_offset) {
|
match segment_for_unit(segments, unit_offset) {
|
||||||
// Not in any forensic segment → ordinary content.
|
// Not in any forensic segment → ordinary content.
|
||||||
None => UnitDisposition::Default,
|
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) => {
|
Some(seg) => {
|
||||||
let seg_variant = seg.variant as u8;
|
let seg_index = seg.index as u8;
|
||||||
match disc_variant {
|
match disc_index {
|
||||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
||||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
||||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
None => UnitDisposition::ForensicNoKey(seg_index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ mod tests {
|
|||||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
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> {
|
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||||
@@ -95,23 +95,23 @@ mod tests {
|
|||||||
parse_individual_segments(&v).expect("parse")
|
parse_individual_segments(&v).expect("parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||||
if variant == 0 {
|
if index == 0 {
|
||||||
UnitKey::new(idx, [0u8; 16])
|
UnitKey::new(idx, [0u8; 16])
|
||||||
} else {
|
} else {
|
||||||
UnitKey::variant(idx, [variant; 16], variant)
|
UnitKey::forensic(idx, [index; 16], index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_picks_the_single_variant_key() {
|
fn resolve_picks_the_single_index_key() {
|
||||||
// Default keys only → no variant resolved.
|
// Default keys only → no index resolved.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||||
assert_eq!(resolve_disc_variant(&[]), None);
|
assert_eq!(resolve_disc_index(&[]), None);
|
||||||
// One variant key among defaults → that variant.
|
// One index key among defaults → that index.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||||
// Defensive: lowest of several distinct variants (deterministic).
|
// Defensive: lowest of several distinct indexes (deterministic).
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -130,29 +130,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_our_variant_decrypts() {
|
fn unit_in_our_index_decrypts() {
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(7)),
|
unit_disposition(off, &segs, Some(7)),
|
||||||
UnitDisposition::Variant(7)
|
UnitDisposition::Index(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_foreign_variant_drops() {
|
fn unit_in_foreign_index_drops() {
|
||||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(3)),
|
unit_disposition(off, &segs, Some(3)),
|
||||||
UnitDisposition::DropForeignVariant(7)
|
UnitDisposition::DropForeignIndex(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forensic_unit_with_no_key_is_concealed() {
|
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 segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -164,7 +164,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn straddling_unit_still_classified_as_its_segment() {
|
fn straddling_unit_still_classified_as_its_segment() {
|
||||||
// A unit whose 32-packet span only tails into the segment still routes
|
// 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 segs = tbl(&[(5, 100, 200)]);
|
||||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
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.
|
// 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!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(5)),
|
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 crypto;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod host_certs;
|
pub mod host_certs;
|
||||||
|
pub mod index_select;
|
||||||
pub mod inf;
|
pub mod inf;
|
||||||
pub mod mkb;
|
pub mod mkb;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
@@ -38,7 +39,6 @@ pub mod segment_key;
|
|||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod variant;
|
pub mod variant;
|
||||||
pub mod variant_select;
|
|
||||||
|
|
||||||
/// On-disc UDF paths to the AACS key-input files.
|
/// 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
|
//! Touching one representative item per module keeps these as a
|
||||||
//! compile-time contract that the module paths stay stable.
|
//! 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::inf::{disc_hash, disc_hash_hex};
|
||||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||||
use super::variant::is_variant_mkb;
|
use super::variant::is_variant_mkb;
|
||||||
@@ -147,7 +147,10 @@ mod tests {
|
|||||||
fn public_helpers_are_callable_by_module_path() {
|
fn public_helpers_are_callable_by_module_path() {
|
||||||
// Touch a representative function from each module so a dropped/renamed
|
// Touch a representative function from each module so a dropped/renamed
|
||||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
// 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 _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
|
|||||||
+193
-58
@@ -1,40 +1,49 @@
|
|||||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||||
//!
|
//!
|
||||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||||
//! forensic-watermark mechanism. The same frames are authored as several
|
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||||
//! 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).
|
|
||||||
//!
|
//!
|
||||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||||
//! them with segment keys and select one coherent variant instead of muxing
|
//! 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.
|
//! unit-key garbage.
|
||||||
//!
|
//!
|
||||||
//! Format (validated against a retail AACS 2.1 disc):
|
//! Format (validated against a retail AACS 2.1 disc):
|
||||||
//! ```text
|
//! ```text
|
||||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||||
//! record[count] (16 bytes each):
|
//! 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)
|
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||||
//! ```
|
//! ```
|
||||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
//! 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.
|
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
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
|
/// 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
|
/// 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
|
/// 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.
|
/// refused. Hardcoded on purpose — not a user setting.
|
||||||
///
|
///
|
||||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
/// [`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
|
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||||
/// in the FMTS clip.
|
/// FMTS clip.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Segment {
|
pub struct Segment {
|
||||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||||
/// table rather than counting up — it selects WHICH variant this range is,
|
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||||
/// the default/non-forensic content carries no segment record at all.)
|
/// content carries no segment record at all.)
|
||||||
pub variant: u16,
|
pub index: u16,
|
||||||
/// First source packet of the segment (inclusive).
|
/// First source packet of the segment (inclusive).
|
||||||
pub start_spn: u32,
|
pub start_spn: u32,
|
||||||
/// Last source packet of the segment (inclusive).
|
/// 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.
|
/// unit's clip-relative byte offset.
|
||||||
///
|
///
|
||||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
/// 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
|
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
/// (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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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 unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||||
let last = ((unit_offset + unit_len - 1) / 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))
|
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
|
/// order. Returns `None` when the header is malformed, the record size is not
|
||||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
/// 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);
|
let mut segments = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 8 + i * record_size;
|
let o = 8 + i * record_size;
|
||||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
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 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]]);
|
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||||
segments.push(Segment {
|
segments.push(Segment {
|
||||||
variant,
|
index,
|
||||||
start_spn,
|
start_spn,
|
||||||
end_spn,
|
end_spn,
|
||||||
});
|
});
|
||||||
@@ -160,12 +169,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
|||||||
Some(segments)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
/// 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> {
|
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||||
@@ -181,11 +252,75 @@ mod tests {
|
|||||||
v
|
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]
|
#[test]
|
||||||
fn parses_real_disc_layout() {
|
fn parses_real_disc_layout() {
|
||||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
// 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
|
// 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(&[
|
let tbl = build_tbl(&[
|
||||||
(1, 343680, 346239),
|
(1, 343680, 346239),
|
||||||
(2, 695616, 698175),
|
(2, 695616, 698175),
|
||||||
@@ -193,9 +328,9 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||||
assert_eq!(segs.len(), 3);
|
assert_eq!(segs.len(), 3);
|
||||||
assert_eq!(segs[0].variant, 1);
|
assert_eq!(segs[0].index, 1);
|
||||||
assert_eq!(segs[1].variant, 2);
|
assert_eq!(segs[1].index, 2);
|
||||||
assert_eq!(segs[2].variant, 3);
|
assert_eq!(segs[2].index, 3);
|
||||||
assert_eq!(segs[0].start_spn, 343680);
|
assert_eq!(segs[0].start_spn, 343680);
|
||||||
assert_eq!(segs[0].end_spn, 346239);
|
assert_eq!(segs[0].end_spn, 346239);
|
||||||
assert_eq!(segs[0].packet_count(), 2560);
|
assert_eq!(segs[0].packet_count(), 2560);
|
||||||
@@ -234,18 +369,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_inside_segment_routes_to_variant() {
|
fn unit_inside_segment_routes_to_index() {
|
||||||
// A real first-record segment: packets [343680, 346239].
|
// A real first-record segment: packets [343680, 346239].
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||||
assert_eq!(hit.variant, 1);
|
assert_eq!(hit.index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn variant_field_cycles_one_to_thirty_two() {
|
fn index_field_cycles_one_to_thirty_two() {
|
||||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
// 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.
|
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||||
let mut recs = Vec::new();
|
let mut recs = Vec::new();
|
||||||
let mut spn = 1000u32;
|
let mut spn = 1000u32;
|
||||||
@@ -258,9 +393,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||||
assert_eq!(segs.len(), 64);
|
assert_eq!(segs.len(), 64);
|
||||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -268,29 +403,29 @@ mod tests {
|
|||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
assert!(segment_for_unit(&segs, off).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// 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,
|
// packets straddle the boundary) must still route to the index key,
|
||||||
// because part of its ciphertext is variant-encrypted.
|
// because part of its ciphertext is forensic-encrypted.
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||||
assert_eq!(hit.variant, 7);
|
assert_eq!(hit.index, 7);
|
||||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
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]
|
#[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.
|
// 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!(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(9_999_999)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+15
-16
@@ -57,41 +57,40 @@ pub struct ProcessingKey(pub [u8; 16]);
|
|||||||
pub struct UnitKey {
|
pub struct UnitKey {
|
||||||
pub idx: u32,
|
pub idx: u32,
|
||||||
pub key: [u8; 16],
|
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
|
/// `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
|
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||||
/// decrypts the forensic segments tagged with that same variant in
|
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||||
/// most one non-zero value is ever in play for a given rip; the decode
|
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||||
/// selects the segments matching it and drops the other variants.
|
pub index_number: u8,
|
||||||
pub variant_number: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnitKey {
|
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.
|
/// 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 {
|
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number: 0,
|
index_number: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number,
|
index_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||||
pub const fn is_default_variant(&self) -> bool {
|
pub const fn is_default_index(&self) -> bool {
|
||||||
self.variant_number == 0
|
self.index_number == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+191
-13
@@ -171,6 +171,183 @@ impl DecryptKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
|
||||||
|
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
|
||||||
|
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
|
||||||
|
/// unit at mux time.
|
||||||
|
///
|
||||||
|
/// This is the pivot that ends the mux "key-server storm": the old path decrypts
|
||||||
|
/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because
|
||||||
|
/// authored-bad content never reaches that bar — concludes "wrong key, fetch a
|
||||||
|
/// fresh one" and re-asks the key service for units it already holds the correct
|
||||||
|
/// key for. There is NO per-unit byte pattern that separates "correctly decrypted
|
||||||
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. The
|
||||||
|
/// map removes the question: we resolve one key per CPS unit / segment up front
|
||||||
|
/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux
|
||||||
|
/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's
|
||||||
|
/// concern, exactly as for a physically-read clear disc.
|
||||||
|
///
|
||||||
|
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool,
|
||||||
|
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
|
||||||
|
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
|
||||||
|
/// common disc pays zero lookup cost and needs no structural walk.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AacsKeyMap {
|
||||||
|
ranges: Vec<(u32, u32, usize)>,
|
||||||
|
default_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AacsKeyMap {
|
||||||
|
/// The whole title is one CPS unit → one key (`idx`) everywhere. This is the
|
||||||
|
/// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk.
|
||||||
|
pub fn single(idx: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
ranges: Vec::new(),
|
||||||
|
default_idx: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build from explicit `[start_lba, end_lba) → key_idx` ranges (multi-CPS /
|
||||||
|
/// FMTS). Ranges are sorted; `default_idx` answers any uncovered LBA.
|
||||||
|
pub fn from_ranges(mut ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self {
|
||||||
|
ranges.sort_by_key(|&(start, _, _)| start);
|
||||||
|
Self {
|
||||||
|
ranges,
|
||||||
|
default_idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit-key index to decrypt the aligned unit at `lba` with. O(log n) —
|
||||||
|
/// the last range whose start is `<= lba` and whose end is `> lba`, else the
|
||||||
|
/// default. Cheap enough to call per aligned unit on the mux hot path.
|
||||||
|
pub fn key_idx_for(&self, lba: u32) -> usize {
|
||||||
|
if self.ranges.is_empty() {
|
||||||
|
return self.default_idx;
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.ranges
|
||||||
|
.binary_search_by(|&(start, _, _)| start.cmp(&lba))
|
||||||
|
{
|
||||||
|
Ok(i) => self.ranges[i].2,
|
||||||
|
Err(0) => self.default_idx,
|
||||||
|
Err(i) => {
|
||||||
|
let (start, end, idx) = self.ranges[i - 1];
|
||||||
|
if lba >= start && lba < end {
|
||||||
|
idx
|
||||||
|
} else {
|
||||||
|
self.default_idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `[start_lba, end_lba) → key_idx` ranges (sorted, disjoint). Empty for a
|
||||||
|
/// single-CPS map (everything uses [`default_idx`](Self::default_idx)).
|
||||||
|
pub fn ranges(&self) -> &[(u32, u32, usize)] {
|
||||||
|
&self.ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key index for any LBA no explicit range claims (the single-CPS key).
|
||||||
|
pub fn default_idx(&self) -> usize {
|
||||||
|
self.default_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct key indices this map can select — the CPS units / segments a
|
||||||
|
/// title actually reaches. Used by the resolver to know which keys to secure
|
||||||
|
/// up front.
|
||||||
|
pub fn key_indices(&self) -> Vec<usize> {
|
||||||
|
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i)| i).collect();
|
||||||
|
v.push(self.default_idx);
|
||||||
|
v.sort_unstable();
|
||||||
|
v.dedup();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
|
||||||
|
/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector;
|
||||||
|
/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its
|
||||||
|
/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided
|
||||||
|
/// the key from disc structure, so we apply it and move on — a unit that decrypts
|
||||||
|
/// to authored-bad TS passes through for the muxer to drop, never re-fetched.
|
||||||
|
///
|
||||||
|
/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside
|
||||||
|
/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the
|
||||||
|
/// decorator can dispatch uniformly. A map index outside the held pool is a
|
||||||
|
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
|
||||||
|
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
|
||||||
|
pub fn decrypt_sectors_mapped(
|
||||||
|
buf: &mut [u8],
|
||||||
|
keys: &DecryptKeys,
|
||||||
|
base_lba: u32,
|
||||||
|
map: &AacsKeyMap,
|
||||||
|
) -> Result<(), crate::error::Error> {
|
||||||
|
let (unit_keys, rdk, format) = match keys {
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys,
|
||||||
|
read_data_key,
|
||||||
|
format,
|
||||||
|
} => (unit_keys, *read_data_key, *format),
|
||||||
|
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
|
||||||
|
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
let unit_sectors = (unit_len / 2048) as u32;
|
||||||
|
|
||||||
|
// Validate every selectable index up front (fail loud) so the per-unit hot
|
||||||
|
// loop can index without bounds churn and a resolver gap never silently
|
||||||
|
// passes ciphertext through as "decrypted".
|
||||||
|
for idx in map.key_indices() {
|
||||||
|
if unit_keys.get(idx).is_none() {
|
||||||
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
|
||||||
|
if chunk.len() != unit_len {
|
||||||
|
return; // trailing partial unit: clear tail on disc, leave as-is
|
||||||
|
}
|
||||||
|
// Gate on the authoritative encrypted flag ONLY (the CPI bits in the clear
|
||||||
|
// seed) — no `is_clean`. A clear unit (flag unset) is left untouched; an
|
||||||
|
// encrypted unit is decrypted with its MAPPED key and trusted.
|
||||||
|
if !aacs::content::aacs_unit_encrypted(chunk, format) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
|
||||||
|
let key_idx = map.key_idx_for(unit_lba);
|
||||||
|
// Bounds already proven above; index directly.
|
||||||
|
let key = &unit_keys[key_idx].1;
|
||||||
|
if let Some(ref rdk_key) = rdk {
|
||||||
|
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||||
|
}
|
||||||
|
aacs::content::decrypt_unit(chunk, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
let nthreads = decrypt_threads();
|
||||||
|
let nunits = buf.len() / unit_len;
|
||||||
|
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||||
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||||
|
decrypt_one(i, chunk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match decrypt_pool() {
|
||||||
|
Some(pool) => pool.install(|| {
|
||||||
|
buf.par_chunks_mut(unit_len)
|
||||||
|
.enumerate()
|
||||||
|
.for_each(|(i, chunk)| decrypt_one(i, chunk));
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||||
|
decrypt_one(i, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt a buffer of sectors in-place.
|
/// Decrypt a buffer of sectors in-place.
|
||||||
///
|
///
|
||||||
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
||||||
@@ -286,7 +463,7 @@ fn decrypt_sectors_impl(
|
|||||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||||
//
|
//
|
||||||
// Detection: ts_sync_destroyed() short-circuits to false for any
|
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
|
||||||
// buffer shorter than a full unit, so it cannot judge a partial. We
|
// buffer shorter than a full unit, so it cannot judge a partial. We
|
||||||
// instead apply the same TS-sync-intactness test it uses internally
|
// instead apply the same TS-sync-intactness test it uses internally
|
||||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
// (ts_sync_count vs ts_packet_total) directly to the available
|
||||||
@@ -308,15 +485,13 @@ fn decrypt_sectors_impl(
|
|||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
|
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
|
||||||
// unit) can't be unit-decrypted, so fail loud. The heuristic is
|
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
|
||||||
// MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely —
|
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
|
||||||
// running it on PS would false-trip `DecryptFailed`. HD-DVD partial-
|
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
|
||||||
// scramble detection is not yet wired (consistent with the UNVERIFIED
|
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
|
||||||
// PS path in `aacs_unit_encrypted`).
|
|
||||||
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
|
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
|
||||||
let partial = &buf[buf.len() - partial_len..];
|
let partial = &buf[buf.len() - partial_len..];
|
||||||
let packets = aacs::content::ts_packet_total(partial);
|
if !aacs::content::is_clean(partial, format) {
|
||||||
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
|
|
||||||
return Err(crate::error::Error::DecryptFailed);
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1324,7 +1499,7 @@ mod tests {
|
|||||||
let mut unit = clear_ts_unit();
|
let mut unit = clear_ts_unit();
|
||||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled before decrypt"
|
"encrypted unit must look scrambled before decrypt"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1339,7 +1514,7 @@ mod tests {
|
|||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf),
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||||
);
|
);
|
||||||
// Every sync position must carry 0x47.
|
// Every sync position must carry 0x47.
|
||||||
@@ -1371,7 +1546,7 @@ mod tests {
|
|||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf),
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||||
"single-key disc: TS syncs must be restored"
|
"single-key disc: TS syncs must be restored"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1401,7 +1576,7 @@ mod tests {
|
|||||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||||
let ciphertext = unit.clone();
|
let ciphertext = unit.clone();
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled going in"
|
"encrypted unit must look scrambled going in"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1462,7 +1637,10 @@ mod tests {
|
|||||||
"exactly one unit's worth of bytes must be reported unverified"
|
"exactly one unit's worth of bytes must be reported unverified"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
|
crate::aacs::content::is_clean(
|
||||||
|
&buf[..aacs::content::ALIGNED_UNIT_LEN],
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
),
|
||||||
"the decryptable unit must come out clear"
|
"the decryptable unit must come out clear"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
|
|||||||
+15
-28
@@ -2655,28 +2655,6 @@ impl Disc {
|
|||||||
self.ensure_decryptable_keys(raw, keys)
|
self.ensure_decryptable_keys(raw, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upfront FMTS (AACS 2.1) key gate, parallel to
|
|
||||||
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
|
|
||||||
/// carries forensic variant segments that need segment (variant) keys the
|
|
||||||
/// unit-key path cannot provide. When
|
|
||||||
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
|
|
||||||
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
|
|
||||||
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
|
|
||||||
/// produced. When `true` (the default today) the segments are skipped as
|
|
||||||
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
|
|
||||||
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
|
|
||||||
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
|
|
||||||
// source (none exists yet), so its variant segments cannot be opened.
|
|
||||||
// Refuse upfront rather than emit a forensic-holed rip.
|
|
||||||
if self.format == DiscFormat::Fmts {
|
|
||||||
return Err(Error::FmtsKeyMissing);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
||||||
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
||||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||||
@@ -5326,7 +5304,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
fn unit_key_validation_gates_on_real_ciphertext() {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
|
||||||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||||
// like resume / mapfile must be unaffected).
|
// like resume / mapfile must be unaffected).
|
||||||
@@ -5345,7 +5323,10 @@ mod tests {
|
|||||||
clear[off] = 0x47;
|
clear[off] = 0x47;
|
||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(!ts_sync_destroyed(&clear));
|
assert!(crate::aacs::content::is_clean(
|
||||||
|
&clear,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, [0x11u8; 16])],
|
&[(0, [0x11u8; 16])],
|
||||||
None,
|
None,
|
||||||
@@ -5357,7 +5338,7 @@ mod tests {
|
|||||||
let uk = [0x5au8; 16];
|
let uk = [0x5au8; 16];
|
||||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||||
assert!(
|
assert!(
|
||||||
ts_sync_destroyed(&enc),
|
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must read scrambled"
|
"encrypted unit must read scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5393,7 +5374,7 @@ mod tests {
|
|||||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||||||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||||
// that leaves any scrambled sample uncovered.
|
// that leaves any scrambled sample uncovered.
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
|
||||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
@@ -5406,8 +5387,14 @@ mod tests {
|
|||||||
let uk1 = [0x22u8; 16];
|
let uk1 = [0x22u8; 16];
|
||||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||||
assert!(ts_sync_destroyed(&sample0));
|
assert!(!crate::aacs::content::is_clean(
|
||||||
assert!(ts_sync_destroyed(&sample1));
|
&sample0,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
assert!(!crate::aacs::content::is_clean(
|
||||||
|
&sample1,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
let samples = vec![sample0.clone(), sample1.clone()];
|
let samples = vec![sample0.clone(), sample1.clone()];
|
||||||
|
|
||||||
|
|||||||
+126
-2
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
|
|||||||
use crate::disc::Key;
|
use crate::disc::Key;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Minimum encrypted-content unit samples a single online key request must carry.
|
||||||
|
///
|
||||||
|
/// The key service identifies a key by which of the submitted units it decrypts,
|
||||||
|
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||||
|
/// variants at the unit level — can return a key that matches an incidental unit
|
||||||
|
/// rather than the one asked about (a false positive). This many distinct units
|
||||||
|
/// make the request unambiguous.
|
||||||
|
///
|
||||||
|
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
|
||||||
|
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
|
||||||
|
/// request) re-exports it, and libfreemkv's own FMTS forensic query
|
||||||
|
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
|
||||||
|
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
|
||||||
|
/// value lives at the lower layer both share.
|
||||||
|
pub const MIN_SAMPLE_UNITS: usize = 8;
|
||||||
|
|
||||||
|
/// A set of encrypted content-unit samples PROVEN to carry at least
|
||||||
|
/// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership.
|
||||||
|
///
|
||||||
|
/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns
|
||||||
|
/// `None` for an under-sized slice, so an online key request simply *cannot be built*
|
||||||
|
/// from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that used to
|
||||||
|
/// live at the request site (and was silently forgotten by an under-sampling caller,
|
||||||
|
/// reading as "key service down") becomes a compile-time obligation: a request builder
|
||||||
|
/// that takes `&DecodeSampleSet` can never receive an unchecked `Vec`.
|
||||||
|
///
|
||||||
|
/// The *count* enforced here is a runtime property of the disc (how many encrypted
|
||||||
|
/// units it yields); the *requested* count is a caller-side compile-time constant that
|
||||||
|
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
|
||||||
|
/// two make under-sampling unrepresentable at the request boundary.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeSampleSet(Vec<Vec<u8>>);
|
||||||
|
|
||||||
|
impl DecodeSampleSet {
|
||||||
|
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
|
||||||
|
/// otherwise (the caller then skips the online source rather than sending an
|
||||||
|
/// ambiguous request). This is the sole way to obtain a `DecodeSampleSet`.
|
||||||
|
pub fn new(units: Vec<Vec<u8>>) -> Option<Self> {
|
||||||
|
(units.len() >= MIN_SAMPLE_UNITS).then_some(Self(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proven-sufficient samples. Guaranteed `>= MIN_SAMPLE_UNITS` in length.
|
||||||
|
pub fn units(&self) -> &[Vec<u8>] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of samples — always `>= MIN_SAMPLE_UNITS`.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always `false` (a `DecodeSampleSet` never holds fewer than `MIN_SAMPLE_UNITS`);
|
||||||
|
/// provided so the type satisfies the usual `len`/`is_empty` pairing.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||||
/// structures a source or key server may key on.
|
/// structures a source or key server may key on.
|
||||||
@@ -318,7 +376,28 @@ pub fn key_fetch(
|
|||||||
inputs: DiscInputs,
|
inputs: DiscInputs,
|
||||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||||
) -> crate::sector::KeyFetch {
|
) -> crate::sector::KeyFetch {
|
||||||
|
// Memoize by the fingerprint of the sample batch. The resolved keys are
|
||||||
|
// disc-level (the same clip's index / CPS keys are identical for every title
|
||||||
|
// that references it), and this one closure is shared across every title's mux
|
||||||
|
// — so the first title resolves a given batch over the network and every later
|
||||||
|
// title (or repeated batch) is answered from the cache with no request. Empty
|
||||||
|
// replies are cached too: a key the service does not have for a batch will not
|
||||||
|
// appear on a re-ask, so re-hitting the network buys nothing.
|
||||||
|
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||||
|
let fp = {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
samples.len().hash(&mut h);
|
||||||
|
for s in samples {
|
||||||
|
s.hash(&mut h);
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
};
|
||||||
|
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||||
|
return hit.clone();
|
||||||
|
}
|
||||||
let sources = make_sources();
|
let sources = make_sources();
|
||||||
let mut di = inputs.clone();
|
let mut di = inputs.clone();
|
||||||
di.samples = samples.to_vec();
|
di.samples = samples.to_vec();
|
||||||
@@ -327,10 +406,15 @@ pub fn key_fetch(
|
|||||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||||
let ctx = DiscInputsCtx::new(&di);
|
let ctx = DiscInputsCtx::new(&di);
|
||||||
fetch_unit_keys(&sources, &ctx)
|
let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|u| u.key)
|
.map(|u| u.key)
|
||||||
.collect()
|
.collect();
|
||||||
|
cache
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(fp, keys.clone());
|
||||||
|
keys
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +509,46 @@ mod tests {
|
|||||||
use crate::aacs::types::UnitKey;
|
use crate::aacs::types::UnitKey;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn units(n: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..n).map(|i| vec![i as u8; 4]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DecodeSampleSet: the online request can't be built under-sized ─────────
|
||||||
|
|
||||||
|
/// Fewer than MIN_SAMPLE_UNITS → no set. Mutation: accepting a short slice
|
||||||
|
/// resurrects the exact autorip bug (a 4-sample request silently skipped /
|
||||||
|
/// read as "service down").
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_rejects_under_min() {
|
||||||
|
for n in 0..MIN_SAMPLE_UNITS {
|
||||||
|
assert!(
|
||||||
|
DecodeSampleSet::new(units(n)).is_none(),
|
||||||
|
"{n} samples (< {MIN_SAMPLE_UNITS}) must not build a DecodeSampleSet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly the minimum, and above it, construct — and expose all samples.
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_accepts_min_and_above() {
|
||||||
|
let exact = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS)).expect("min builds");
|
||||||
|
assert_eq!(exact.len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert_eq!(exact.units().len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert!(!exact.is_empty());
|
||||||
|
|
||||||
|
let more = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS + 5)).expect("above min builds");
|
||||||
|
assert_eq!(more.len(), MIN_SAMPLE_UNITS + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapped units round-trip byte-for-byte (the request carries exactly what
|
||||||
|
/// was gathered — no reordering/truncation).
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_preserves_units() {
|
||||||
|
let raw = units(MIN_SAMPLE_UNITS);
|
||||||
|
let set = DecodeSampleSet::new(raw.clone()).unwrap();
|
||||||
|
assert_eq!(set.units(), raw.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||||
|
|
||||||
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||||
|
|||||||
+5
-1
@@ -187,7 +187,10 @@ pub use identity::DriveId;
|
|||||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||||
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
pub use decrypt::{
|
||||||
|
AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads,
|
||||||
|
set_decrypt_threads,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -248,6 +251,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
|||||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||||
// `SectorSource` to get plaintext sectors out.
|
// `SectorSource` to get plaintext sectors out.
|
||||||
pub use mux::build_iso_pipeline;
|
pub use mux::build_iso_pipeline;
|
||||||
|
pub use mux::resolve_mux_key_map;
|
||||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||||
pub use sector::{
|
pub use sector::{
|
||||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ pub use network::NetworkStream;
|
|||||||
pub use null::NullStream;
|
pub use null::NullStream;
|
||||||
pub use pipelined_stream::PipelinedPesStream;
|
pub use pipelined_stream::PipelinedPesStream;
|
||||||
pub use resolve::build_iso_pipeline;
|
pub use resolve::build_iso_pipeline;
|
||||||
|
pub use resolve::resolve_mux_key_map;
|
||||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||||
pub use stdio::StdioStream;
|
pub use stdio::StdioStream;
|
||||||
|
|
||||||
|
|||||||
+369
-25
@@ -358,12 +358,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
// case must NOT raise a false E7023.
|
// case must NOT raise a false E7023.
|
||||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
// Upfront FMTS gate, parallel to the unit-key gate above. With
|
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||||
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
|
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||||
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
|
// the key-fetch closure and can actually attempt resolution. (An older
|
||||||
// lacking segment keys fails here rather than emitting a holed mux.
|
// upfront blanket-reject gate lived here; it predated the resolver and
|
||||||
disc.ensure_forensic_segments_decryptable(opts.raw)
|
// rejected every 2.1 disc before a source could be tried.)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
|
||||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||||
// by probing the first DECRYPTED access units of the chosen title.
|
// by probing the first DECRYPTED access units of the chosen title.
|
||||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||||
@@ -600,6 +599,350 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
(parsers, pid_to_track, ts, ps)
|
(parsers, pid_to_track, ts, ps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
|
||||||
|
/// before muxing. It decides which held unit key decrypts each of the title's
|
||||||
|
/// LBA ranges and secures any key the pool is missing through the app's
|
||||||
|
/// configured source (`fetch`) up front, never reactively per unit at mux time.
|
||||||
|
///
|
||||||
|
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
|
||||||
|
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
|
||||||
|
/// content never reaches that bar — re-asked the key service for a key it already
|
||||||
|
/// held. There is no per-unit byte pattern that separates "correctly decrypted
|
||||||
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
|
||||||
|
/// we answer the answerable question instead: which CPS unit does each LBA range
|
||||||
|
/// belong to, decided by the disc's key structure (validated once against real
|
||||||
|
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
|
||||||
|
/// decrypts each unit with its mapped key and trusts it.
|
||||||
|
///
|
||||||
|
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
|
||||||
|
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
|
||||||
|
/// the key that opens a real sample from it; a bad-content extent no sample can
|
||||||
|
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
|
||||||
|
/// layers onto the same structure.
|
||||||
|
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
|
||||||
|
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
|
||||||
|
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
|
||||||
|
/// applies, and the forensic units garble and are dropped by the demux).
|
||||||
|
///
|
||||||
|
/// The forensic segments each carry an **index** tag (1..32) selecting one of 32
|
||||||
|
/// **index keys** the base Unit Key cannot open (see [`crate::aacs::segment`]).
|
||||||
|
/// This resolves those keys up front from the configured source — sending, per
|
||||||
|
/// index, a batch of same-index units the service maps to that index's key — adds
|
||||||
|
/// them to the pool, and builds a per-segment LBA→key map. Applying a segment's
|
||||||
|
/// key over its whole range decodes the ~40 units of that index's interleave half
|
||||||
|
/// to clean TS and garbles the other ~40 (the alternate half), which the demux
|
||||||
|
/// then drops, yielding one coherent stream. The base Unit Key covers everything
|
||||||
|
/// outside a segment.
|
||||||
|
fn resolve_fmts_key_map(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
title: &DiscTitle,
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
|
_format: ContentFormat,
|
||||||
|
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
||||||
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
|
||||||
|
|
||||||
|
// Off by default: while `BYPASS_FMTS_KEY` is set, forensic decode is disabled —
|
||||||
|
// no segment table read, no key-service traffic — and the caller's base-Unit-Key
|
||||||
|
// path applies (the forensic units garble and the demux drops them, the shipped
|
||||||
|
// behaviour). Flip `BYPASS_FMTS_KEY` to false to activate forensic decode once
|
||||||
|
// the index-key resolution is validated end to end.
|
||||||
|
if crate::aacs::segment::BYPASS_FMTS_KEY {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the segment map; absent → not an FMTS disc.
|
||||||
|
let Ok(udf) = crate::udf::read_filesystem(reader) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(segments) = parse_individual_segments(&tbl) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like
|
||||||
|
// a Unit Key. Without a configured key source we cannot obtain them, so we
|
||||||
|
// cannot produce a complete rip: fail loud rather than silently drop the
|
||||||
|
// forensic segments. (The caller may still choose `--raw`, which never reaches
|
||||||
|
// this path.)
|
||||||
|
let Some(fetch) = fetch else {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
};
|
||||||
|
tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution");
|
||||||
|
|
||||||
|
// Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`.
|
||||||
|
let read_unit =
|
||||||
|
|reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| {
|
||||||
|
let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64;
|
||||||
|
let lba = clip_byte_to_lba(&title.extents, clip_byte)?;
|
||||||
|
let mut c = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
reader.read_sectors(lba, 3, &mut c, false).ok()?;
|
||||||
|
Some(c)
|
||||||
|
};
|
||||||
|
// ── ONE forensic query. The key service returns ALL forensic index keys for the
|
||||||
|
// disc in a single response, ORDERED by index (array element i = index i+1).
|
||||||
|
// So send one clean single-variant batch (a segment's even-phase units) and
|
||||||
|
// read the whole set back — no per-index probing, no phase measurement, no
|
||||||
|
// decrypt-and-check: the array position IS the index. The first readable
|
||||||
|
// segment whose batch yields the full set wins; a short (e.g. 1-key,
|
||||||
|
// base-UK-shaped) response means that batch wasn't forensic (a wrong
|
||||||
|
// feature-title mapping), so try the next segment.
|
||||||
|
//
|
||||||
|
// ANCHOR RULE: the query MUST sample an INDEX-1 segment. The key service only
|
||||||
|
// returns the full set for the canonical anchor sample (a unit that decrypts
|
||||||
|
// under the index-1 key); a batch from any other forensic index is rejected
|
||||||
|
// (a base-UK-shaped miss). The `index == 1` filter guarantees every batch we
|
||||||
|
// send is an anchor — and the forensic tag cycles 1..32 in file order, so
|
||||||
|
// ~1-in-32 segments qualify (~25 across the feature), leaving ample read-fault
|
||||||
|
// fallback within the `MAX_ANCHOR_ATTEMPTS` budget. ─────────────────────────
|
||||||
|
const N_INDEX: usize = 32;
|
||||||
|
// Each forensic batch carries the server's minimum-samples count (the same
|
||||||
|
// disambiguation floor the online source enforces), drawn as even-phase units
|
||||||
|
// to land one clean variant half.
|
||||||
|
const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS;
|
||||||
|
// Read-fault fallback budget: how many INDEX-1 (anchor) segments to attempt
|
||||||
|
// before giving up. Only matters when the leading anchor segments are
|
||||||
|
// unreadable; each attempt is one server round-trip, so it is bounded.
|
||||||
|
const MAX_ANCHOR_ATTEMPTS: usize = 16;
|
||||||
|
let mut index_keys: Vec<[u8; 16]> = Vec::new();
|
||||||
|
for seg in segments
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.index == 1)
|
||||||
|
.take(MAX_ANCHOR_ATTEMPTS)
|
||||||
|
{
|
||||||
|
let mut batch: Vec<Vec<u8>> = Vec::new();
|
||||||
|
for p in 0..BATCH_UNITS {
|
||||||
|
if let Some(c) = read_unit(reader, seg, p * 2) {
|
||||||
|
batch.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch.len() < BATCH_UNITS {
|
||||||
|
continue; // read fault / short tail
|
||||||
|
}
|
||||||
|
let fresh = fetch(&batch);
|
||||||
|
if fresh.len() >= N_INDEX {
|
||||||
|
index_keys = fresh;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(target: "freemkv::keysource", held = index_keys.len(), need = N_INDEX, "fmts: collection done");
|
||||||
|
// The full set is required. Anything short holes the rip — fail loud like a
|
||||||
|
// missing Unit Key rather than emit forensic-holed output.
|
||||||
|
if index_keys.len() < N_INDEX {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map array position → forensic index (element i = index i+1); add each key to
|
||||||
|
// the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0).
|
||||||
|
let base_idx = 0usize;
|
||||||
|
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for (i, k) in index_keys.iter().take(N_INDEX).enumerate() {
|
||||||
|
let tag = (i + 1) as u16;
|
||||||
|
let slot = match unit_keys.iter().position(|(_, h)| h == k) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
let s = unit_keys.len();
|
||||||
|
// CPS-unit id is cosmetic for the mapped decrypt (it indexes by
|
||||||
|
// slot); use a high, distinct number for the forensic keys.
|
||||||
|
unit_keys.push((1000 + s as u32, *k));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tag_slot.insert(tag, slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build the per-segment LBA ranges directly from the tag. Each segment is
|
||||||
|
// decoded from its TAG half: the map routes the segment's whole span to its
|
||||||
|
// tag's key; the tag key opens the tag half wherever it interleaves, and the
|
||||||
|
// un-served version-B half — decrypted with that (for it, wrong) key —
|
||||||
|
// garbles and the demux drops it, leaving one clean variant per span. No
|
||||||
|
// re-read and no phase needed here: byte-5 `seg.index` selects the key. A
|
||||||
|
// segment whose tag is somehow absent (cannot happen with all 32 held) or
|
||||||
|
// that straddles an extent boundary is left unmapped and tallied. ─────────
|
||||||
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(segments.len());
|
||||||
|
let mut unresolved = 0usize;
|
||||||
|
for seg in &segments {
|
||||||
|
let Some(&slot) = tag_slot.get(&seg.index) else {
|
||||||
|
unresolved += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start_byte = seg.start_spn as u64 * 192;
|
||||||
|
let end_byte = (seg.end_spn as u64 + 1) * 192;
|
||||||
|
let (Some(a), Some(b)) = (
|
||||||
|
clip_byte_to_lba(&title.extents, start_byte),
|
||||||
|
clip_byte_to_lba(&title.extents, end_byte - 1),
|
||||||
|
) else {
|
||||||
|
unresolved += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Only emit a contiguous within-extent range (segments are ~480 KB; a rare
|
||||||
|
// extent-straddle is left unresolved rather than given a wrong span).
|
||||||
|
if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 {
|
||||||
|
ranges.push((a, b + 1, slot));
|
||||||
|
} else {
|
||||||
|
unresolved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every forensic segment must map to an index key. Any that did not is a hole
|
||||||
|
// in the rip — with the full 32-key set in hand this should never happen, so
|
||||||
|
// treat it as a hard failure (a read fault or an unexpected on-disc layout)
|
||||||
|
// rather than silently emitting a segment the base Unit Key only garbles.
|
||||||
|
if unresolved != 0 {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges(
|
||||||
|
ranges, base_idx,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_mux_key_map(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
title: &DiscTitle,
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
|
format: ContentFormat,
|
||||||
|
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
||||||
|
use crate::aacs::content::{
|
||||||
|
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool_len = match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
|
||||||
|
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
|
||||||
|
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Secure the disc's key up front from the configured source when the pool is
|
||||||
|
// empty (a genuine "no key yet" — e.g. keydb miss, online-only disc).
|
||||||
|
if pool_len == 0 {
|
||||||
|
if let Some(f) = fetch {
|
||||||
|
let samples = crate::keysource::read_encrypted_units(reader, title, 8);
|
||||||
|
if !samples.is_empty() {
|
||||||
|
let fresh = f(&samples);
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for k in fresh {
|
||||||
|
if !unit_keys.iter().any(|(_, h)| *h == k) {
|
||||||
|
let i = unit_keys.len() as u32;
|
||||||
|
unit_keys.push((i, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we
|
||||||
|
// could not obtain from any source. That is the same situation as any known
|
||||||
|
// key we don't hold — fail loud at resolve time rather than deferring an
|
||||||
|
// opaque decrypt error (or, worse, emitting ciphertext) at mux time.
|
||||||
|
let empty = matches!(
|
||||||
|
keys,
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty()
|
||||||
|
);
|
||||||
|
if empty {
|
||||||
|
return Err(crate::error::Error::DecryptFailed.into());
|
||||||
|
}
|
||||||
|
return Ok(crate::decrypt::AacsKeyMap::single(0));
|
||||||
|
}
|
||||||
|
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
|
||||||
|
// segments need per-index keys the base Unit Key can't open. Resolve them up
|
||||||
|
// front from the configured source and build a per-segment map. Returns `None`
|
||||||
|
// when the disc is not FMTS, or no key source is configured (then the base UK
|
||||||
|
// path below applies and the forensic units garble → demux drops them).
|
||||||
|
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
|
||||||
|
return Ok(map);
|
||||||
|
}
|
||||||
|
if pool_len == 1 {
|
||||||
|
// One CPS unit → one key everywhere. No structural walk, no sampling.
|
||||||
|
return Ok(crate::decrypt::AacsKeyMap::single(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-CPS: read a spread of real encrypted units from each extent and pick
|
||||||
|
// the held key that opens one (the `is_clean` proof is sound HERE — samples
|
||||||
|
// are guaranteed real content, not the authored-bad units that trip the mux).
|
||||||
|
let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec<Vec<u8>> {
|
||||||
|
let total_units = sectors / ALIGNED_UNIT_SECTORS;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if total_units == 0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const PROBES: u32 = 8;
|
||||||
|
for p in 1..=PROBES {
|
||||||
|
let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32;
|
||||||
|
if unit >= total_units {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
|
||||||
|
let mut buf = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
if reader
|
||||||
|
.read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false)
|
||||||
|
.is_ok()
|
||||||
|
&& aacs_unit_encrypted(&buf, format)
|
||||||
|
{
|
||||||
|
out.push(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
|
||||||
|
for (i, (_, k)) in pool.iter().enumerate() {
|
||||||
|
if samples.iter().any(|s| {
|
||||||
|
let mut u = s.clone();
|
||||||
|
decrypt_unit(&mut u, k);
|
||||||
|
is_clean(&u, format)
|
||||||
|
}) {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
||||||
|
let mut last_idx = 0usize;
|
||||||
|
for ext in &title.extents {
|
||||||
|
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
|
||||||
|
// Snapshot the current pool for the pure `pick` closure.
|
||||||
|
let pool: Vec<(u32, [u8; 16])> = match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let mut idx = pick(&samples, &pool);
|
||||||
|
if idx.is_none() {
|
||||||
|
if let Some(f) = fetch {
|
||||||
|
if !samples.is_empty() {
|
||||||
|
let fresh = f(&samples);
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for k in fresh {
|
||||||
|
if !unit_keys.iter().any(|(_, h)| *h == k) {
|
||||||
|
let i = unit_keys.len() as u32;
|
||||||
|
unit_keys.push((i, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = pick(&samples, unit_keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bad-content extent no sample can classify inherits its predecessor's
|
||||||
|
// key (CPS boundaries are contiguous, so the neighbour is almost always
|
||||||
|
// right); this never storms and never mis-fails a decryptable disc.
|
||||||
|
let idx = idx.unwrap_or(last_idx);
|
||||||
|
last_idx = idx;
|
||||||
|
ranges.push((
|
||||||
|
ext.start_lba,
|
||||||
|
ext.start_lba.saturating_add(ext.sector_count),
|
||||||
|
idx,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
||||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||||
/// `PipelinedPesStream`.
|
/// `PipelinedPesStream`.
|
||||||
@@ -618,18 +961,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||||
/// returns, then re-decrypts. `None` means no mid-stream key recovery — the
|
|
||||||
/// unit's best-effort bytes pass through to the muxer as-is.
|
|
||||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||||
// point; grouping them into a struct would only move the same fields around.
|
// point; grouping them into a struct would only move the same fields around.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||||
reader: S,
|
mut reader: S,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
keys: crate::decrypt::DecryptKeys,
|
mut keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
halt: Option<crate::halt::Halt>,
|
halt: Option<crate::halt::Halt>,
|
||||||
@@ -646,20 +987,23 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
|
// MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT
|
||||||
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
|
// — one key per CPS unit / segment, secured from the configured source and
|
||||||
// muxer's problem, not a decrypt failure, so the mux never conceals a unit or
|
// recorded against the LBA ranges it covers. The mux then decrypts each unit
|
||||||
// counts it as loss.
|
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
|
||||||
|
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
|
||||||
|
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
|
||||||
|
let key_map =
|
||||||
|
match &keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
||||||
|
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let mut decrypting =
|
let mut decrypting =
|
||||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||||
// Install the fresh-key-on-failure callback (if the app supplied one). This is
|
if let Some(map) = key_map {
|
||||||
// how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a
|
decrypting = decrypting.with_key_map(map);
|
||||||
// unit no held key opens — "get the key when we need it." It fires only on a
|
|
||||||
// genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that
|
|
||||||
// decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer
|
|
||||||
// storms the key source the way the old TS supermajority gate did.
|
|
||||||
if let Some(cb) = fetch {
|
|
||||||
decrypting = decrypting.with_key_fetch(cb);
|
|
||||||
}
|
}
|
||||||
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
|
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
|
||||||
// broken-TS unit is the muxer's concern, and a missing key is an up-front
|
// broken-TS unit is the muxer's concern, and a missing key is an up-front
|
||||||
|
|||||||
+121
-4
@@ -79,6 +79,14 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
|||||||
/// batches at highway speed, now that the mux installs a key-fetch for
|
/// batches at highway speed, now that the mux installs a key-fetch for
|
||||||
/// multi-CPS — reuses one allocation instead of alloc/free-ing every read.
|
/// multi-CPS — reuses one allocation instead of alloc/free-ing every read.
|
||||||
cipher_scratch: Vec<u8>,
|
cipher_scratch: Vec<u8>,
|
||||||
|
/// Proactive AACS key map (see [`crate::decrypt::AacsKeyMap`]). When set, the
|
||||||
|
/// mux resolved one key per CPS unit / segment UP FRONT, so this read decrypts
|
||||||
|
/// each aligned unit with its MAPPED key and TRUSTS it — no per-unit
|
||||||
|
/// `is_clean` verdict, no reactive key-fetch, no key-server storm. `None`
|
||||||
|
/// keeps the legacy trial-and-recover path (sweep / patch, or a mux that did
|
||||||
|
/// not build a map). Mutually exclusive with `recovery` in practice: the mux
|
||||||
|
/// installs one or the other.
|
||||||
|
key_map: Option<Arc<crate::decrypt::AacsKeyMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: SectorSource> DecryptingSectorSource<S> {
|
impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||||
@@ -99,9 +107,21 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
recovery: None,
|
recovery: None,
|
||||||
content_ranges: None,
|
content_ranges: None,
|
||||||
cipher_scratch: Vec::new(),
|
cipher_scratch: Vec::new(),
|
||||||
|
key_map: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the mux
|
||||||
|
/// resolved one key per CPS unit / segment up front, so every aligned unit is
|
||||||
|
/// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check,
|
||||||
|
/// no reactive key-fetch. This is the storm-free mux path; it supersedes
|
||||||
|
/// [`with_key_fetch`](Self::with_key_fetch) (do not set both). AACS-only; a
|
||||||
|
/// CSS / clear disc ignores it.
|
||||||
|
pub fn with_key_map(mut self, map: Arc<crate::decrypt::AacsKeyMap>) -> Self {
|
||||||
|
self.key_map = Some(map);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Restrict decrypt to the disc's encrypted-content extents
|
/// Restrict decrypt to the disc's encrypted-content extents
|
||||||
/// (sorted/merged `(start_lba, sector_count)` — see
|
/// (sorted/merged `(start_lba, sector_count)` — see
|
||||||
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
||||||
@@ -221,6 +241,18 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
let n = self
|
let n = self
|
||||||
.inner
|
.inner
|
||||||
.read_sectors_fua(lba, count, buf, recovery, fua)?;
|
.read_sectors_fua(lba, count, buf, recovery, fua)?;
|
||||||
|
|
||||||
|
// PROACTIVE MAP PATH (the storm-free mux): when a key map is installed the
|
||||||
|
// mux resolved one key per CPS unit / segment up front, so decrypt each
|
||||||
|
// aligned unit with its MAPPED key and trust it — no per-unit `is_clean`
|
||||||
|
// verdict, no reactive key-fetch. A resolver gap surfaces loud from
|
||||||
|
// `decrypt_sectors_mapped` (DecryptFailed); authored-bad TS just passes
|
||||||
|
// through for the muxer to drop.
|
||||||
|
if let Some(map) = self.key_map.clone() {
|
||||||
|
crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?;
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS).
|
// Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS).
|
||||||
// With a content map installed the `*_in_content` entry skips units OUTSIDE
|
// With a content map installed the `*_in_content` entry skips units OUTSIDE
|
||||||
// the encrypted extents (clear filesystem / nav pass through untouched); the
|
// the encrypted extents (clear filesystem / nav pass through untouched); the
|
||||||
@@ -1156,7 +1188,7 @@ mod tests {
|
|||||||
// The recovered key decrypts the unit: it is now clean TS in `buf`.
|
// The recovered key decrypts the unit: it is now clean TS in `buf`.
|
||||||
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(
|
assert!(
|
||||||
!crate::aacs::content::ts_sync_destroyed(unit0),
|
crate::aacs::content::is_clean(unit0, crate::disc::ContentFormat::BdTs),
|
||||||
"fetch supplied the key → the unit decrypts to clean TS"
|
"fetch supplied the key → the unit decrypts to clean TS"
|
||||||
);
|
);
|
||||||
let got = seen.lock().unwrap();
|
let got = seen.lock().unwrap();
|
||||||
@@ -1166,7 +1198,7 @@ mod tests {
|
|||||||
"callback must be invoked once with the failing unit"
|
"callback must be invoked once with the failing unit"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
crate::aacs::content::ts_sync_destroyed(&got[0]),
|
!crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs),
|
||||||
"the sample handed to the callback is the still-scrambled ciphertext"
|
"the sample handed to the callback is the still-scrambled ciphertext"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1175,6 +1207,91 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// THE MUX-STORM REGRESSION. A unit the held key OPENS (>= the 4-packet proof
|
||||||
|
/// floor) but that carries many authored-bad packets (< half synced) must
|
||||||
|
/// NEVER be handed to the key-fetch closure — its key is already in hand. Only
|
||||||
|
/// a GENUINE miss (no held key opens it) is sampled. Before the min(E,4)
|
||||||
|
/// unification, the bad-encoded unit tripped the old >50% majority in
|
||||||
|
/// `aacs_unit_needs_decrypt`, so every batch re-sampled it to the key service
|
||||||
|
/// (the Jason Bourne / Stand By Me stall). This drives the REAL
|
||||||
|
/// `DecryptingSectorSource` recovery path, not a synthetic check.
|
||||||
|
#[test]
|
||||||
|
fn bad_encoded_opened_unit_is_never_sampled_to_the_key_service() {
|
||||||
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
let held = [0x5au8; 16]; // opens the bad-encoded unit
|
||||||
|
let orphan = [0x77u8; 16]; // opens the genuine-miss unit (NOT held)
|
||||||
|
// Knock out packets 1..27 (26 authored-bad) → ~5 synced: >= the 4-packet
|
||||||
|
// floor (OPENED) yet < half (what the old >50% majority false-flagged).
|
||||||
|
let bad_pkts: Vec<usize> = (1..27).collect();
|
||||||
|
let bad_encoded = encrypt_aacs_unit_bad(&held, &bad_pkts);
|
||||||
|
let genuine_miss = encrypt_aacs_unit(&orphan);
|
||||||
|
|
||||||
|
// One 6-sector read spans both units: bad-encoded at [0,3), miss at [3,6).
|
||||||
|
struct TwoUnits {
|
||||||
|
a: Vec<u8>,
|
||||||
|
b: Vec<u8>,
|
||||||
|
}
|
||||||
|
impl SectorSource for TwoUnits {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
6
|
||||||
|
}
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
_count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_r: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let n = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
buf[..n].copy_from_slice(&self.a);
|
||||||
|
buf[n..2 * n].copy_from_slice(&self.b);
|
||||||
|
Ok(2 * n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let seen_cb = Arc::clone(&seen);
|
||||||
|
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
|
||||||
|
seen_cb.lock().unwrap().extend_from_slice(samples);
|
||||||
|
Vec::new() // service has nothing for the orphan — forces the sampling path
|
||||||
|
});
|
||||||
|
|
||||||
|
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]);
|
||||||
|
let mut dec = DecryptingSectorSource::new(
|
||||||
|
TwoUnits {
|
||||||
|
a: bad_encoded.clone(),
|
||||||
|
b: genuine_miss.clone(),
|
||||||
|
},
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, held)], // opens bad_encoded, NOT genuine_miss
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_content_ranges(ranges)
|
||||||
|
.with_key_fetch(fetch);
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 6 * 2048];
|
||||||
|
let _ = dec.read_sectors(0, 6, &mut buf, false);
|
||||||
|
|
||||||
|
let got = seen.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!got.is_empty(),
|
||||||
|
"the genuine orphan-key miss must trigger a fetch"
|
||||||
|
);
|
||||||
|
for s in got.iter() {
|
||||||
|
assert_ne!(
|
||||||
|
&s[..ALIGNED_UNIT_LEN.min(s.len())],
|
||||||
|
&bad_encoded[..],
|
||||||
|
"a bad-encoded unit the key OPENED must NEVER be sampled (the storm)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
got.iter().any(|s| s.as_slice() == genuine_miss.as_slice()),
|
||||||
|
"only the genuine miss is sampled to the key service"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
|
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
|
||||||
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
|
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
|
||||||
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
|
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
|
||||||
@@ -1257,7 +1374,7 @@ mod tests {
|
|||||||
"fetch fired for BOTH units — the dry result for A did not latch off B"
|
"fetch fired for BOTH units — the dry result for A did not latch off B"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!crate::aacs::content::ts_sync_destroyed(&buf2),
|
crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs),
|
||||||
"unit B is decrypted after its on-demand fetch"
|
"unit B is decrypted after its on-demand fetch"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1505,7 @@ mod tests {
|
|||||||
.expect("fetch recovers the orphan unit's key");
|
.expect("fetch recovers the orphan unit's key");
|
||||||
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
|
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
|
||||||
assert!(
|
assert!(
|
||||||
!crate::aacs::content::ts_sync_destroyed(&buf),
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||||
"the recovered read is clean TS"
|
"the recovered read is clean TS"
|
||||||
);
|
);
|
||||||
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
|
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
|
||||||
|
|||||||
+15
-9
@@ -131,12 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it looks encrypted (body TS syncs scrambled)
|
// Verify it looks encrypted (body TS syncs scrambled)
|
||||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&plain,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Now decrypt
|
// Now decrypt
|
||||||
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&plain),
|
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"decrypted unit should read as clear (TS syncs restored)"
|
"decrypted unit should read as clear (TS syncs restored)"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -254,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() {
|
|||||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
|
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_ts_sync_destroyed_detection() {
|
fn aacs_is_clean_detection() {
|
||||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||||
// scrambled. (Flag bits play no role.)
|
// scrambled. (Flag bits play no role.)
|
||||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
@@ -266,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&clear),
|
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"clear unit (syncs intact) must not be scrambled"
|
"clear unit (syncs intact) must not be scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,21 +278,21 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
flagged[0] = 0xC0; // copy-control bits
|
flagged[0] = 0xC0; // copy-control bits
|
||||||
flagged[7] = 0xC0; // TSC bits
|
flagged[7] = 0xC0; // TSC bits
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&flagged),
|
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"flag bits must not be read as encryption"
|
"flag bits must not be read as encryption"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A scrambled body (syncs destroyed) → scrambled.
|
// A scrambled body (syncs destroyed) → scrambled.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&scrambled),
|
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"unit with no intact TS syncs must read as scrambled"
|
"unit with no intact TS syncs must read as scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Too short
|
// Too short
|
||||||
let short = vec![0xFFu8; 100];
|
let short = vec![0xFFu8; 100];
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&short),
|
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"short buffer should not be detected"
|
"short buffer should not be detected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -312,7 +315,10 @@ fn aacs_clear_unit_reports_not_encrypted() {
|
|||||||
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||||
unit[0] &= 0x3F;
|
unit[0] &= 0x3F;
|
||||||
|
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
||||||
|
|||||||
@@ -108,16 +108,25 @@ fn aacs_encryption_flag_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Flag bits on a synced unit do not make it look encrypted.
|
// Flag bits on a synced unit do not make it look encrypted.
|
||||||
unit[0] = 0xC0;
|
unit[0] = 0xC0;
|
||||||
unit[7] = 0xC0;
|
unit[7] = 0xC0;
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Scrambled body (syncs gone) → encrypted.
|
// Scrambled body (syncs gone) → encrypted.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(aacs::content::ts_sync_destroyed(&scrambled));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&scrambled,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||||
|
|||||||
Reference in New Issue
Block a user