1.1.1: AACS decrypt + key-resolution hardening
- decrypt_unit: padding-aware acceptance — recover real video at content- fragment tails (the phantom mux-loss class) without weakening wrong-key rejection (a full content unit still needs all 32 TS syncs). - scan: read the MKB via the bounded read_mkb_content so Disc::inputs() carries it. Online key resolution was shipping mkb=0 (a full read of the ~128 MiB MKB_RO allocation fails) → the decode service 404'd. - resolve_vid_only: surface an MKB read error instead of silently emptying. - fetch: a per-sample dry-set replaces the global fetch_spent latch, so a second CPS unit's key can still be fetched after the first came back empty. - verify::push_ranges: saturating arithmetic (corrupt-disc panic guard). - Tests for all of the above.
This commit is contained in:
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.1.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ISO mux no longer drops real video at content-fragment tails.** A title's
|
||||||
|
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
|
||||||
|
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
|
||||||
|
demanded the TS sync byte on *all 32* source packets, so it rejected such a
|
||||||
|
tail unit over its legitimate padding — discarding the real video packets it
|
||||||
|
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
|
||||||
|
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
|
||||||
|
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
|
||||||
|
non-zero must restore their TS sync; the zero padding is excluded from the
|
||||||
|
check and emitted as clean zeros. A full content unit still requires all 32
|
||||||
|
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
|
||||||
|
to decrypt is still rejected as a genuine bad read.
|
||||||
|
- **ISO online key resolution now sends the Media Key Block.** Capturing a
|
||||||
|
disc's AACS inputs at scan read the MKB with a full `read_file` of the
|
||||||
|
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
|
||||||
|
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
|
||||||
|
service and the request was rejected (no key → no decrypt). Scan now reads the
|
||||||
|
MKB through the same bounded prefix-grow + trim reader as the out-of-band
|
||||||
|
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
|
||||||
|
reader for every caller.
|
||||||
|
|
||||||
## [1.1.0]
|
## [1.1.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+151
-1
@@ -258,7 +258,62 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
|
|||||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||||
/// there is no flag to clear.
|
/// there is no flag to clear.
|
||||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||||
decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
|
if unit.len() < ALIGNED_UNIT_LEN {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !aacs_unit_encrypted(unit) {
|
||||||
|
return true; // CPI flag clear → plaintext, pass through untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
// PADDING-AWARE acceptance. The question this answers is "did we read good,
|
||||||
|
// decryptable data?" — NOT "are all 32 packets present". A content fragment
|
||||||
|
// can END mid-unit, with the disc zero-padding the rest of the aligned unit
|
||||||
|
// to the next fragment. Such a tail unit is `[real encrypted packets][source
|
||||||
|
// zeros]`: the real packets decrypt perfectly, but the strict all-32
|
||||||
|
// `unit_is_clean_ts` would reject the whole unit over the padding tail and
|
||||||
|
// discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet
|
||||||
|
// (all 192 bytes zero before decrypt) can only be padding, never content.
|
||||||
|
//
|
||||||
|
// So: a packet whose SOURCE bytes are all zero is padding — excluded from the
|
||||||
|
// verify and emitted as clean zeros. Every other (content) packet must
|
||||||
|
// restore its TS sync. A full content unit has no zero-source packets, so this
|
||||||
|
// is byte-identical to the old all-32 check (no regression, no wrong-key
|
||||||
|
// hole). The discriminator between a legitimate short tail and a genuine
|
||||||
|
// misread is exactly this: a misread leaves the failing packets' SOURCE
|
||||||
|
// non-zero (real ciphertext that won't decrypt) → still rejected.
|
||||||
|
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||||
|
let npkt = ALIGNED_UNIT_LEN / PKT;
|
||||||
|
let mut pad = [false; ALIGNED_UNIT_LEN / 192];
|
||||||
|
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
|
||||||
|
let off = p * PKT;
|
||||||
|
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save original first 16 bytes (the plaintext seed / header) and derive the
|
||||||
|
// per-unit decrypt key (identical to `decrypt_unit_checked`).
|
||||||
|
let mut header = [0u8; 16];
|
||||||
|
header.copy_from_slice(&unit[..16]);
|
||||||
|
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||||
|
let mut decrypt_key = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
decrypt_key[i] = derived[i] ^ header[i];
|
||||||
|
}
|
||||||
|
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||||
|
|
||||||
|
// Verify content packets; zero out padding packets (their decrypted bytes are
|
||||||
|
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
|
||||||
|
// fill is lossless and gives the demux a tidy gap instead of garbage).
|
||||||
|
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
|
||||||
|
let off = p * PKT;
|
||||||
|
if is_pad {
|
||||||
|
for b in unit[off..off + PKT].iter_mut() {
|
||||||
|
*b = 0;
|
||||||
|
}
|
||||||
|
} else if unit[off + 4] != TS_SYNC {
|
||||||
|
return false; // a real content packet failed → genuinely undecryptable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
|
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
|
||||||
@@ -703,6 +758,101 @@ mod tests {
|
|||||||
unit
|
unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
|
||||||
|
//
|
||||||
|
// A content fragment can end mid-unit, with the disc zero-padding the rest
|
||||||
|
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
|
||||||
|
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
|
||||||
|
// — its real packets decrypt; the source-zero tail is padding, not content
|
||||||
|
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
|
||||||
|
// genuine misread / wrong key). The discriminator is the SOURCE bytes of the
|
||||||
|
// failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data.
|
||||||
|
|
||||||
|
/// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from
|
||||||
|
/// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a
|
||||||
|
/// non-zero `fill` models a corrupt/misread tail.
|
||||||
|
fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec<u8> {
|
||||||
|
let mut unit = clear_unit();
|
||||||
|
aacs_encrypt_unit(&mut unit, unit_key);
|
||||||
|
for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() {
|
||||||
|
*b = fill;
|
||||||
|
}
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decryptable_full_content_unit_under_correct_key() {
|
||||||
|
let key = [0x5Au8; 16];
|
||||||
|
let mut unit = clear_unit();
|
||||||
|
aacs_encrypt_unit(&mut unit, &key);
|
||||||
|
assert!(
|
||||||
|
decrypt_unit(&mut unit, &key),
|
||||||
|
"full clean unit is decryptable"
|
||||||
|
);
|
||||||
|
assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fragment_tail_with_source_zero_pad_is_decryptable() {
|
||||||
|
// 11 real content packets, then source-zero padding (the Dunkirk shape).
|
||||||
|
let key = [0x5Au8; 16];
|
||||||
|
let mut unit = tail_filled_unit(&key, 11, 0x00);
|
||||||
|
assert!(
|
||||||
|
decrypt_unit(&mut unit, &key),
|
||||||
|
"real prefix + source-zero pad IS decryptable"
|
||||||
|
);
|
||||||
|
for p in 0..11 {
|
||||||
|
assert_eq!(
|
||||||
|
unit[p * BD_SOURCE_PACKET_BYTES + 4],
|
||||||
|
TS_SYNC,
|
||||||
|
"content pkt {p} restored its sync"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Padding emitted as clean zeros, not decrypted garbage.
|
||||||
|
for p in 11..32 {
|
||||||
|
let off = p * BD_SOURCE_PACKET_BYTES;
|
||||||
|
assert!(
|
||||||
|
unit[off..off + BD_SOURCE_PACKET_BYTES]
|
||||||
|
.iter()
|
||||||
|
.all(|&b| b == 0),
|
||||||
|
"padding pkt {p} zeroed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fragment_tail_with_nonzero_garbage_is_not_decryptable() {
|
||||||
|
// Same shape, but the tail is NON-zero — a genuine misread, not padding.
|
||||||
|
let key = [0x5Au8; 16];
|
||||||
|
let mut unit = tail_filled_unit(&key, 11, 0xC3);
|
||||||
|
assert!(
|
||||||
|
!decrypt_unit(&mut unit, &key),
|
||||||
|
"real prefix + non-zero garbage tail is NOT decryptable (misread)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_full_unit_is_not_decryptable() {
|
||||||
|
let mut unit = clear_unit();
|
||||||
|
aacs_encrypt_unit(&mut unit, &[0x11u8; 16]);
|
||||||
|
assert!(
|
||||||
|
!decrypt_unit(&mut unit, &[0x22u8; 16]),
|
||||||
|
"wrong key on a full content unit is rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cpi_clear_unit_passes_through_decryptable() {
|
||||||
|
// CPI-clear (plaintext) unit: decryptable by definition, untouched.
|
||||||
|
let mut unit = clear_unit(); // byte 0 high bits clear
|
||||||
|
let before = unit.clone();
|
||||||
|
assert!(
|
||||||
|
decrypt_unit(&mut unit, &[0u8; 16]),
|
||||||
|
"clear unit passes through as decryptable"
|
||||||
|
);
|
||||||
|
assert_eq!(unit, before, "clear unit left untouched");
|
||||||
|
}
|
||||||
|
|
||||||
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
|
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+28
-16
@@ -356,22 +356,34 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
return Err(Error::AacsBusKeyUnavailable);
|
return Err(Error::AacsBusKeyUnavailable);
|
||||||
}
|
}
|
||||||
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
// Read the MKB record stream via the SAME bounded reader the
|
||||||
// the real record length (same as `read_aacs_inputs`). Without this the
|
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
|
||||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
|
||||||
// processing-key `decrypt_with` derivation consume, and which a key
|
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
|
||||||
// source ships to an online service — is the full 128 MiB pad, not the
|
// file-backed / large readers — which left `a.mkb` empty here, silently
|
||||||
// ~few-MB record stream.
|
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
|
||||||
let mkb_bytes = udf_fs
|
// the decode service and it 404'd, while autorip's separate
|
||||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
// `Disc::inputs()` is the single complete source of AACS inputs.
|
||||||
.ok()
|
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
|
||||||
.unwrap_or_default();
|
// here is invisible until an online key service rejects the request, so
|
||||||
// Trim to the real record length. Use trim_mkb rather than a raw
|
// a transient I/O hiccup must not masquerade as "no MKB". We still
|
||||||
// truncate: trim_mkb only truncates when content_len > 0 and strictly
|
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
|
||||||
// inside the buffer, so a malformed/unrecognised MKB is preserved
|
// it), but the cause is now on the log.
|
||||||
// intact instead of being zeroed by truncate(0).
|
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
|
||||||
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "scan_aacs_mkb",
|
||||||
|
error = %e,
|
||||||
|
"MKB read failed at scan; AACS inputs will carry an empty MKB \
|
||||||
|
(online key resolution cannot proceed without it). Continuing \
|
||||||
|
— disc-hash-keyed lookups are unaffected."
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|||||||
+3
-1
@@ -477,7 +477,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
|
|||||||
present.sort_unstable();
|
present.sort_unstable();
|
||||||
for lba in present {
|
for lba in present {
|
||||||
if let Some(last) = out.last_mut() {
|
if let Some(last) = out.last_mut() {
|
||||||
if last.0 + last.1 == lba {
|
// Saturating: LBAs come from disc-controlled ICB extents, so a
|
||||||
|
// corrupt disc must not panic here (matches `udf::merge_ranges`).
|
||||||
|
if last.0.saturating_add(last.1) == lba {
|
||||||
last.1 += 1;
|
last.1 += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-9
@@ -113,10 +113,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
|||||||
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
|
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
|
||||||
/// to ask its key source for a key when a unit fails to decrypt.
|
/// to ask its key source for a key when a unit fails to decrypt.
|
||||||
fetch: Option<KeyFetch>,
|
fetch: Option<KeyFetch>,
|
||||||
/// Latched once a fetch call returns no NEW key — further failures on this
|
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
|
||||||
/// decorator then skip the callback (the source has nothing more to offer, so
|
/// already returned NO new key for. A later failure re-asks the source only
|
||||||
/// re-asking would only burn key-server requests).
|
/// for units NOT in this set — so on a multi-CPS disc the source is still
|
||||||
fetch_spent: bool,
|
/// asked for the *second* CPS unit's key even after the first came back dry
|
||||||
|
/// (the old global latch blocked that), while the *same* failing unit is
|
||||||
|
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
|
||||||
|
fetch_dry: std::collections::HashSet<u64>,
|
||||||
/// How many times the fetch closure has been invoked, capped at
|
/// How many times the fetch closure has been invoked, capped at
|
||||||
/// [`MAX_FETCH_CALLS`].
|
/// [`MAX_FETCH_CALLS`].
|
||||||
fetch_calls: usize,
|
fetch_calls: usize,
|
||||||
@@ -156,7 +159,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
unit_base: 0,
|
unit_base: 0,
|
||||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||||
fetch: None,
|
fetch: None,
|
||||||
fetch_spent: false,
|
fetch_dry: std::collections::HashSet::new(),
|
||||||
fetch_calls: 0,
|
fetch_calls: 0,
|
||||||
verify_only: false,
|
verify_only: false,
|
||||||
content_ranges: None,
|
content_ranges: None,
|
||||||
@@ -289,6 +292,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
return prev_dropped;
|
return prev_dropped;
|
||||||
}
|
}
|
||||||
|
// Skip the call when EVERY failing unit here is one a prior fetch already
|
||||||
|
// came back empty for — re-asking the identical ciphertext only burns a
|
||||||
|
// key-server request. A unit we have NOT asked about yet (e.g. a second
|
||||||
|
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
|
||||||
|
// global `fetch_spent` latch wrongly blocked it.
|
||||||
|
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
|
||||||
|
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
|
||||||
|
return prev_dropped;
|
||||||
|
}
|
||||||
// Ask the application's key source for keys that open this ciphertext.
|
// Ask the application's key source for keys that open this ciphertext.
|
||||||
self.fetch_calls += 1;
|
self.fetch_calls += 1;
|
||||||
let fresh = match self.fetch.as_ref() {
|
let fresh = match self.fetch.as_ref() {
|
||||||
@@ -307,8 +319,9 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if added == 0 {
|
if added == 0 {
|
||||||
// Nothing new — stop asking for the rest of this decorator's life.
|
// Nothing new for THESE units — remember them so we don't re-ask the
|
||||||
self.fetch_spent = true;
|
// same ciphertext, but leave the door open for other units.
|
||||||
|
self.fetch_dry.extend(fps);
|
||||||
return prev_dropped;
|
return prev_dropped;
|
||||||
}
|
}
|
||||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||||
@@ -317,6 +330,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
.unwrap_or(prev_dropped)
|
.unwrap_or(prev_dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
|
||||||
|
/// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to
|
||||||
|
/// equal fingerprints within a process — all the dedup needs.
|
||||||
|
fn sample_fp(sample: &[u8]) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
sample.hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
|
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
|
||||||
/// failed verify read. Called only on the failure (cold) path. On a fresh
|
/// failed verify read. Called only on the failure (cold) path. On a fresh
|
||||||
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
|
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
|
||||||
@@ -457,8 +480,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
|
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
|
||||||
let content_ref = content.as_deref();
|
let content_ref = content.as_deref();
|
||||||
// Whether a fresh-key fetch is still worth attempting on this decorator.
|
// Whether a fresh-key fetch is still worth attempting on this decorator.
|
||||||
let fetch_viable =
|
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
||||||
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
|
||||||
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
|
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
|
||||||
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
|
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
|
||||||
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
|
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
|
||||||
@@ -1338,6 +1360,87 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`
|
||||||
|
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
|
||||||
|
/// be asked for after unit A came back dry.
|
||||||
|
#[test]
|
||||||
|
fn fetch_dry_does_not_block_a_distinct_later_unit() {
|
||||||
|
let key_a = [0x5au8; 16];
|
||||||
|
let key_b = [0x77u8; 16];
|
||||||
|
let unit_a = encrypt_aacs_unit(&key_a);
|
||||||
|
let unit_b = encrypt_aacs_unit(&key_b);
|
||||||
|
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
|
||||||
|
|
||||||
|
struct AltSource {
|
||||||
|
units: Vec<Vec<u8>>,
|
||||||
|
idx: usize,
|
||||||
|
}
|
||||||
|
impl SectorSource for AltSource {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
6
|
||||||
|
}
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_r: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let bytes = count as usize * 2048;
|
||||||
|
let u = &self.units[self.idx.min(self.units.len() - 1)];
|
||||||
|
buf[..bytes].copy_from_slice(u);
|
||||||
|
self.idx += 1;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback serves key_b only when asked about unit B; nothing for A.
|
||||||
|
let unit_b_cb = unit_b.clone();
|
||||||
|
let calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let calls_cb = Arc::clone(&calls);
|
||||||
|
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
|
||||||
|
*calls_cb.lock().unwrap() += 1;
|
||||||
|
if samples.iter().any(|s| *s == unit_b_cb) {
|
||||||
|
vec![key_b]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut wrapped = DecryptingSectorSource::new(
|
||||||
|
AltSource {
|
||||||
|
units: vec![unit_a, unit_b],
|
||||||
|
idx: 0,
|
||||||
|
},
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
|
||||||
|
read_data_key: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_key_fetch(fetch);
|
||||||
|
|
||||||
|
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
|
||||||
|
let mut buf = vec![0u8; 3 * 2048];
|
||||||
|
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
|
||||||
|
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
|
||||||
|
// recover key_b → B decrypts cleanly.
|
||||||
|
let mut buf2 = vec![0u8; 3 * 2048];
|
||||||
|
wrapped
|
||||||
|
.read_sectors(3, 3, &mut buf2, false)
|
||||||
|
.expect("unit B recovers via its own fetch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*calls.lock().unwrap(),
|
||||||
|
2,
|
||||||
|
"fetch fired for BOTH units — the dry result for A did not latch off B"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!crate::aacs::ts_sync_destroyed(&buf2),
|
||||||
|
"unit B is decrypted after its on-demand fetch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
||||||
/// source unchanged. Grounding: the accessor methods.
|
/// source unchanged. Grounding: the accessor methods.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user