Mux: pure decrypt, policy at the caller (no null, no key-server storm)
decrypt_sectors is now a pure decrypt — apply the CPS unit key, leave the
plaintext, report how many bytes did not reach clean TS ("unverified"). It
never restores ciphertext, nulls, or re-fetches. "Did a key produce clean TS?"
is a key-selection / read-verify signal, not the verdict "did we decrypt?": a
correct key can decrypt a bad-encoded region, and broken TS is a muxer concern
(the demuxer drops the packet and resyncs).
Callers own the policy:
- mux (read > decrypt > mux): pass the decrypted bytes to the muxer, whatever
they are; fail loud only on a genuine can't-decrypt (no key / misaligned).
- sweep/patch (reading from a disc): an unverified unit is a bad read — recover
a fresh key and retry, or fail loud so disc-recovery re-reads it.
Removes three duplicated decisions — the decrypt-time ciphertext restore, the
mux NULL-TS conceal loop, and the per-unit key-server refetch — plus the dead
aacs_unit_still_ciphertext predicate. Key-fetch recovery now samples the on-disc
ciphertext explicitly (a pure decrypt leaves the buffer plaintext) and lives
only on the rip/verify path, never the mux.
Fixes the 30-90s/region mux stalls and key-server storm on bad-encoded UHD runs
that 1.4.1 left behind (it relaxed the gate but not the surrounding machinery).
This commit is contained in:
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.2] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mux no longer nulls decryptable video or storms the key server on a
|
||||
bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding
|
||||
machinery in place. On a unit that a key *decrypted* but that did not
|
||||
reassemble to clean MPEG-TS, the read path still restored the ciphertext,
|
||||
tallied it as loss, re-asked the online key server (which returned the same
|
||||
correct key, forever), and the mux concealed the unit as NULL TS. On a UHD
|
||||
title with an authored bad-encoded run this stalled each region for 30–90 s per
|
||||
unit — the key server brute-forcing and re-returning the one right key — while
|
||||
nulling video that had, in fact, already decrypted. The root cause was one
|
||||
conflation duplicated across several sites: *"did a key produce clean TS?"* was
|
||||
treated as the verdict *"did we decrypt?"*. They are not the same — a correct
|
||||
key can decrypt content whose underlying encoding is broken, and broken TS is a
|
||||
muxer concern (the demuxer drops the packet and resyncs), never a decrypt
|
||||
verdict.
|
||||
|
||||
### Changed
|
||||
|
||||
- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a
|
||||
pure decrypt: it applies the CPS unit key to every encrypted unit in place,
|
||||
leaves the plaintext, and reports how many bytes did not reach clean TS
|
||||
("unverified"). It never restores ciphertext, nulls, or re-fetches a key.
|
||||
Clean TS is used only as a multi-key *selection* hint and a read *verify*
|
||||
signal. The callers decide what an unverified unit means:
|
||||
- **mux** (`read → decrypt → mux`): pass the decrypted bytes to the muxer,
|
||||
whatever they are; the muxer handles bad TS. The mux never conceals,
|
||||
re-fetches, or counts broken TS as loss — it fails loud only when it
|
||||
genuinely cannot decrypt (no key / misaligned unit), since a mux over
|
||||
already-captured data must otherwise always succeed.
|
||||
- **sweep / patch** (reading from a disc): an unverified unit means the read
|
||||
did not prove out; recover a fresh key and retry, or fail the read so the
|
||||
disc-recovery path re-reads it.
|
||||
|
||||
This removes three duplicated decisions — the decrypt-time ciphertext restore,
|
||||
the mux NULL-TS conceal loop, and the per-unit key-server refetch — and the
|
||||
dead `aacs_unit_still_ciphertext` predicate. The key-fetch recovery now samples
|
||||
the on-disc ciphertext explicitly (a pure decrypt leaves the buffer plaintext)
|
||||
and lives only on the rip/verify path, never the mux.
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -167,16 +167,6 @@ pub fn unit_content_decrypted(unit: &[u8]) -> bool {
|
||||
content == 0 || synced * 4 >= content * 3
|
||||
}
|
||||
|
||||
/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the
|
||||
/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt
|
||||
/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it
|
||||
/// (below the supermajority-sync gate). A unit the right key opened — even one
|
||||
/// carrying a few defective packets — is NOT ciphertext and is never concealed;
|
||||
/// its bytes belong to the muxer.
|
||||
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
|
||||
aacs_unit_encrypted(unit) && !unit_content_decrypted(unit)
|
||||
}
|
||||
|
||||
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
|
||||
/// source packets — the [A2] mux loss-concealment fill for a content unit that
|
||||
/// genuinely would not decrypt.
|
||||
@@ -962,10 +952,6 @@ mod tests {
|
||||
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
|
||||
);
|
||||
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
|
||||
assert!(
|
||||
!aacs_unit_still_ciphertext(&unit),
|
||||
"an opened unit is NOT ciphertext -> the mux never conceals it"
|
||||
);
|
||||
for p in 0..32 {
|
||||
if p == 17 {
|
||||
continue;
|
||||
@@ -1009,23 +995,10 @@ mod tests {
|
||||
assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_ciphertext_tracks_the_open_verdict() {
|
||||
// Fully clean -> opened, not ciphertext.
|
||||
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true)));
|
||||
// One defect -> still opened, still not ciphertext.
|
||||
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true)));
|
||||
// Wrong-key noise floor -> not opened -> ciphertext (concealable).
|
||||
assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true)));
|
||||
// CPI-clear bytes are never "ciphertext" regardless of sync count.
|
||||
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_padding_unit_is_trivially_opened() {
|
||||
// CPI set but every packet is source-zero padding: nothing to decrypt.
|
||||
assert!(unit_content_decrypted(&decrypted_shape(0, 0, true)));
|
||||
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1606,60 +1579,4 @@ mod tests {
|
||||
// 6144 = 32 packets.
|
||||
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
|
||||
}
|
||||
|
||||
/// Direct coverage for the padding-aware conceal predicate. It must conceal
|
||||
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
|
||||
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
|
||||
#[test]
|
||||
fn aacs_unit_still_ciphertext_is_padding_aware() {
|
||||
let pkt = BD_SOURCE_PACKET_BYTES;
|
||||
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
|
||||
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
|
||||
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
|
||||
let build = |cpi: bool, kinds: &[u8]| {
|
||||
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
if cpi {
|
||||
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
|
||||
}
|
||||
for (i, &k) in kinds.iter().enumerate() {
|
||||
let off = i * pkt;
|
||||
match k {
|
||||
b'S' => {
|
||||
u[off + 4] = TS_SYNC;
|
||||
for b in &mut u[off + 5..off + pkt] {
|
||||
*b = 0x10;
|
||||
}
|
||||
}
|
||||
b'C' => {
|
||||
// Scrambled: non-zero payload, no 0x47 at the sync position.
|
||||
for b in &mut u[off + 4..off + pkt] {
|
||||
*b = 0x5A;
|
||||
}
|
||||
}
|
||||
_ => {} // b'P' → leave zero
|
||||
}
|
||||
}
|
||||
u
|
||||
};
|
||||
|
||||
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
|
||||
// All-zero unit (CPI clear) → not encrypted → false.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
|
||||
// Fully decrypted (all packets carry their sync) → false.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
|
||||
// Fully ciphertext (no packet carries its sync) → true.
|
||||
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
|
||||
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
|
||||
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
|
||||
// skips the zero padding and sees every non-zero packet has its sync.
|
||||
let mut tail = [b'P'; 32];
|
||||
for k in tail.iter_mut().take(11) {
|
||||
*k = b'S';
|
||||
}
|
||||
assert!(
|
||||
!aacs_unit_still_ciphertext(&build(true, &tail)),
|
||||
"a decrypted short padding-tail must NOT be flagged as ciphertext"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+75
-55
@@ -180,14 +180,16 @@ impl DecryptKeys {
|
||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||
/// Never produces silently corrupted output.
|
||||
///
|
||||
/// On success returns the number of bytes belonging to scrambled AACS units
|
||||
/// that **no available key could decrypt** — those units are restored to their
|
||||
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
|
||||
/// genuine encrypted content this is silent data loss the downstream TS
|
||||
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
|
||||
/// count into the mux loss accounting so a partial key failure can't be reported
|
||||
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
|
||||
/// scrambled unit decrypted.
|
||||
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
|
||||
/// plaintext is left as-is — this function applies NO policy (it never restores
|
||||
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
|
||||
/// belonging to units a key was applied to but that did NOT reassemble to clean
|
||||
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
|
||||
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
|
||||
/// decrypt content whose encoding is broken. The caller decides what an
|
||||
/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch
|
||||
/// verify path recovers a key and retries, or fails the read. `0` for `None` /
|
||||
/// `Css` and for any AACS buffer where every unit reached clean TS.
|
||||
pub fn decrypt_sectors(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
@@ -320,27 +322,31 @@ fn decrypt_sectors_impl(
|
||||
// accounting so a partial key failure isn't reported as a clean rip.
|
||||
let dropped_bytes = AtomicUsize::new(0);
|
||||
|
||||
// Per-unit decrypt closure. For a scrambled full aligned unit:
|
||||
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
|
||||
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||
// common case where a disc run uses one CPS unit throughout).
|
||||
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
||||
// 3. Accept the first key whose output passes the TS-sync verify.
|
||||
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
|
||||
// genuine decrypt failure). See test
|
||||
// `nav_file_unit_survives_decrypt_attempt`.
|
||||
// 3. Select the first key whose output passes the TS-sync verify.
|
||||
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
|
||||
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
|
||||
// tally the unit as unverified. Never restore ciphertext / null.
|
||||
// Nav protection is the caller's content gate, not a restore here.
|
||||
//
|
||||
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
||||
// must happen first — it's a shared layer on top that is key-independent
|
||||
// across all CPS units on the disc.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
// Gate on `aacs_unit_needs_decrypt` (CPI set AND TS syncs not yet
|
||||
// restored): CPI alone isn't enough because the plaintext seed keeps
|
||||
// the CPI bit set after decryption, so an already-decrypted unit would
|
||||
// be decrypted a SECOND time (scrambling it) on any re-run of this
|
||||
// pass. The intact-TS half makes it idempotent.
|
||||
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
return;
|
||||
}
|
||||
// Save original bytes so we can restore if no key validates.
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
|
||||
// Build a bus-decrypted copy to try unit keys against, or work
|
||||
// in-place when there is no bus layer.
|
||||
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
|
||||
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
|
||||
if let Some(ref rdk_key) = rdk {
|
||||
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||
}
|
||||
@@ -351,6 +357,17 @@ fn decrypt_sectors_impl(
|
||||
let try_order =
|
||||
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
|
||||
|
||||
// DECRYPT the unit — apply a key, leave the plaintext. "Did a key
|
||||
// produce clean TS?" is NOT "did we decrypt?": a correct key can
|
||||
// decrypt content whose underlying encoding is broken (bad TS sync),
|
||||
// which is a MUXER concern, never a decrypt verdict. Clean TS is used
|
||||
// ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first
|
||||
// key that yields clean TS is the definite match. When none does we
|
||||
// STILL decrypted (the cached-hint key is applied): keep those bytes
|
||||
// and report the unit as UNVERIFIED. This function applies no policy;
|
||||
// the caller decides what an unverified unit means (the mux passes it
|
||||
// to the muxer; sweep/patch treat it as a read to recover or fail).
|
||||
let mut applied: Option<Vec<u8>> = None;
|
||||
for idx in try_order {
|
||||
if let Some(key) = raw_keys.get(idx) {
|
||||
// Work on a per-key copy so a failing attempt doesn't
|
||||
@@ -361,17 +378,19 @@ fn decrypt_sectors_impl(
|
||||
last_key_idx.store(idx, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if applied.is_none() {
|
||||
applied = Some(attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No key validated — restore the original encrypted bytes and
|
||||
// tally the loss. The unit is flagged encrypted (we only reach
|
||||
// here past the CPI gate) but no key applied: genuine encrypted
|
||||
// content with a missing/wrong sub-key. We always tally; the mux
|
||||
// read path treats
|
||||
// the count as loss (its extents are real content), while
|
||||
// metadata-probe callers that don't install a loss sink ignore it.
|
||||
chunk.copy_from_slice(&original);
|
||||
// No key yielded clean TS. Keep the applied-key plaintext (the pool is
|
||||
// non-empty past the guard, so `applied` is always `Some`) and tally
|
||||
// the unit as unverified. Never restore ciphertext; that is a caller
|
||||
// concern, threaded through the recovery ciphertext, not this seam.
|
||||
if let Some(decrypted) = applied {
|
||||
chunk.copy_from_slice(&decrypted);
|
||||
}
|
||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
};
|
||||
|
||||
@@ -442,13 +461,15 @@ fn decrypt_sectors_impl(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
|
||||
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
|
||||
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
|
||||
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
|
||||
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
|
||||
/// content-gated, and every whole-disc caller passes the encrypted-content
|
||||
/// extents so nav LBAs are skipped entirely and left untouched.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
fn nav_file_unit_survives_when_gated_out_of_content() {
|
||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
unit[0] = b'M';
|
||||
unit[1] = b'P';
|
||||
@@ -463,10 +484,12 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
|
||||
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
|
||||
// so this nav unit is OUTSIDE content and the gate skips it untouched.
|
||||
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
unit, snapshot,
|
||||
"non-m2ts unit must be restored after failed decrypt"
|
||||
"a nav unit outside the content extents must be left untouched by the gate"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1290,21 +1313,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
|
||||
/// unit that NO supplied key can decrypt is restored to its original
|
||||
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
|
||||
/// returns the unit's byte length as the dropped count. Before the fix this
|
||||
/// returned `()` and the still-encrypted bytes flowed downstream to be
|
||||
/// silently dropped by the TS assembler with zero loss accounting — a rip
|
||||
/// missing real content reported `lost_video_secs=0` and passed the abort
|
||||
/// gate even under `abort_on_lost_secs=0`.
|
||||
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
|
||||
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
|
||||
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
|
||||
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
|
||||
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
|
||||
/// muxer). This is the single decrypt authority applying no policy.
|
||||
///
|
||||
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
|
||||
/// no-key-validated restore path; the function returns that tally.
|
||||
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
|
||||
/// this fails.
|
||||
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
|
||||
/// the removal of the `copy_from_slice(&original)` restore.
|
||||
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
|
||||
#[test]
|
||||
fn aacs_undecryptable_unit_reports_dropped_bytes() {
|
||||
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
||||
|
||||
@@ -1322,17 +1342,17 @@ mod tests {
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = unit;
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
|
||||
.expect("undecryptable unit is not a hard error");
|
||||
let unverified =
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
unverified,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"the whole scrambled unit must be reported as dropped when no key validates"
|
||||
"a unit that did not reach clean TS is reported unverified"
|
||||
);
|
||||
assert_eq!(
|
||||
assert_ne!(
|
||||
buf, ciphertext,
|
||||
"an undecryptable unit must be restored to its original ciphertext, not garbled"
|
||||
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1369,16 +1389,16 @@ mod tests {
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"exactly one unit's worth of bytes must be reported dropped"
|
||||
"exactly one unit's worth of bytes must be reported unverified"
|
||||
);
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
|
||||
"the decryptable unit must come out clear"
|
||||
);
|
||||
assert_eq!(
|
||||
assert_ne!(
|
||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||
&unit_b_ciphertext[..],
|
||||
"the undecryptable unit must be restored to ciphertext"
|
||||
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -237,10 +237,11 @@ impl DiscStream {
|
||||
|
||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
||||
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
|
||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
||||
// decode/mux stream (live-drive single-pass / direct), never the
|
||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
||||
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||
let mut reader =
|
||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
||||
|
||||
|
||||
+11
-17
@@ -645,26 +645,20 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||
_ => 1,
|
||||
};
|
||||
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
|
||||
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
|
||||
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
|
||||
// decorator; only this mux pipeline opts in.
|
||||
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
|
||||
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
|
||||
// muxer's problem, not a decrypt failure, so the mux never conceals, re-fetches
|
||||
// a key, or counts it as loss — it fails only when it genuinely can't decrypt
|
||||
// (no key / misaligned unit). The `fetch` key-recovery seam is a rip/verify
|
||||
// concern (Disc::sweep / Disc::patch), deliberately NOT installed on the mux:
|
||||
// key recovery happens up front, and the mux never re-asks mid-stream.
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
|
||||
.tolerate_decrypt_loss();
|
||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
||||
// decrypts is re-tried via the application's key source before being counted
|
||||
// as loss. An AACS 2.1 forensic-segment unit that no key opens is just an
|
||||
// undecryptable unit like any other: concealed and counted as decrypt loss —
|
||||
// a loss is a loss, no FMTS special casing.
|
||||
if let Some(cb) = fetch {
|
||||
decrypting = decrypting.with_key_fetch(cb);
|
||||
}
|
||||
// Grab the loss counters before the decorator is moved into the producer
|
||||
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
|
||||
// silent loss the demux drops; the consuming stream surfaces it through
|
||||
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
|
||||
// than a clean rip. Forensic (2.1) undecryptable units land here too.
|
||||
let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it
|
||||
// Loss counter: the mux does not tally broken-TS units (the muxer handles them),
|
||||
// so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the
|
||||
// abort gate, which now reflects only a genuine can't-decrypt.
|
||||
let decrypt_loss = decrypting.decrypt_loss();
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
||||
|
||||
+103
-154
@@ -87,15 +87,14 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
///
|
||||
/// [`set_unit_base`]: Self::set_unit_base
|
||||
unit_base: u32,
|
||||
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
|
||||
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
|
||||
/// clear nav-file is never corrupted). On the mux read path
|
||||
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
|
||||
/// here (not silently dropped); on the rip path the read fails loud for
|
||||
/// re-read. Either way this counter is the loss signal: mux read paths fold it
|
||||
/// into their accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
|
||||
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
|
||||
/// producer thread and the consuming `Stream` see the same tally.
|
||||
/// Cumulative bytes of AACS units that did not reassemble to clean TS on the
|
||||
/// VERIFY paths (sweep / patch / --no-raw verify). `decrypt_sectors` is a pure
|
||||
/// decrypt (it leaves the applied-key plaintext and never restores/nulls), so
|
||||
/// this counter is populated only on the fail-loud verify path — the mux path
|
||||
/// returns pass-through before tallying, treating broken TS as a muxer concern
|
||||
/// rather than decrypt loss. Where it is populated it feeds [`decrypt_loss`]
|
||||
/// so a partial verify failure can't be reported as a perfect rip. Shared `Arc`
|
||||
/// so the highway's producer thread and the consuming `Stream` see one tally.
|
||||
///
|
||||
/// [`decrypt_loss`]: Self::decrypt_loss
|
||||
decrypt_dropped: Arc<AtomicU64>,
|
||||
@@ -125,16 +124,16 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
|
||||
/// allocation on the sweep's hot path. Grown on demand, never shrunk.
|
||||
scratch: Vec<u8>,
|
||||
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
|
||||
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
|
||||
/// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into
|
||||
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
|
||||
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
|
||||
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
|
||||
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
|
||||
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
|
||||
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
|
||||
/// way — fail-loud re-reads it, conceal replaces it with null packets.
|
||||
/// MUX pass-through switch (read > decrypt > mux). When `true`, every
|
||||
/// encrypted unit is decrypted in place and the bytes pass straight to the
|
||||
/// muxer — a unit that decrypts to broken TS is the muxer's concern, so the mux
|
||||
/// never conceals, re-fetches a key, or counts it as loss, and the read returns
|
||||
/// `Ok` (it can never abort over a bad-encoded unit). It hard-fails only on a
|
||||
/// genuine can't-decrypt (no key / misaligned unit), which surfaces as `Err`.
|
||||
/// This is "decrypt-verify is a RIP gate, not a MUX gate": the rip/verify path
|
||||
/// leaves this `false` (default) and fails loud via [`DECRYPT_VERIFY_READ`] so
|
||||
/// its read-error recovery re-reads the disc. Ciphertext is NEVER passed
|
||||
/// downstream either way — with a keyed disc every unit has a key applied.
|
||||
///
|
||||
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
|
||||
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
|
||||
@@ -165,11 +164,11 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
|
||||
/// (filled with NULL TS packets), tallied, logged loud, and the read still
|
||||
/// succeeds — the mux never aborts over it. See
|
||||
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
|
||||
/// NOT set this (it relies on fail-loud read-error recovery).
|
||||
/// Opt into the MUX pass-through policy (read > decrypt > mux): decrypt every
|
||||
/// unit in place and pass the bytes to the muxer, never conceal / re-fetch /
|
||||
/// count broken TS as loss; fail loud only on a genuine can't-decrypt. See the
|
||||
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss) field. The rip/verify
|
||||
/// path must NOT set this (it relies on fail-loud read-error recovery).
|
||||
pub fn tolerate_decrypt_loss(mut self) -> Self {
|
||||
self.tolerate_decrypt_loss = true;
|
||||
self
|
||||
@@ -428,6 +427,29 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// recovery closure self-limits (its budget lives in its captures), so the
|
||||
// decorator simply calls it whenever there is a miss.
|
||||
let unit_key_idx = self.unit_key_idx;
|
||||
|
||||
// ── MUX: read > decrypt > mux ──────────────────────────────────────────
|
||||
// `tolerate_decrypt_loss` (and not verify_only) is the mux path: it is fed
|
||||
// already-captured data and reads only content extents. `decrypt_buf`
|
||||
// applies the CPS unit key to every encrypted unit IN PLACE; the resulting
|
||||
// bytes — clean MPEG-TS or a bad-encoded region — belong to the muxer, which
|
||||
// drops broken packets and resyncs. TS validity is NOT a decrypt verdict, so
|
||||
// the mux never conceals, never re-fetches a key, and never counts broken TS
|
||||
// as loss. Its ONLY failure is a genuine can't-decrypt (no key for a unit, or
|
||||
// a misaligned unit), which `decrypt_buf` surfaces as `Err` — propagate it
|
||||
// (fail loud), because a mux over captured data must otherwise always succeed.
|
||||
if self.tolerate_decrypt_loss && !self.verify_only {
|
||||
Self::decrypt_buf(
|
||||
&mut buf[..n],
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
lba,
|
||||
content_ref,
|
||||
)?;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
// ── SWEEP / PATCH / VERIFY: decrypt to verify the disc read ─────────────
|
||||
// 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`
|
||||
@@ -468,12 +490,23 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut scratch, &mut self.keys, &rctx)
|
||||
// Target = the decrypted scratch; ciphertext = the untouched
|
||||
// `buf` (verify-only keeps the ISO encrypted).
|
||||
r(&mut scratch, &buf[..n], &mut self.keys, &rctx)
|
||||
}
|
||||
};
|
||||
self.scratch = scratch;
|
||||
o
|
||||
} else {
|
||||
// In-place decrypt (decrypt-sweep / decrypt-patch). Pure decrypt
|
||||
// overwrites `buf` with plaintext, so keep the on-disc ciphertext for
|
||||
// the recovery retry BEFORE decrypting — but only when recovery is
|
||||
// installed (the retry's sole consumer).
|
||||
let ciphertext: Option<Vec<u8>> = if self.recovery.is_some() {
|
||||
Some(buf[..n].to_vec())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let d = Self::decrypt_buf(
|
||||
&mut buf[..n],
|
||||
&mut self.keys,
|
||||
@@ -490,7 +523,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut buf[..n], &mut self.keys, &rctx)
|
||||
let cipher = ciphertext
|
||||
.as_deref()
|
||||
.expect("recovery installed → captured");
|
||||
r(&mut buf[..n], cipher, &mut self.keys, &rctx)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -501,80 +537,9 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
if dropped > 0 {
|
||||
self.decrypt_dropped
|
||||
.fetch_add(dropped as u64, Ordering::Relaxed);
|
||||
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
|
||||
// content unit is NOT a read failure — never abort the mux over it.
|
||||
// Overwrite each still-scrambled in-content unit with valid NULL TS
|
||||
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
|
||||
// packets surface as a CC gap the TS assembler already drops a partial
|
||||
// PES on), tally it (done above), log it LOUD with the LBA, and return
|
||||
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
|
||||
// rip stays fail-loud. Ciphertext is never passed downstream: it is
|
||||
// replaced by null packets, not emitted.
|
||||
if self.tolerate_decrypt_loss && !self.verify_only {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
let mut concealed = 0usize;
|
||||
let mut first_lba = lba;
|
||||
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
|
||||
if chunk.len() < unit_len {
|
||||
continue; // trailing partial can't be a whole scrambled unit
|
||||
}
|
||||
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
|
||||
// the PADDING-AWARE test that matches `decrypt_unit`'s success
|
||||
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
|
||||
// A successfully padding-aware-decrypted content-fragment TAIL
|
||||
// (the 1.2.0 fix: a few real packets + source-zero padding) has
|
||||
// <16 TS syncs, so the majority vote would mis-flag it as
|
||||
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
|
||||
// padding-aware predicate excludes zero-payload (padding) packets
|
||||
// and flags the unit only when a real content packet is still
|
||||
// un-restored ciphertext. In-content gating already happened in
|
||||
// `decrypt_buf`, which restored only failed units to ciphertext;
|
||||
// clear nav and decrypted tails pass through clean.
|
||||
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
|
||||
if concealed == 0 {
|
||||
first_lba =
|
||||
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
|
||||
}
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
concealed += 1;
|
||||
}
|
||||
}
|
||||
if concealed > 0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::decrypt",
|
||||
lba = first_lba,
|
||||
units = concealed,
|
||||
bytes = dropped,
|
||||
"mux: undecryptable content concealed as NULL TS (loss tallied)"
|
||||
);
|
||||
} else {
|
||||
// dropped > 0 (decrypt reported undecryptable bytes) yet the
|
||||
// padding-aware predicate matched NOTHING to conceal — a
|
||||
// contradiction: the only way a genuinely-ciphertext unit passes
|
||||
// `aacs_unit_still_ciphertext` is if every one of its non-zero
|
||||
// packets coincidentally carried a 0x47 (~256^-31). Belt-and-
|
||||
// suspenders so ciphertext can NEVER reach the mux: fall back to
|
||||
// the strict majority-vote predicate and conceal whatever it
|
||||
// flags, loudly. Cryptographically unreachable in practice.
|
||||
let mut forced = 0usize;
|
||||
for chunk in buf[..n].chunks_mut(unit_len) {
|
||||
if chunk.len() == unit_len
|
||||
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
|
||||
{
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
forced += 1;
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::decrypt",
|
||||
lba,
|
||||
bytes = dropped,
|
||||
forced,
|
||||
"mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)"
|
||||
);
|
||||
}
|
||||
return Ok(n);
|
||||
}
|
||||
// The mux path already returned above (read > decrypt > mux); only the
|
||||
// verify callers (sweep / patch / --no-raw verify) reach here. An
|
||||
// unverified unit means this disc read did NOT prove out.
|
||||
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
|
||||
// means this read did NOT truly succeed — it returned ciphertext the
|
||||
// TS assembler would silently drop. Fail the read loud so the caller's
|
||||
@@ -593,10 +558,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// does any held key get it closer to clear TS?) plus the inner
|
||||
// read latency are ground truth about the SOURCE — enough to
|
||||
// classify the failure as source-zeros, marginal-media garbage,
|
||||
// or a clean read no held key opens. In verify-only mode `buf`
|
||||
// is untouched ciphertext (every unit looks scrambled), so the
|
||||
// post-decrypt `scratch` is what distinguishes failed units
|
||||
// (restored to ciphertext) from succeeded ones (now plaintext).
|
||||
// or a clean read no held key opens. In verify-only mode `buf` is
|
||||
// untouched ciphertext, so the post-decrypt `scratch` is what
|
||||
// distinguishes failed units (still TS-destroyed) from succeeded
|
||||
// ones (now clean TS).
|
||||
let diag: &[u8] = if self.verify_only {
|
||||
&self.scratch
|
||||
} else {
|
||||
@@ -1313,13 +1278,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
|
||||
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
|
||||
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
|
||||
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
|
||||
/// This is the inverse of the fail-loud rip path proven directly above.
|
||||
/// MUX (read > decrypt > mux): with `tolerate_decrypt_loss()` an undecryptable
|
||||
/// AACS content unit must NOT fail the read and must NOT be nulled. The best key
|
||||
/// is applied and the (bad) bytes pass through to the muxer; broken TS is a
|
||||
/// muxer concern, so it is NOT counted as decrypt loss. The mux only hard-fails
|
||||
/// on a genuine can't-decrypt (no key at all / misaligned unit).
|
||||
#[test]
|
||||
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
|
||||
fn tolerate_decrypt_loss_passes_undecryptable_unit_through() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16];
|
||||
|
||||
@@ -1369,29 +1334,23 @@ mod tests {
|
||||
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
|
||||
let n = wrapped
|
||||
.read_sectors(0, 6, &mut buf, false)
|
||||
.expect("tolerate_decrypt_loss must conceal, not error");
|
||||
.expect("the mux never aborts on a bad-decrypt unit");
|
||||
assert_eq!(n, 6 * 2048);
|
||||
|
||||
// The undecryptable unit is tallied as loss.
|
||||
// Broken TS is a muxer concern, not decrypt loss — the mux counts none.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"the concealed unit is still counted as loss"
|
||||
0,
|
||||
"the mux does not count broken TS as decrypt loss"
|
||||
);
|
||||
|
||||
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
|
||||
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
|
||||
// Unit 0 is passed through DECRYPTED (the wrong key was applied), NOT
|
||||
// null-TS concealed: it is not the all-0x47/PID-0x1FFF null pattern.
|
||||
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 0;
|
||||
while off + 192 <= unit0.len() {
|
||||
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
|
||||
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
|
||||
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
|
||||
off += 192;
|
||||
}
|
||||
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
|
||||
assert!(
|
||||
!crate::aacs::content::ts_sync_destroyed(unit0),
|
||||
"concealed unit reads as well-formed TS, not scrambled"
|
||||
!all_null,
|
||||
"the undecryptable unit is passed through, never null-TS concealed"
|
||||
);
|
||||
|
||||
// Unit 1 (clear) passed through untouched.
|
||||
@@ -1400,17 +1359,14 @@ mod tests {
|
||||
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
|
||||
}
|
||||
|
||||
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
|
||||
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
|
||||
/// content packets + source-zero padding — the 1.2.0 shape) carries <16 TS
|
||||
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
|
||||
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
|
||||
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
|
||||
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
|
||||
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
|
||||
/// good tail byte-for-byte intact.
|
||||
/// MUX pass-through, mixed buffer: a unit the pool CAN decrypt (a
|
||||
/// content-fragment TAIL — a few real packets + source-zero padding, the 1.2.0
|
||||
/// shape, <16 TS syncs) comes out byte-for-byte correct, and a unit it CANNOT
|
||||
/// (encrypted under an absent key) is passed through best-effort — never
|
||||
/// null-TS filled, never counted as loss. The old path nulled the good tail
|
||||
/// (silent data loss) whenever it shared a buffer with an undecryptable unit.
|
||||
#[test]
|
||||
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
|
||||
fn mux_passes_both_decryptable_and_undecryptable_units_through() {
|
||||
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
|
||||
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
|
||||
|
||||
@@ -1471,38 +1427,31 @@ mod tests {
|
||||
let mut buf = vec![0u8; 6 * 2048];
|
||||
let n = wrapped
|
||||
.read_sectors(0, 6, &mut buf, false)
|
||||
.expect("tolerate_decrypt_loss must conceal, not error");
|
||||
.expect("the mux never aborts on a bad-decrypt unit");
|
||||
assert_eq!(n, 6 * 2048);
|
||||
|
||||
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
|
||||
// Broken TS is a muxer concern — the mux counts no loss.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"exactly one unit (the undecryptable one) is counted as loss"
|
||||
0,
|
||||
"the mux does not count broken TS as decrypt loss"
|
||||
);
|
||||
|
||||
// Unit A → NULL TS (concealed).
|
||||
// Unit A (absent key) → passed through best-effort, NOT null-TS concealed.
|
||||
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 0;
|
||||
while off + 192 <= unit0.len() {
|
||||
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
|
||||
assert_eq!(
|
||||
unit0[off + 6],
|
||||
0xFF,
|
||||
"unit A null packet PID low 0xFF at {off}"
|
||||
);
|
||||
off += 192;
|
||||
}
|
||||
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
|
||||
assert!(
|
||||
!all_null,
|
||||
"the undecryptable unit is passed through, never null-TS concealed"
|
||||
);
|
||||
|
||||
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
|
||||
// overwritten with NULL TS). This is the silent-data-loss the old
|
||||
// majority-vote predicate caused.
|
||||
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact.
|
||||
let unit1 = &buf
|
||||
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert_eq!(
|
||||
unit1,
|
||||
&expected_tail[..],
|
||||
"the decrypted padding-tail unit must be left byte-for-byte intact"
|
||||
"the decryptable padding-tail unit comes out byte-for-byte correct"
|
||||
);
|
||||
// Sanity: its real content packets carry their TS sync; its padding is zero.
|
||||
for p in 0..KEEP {
|
||||
|
||||
+48
-30
@@ -103,25 +103,30 @@ pub struct RecoverCtx {
|
||||
pub prev_dropped: usize,
|
||||
}
|
||||
|
||||
/// A recovery: given a read's still-scrambled `buf` and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
|
||||
/// — the installed recovery decides what to do with the generic keys, so any
|
||||
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
|
||||
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
|
||||
/// state (the AACS dedup set / call budget) lives in the closure's captures with
|
||||
/// no lock; `Send` so it can ride the mux highway's producer thread.
|
||||
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||
/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the
|
||||
/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place
|
||||
/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it.
|
||||
/// `ciphertext` is separate from `target` because a pure decrypt overwrites the
|
||||
/// target with plaintext — the key server still needs the original on-disc bytes,
|
||||
/// and the retry re-decrypts from them. The type names NO encryption scheme; any
|
||||
/// scheme is just a different [`Recover`] the input stream installs. `FnMut` so
|
||||
/// per-recovery state (dedup set / call budget) lives in the closure's captures;
|
||||
/// `Send` so it can ride the mux highway's producer thread.
|
||||
pub type Recover =
|
||||
Box<dyn FnMut(&mut [u8], &[u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||
|
||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the
|
||||
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
|
||||
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
|
||||
/// Returns the post-retry dropped-byte count.
|
||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did
|
||||
/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt.
|
||||
/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the
|
||||
/// post-retry unverified-byte count.
|
||||
fn aacs_fetch_step(
|
||||
dry: &mut HashSet<u64>,
|
||||
calls: &mut usize,
|
||||
fetch: &KeyFetch,
|
||||
buf: &mut [u8],
|
||||
target: &mut [u8],
|
||||
ciphertext: &[u8],
|
||||
keys: &mut DecryptKeys,
|
||||
ctx: &RecoverCtx,
|
||||
) -> usize {
|
||||
@@ -130,14 +135,19 @@ fn aacs_fetch_step(
|
||||
return prev_dropped;
|
||||
}
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
|
||||
// on-disc ciphertext no held key could open. A trailing partial unit
|
||||
// Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect
|
||||
// them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened
|
||||
// one is now clean TS and is skipped), but SAMPLE the matching on-disc
|
||||
// `ciphertext` — the exact bytes the key server needs. A trailing partial unit
|
||||
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
|
||||
// correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
for (t, c) in target
|
||||
.chunks_exact(unit_len)
|
||||
.zip(ciphertext.chunks_exact(unit_len))
|
||||
{
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(t) {
|
||||
samples.push(c.to_vec());
|
||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||
break;
|
||||
}
|
||||
@@ -172,10 +182,13 @@ fn aacs_fetch_step(
|
||||
dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
// Retry now that the pool has grown. Reset the target to the on-disc
|
||||
// ciphertext first (a pure decrypt already overwrote it with the failed
|
||||
// plaintext), then re-run the ONE decrypt. A unit that still won't reach clean
|
||||
// TS stays unverified; a retry error must not mask the original count.
|
||||
target.copy_from_slice(ciphertext);
|
||||
redecrypt(
|
||||
buf,
|
||||
target,
|
||||
keys,
|
||||
ctx.unit_key_idx,
|
||||
ctx.lba,
|
||||
@@ -187,7 +200,7 @@ fn aacs_fetch_step(
|
||||
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
||||
/// caller that wants an explicit "give up" recovery has one.
|
||||
pub fn none() -> Recover {
|
||||
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||
Box::new(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||
}
|
||||
|
||||
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
||||
@@ -195,9 +208,9 @@ pub fn none() -> Recover {
|
||||
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||
let mut dry: HashSet<u64> = HashSet::new();
|
||||
let mut calls: usize = 0;
|
||||
Box::new(move |buf, keys, ctx| {
|
||||
Box::new(move |target, ciphertext, keys, ctx| {
|
||||
MissOutcome::loss(aacs_fetch_step(
|
||||
&mut dry, &mut calls, &fetch, buf, keys, ctx,
|
||||
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -244,7 +257,8 @@ mod tests {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
|
||||
let cipher = buf.clone();
|
||||
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
|
||||
assert_eq!(out.dropped, 6144);
|
||||
}
|
||||
|
||||
@@ -266,7 +280,8 @@ mod tests {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let cipher = buf.clone();
|
||||
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
||||
unreachable!()
|
||||
@@ -291,9 +306,11 @@ mod tests {
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_unit(0x44);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let cipher = buf.clone();
|
||||
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
||||
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let cipher2 = buf2.clone();
|
||||
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
@@ -319,7 +336,8 @@ mod tests {
|
||||
// so the decorator can call it unconditionally.
|
||||
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
||||
let mut buf = scrambled_unit(i);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let cipher = buf.clone();
|
||||
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
}
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
|
||||
Reference in New Issue
Block a user