Fix mux concealing decryptable video over a single defective packet
AACS content decryption rejected a whole 6144-byte aligned unit unless EVERY content packet was conformant MPEG-TS. One authored-bad packet (a pressing/encoding defect or an AACS 2.1 forensic-variant frame) made the mux conceal the entire unit as NULL TS — destroying up to 31/32 good packets and tallying them as loss, surfacing as false "corruption" on otherwise-clean discs (observed across two UHD titles). decrypt_unit now asks only "did a key OPEN this unit?" — a padding-aware >=75% supermajority of content packets restoring their 0x47 sync, a gate no wrong key can reach (uniform-AES noise floor) yet one that tolerates a minority of authored-bad packets. Opened units pass through VERBATIM; a non-conforming packet is left for the demuxer to drop on sync-loss and resync past. TS-sync conformance is a muxer concern, never a decryption verdict. The post-read verify/sweep gate now shares the same primitive so it can never disagree with the mux decrypt. Also unify the MVC (Blu-ray 3D) track signals: the mvcC CodecPrivate extension, the BlockAdditionMapping, and each per-frame BlockAdditional all derive from one MVCDecoderConfigurationRecord built once per track, so a malformed dependent-view parameter set can no longer orphan a BlockAddID.
This commit is contained in:
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mux no longer discards good video over a single defective packet.** AACS
|
||||
content decryption judged a 6144-byte aligned unit "undecryptable" unless
|
||||
**every** content packet was conformant MPEG-TS. A single authored-bad packet
|
||||
— a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the
|
||||
mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good
|
||||
packets and tallying them as loss. On discs carrying such packets this
|
||||
surfaced as false "corruption" over large runs of otherwise-perfect video
|
||||
(observed across two UHD titles: ~466 MB concealed, every unit decryptable).
|
||||
The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware
|
||||
**≥75% supermajority** of content packets restoring their `0x47` sync, a gate
|
||||
no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates
|
||||
a minority of authored-bad packets. Opened units pass through **verbatim**; a
|
||||
non-conforming packet is left for the demuxer to drop on sync-loss and resync
|
||||
past — TS-sync conformance is a muxer concern, never a decryption verdict. The
|
||||
read/decrypt path no longer rewrites content bytes. The post-read verify/sweep
|
||||
gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can
|
||||
never disagree with the mux decrypt and never false-marks a defect unit as a
|
||||
bad read.
|
||||
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
|
||||
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
|
||||
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
|
||||
built once per track, so they can no longer diverge. A track is flagged 3D
|
||||
only when that record actually builds — a malformed dependent-view parameter
|
||||
set no longer emits a mapping with no matching record (previously the flag was
|
||||
taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base
|
||||
track's `CodecPrivate` now carries the `mvcC` extension block
|
||||
(`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block − 4) so
|
||||
players and mediainfo detect MVC at the track level.
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
+249
-41
@@ -113,51 +113,68 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
|
||||
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
|
||||
}
|
||||
|
||||
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
|
||||
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
|
||||
/// bytes.
|
||||
/// The one canonical "did a key OPEN this unit?" signal — a padding-aware,
|
||||
/// defect-TOLERANT structural check on the POST-decrypt bytes.
|
||||
///
|
||||
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
|
||||
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
|
||||
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
|
||||
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
|
||||
/// syncs after decrypt, so the majority vote calls it "destroyed" and
|
||||
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
|
||||
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
|
||||
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
|
||||
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
|
||||
/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may
|
||||
/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The
|
||||
/// read/decrypt path is not allowed to reject or conceal a unit just because a
|
||||
/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise
|
||||
/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1
|
||||
/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs
|
||||
/// past. The ONLY thing the decrypt path legitimately needs from TS structure is
|
||||
/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at
|
||||
/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a
|
||||
/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is
|
||||
/// its answer.
|
||||
///
|
||||
/// The correct, padding-aware notion of "still ciphertext", checkable on the
|
||||
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
|
||||
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
|
||||
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
|
||||
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
|
||||
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
|
||||
/// (content) packet carries `0x47`; padding packets are all-zero.
|
||||
/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the
|
||||
/// non-padding content packets carry their `0x47` TS sync. Rationale:
|
||||
/// * WRONG key → AES output is uniform random → each content packet carries
|
||||
/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by
|
||||
/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a
|
||||
/// wrong key can NEVER pass — no silent-corruption hole.
|
||||
/// * RIGHT key → every real content packet decrypts to clear TS. A handful of
|
||||
/// authored-bad packets (pressing defects / variant frames) legitimately
|
||||
/// lack `0x47`, but they are a small minority and MUST NOT reject the unit —
|
||||
/// the decrypted bytes (defects and all) pass through verbatim; the muxer
|
||||
/// handles the bad packets. This is the whole point: correctness of a
|
||||
/// *packet* is not a decryption verdict.
|
||||
///
|
||||
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
|
||||
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
|
||||
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
|
||||
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
|
||||
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
|
||||
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
|
||||
if !aacs_unit_encrypted(unit) {
|
||||
return false;
|
||||
}
|
||||
/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source
|
||||
/// padding (or a NULL packet) and is excluded from the count, so a legitimate
|
||||
/// content-fragment TAIL (a few real packets + source-zero padding) is judged on
|
||||
/// its real packets only. A full content unit reduces to "nearly all 32 synced".
|
||||
pub fn unit_content_decrypted(unit: &[u8]) -> bool {
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let limit = ALIGNED_UNIT_LEN.min(unit.len());
|
||||
let mut content = 0usize;
|
||||
let mut synced = 0usize;
|
||||
let mut off = 0;
|
||||
while off + PKT <= limit {
|
||||
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
|
||||
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
|
||||
// other (content) packet that lacks its TS sync is un-restored ciphertext.
|
||||
// All-zero payload → padding / NULL packet → excluded from the verdict.
|
||||
let payload = &unit[off + 4..off + PKT];
|
||||
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
|
||||
return true;
|
||||
if !payload.iter().all(|&b| b == 0) {
|
||||
content += 1;
|
||||
if unit[off + 4] == TS_SYNC {
|
||||
synced += 1;
|
||||
}
|
||||
}
|
||||
off += PKT;
|
||||
}
|
||||
false
|
||||
// No content packets (all padding) → trivially "opened". Otherwise require a
|
||||
// >=75% supermajority of content packets restored — the wrong-key-proof gate.
|
||||
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
|
||||
@@ -349,20 +366,25 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
|
||||
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).
|
||||
// Restore source-zero padding packets to zero (their decrypted bytes are
|
||||
// garbage from AES-decrypting zeros, but the source WAS zero, so writing the
|
||||
// true source back is faithful — not concealment — and gives the demux a tidy
|
||||
// gap instead of AES noise). Content packets are left EXACTLY as decrypted:
|
||||
// the read/decrypt path never rewrites content, so an authored-bad packet
|
||||
// (no `0x47`) passes through verbatim for the muxer to drop.
|
||||
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
|
||||
let off = p * PKT;
|
||||
if is_pad {
|
||||
let off = p * PKT;
|
||||
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
|
||||
// KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS
|
||||
// structure of a supermajority of content packets)? A wrong key can't; the
|
||||
// right key can even when a few content packets are authored-bad. This is NOT
|
||||
// a per-packet conformance gate — that belongs to the muxer.
|
||||
unit_content_decrypted(unit)
|
||||
}
|
||||
|
||||
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
|
||||
@@ -882,6 +904,192 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
|
||||
//
|
||||
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
|
||||
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
|
||||
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
|
||||
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
|
||||
// that single packet, so the mux concealed ~all of it as NULL and 21/22 good
|
||||
// video packets were destroyed and tallied as loss ("false corruption").
|
||||
// The read/decrypt path must instead ACCEPT the unit (the key opened it) and
|
||||
// pass the defective packet through VERBATIM for the muxer to drop. TS-sync
|
||||
// conformance is a muxer concern, never a decryption verdict.
|
||||
|
||||
/// Build a unit that decrypts to 32 content packets, with `defect_pkts`
|
||||
/// marked authored-bad (a non-`0x47` at the sync position + non-zero payload
|
||||
/// so they count as genuine content, not padding), encrypted under `key`.
|
||||
fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec<u8> {
|
||||
let mut unit = clear_unit();
|
||||
for &p in defect_pkts {
|
||||
let off = p * BD_SOURCE_PACKET_BYTES;
|
||||
unit[off + 4] = 0x80; // NOT a TS sync
|
||||
unit[off + 5] = 0xAB; // non-zero payload => real content, not padding
|
||||
}
|
||||
aacs_encrypt_unit(&mut unit, key);
|
||||
unit
|
||||
}
|
||||
|
||||
/// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of
|
||||
/// them carrying `0x47`; the remaining packets are source-zero padding. CPI
|
||||
/// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates.
|
||||
fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec<u8> {
|
||||
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
for p in 0..content {
|
||||
let off = p * BD_SOURCE_PACKET_BYTES;
|
||||
u[off + 5] = 0xAB; // non-zero payload => counted as content
|
||||
u[off + 4] = if p < synced { TS_SYNC } else { 0x80 };
|
||||
}
|
||||
if cpi {
|
||||
u[0] |= 0xC0;
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() {
|
||||
// 1 defective content packet in an otherwise-perfect unit (the real case).
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = unit_with_defects(&key, &[17]);
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &key),
|
||||
"31/32 content packets synced -> the key OPENED the unit"
|
||||
);
|
||||
let off = 17 * BD_SOURCE_PACKET_BYTES;
|
||||
assert_eq!(
|
||||
unit[off + 4],
|
||||
0x80,
|
||||
"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;
|
||||
}
|
||||
assert_eq!(
|
||||
unit[p * BD_SOURCE_PACKET_BYTES + 4],
|
||||
TS_SYNC,
|
||||
"every other packet restored its sync (pkt {p})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_defect_packets_within_tolerance_still_decrypt() {
|
||||
// Up to 25% authored-bad content packets are tolerated (opened + passed
|
||||
// through); the muxer drops them.
|
||||
let key = [0x33u8; 16];
|
||||
let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16%
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &key),
|
||||
"27/32 synced (>=75%) -> opened"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_never_opens_a_unit() {
|
||||
// A wrong key restores ~0 syncs -> far below the supermajority gate.
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &key);
|
||||
assert!(
|
||||
!decrypt_unit(&mut unit, &[0x22u8; 16]),
|
||||
"wrong key cannot open the unit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_accepts_at_75pct_and_rejects_just_below() {
|
||||
// 24/32 = exactly 75% opens; 23/32 does not.
|
||||
assert!(unit_content_decrypted(&decrypted_shape(32, 24, false)));
|
||||
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]
|
||||
fn defect_count_boundary_via_real_decrypt() {
|
||||
// Pin the 75% gate on the REAL decrypt path (not just the predicate):
|
||||
// 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced =>
|
||||
// does NOT open. Packets 1.. avoid the clear-seed packet 0.
|
||||
let key = [0x77u8; 16];
|
||||
let eight: Vec<usize> = (1..9).collect();
|
||||
let mut u_ok = unit_with_defects(&key, &eight);
|
||||
assert!(
|
||||
decrypt_unit(&mut u_ok, &key),
|
||||
"8 defects (24/32 = 75%) -> opened"
|
||||
);
|
||||
let nine: Vec<usize> = (1..10).collect();
|
||||
let mut u_no = unit_with_defects(&key, &nine);
|
||||
assert!(
|
||||
!decrypt_unit(&mut u_no, &key),
|
||||
"9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_content_units_keep_the_wrong_key_floor() {
|
||||
// Tiny content units are where a coincidental wrong-key sync matters most.
|
||||
// The gate stays strict enough that a single fluke can't "open" them.
|
||||
assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open
|
||||
assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not
|
||||
assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open
|
||||
assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_tail_padding_plus_one_defect_still_decrypts() {
|
||||
// Both tolerances at once: source-zero padding tail EXCLUDED, and the one
|
||||
// authored-bad packet in the real prefix TOLERATED. 20 real packets
|
||||
// (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros.
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = clear_unit();
|
||||
let off = 5 * BD_SOURCE_PACKET_BYTES;
|
||||
unit[off + 4] = 0x80; // defect inside the real content
|
||||
unit[off + 5] = 0xAB;
|
||||
for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() {
|
||||
*b = 0; // source-zero padding tail (packets 20..32)
|
||||
}
|
||||
aacs_encrypt_unit(&mut unit, &key);
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &key),
|
||||
"19/20 real packets synced + zero pad -> opened"
|
||||
);
|
||||
assert_eq!(
|
||||
unit[off + 4],
|
||||
0x80,
|
||||
"the defect packet passes through verbatim"
|
||||
);
|
||||
for p in 20..32 {
|
||||
let o = p * BD_SOURCE_PACKET_BYTES;
|
||||
assert!(
|
||||
unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0),
|
||||
"padding pkt {p} emitted as clean zeros"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_full_unit_is_not_decryptable() {
|
||||
let mut unit = clear_unit();
|
||||
|
||||
+74
-11
@@ -6,8 +6,11 @@
|
||||
//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit
|
||||
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
|
||||
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
|
||||
//! clip-file units, then applies the standards-correct
|
||||
//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs).
|
||||
//! clip-file units, then decrypts each with the SAME primitive the mux/rip path
|
||||
//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real
|
||||
//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" —
|
||||
//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing
|
||||
//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is.
|
||||
//!
|
||||
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
|
||||
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
|
||||
@@ -62,6 +65,15 @@ pub enum ContainerKind {
|
||||
Ps,
|
||||
}
|
||||
|
||||
/// PS-container per-unit decrypt for the verify gate — the analogue of
|
||||
/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps
|
||||
/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check.
|
||||
/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool`
|
||||
/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either.
|
||||
fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool {
|
||||
aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps)
|
||||
}
|
||||
|
||||
/// A clip's on-disc layout: declared file size, its absolute disc extents in
|
||||
/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`;
|
||||
/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the
|
||||
@@ -185,7 +197,9 @@ impl UnitVerifier {
|
||||
})
|
||||
}
|
||||
|
||||
/// The post-decrypt structural check for a clip's container.
|
||||
/// The post-decrypt structural check for a clip's container — used ONLY on the
|
||||
/// CPI-clear branch (a plaintext unit that is structurally clean is
|
||||
/// decryptable-as-is; if not, we return Unknown, never bad).
|
||||
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
|
||||
match self.containers[clip as usize] {
|
||||
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
|
||||
@@ -193,6 +207,21 @@ impl UnitVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-unit DECRYPT for a clip's container — the key-selection primitive
|
||||
/// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be
|
||||
/// the exact same primitive the mux/rip decrypt path uses, so verify and the
|
||||
/// real decrypt never disagree (a unit the mux will happily decrypt must not
|
||||
/// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose
|
||||
/// verdict is defect-TOLERANT (a supermajority of content packets restored) —
|
||||
/// an authored-bad packet or forensic-variant frame no longer false-flags the
|
||||
/// whole unit as undecryptable.
|
||||
fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool {
|
||||
match self.containers[clip as usize] {
|
||||
ContainerKind::Ts => aacs::content::decrypt_unit,
|
||||
ContainerKind::Ps => decrypt_unit_ps,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc
|
||||
/// sector `disc_lba`). Routes each backing sector into its clip-file unit;
|
||||
/// every unit that becomes fully assembled is verified immediately. Returns
|
||||
@@ -215,7 +244,8 @@ impl UnitVerifier {
|
||||
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
|
||||
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
|
||||
let accept = self.accept_for(clip);
|
||||
match self.decryptability(&raw, accept) {
|
||||
let decrypt = self.decrypt_for(clip);
|
||||
match self.decryptability(&raw, accept, decrypt) {
|
||||
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
|
||||
Decryptability::Decryptable | Decryptability::Unknown => {}
|
||||
}
|
||||
@@ -309,6 +339,7 @@ impl UnitVerifier {
|
||||
&mut self,
|
||||
raw: &[u8; ALIGNED_UNIT_LEN],
|
||||
accept: fn(&[u8]) -> bool,
|
||||
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
|
||||
) -> Decryptability {
|
||||
// CPI clear -> the unit is plaintext by spec (no key needed). If it is
|
||||
// structurally clean, it is decryptable-as-is. If it ISN'T, we
|
||||
@@ -327,7 +358,7 @@ impl UnitVerifier {
|
||||
};
|
||||
}
|
||||
// Encrypted: any held key that decrypts to a structurally-clean unit.
|
||||
if self.try_keys(raw, accept) {
|
||||
if self.try_keys(raw, decrypt) {
|
||||
return Decryptability::Decryptable;
|
||||
}
|
||||
// No held key works. Ask the application's key source ONCE for this
|
||||
@@ -350,7 +381,7 @@ impl UnitVerifier {
|
||||
self.fetch_spent = true; // service has nothing new; stop asking
|
||||
return Decryptability::Unknown;
|
||||
}
|
||||
if self.try_keys(raw, accept) {
|
||||
if self.try_keys(raw, decrypt) {
|
||||
return Decryptability::Decryptable;
|
||||
}
|
||||
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
|
||||
@@ -359,12 +390,18 @@ impl UnitVerifier {
|
||||
Decryptability::Unknown
|
||||
}
|
||||
|
||||
/// True if any currently-held key decrypts `raw` to a structurally-clean unit
|
||||
/// under the container's `accept` check.
|
||||
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
|
||||
/// True if any currently-held key OPENS `raw` — i.e. the container's per-unit
|
||||
/// `decrypt` (the same primitive the mux uses) reports the key restored the
|
||||
/// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few
|
||||
/// authored-bad packets still counts as decryptable (never marked bad).
|
||||
fn try_keys(
|
||||
&self,
|
||||
raw: &[u8; ALIGNED_UNIT_LEN],
|
||||
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
|
||||
) -> bool {
|
||||
for k in &self.keys {
|
||||
let mut scratch = *raw;
|
||||
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
|
||||
if decrypt(&mut scratch, k) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -439,9 +476,10 @@ impl UnitVerifier {
|
||||
}
|
||||
}
|
||||
let accept = self.accept_for(clip);
|
||||
let decrypt = self.decrypt_for(clip);
|
||||
if readable
|
||||
&& matches!(
|
||||
self.decryptability(&raw, accept),
|
||||
self.decryptability(&raw, accept, decrypt),
|
||||
Decryptability::Undecryptable
|
||||
)
|
||||
{
|
||||
@@ -898,6 +936,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_defect_packet_unit_is_not_flagged_bad() {
|
||||
// A unit with ONE authored-bad content packet (a pressing defect). The
|
||||
// held key OPENS it (31/32 syncs restored), so verify must AGREE with the
|
||||
// mux decrypt and NOT mark it bad — the bad packet is the muxer's problem,
|
||||
// not a read/decrypt failure. (Old strict all-32 gate flagged it bad.)
|
||||
let key = [0x5a; 16];
|
||||
let mut u = clear_unit();
|
||||
let off = 17 * 192; // corrupt packet 17's sync in the plaintext
|
||||
u[off + 4] = 0x80;
|
||||
u[off + 5] = 0xAB;
|
||||
encrypt_unit(&mut u, &key);
|
||||
let mut iso = MockIso {
|
||||
sectors: Default::default(),
|
||||
err_lba: None,
|
||||
};
|
||||
place_unit(&mut iso, [100, 101, 102], &u);
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
|
||||
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
|
||||
assert!(
|
||||
bad.is_empty(),
|
||||
"defect-packet unit is opened by the held key -> not flagged bad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_fragmented_unit_reads_distant_sectors() {
|
||||
let key = [0x5a; 16];
|
||||
|
||||
+119
-8
@@ -121,6 +121,29 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
|
||||
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
|
||||
/// block, per the Matroska Codec Specifications §4.3.9:
|
||||
///
|
||||
/// ```text
|
||||
/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
|
||||
/// ```
|
||||
///
|
||||
/// The size field is the extension block length **excluding the 4-byte size
|
||||
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
|
||||
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
|
||||
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
|
||||
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
|
||||
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
|
||||
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
|
||||
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
|
||||
out.extend_from_slice(avcc);
|
||||
out.extend_from_slice(&ext_size.to_be_bytes());
|
||||
out.extend_from_slice(b"mvcC");
|
||||
out.extend_from_slice(record);
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
||||
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
||||
@@ -882,9 +905,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||
std::collections::HashMap::new();
|
||||
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
|
||||
// Filled below from the SAME built record that drives the CodecPrivate
|
||||
// mvcC extension, so the three MVC signals never diverge.
|
||||
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||
track_uids.push(track_uid);
|
||||
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
|
||||
// track from the dependent view's subset-SPS/PPS. `None` for every
|
||||
// non-3D track (and if the params are malformed) — the single source
|
||||
// of truth for the CodecPrivate mvcC extension, the
|
||||
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
|
||||
let mvc_record = track
|
||||
.mvc_params
|
||||
.as_ref()
|
||||
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
|
||||
track_has_mvc_mapping.push(mvc_record.is_some());
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||
@@ -904,7 +941,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
}
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
match mvc_record.as_ref() {
|
||||
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
|
||||
// followed by the `mvcC` extension block. This is the
|
||||
// track-level signal decoders/mediainfo read to recognise the
|
||||
// stereoscopic MVC track (the per-frame dependent view rides
|
||||
// in BlockAdditional under the mapping below).
|
||||
Some(record) => {
|
||||
let cp_mvc = mvc_codec_private(cp, record);
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
|
||||
}
|
||||
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
|
||||
// the unchanged path, byte-identical to a 2D mux.
|
||||
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
|
||||
}
|
||||
}
|
||||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||
// (placeholder reserve + later seek-back fill via
|
||||
@@ -1004,8 +1054,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
|
||||
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
|
||||
match mvc_record.as_ref() {
|
||||
Some(record) => {
|
||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
@@ -1013,18 +1063,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
BLOCK_ADD_ID_VALUE_MVC,
|
||||
)?;
|
||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
|
||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
|
||||
ebml::end_master(&mut writer, map_pos)?;
|
||||
} else {
|
||||
}
|
||||
// `mvc_params` present but the record failed to build (malformed
|
||||
// parameter sets): no mapping, and `track_has_mvc_mapping` above
|
||||
// is already `false`, so BlockAdditionals are dropped — the file
|
||||
// stays conforming rather than carrying an orphaned BlockAddID.
|
||||
None if track.mvc_params.is_some() => {
|
||||
let (s, p) = track.mvc_params.as_ref().unwrap();
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||||
subset_sps.len(),
|
||||
pps.len(),
|
||||
s.len(),
|
||||
p.len(),
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||
@@ -1117,7 +1174,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
primary_video_track: tracks
|
||||
.iter()
|
||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
|
||||
track_has_mvc_mapping,
|
||||
continuity: TimelineContinuity::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
@@ -4600,6 +4657,60 @@ mod tests {
|
||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_codec_private_appends_mvcc_extension_block() {
|
||||
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
|
||||
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
|
||||
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
|
||||
let out = mvc_codec_private(&avcc, &record);
|
||||
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
|
||||
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
|
||||
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
|
||||
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
|
||||
assert_eq!(
|
||||
&out[avcc.len() + 8..],
|
||||
&record[..],
|
||||
"record after the mvcC fourcc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
|
||||
// An MVC base track's CodecPrivate must be the base avcC followed by the
|
||||
// mvcC extension — the track-level signal mediainfo/decoders read.
|
||||
let avcc = vec![
|
||||
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
|
||||
];
|
||||
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
|
||||
let pps = vec![0x68, 0xEE, 0x3C];
|
||||
let mut v = make_video_track();
|
||||
v.codec_private = Some(avcc.clone());
|
||||
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
|
||||
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
|
||||
.unwrap()
|
||||
.writer
|
||||
.into_inner();
|
||||
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
|
||||
let expected = mvc_codec_private(&avcc, &record);
|
||||
assert!(
|
||||
data.windows(expected.len())
|
||||
.any(|w| w == expected.as_slice()),
|
||||
"emitted CodecPrivate must be avcC + mvcC extension"
|
||||
);
|
||||
|
||||
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
|
||||
let mut v2 = make_video_track();
|
||||
v2.codec_private = Some(avcc.clone());
|
||||
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
|
||||
.unwrap()
|
||||
.writer
|
||||
.into_inner();
|
||||
assert!(
|
||||
!d2.windows(4).any(|w| w == b"mvcC"),
|
||||
"2D track CodecPrivate must not carry an mvcC extension"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||
//
|
||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||
|
||||
Reference in New Issue
Block a user