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:
Matthew Jackson
2026-06-28 21:12:57 -07:00
parent eba34f4c20
commit 59681dfd4b
5 changed files with 319 additions and 27 deletions
+25
View File
@@ -1,5 +1,30 @@
# 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]
### Added
+151 -1
View File
@@ -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;
/// there is no flag to clear.
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`
@@ -703,6 +758,101 @@ mod tests {
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) ────────────────────────────────
#[test]
+28 -16
View File
@@ -356,22 +356,34 @@ impl Disc {
);
return Err(Error::AacsBusKeyUnavailable);
}
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
// the real record length (same as `read_aacs_inputs`). Without this the
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
// processing-key `decrypt_with` derivation consume, and which a key
// source ships to an online service — is the full 128 MiB pad, not the
// ~few-MB record stream.
let mkb_bytes = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.ok()
.unwrap_or_default();
// Trim to the real record length. Use trim_mkb rather than a raw
// truncate: trim_mkb only truncates when content_len > 0 and strictly
// inside the buffer, so a malformed/unrecognised MKB is preserved
// intact instead of being zeroed by truncate(0).
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
// Read the MKB record stream via the SAME bounded reader the
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
// file-backed / large readers — which left `a.mkb` empty here, silently
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
// the decode service and it 404'd, while autorip's separate
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
// `Disc::inputs()` is the single complete source of AACS inputs.
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
// here is invisible until an online key service rejects the request, so
// a transient I/O hiccup must not masquerade as "no MKB". We still
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
// it), but the cause is now on the log.
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
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);
tracing::debug!(
+3 -1
View File
@@ -477,7 +477,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
present.sort_unstable();
for lba in present {
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;
continue;
}
+112 -9
View File
@@ -113,10 +113,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// [`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.
fetch: Option<KeyFetch>,
/// Latched once a fetch call returns no NEW key — further failures on this
/// decorator then skip the callback (the source has nothing more to offer, so
/// re-asking would only burn key-server requests).
fetch_spent: bool,
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
/// already returned NO new key for. A later failure re-asks the source only
/// for units NOT in this set — so on a multi-CPS disc the source is still
/// 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
/// [`MAX_FETCH_CALLS`].
fetch_calls: usize,
@@ -156,7 +159,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
fetch: None,
fetch_spent: false,
fetch_dry: std::collections::HashSet::new(),
fetch_calls: 0,
verify_only: false,
content_ranges: None,
@@ -289,6 +292,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
if samples.is_empty() {
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.
self.fetch_calls += 1;
let fresh = match self.fetch.as_ref() {
@@ -307,8 +319,9 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
}
if added == 0 {
// Nothing new — stop asking for the rest of this decorator's life.
self.fetch_spent = true;
// Nothing new for THESE units — remember them so we don't re-ask the
// same ciphertext, but leave the door open for other units.
self.fetch_dry.extend(fps);
return prev_dropped;
}
// 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)
}
/// 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
/// 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
@@ -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_ref = content.as_deref();
// Whether a fresh-key fetch is still worth attempting on this decorator.
let fetch_viable =
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
// 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:
// * 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
/// source unchanged. Grounding: the accessor methods.
#[test]