diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 8b9d66cd..3a92f024 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -42,37 +42,33 @@ const TS_SYNC: u8 = 0x47; // ── Content decryption ────────────────────────────────────────────────────── -// HD-DVD `.evo` AACS-encrypted-unit flag: PES_scrambling_control at offset 20 (bits 5-4), -// spec-derived but UNVERIFIED. -const PS_SCRAMBLE_OFF: usize = 20; -const PS_SCRAMBLE_MASK: u8 = 0x30; - /// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal, per /// container: for `BdTs` the Copy Permission Indicator in the top 2 bits of /// byte 0 (`[BD]` §3.10.2, always clear in the unencrypted 16-byte seed — -/// `(buf[0] & 0xC0) == 0` → clear); for `MpegPs` (HD-DVD `.evo`) the -/// `PES_scrambling_control` flag, see [`PS_SCRAMBLE_OFF`] (UNVERIFIED against -/// a real encrypted disc). +/// `(buf[0] & 0xC0) == 0` → clear); for `MpegPs` (HD-DVD `.evo`) whether any +/// pack of the slice carries `PES_scrambling_control` `01` (`[HD]` §4.3.2: HD DVD +/// encrypts per pack, so every pack is judged, not the first). /// /// Readable WITHOUT a key. Only meaningful when `unit` is read at the correct /// clip-FILE-anchored boundary. pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool { use crate::disc::ContentFormat; - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } match format { - ContentFormat::BdTs => (unit[0] & 0xC0) != 0, - // UNVERIFIED-HDDVD-DECRYPT (1 of 2): PES_scrambling_control location for - // HD-DVD `.evo` is spec-derived, never confirmed against a real encrypted - // disc (only decrypted rips available). Verify against a real one if ripping misbehaves. - ContentFormat::MpegPs => (unit[PS_SCRAMBLE_OFF] & PS_SCRAMBLE_MASK) != 0, + ContentFormat::BdTs => unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0, + ContentFormat::MpegPs => any_pack_scrambled(unit), } } +// Whether any pack of `buf` (a trailing partial pack included) is flagged scrambled. +fn any_pack_scrambled(buf: &[u8]) -> bool { + use super::hddvd::{PackKind, classify}; + buf.chunks(SECTOR_BYTES) + .any(|p| classify(p) == PackKind::Scrambled) +} + /// The AACS encrypted flag from an aligned unit's CLEAR seed, readable even on a /// trailing PARTIAL unit (unlike [`aacs_unit_encrypted`], which requires a whole -/// 6144-byte unit). The flag lives at a fixed low offset in the clear header, so a +/// 6144-byte unit for `BdTs`). The flag lives at a fixed low offset in the clear header, so a /// fragment that still contains that byte can be classified. Used to catch an /// encrypted unit truncated across a buffer/extent boundary — a fragment we cannot /// CBC-decrypt and must not emit as clear. `false` for a slice too short to hold @@ -81,9 +77,7 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) use crate::disc::ContentFormat; match format { ContentFormat::BdTs => unit.first().is_some_and(|b| b & 0xC0 != 0), - ContentFormat::MpegPs => unit - .get(PS_SCRAMBLE_OFF) - .is_some_and(|b| b & PS_SCRAMBLE_MASK != 0), + ContentFormat::MpegPs => any_pack_scrambled(unit), } } @@ -207,24 +201,14 @@ pub fn ts_packet_total(unit: &[u8]) -> usize { unit.len() / BD_SOURCE_PACKET_BYTES } -// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): every 2048-byte pack in the 6144-byte -// unit must start with `00 00 01 BA`; UNVERIFIED against a real encrypted disc. +// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): no pack still flagged scrambled and +// none failing its payload check past the clear head (`hddvd::payload_check`). The pack start +// code proves nothing: an encrypted pack keeps it in its clear 128-byte head (`[HD]` §4.3.2). fn is_clean_ps(unit: &[u8]) -> bool { - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } - const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; - // Skip pack 0: its start bytes lie in the clear 16-byte seed, present for ANY - // key, so it proves nothing (mirrors `is_clean_ts` skipping packet 0). Packs 1 - // and 2 (offsets 2048/4096) are in the encrypted region and discriminate the key. - let mut o = SECTOR_BYTES; - while o + 4 <= ALIGNED_UNIT_LEN { - if unit[o..o + 4] != PACK_START { - return false; - } - o += SECTOR_BYTES; // one MPEG-2 PS pack per 2048-byte sector - } - true + !any_pack_scrambled(unit) + && unit.as_chunks::().0.iter().all(|p| { + p[..4] == [0x00, 0x00, 0x01, 0xBA] && super::hddvd::payload_check(p) != Some(false) + }) } /// Decrypt one AACS aligned unit (6144 bytes) IN PLACE — PURE crypto that @@ -883,15 +867,12 @@ mod tests { #[test] fn is_clean_dispatches_by_container_format() { + use crate::aacs::hddvd::tests::{audio_pack, cpi, video_pack}; + use crate::aacs::hddvd::{decrypt_pack, encrypt_pack}; use crate::disc::ContentFormat; - // Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA` at - // each 2048-byte pack boundary (0/2048/4096), validated against real retail rips. - // Offset 0 is the clear-seed freebie; packs at 2048/4096 are what discriminate a key. - let mut ps = vec![0u8; ALIGNED_UNIT_LEN]; - for off in [0usize, 2048, 4096] { - ps[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); - ps[off + 4] = 0x44; // '01': a 13818-1 pack - } + let kt = [0x24u8; 16]; + let c = cpi(1, 9); + let ps: Vec = [audio_pack(1), video_pack(2), audio_pack(3)].concat(); assert!( is_clean(&ps, ContentFormat::MpegPs), "clean PS opens for MpegPs" @@ -901,11 +882,14 @@ mod tests { "PS content has no 0x47 TS syncs -> not clean as TS" ); - // Wrong key garbles the ENCRYPTED packs (2048/4096); offset 0 stays a pack - // start (it is the clear seed) but that alone proves nothing -> rejected. + // A wrong key clears the flags but garbles bytes 128.. of every pack: rejected, + // though every pack still starts `00 00 01 BA` (it is in the clear head). let mut wrong = ps.clone(); - wrong[2048] = 0xFF; - wrong[4096] = 0xFF; + for p in wrong.chunks_mut(SECTOR_BYTES) { + encrypt_pack(p, &kt, &c); + decrypt_pack(p, &[0x25; 16], &c); + assert_eq!(p[..4], [0x00, 0x00, 0x01, 0xBA]); + } assert!( !is_clean(&wrong, ContentFormat::MpegPs), "garbled encrypted packs -> wrong key rejected" @@ -924,49 +908,48 @@ mod tests { } #[test] - fn ps_container_encrypt_detection_and_idempotency() { + fn ps_container_encrypt_detection_is_per_pack_and_idempotent() { + use crate::aacs::hddvd::tests::{audio_pack, cpi, nav_pack, video_pack}; + use crate::aacs::hddvd::{decrypt_pack, encrypt_pack}; use crate::disc::ContentFormat; let ps = ContentFormat::MpegPs; + let kt = [0x24u8; 16]; + let c = cpi(1, 9); - // Base clean PS unit: pack_start_code at each 2048 boundary. - let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; - for off in [0usize, 2048, 4096] { - clear[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); - clear[off + 4] = 0x44; // '01': a 13818-1 pack - } - // PES scrambling_control (byte 20, bits 5-4) == 0 → not encrypted. + // An NV_PCK leads the unit: its byte 20 is the system header's rate_bound, so the + // first pack alone says "clear" while the two packs after it are encrypted. + let clear: Vec = [nav_pack(&c), audio_pack(1), video_pack(2)].concat(); assert!( !aacs_unit_encrypted(&clear, ps), - "scrambling_control clear ⇒ not encrypted" + "no pack flagged => not encrypted" ); - - // Encrypted + still-scrambled: flag set, packs 1/2 garbled (would be - // ciphertext on a real disc) → is_clean_ps false → needs decrypt. let mut enc = clear.clone(); - enc[PS_SCRAMBLE_OFF] |= 0x10; // PES_scrambling_control = 01 - enc[2048] = 0xFF; - enc[4096] = 0xFF; + for p in enc.chunks_mut(SECTOR_BYTES).skip(1) { + encrypt_pack(p, &kt, &c); + } + assert_eq!(enc[..SECTOR_BYTES], clear[..SECTOR_BYTES]); assert!( aacs_unit_encrypted(&enc, ps), - "scrambling_control set ⇒ encrypted" - ); - assert!( - aacs_unit_needs_decrypt(&enc, ps), - "flagged + packs not restored ⇒ needs decrypt" + "a flagged later pack => encrypted" ); + assert!(aacs_unit_needs_decrypt(&enc, ps)); + // Only the last pack encrypted, cut short: the fragment still reads encrypted. + let mut tail = clear.clone(); + encrypt_pack(&mut tail[2 * SECTOR_BYTES..], &kt, &c); + assert!(aacs_unit_seed_encrypted(&tail[..2 * SECTOR_BYTES + 21], ps)); + assert!(!aacs_unit_seed_encrypted( + &tail[..2 * SECTOR_BYTES + 20], + ps + )); - // Decrypted: the flag survives (it is in the preserved header) but packs - // are valid → needs_decrypt flips false (idempotent re-decrypt). - let mut dec = clear.clone(); - dec[PS_SCRAMBLE_OFF] |= 0x10; - assert!( - aacs_unit_encrypted(&dec, ps), - "flag survives decryption (header preserved)" - ); - assert!( - !aacs_unit_needs_decrypt(&dec, ps), - "valid packs restored ⇒ no re-decrypt (idempotent)" - ); + // Decrypted: the flags are cleared and the payload checks out (idempotent). + let mut dec = enc.clone(); + for p in dec.chunks_mut(SECTOR_BYTES).skip(1) { + decrypt_pack(p, &kt, &c); + } + assert_eq!(dec, clear); + assert!(!aacs_unit_encrypted(&dec, ps)); + assert!(!aacs_unit_needs_decrypt(&dec, ps)); } #[test] @@ -1543,22 +1526,23 @@ mod tests { assert!(!aacs_unit_seed_encrypted(&[], BdTs)); } - /// The MpegPs (HD-DVD `.evo`) side reads `PES_scrambling_control` at its own - /// fixed offset, and a fragment shorter than that offset must be reported - /// clear rather than panic. + /// The MpegPs (HD-DVD `.evo`) side reads `PES_scrambling_control` from the PES header, + /// and a fragment too short to hold it reads clear rather than panic. #[test] fn aacs_unit_seed_encrypted_reads_the_ps_scramble_flag_or_says_clear() { + use crate::aacs::hddvd::tests::video_pack; use crate::disc::ContentFormat::MpegPs; - let mut frag = vec![0u8; PS_SCRAMBLE_OFF + 1]; - assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte zero"); - frag[PS_SCRAMBLE_OFF] = PS_SCRAMBLE_MASK; - assert!(aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte set"); + let mut frag = video_pack(1)[..21].to_vec(); + assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "flag bits zero"); + frag[20] |= 0x10; + assert!(aacs_unit_seed_encrypted(&frag, MpegPs), "flag bits 01"); // Bits outside the mask are not the scrambling control. - frag[PS_SCRAMBLE_OFF] = !PS_SCRAMBLE_MASK; + frag[20] = 0x80 | 0x0F; assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "outside the mask"); + frag[20] |= 0x10; // A fragment that stops short of the flag byte is not classifiable. - assert!(!aacs_unit_seed_encrypted(&frag[..PS_SCRAMBLE_OFF], MpegPs)); + assert!(!aacs_unit_seed_encrypted(&frag[..20], MpegPs)); } // `aacs_unit_encrypted` requires a WHOLE 6144-byte unit: on anything shorter diff --git a/src/aacs/hddvd.rs b/src/aacs/hddvd.rs new file mode 100644 index 00000000..85050060 --- /dev/null +++ b/src/aacs/hddvd.rs @@ -0,0 +1,393 @@ +//! HD DVD pack encryption: `[HD]` = AACS "HD DVD and DVD Pre-recorded Book", Final Rev 0.952. +//! +//! An EVOB is encrypted per 2048-byte pack (`[HD]` §4.3.2): bytes 0..128 are the Unencrypted +//! Portion, bytes 128..2048 the Encrypted Portion, CBC-encrypted under the AACS IV with the +//! Content Key `Kc = AES-G(Kt, Dtk || CPI_lsb96)`. `Dtk` is pack bytes 84..88 (Table 4-7). +//! `Kt` is the Title Key that `TITLE_KEY_PTR` of the EVOBU's CPI names (Table 4-2), and the CPI +//! rides in the GCI packet of the NV_PCK that leads every EVOBU. A pack is encrypted when its +//! `PES_scrambling_control` is `01` (§4.3.2); an HL_PCK is always encrypted while KEY_VF is +//! `01` or `10` (§4.3.2). NV_PCK and ADV_PCK are never encrypted (§4.3.1). + +use super::crypto::{aes_cbc_decrypt, aes_g}; +use crate::consts::SECTOR_BYTES; + +/// One HD DVD pack: a 2048-byte sector. +pub(crate) const PACK_LEN: usize = SECTOR_BYTES; +/// `[HD]` §4.3.2: "the first 128 bytes are called the Unencrypted Portion". +pub(crate) const CLEAR_LEN: usize = 128; +// `[HD]` Table 4-7: Title Key Data (Dtk) is bytes 84..=87 of the pack. +const DTK_OFF: usize = 84; +const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; +const SYSTEM_HEADER: u8 = 0xBB; +const PRIVATE_STREAM_1: u8 = 0xBD; +const PADDING: u8 = 0xBE; +const PRIVATE_STREAM_2: u8 = 0xBF; +// private_stream_2 sub_stream_ids: the GCI packet of an NV_PCK, and an HL_PCK. +const GCI_SUB_STREAM_ID: u8 = 0x04; +const HLI_SUB_STREAM_ID: u8 = 0x08; +// The CPI is 16 bytes at offset 12 of the GCI data (after its sub_stream_id). The HD DVD-Video +// book that fixes it is not public; measured on encrypted discs (pack offset 60 of an NV_PCK). +const CPI_IN_GCI: usize = 12; +const SCRAMBLE_MASK: u8 = 0x30; + +/// The 16-byte Content Protection Information of an EVOBU (`[HD]` §4.2, Tables 4-1 and 4-2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Cpi(pub(crate) [u8; 16]); + +impl Cpi { + /// `KEY_VF` (Table 4-2): `0b10` the Title Key Pointer is valid, `0b01` the Segment Key + /// Pointer, `0b00` neither. + pub(crate) fn key_vf(&self) -> u8 { + self.0[0] >> 6 + } + + /// `TITLE_KEY_PTR` (Table 4-2): the 1-based Title Key entry of the Title Key File. + pub(crate) fn title_key_ptr(&self) -> u32 { + u32::from(u16::from_be_bytes([self.0[1], self.0[2]])) + } +} + +/// What a pack is, as far as decryption is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PackKind { + /// An NV_PCK, with the CPI of its GCI packet when one parses. + Nav(Option), + /// A PES pack whose `PES_scrambling_control` is not `00`. + Scrambled, + /// An HL_PCK: encrypted whenever its EVOBU's KEY_VF is `01` or `10`. + Highlight, + /// Anything else: never encrypted. + Clear, +} + +// Offset of the first packet after an MPEG-2 pack header (with its stuffing), or `None`. +fn first_packet(pack: &[u8]) -> Option { + if pack.len() < 14 || pack[..4] != PACK_START || pack[4] & 0xC0 != 0x40 { + return None; + } + Some(14 + usize::from(pack[13] & 0x07)) +} + +// Does stream id `sid` carry the optional PES header (with PES_scrambling_control)? +fn has_pes_flags(sid: u8) -> bool { + sid >= PRIVATE_STREAM_1 && !matches!(sid, PADDING | PRIVATE_STREAM_2) +} + +/// Classify one pack. A slice shorter than a pack is judged on the bytes it holds. +pub(crate) fn classify(pack: &[u8]) -> PackKind { + let Some(o) = first_packet(pack) else { + return PackKind::Clear; + }; + if pack.len() < o + 7 || pack[o..o + 3] != [0, 0, 1] { + return PackKind::Clear; + } + match pack[o + 3] { + SYSTEM_HEADER => PackKind::Nav(nav_cpi(pack, o)), + PRIVATE_STREAM_2 if pack[o + 6] == HLI_SUB_STREAM_ID => PackKind::Highlight, + sid if has_pes_flags(sid) && pack[o + 6] & 0xC0 == 0x80 => { + match pack[o + 6] & SCRAMBLE_MASK { + 0 => PackKind::Clear, + _ => PackKind::Scrambled, + } + } + _ => PackKind::Clear, + } +} + +// The CPI of the GCI packet in an NV_PCK whose first packet (the system header) is at `o`. +fn nav_cpi(pack: &[u8], mut q: usize) -> Option { + while q + 7 <= pack.len() && pack[q..q + 3] == [0, 0, 1] { + let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]])); + if pack[q + 3] == PRIVATE_STREAM_2 && pack[q + 6] == GCI_SUB_STREAM_ID { + if len < 1 + CPI_IN_GCI + 16 { + return None; + } + let at = q + 7 + CPI_IN_GCI; + return Some(Cpi(pack.get(at..at + 16)?.try_into().ok()?)); + } + q += 6 + len; + } + None +} + +/// Whether `pack` must be decrypted under an EVOBU whose CPI is `cpi`. +pub(crate) fn needs_key(kind: PackKind, cpi: Option<&Cpi>) -> bool { + match kind { + PackKind::Scrambled => true, + PackKind::Highlight => cpi.is_some_and(|c| matches!(c.key_vf(), 0b01 | 0b10)), + PackKind::Nav(_) | PackKind::Clear => false, + } +} + +/// `[HD]` §4.3.2: `Kc = AES-G(Kt, Dtk || CPI_lsb96)`, `Dtk` = pack bytes 84..88. +pub(crate) fn content_key(title_key: &[u8; 16], pack: &[u8], cpi: &Cpi) -> [u8; 16] { + let mut d = [0u8; 16]; + d[..4].copy_from_slice(&pack[DTK_OFF..DTK_OFF + 4]); + d[4..].copy_from_slice(&cpi.0[4..]); + aes_g(title_key, &d) +} + +/// Decrypt one whole pack in place (`[HD]` §4.3.4 step 3) and mark a PES pack unscrambled. +/// Bytes 0..128 are never touched but for that flag. A short slice is left alone. +pub(crate) fn decrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) { + if pack.len() != PACK_LEN { + return; + } + let kc = content_key(title_key, pack, cpi); + aes_cbc_decrypt(&kc, &mut pack[CLEAR_LEN..]); + clear_scrambling(pack); +} + +// `[HD]` §4.3.2: "Otherwise, the PES_scrambling_control shall be 00₂": true of the plaintext. +fn clear_scrambling(pack: &mut [u8]) { + if let Some(o) = first_packet(pack) + && pack.len() >= o + 7 + && has_pes_flags(pack[o + 3]) + && pack[o + 6] & 0xC0 == 0x80 + { + pack[o + 6] &= !SCRAMBLE_MASK; + } +} + +/// The exact inverse of [`decrypt_pack`] for test fixtures: sets the scrambling flag of a PES +/// pack, then CBC-encrypts bytes 128..2048 (`[HD]` §4.3.2, `Ce = AES-128CBCE(Kc, C)`). +#[cfg(test)] +pub(crate) fn encrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) { + if let Some(o) = first_packet(pack) + && has_pes_flags(pack[o + 3]) + { + pack[o + 6] = (pack[o + 6] & !SCRAMBLE_MASK) | 0x10; + } + let kc = content_key(title_key, pack, cpi); + super::crypto::aes_cbc_encrypt(&kc, &mut pack[CLEAR_LEN..]); +} + +/// Structural proof that a pack's bytes past 128 are plaintext, for key proof and verify. +/// +/// `Some(false)` when a test that random bytes pass about once in 2^16 fails: a PES or padding +/// start code where the chain of packet lengths puts one at or past byte 128, or the AC-3 / +/// E-AC-3 sync word where a `private_stream_1` first-access-unit pointer lands at or past it. +/// `Some(true)` when such a test ran and passed, `None` when none applied (most video packs, +/// whose one PES fills the pack). +pub(crate) fn payload_check(pack: &[u8]) -> Option { + if pack.len() != PACK_LEN { + return None; + } + let mut q = first_packet(pack)?; + let mut tested = false; + while q + 6 <= PACK_LEN { + if pack[q..q + 3] != [0, 0, 1] { + // A broken chain in the clear head proves nothing about the key. + return (q >= CLEAR_LEN).then_some(false).or(tested.then_some(true)); + } + tested |= q >= CLEAR_LEN; + let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]])); + if pack[q + 3] == PRIVATE_STREAM_1 { + match ac3_sync_check(pack, q, len) { + Some(false) => return Some(false), + Some(true) => tested = true, + None => {} + } + } + q += 6 + len; + } + tested.then_some(true) +} + +// The AC-3 / E-AC-3 sync word at the first access unit of a private_stream_1 packet at `q`. +// Sub-stream ids 0x80-0x87 (AC-3) and 0xC0-0xC7 (E-AC-3); the pointer counts from its own +// last byte, as on DVD. +fn ac3_sync_check(pack: &[u8], q: usize, len: usize) -> Option { + if pack.get(q + 6)? & 0xC0 != 0x80 { + return None; + } + let hd = q + 9 + usize::from(*pack.get(q + 8)?); + let sub = *pack.get(hd)?; + if !matches!(sub, 0x80..=0x87 | 0xC0..=0xC7) || hd + 4 > PACK_LEN { + return None; + } + let ptr = usize::from(u16::from_be_bytes([pack[hd + 2], pack[hd + 3]])); + let at = hd + 3 + ptr; + let end = (q + 6 + len).min(PACK_LEN); + (ptr != 0 && at >= CLEAR_LEN && at + 2 <= end).then(|| pack[at..at + 2] == [0x0B, 0x77]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + // A pack header with no stuffing, SCR and mux rate zero. + fn pack_header() -> Vec { + let mut v = PACK_START.to_vec(); + v.extend_from_slice(&[0x44, 0, 0x04, 0, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8]); + v + } + + // A PES packet with an empty optional header, `payload` bytes long. + fn pes(sid: u8, payload: &[u8]) -> Vec { + let mut v = vec![0, 0, 1, sid]; + v.extend_from_slice(&((payload.len() + 3) as u16).to_be_bytes()); + v.extend_from_slice(&[0x80, 0x00, 0x00]); + v.extend_from_slice(payload); + v + } + + // A padding packet filling the pack to 2048 bytes. + fn pad_to_pack(mut v: Vec) -> Vec { + let left = PACK_LEN - v.len() - 6; + v.extend_from_slice(&[0, 0, 1, PADDING]); + v.extend_from_slice(&(left as u16).to_be_bytes()); + v.resize(PACK_LEN, 0xFF); + v + } + + /// A synthetic CPI: KEY_VF `10`, `TITLE_KEY_PTR` = `ptr`, CH_PTR = `ch`. + pub(crate) fn cpi(ptr: u16, ch: u32) -> Cpi { + let mut c = [0u8; 16]; + c[0] = 0x80; + c[1..3].copy_from_slice(&ptr.to_be_bytes()); + c[4..8].copy_from_slice(&ch.to_be_bytes()); + c[8] = 0x80; + Cpi(c) + } + + /// An NV_PCK: system header, then a GCI packet carrying `cpi` at GCI data offset 12. + pub(crate) fn nav_pack(cpi: &Cpi) -> Vec { + let mut v = pack_header(); + v.extend_from_slice(&[0, 0, 1, SYSTEM_HEADER, 0, 21]); + v.extend_from_slice(&[0x80; 21]); + let mut gci = vec![0, 0, 1, PRIVATE_STREAM_2, 0x01, 0x01, GCI_SUB_STREAM_ID]; + gci.extend_from_slice(&[0x11; CPI_IN_GCI]); + gci.extend_from_slice(&cpi.0); + gci.resize(6 + 0x101, 0); + v.extend_from_slice(&gci); + pad_to_pack(v) + } + + /// A plaintext E-AC-3 audio pack: its first access unit (sync `0B 77`) at byte 400, then + /// padding from byte 1800. `seed` varies the Dtk bytes and the payload. + pub(crate) fn audio_pack(seed: u8) -> Vec { + let mut v = pack_header(); + let hd_len = 1800 - v.len() - 6 - 3; + let mut body: Vec = (0..hd_len).map(|i| (i as u8).wrapping_mul(seed)).collect(); + body[0] = 0xC0; + body[1] = 1; + // The pointer counts from its own last byte (body[3], pack byte 26). + let ptr = 400 - 26; + body[2..4].copy_from_slice(&(ptr as u16).to_be_bytes()); + body[400 - 23..400 - 21].copy_from_slice(&[0x0B, 0x77]); + v.extend_from_slice(&pes(PRIVATE_STREAM_1, &body)); + pad_to_pack(v) + } + + /// A plaintext video pack whose one PES fills the pack: no structural test applies. + pub(crate) fn video_pack(seed: u8) -> Vec { + let mut v = pack_header(); + let n = PACK_LEN - v.len() - 9; + let body: Vec = (0..n).map(|i| (i as u8) ^ seed).collect(); + v.extend_from_slice(&pes(0xE0, &body)); + v + } + + fn hl_pack() -> Vec { + let mut v = pack_header(); + v.extend_from_slice(&[0, 0, 1, PRIVATE_STREAM_2, 0x07, 0xEC, HLI_SUB_STREAM_ID]); + v.resize(PACK_LEN, 0x5A); + v + } + + const KT: [u8; 16] = [0x42; 16]; + + #[test] + fn nav_pack_yields_its_cpi_and_fields() { + let c = cpi(17, 9); + assert_eq!(classify(&nav_pack(&c)), PackKind::Nav(Some(c))); + assert_eq!(c.key_vf(), 0b10); + assert_eq!(c.title_key_ptr(), 17); + } + + #[test] + fn content_key_is_aes_g_of_dtk_and_cpi_lsb96() { + let c = cpi(1, 0x0102_0304); + let pack = audio_pack(3); + let mut d = [0u8; 16]; + d[..4].copy_from_slice(&pack[84..88]); + d[4..].copy_from_slice(&c.0[4..16]); + assert_eq!(content_key(&KT, &pack, &c), aes_g(&KT, &d)); + // CPI msb bytes (KMI) are not key input; Dtk and CH_PTR are. + let mut other = c; + other.0[3] ^= 0xFF; + assert_eq!(content_key(&KT, &pack, &other), aes_g(&KT, &d)); + assert_ne!(content_key(&KT, &pack, &cpi(1, 5)), aes_g(&KT, &d)); + } + + #[test] + fn encrypt_keeps_the_clear_head_and_decrypt_restores_the_pack() { + let c = cpi(3, 77); + let plain = audio_pack(5); + let mut p = plain.clone(); + encrypt_pack(&mut p, &KT, &c); + assert_eq!(classify(&p), PackKind::Scrambled); + assert_eq!(p[..20], plain[..20]); + assert_eq!(p[21..CLEAR_LEN], plain[21..CLEAR_LEN]); + assert_ne!(p[CLEAR_LEN..], plain[CLEAR_LEN..]); + assert_eq!(p[..4], PACK_START, "an encrypted pack keeps its start code"); + decrypt_pack(&mut p, &KT, &c); + assert_eq!(p, plain); + assert_eq!(classify(&p), PackKind::Clear); + } + + #[test] + fn a_shifted_dtk_or_wrong_key_does_not_open_the_pack() { + let c = cpi(3, 77); + let mut p = audio_pack(5); + encrypt_pack(&mut p, &KT, &c); + let mut wrong = p.clone(); + decrypt_pack(&mut wrong, &[0x43; 16], &c); + assert_eq!(payload_check(&wrong), Some(false)); + // Dtk one byte early (83..87) is the wrong key input. + let mut shifted = p.clone(); + let mut d = [0u8; 16]; + d[..4].copy_from_slice(&shifted[83..87]); + d[4..].copy_from_slice(&c.0[4..]); + aes_cbc_decrypt(&aes_g(&KT, &d), &mut shifted[CLEAR_LEN..]); + assert_eq!(payload_check(&shifted), Some(false)); + decrypt_pack(&mut p, &KT, &c); + assert_eq!(payload_check(&p), Some(true)); + } + + #[test] + fn payload_check_judges_only_bytes_past_the_clear_head() { + assert_eq!(payload_check(&audio_pack(9)), Some(true)); + assert_eq!(payload_check(&video_pack(9)), None); + let mut bad = audio_pack(9); + bad[400] ^= 1; + assert_eq!(payload_check(&bad), Some(false)); + let mut bad_pad = audio_pack(9); + bad_pad[1800] = 0x47; + assert_eq!(payload_check(&bad_pad), Some(false)); + } + + #[test] + fn scrambling_is_read_per_pack_from_the_pes_header() { + let mut v = video_pack(1); + assert_eq!(classify(&v), PackKind::Clear); + v[20] |= 0x10; + assert_eq!(classify(&v), PackKind::Scrambled); + // A system header's byte 20 is rate_bound, never a scrambling flag. + let mut nav = nav_pack(&cpi(1, 1)); + nav[20] = 0xB2; + assert!(matches!(classify(&nav), PackKind::Nav(_))); + } + + #[test] + fn highlight_packs_need_a_key_only_under_a_valid_key_pointer() { + let hl = hl_pack(); + assert_eq!(classify(&hl), PackKind::Highlight); + assert!(needs_key(classify(&hl), Some(&cpi(1, 1)))); + let mut none = cpi(1, 1); + none.0[0] = 0; + assert!(!needs_key(classify(&hl), Some(&none))); + assert!(!needs_key(classify(&hl), None)); + } +} diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index a90fb017..f2d3a47e 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -5,6 +5,8 @@ pub mod content; pub mod crypto; pub mod derive; +// HD DVD per-pack content encryption (`[HD]` §4.3). +pub(crate) mod hddvd; pub mod host_certs; // No production caller; not the live FMTS path. #[doc(hidden)] diff --git a/src/decrypt.rs b/src/decrypt.rs index 18d5d274..6fa3ecf5 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -465,6 +465,9 @@ fn apply_aacs_map( // CSS descrambles via `decrypt_sectors` and `None` is already clear. _ => return Ok(0), }; + if format == crate::disc::ContentFormat::MpegPs { + return decrypt_hddvd_packs(buf, keys, base_lba, map, content, None).map(|r| r.blanked); + } let unit_len = aacs::content::ALIGNED_UNIT_LEN; let unit_sectors = aacs::content::ALIGNED_UNIT_SECTORS; @@ -635,6 +638,114 @@ fn apply_aacs_map( Ok(failed) } +/// What a per-pack HD DVD decrypt of one read found. +#[derive(Debug)] +pub(crate) struct HdPacks { + /// The CPI in force after the read's last pack: the lead CPI of a read that follows it. + pub(crate) last_cpi: Option, + /// Encrypted packs blanked because no NV_PCK before them gave their EVOBU's CPI. + pub(crate) blanked: usize, +} + +/// Decrypt the HD DVD packs of `buf` (read at `base_lba`) in place, per `[HD]` §4.3.4. +/// +/// Each NV_PCK sets the CPI for the packs after it; `lead` is the CPI in force at the first +/// pack (from an earlier read). A pack flagged scrambled, or an HL_PCK under a valid KEY_VF, is +/// decrypted with the Title Key its CPI's `TITLE_KEY_PTR` names (`unit_keys` numbers keys by +/// that pointer); the map only says which sectors are keyed content. An encrypted pack with no +/// CPI is blanked and counted. Ciphertext outside the map, under a Segment Key (KEY_VF `01`), +/// or under a Title Key not held fails loud: shipping it would pass ciphertext as clear. +pub(crate) fn decrypt_hddvd_packs( + buf: &mut [u8], + keys: &DecryptKeys, + base_lba: u32, + map: &AacsKeyMap, + content: Option<&[(u32, u32)]>, + lead: Option, +) -> Result { + use aacs::hddvd::{PACK_LEN, PackKind, classify, decrypt_pack, needs_key}; + let DecryptKeys::Aacs { unit_keys, .. } = keys else { + return Ok(HdPacks { + last_cpi: lead, + blanked: 0, + }); + }; + let mut cur = lead; + let mut jobs: Vec> = Vec::new(); + let mut blank = Vec::new(); + let mut refusal: Option<&'static str> = None; + for (i, pack) in buf.as_chunks::().0.iter().enumerate() { + jobs.push(None); + let lba = base_lba.saturating_add(i as u32); + let kind = classify(pack); + if let PackKind::Nav(cpi) = kind { + cur = cpi; + continue; + } + if content.is_some_and(|r| !lba_in_content_ranges(lba, r)) || !needs_key(kind, cur.as_ref()) + { + continue; + } + if map.entry_for(lba).is_none() { + refusal.get_or_insert("encrypted pack outside every keyed range"); + continue; + } + let Some(cpi) = cur else { + blank.push(i); + continue; + }; + if cpi.key_vf() != 0b10 { + refusal.get_or_insert("pack under a Segment Key or no valid key pointer"); + continue; + } + match unit_keys.iter().find(|(n, _)| *n == cpi.title_key_ptr()) { + Some((_, kt)) => jobs[i] = Some((kt, cpi)), + None => { + refusal.get_or_insert("the pack's Title Key is not held"); + } + } + } + if let Some(why) = refusal { + tracing::error!(target: "freemkv::decrypt", lba = base_lba, code = crate::error::E_DECRYPT_FAILED, why, "HD DVD pack cannot be decrypted"); + return Err(crate::error::Error::DecryptFailed); + } + let run = |(i, pack): (usize, &mut [u8])| { + if let Some((kt, cpi)) = jobs[i] { + decrypt_pack(pack, kt, &cpi); + } + }; + let pool = (decrypt_threads() > 1 && jobs.len() >= PARALLEL_MIN_UNITS * 3) + .then(decrypt_pool) + .flatten(); + match pool { + Some(pool) => pool.install(|| { + buf.par_chunks_exact_mut(PACK_LEN).enumerate().for_each(run); + }), + None => buf + .as_chunks_mut::() + .0 + .iter_mut() + .map(|p| p.as_mut_slice()) + .enumerate() + .for_each(run), + } + for &i in &blank { + buf[i * PACK_LEN..(i + 1) * PACK_LEN].fill(0); + } + if let Some(&first) = blank.first() { + tracing::warn!( + target: "freemkv::decrypt", + lba = base_lba.saturating_add(first as u32), + packs = blank.len(), + "encrypted HD DVD pack with no NV_PCK before it: blanked, the rip carries on" + ); + } + Ok(HdPacks { + last_cpi: cur, + blanked: blank.len(), + }) +} + /// FMTS units of one read that must fail their verify, none verifying, under one key before /// it is a wrong key rather than damage: a wrong key fails them all. const WRONG_KEY_FAILURES: usize = 2; @@ -778,6 +889,102 @@ fn decrypt_span( mod tests { use super::*; + // HD DVD: a buffer of `n` packs, an NV_PCK (CPI naming Title Key 2) every 4th from pack + // `nav_at`, the rest audio packs encrypted per pack under `kt` (`[HD]` §4.3.2). + fn hd_buf(n: usize, nav_at: usize, kt: &[u8; 16]) -> (Vec, Vec) { + use crate::aacs::hddvd::encrypt_pack; + use crate::aacs::hddvd::tests::{audio_pack, cpi, nav_pack}; + let c = cpi(2, 7); + let plain: Vec = (0..n) + .flat_map(|i| match i >= nav_at && (i - nav_at).is_multiple_of(4) { + true => nav_pack(&c), + false => audio_pack(i as u8 | 1), + }) + .collect(); + let mut enc = plain.clone(); + for (i, p) in enc.chunks_mut(2048).enumerate() { + if !(i >= nav_at && (i - nav_at).is_multiple_of(4)) { + encrypt_pack(p, kt, &c); + } + } + (plain, enc) + } + + fn hd_keys(keys: &[(u32, [u8; 16])]) -> DecryptKeys { + DecryptKeys::Aacs { + unit_keys: keys.to_vec(), + format: crate::disc::ContentFormat::MpegPs, + } + } + + #[test] + fn hddvd_packs_decrypt_under_the_title_key_their_cpi_names() { + let kt = [0x61; 16]; + let (plain, mut buf) = hd_buf(12, 0, &kt); + let map = AacsKeyMap::from_ranges(vec![(0, 100, 0)]); + let keys = hd_keys(&[(1, [0x99; 16]), (2, kt)]); + let r = decrypt_hddvd_packs(&mut buf, &keys, 0, &map, None, None).unwrap(); + assert_eq!(buf, plain); + assert_eq!(r.blanked, 0); + assert_eq!(r.last_cpi.map(|c| c.title_key_ptr()), Some(2)); + } + + #[test] + fn hddvd_packs_before_the_first_nav_use_the_lead_cpi_or_are_blanked() { + use crate::aacs::hddvd::tests::cpi; + let kt = [0x61; 16]; + let map = AacsKeyMap::from_ranges(vec![(0, 100, 0)]); + let keys = hd_keys(&[(2, kt)]); + let (plain, enc) = hd_buf(9, 2, &kt); + let mut buf = enc.clone(); + decrypt_hddvd_packs(&mut buf, &keys, 0, &map, None, Some(cpi(2, 7))).unwrap(); + assert_eq!(buf, plain); + let mut buf = enc.clone(); + let r = decrypt_hddvd_packs(&mut buf, &keys, 0, &map, None, None).unwrap(); + assert_eq!(r.blanked, 2); + assert!(buf[..2 * 2048].iter().all(|&b| b == 0)); + assert_eq!(buf[2 * 2048..], plain[2 * 2048..]); + } + + #[test] + fn hddvd_ciphertext_no_held_key_opens_fails_loud() { + let kt = [0x61; 16]; + let (_, enc) = hd_buf(8, 0, &kt); + let map = AacsKeyMap::from_ranges(vec![(0, 100, 0)]); + // Title Key 2 not held. + let mut buf = enc.clone(); + let r = decrypt_hddvd_packs(&mut buf, &hd_keys(&[(1, kt)]), 0, &map, None, None); + assert!(matches!(r, Err(crate::error::Error::DecryptFailed))); + // Encrypted packs outside every keyed range. + let mut buf = enc.clone(); + let outside = AacsKeyMap::from_ranges(vec![(500, 600, 0)]); + let r = decrypt_hddvd_packs(&mut buf, &hd_keys(&[(2, kt)]), 0, &outside, None, None); + assert!(matches!(r, Err(crate::error::Error::DecryptFailed))); + // KEY_VF 01: a Segment Key, not held. + let mut buf = enc.clone(); + buf[60] = 0x40; + let r = decrypt_hddvd_packs(&mut buf, &hd_keys(&[(2, kt)]), 0, &map, None, None); + assert!(matches!(r, Err(crate::error::Error::DecryptFailed))); + } + + #[test] + fn hddvd_packs_outside_the_content_ranges_pass_through() { + let kt = [0x61; 16]; + let (_, enc) = hd_buf(8, 0, &kt); + let mut buf = enc.clone(); + let map = AacsKeyMap::from_ranges(vec![(0, 100, 0)]); + decrypt_hddvd_packs( + &mut buf, + &hd_keys(&[(2, kt)]), + 0, + &map, + Some(&[(50, 10)]), + None, + ) + .unwrap(); + assert_eq!(buf, enc); + } + /// Build a clear-TS region: a 0x47 sync byte at offset 4 of every 192-byte /// BD-TS packet (matching `ts_sync_count`'s probe stride), filler elsewhere. /// Reads as NOT scrambled. diff --git a/src/keys/evidence.rs b/src/keys/evidence.rs index 12793fbd..4c6a4c40 100644 --- a/src/keys/evidence.rs +++ b/src/keys/evidence.rs @@ -50,26 +50,21 @@ impl Disc { } } -/// Whether the medium's encrypted-unit test can be trusted (design X-10). +/// How the medium flags its encrypted content and how a key is proven (design X-10). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Detector { - /// BD/UHD CPI flags: keys are proven on ciphertext. + /// BD/UHD CPI flags: keys are proven on ciphertext units. Verified, - /// HD DVD: the flag is unverified, so a unit reads clear only when it is also whole - /// content; encrypted content gets a single declared key applied without proof. - Unverified, + /// HD DVD: each pack's `PES_scrambling_control` (`[HD]` §4.3.2). Keys are numbered by the + /// CPI's `TITLE_KEY_PTR` and proven on decrypted packs, per EVOBU. + PerPack, } impl Detector { // The format's own test of one aligned unit, feeding the one encryption decision - // (`resolve::acquire`). Verified: the CPI flag. Unverified: encrypted unless the unit is - // unflagged and structurally whole content (an AACS unit's ciphertext is not). + // (`resolve::acquire`): the CPI flag, or any pack flagged scrambled. pub(crate) fn unit_encrypted(self, unit: &[u8], format: ContentFormat) -> bool { - use crate::aacs::content::{aacs_unit_encrypted, is_clean}; - match self { - Detector::Verified => aacs_unit_encrypted(unit, format), - Detector::Unverified => aacs_unit_encrypted(unit, format) || !is_clean(unit, format), - } + crate::aacs::content::aacs_unit_encrypted(unit, format) } } @@ -206,7 +201,7 @@ impl KeyEvidence { format: t.content_format, }), detector: match disc.format { - DiscFormat::HdDvd => Detector::Unverified, + DiscFormat::HdDvd => Detector::PerPack, _ => Detector::Verified, }, container: disc.content_format, @@ -237,7 +232,7 @@ impl KeyEvidence { volume_label: inputs.volume_label, }); let whole = ev.scope == KeyScope::WholeDisc; - if ev.detector == Detector::Unverified { + if ev.detector == Detector::PerPack { // Listed whatever the declared count: acquisition judges a copy in the clear from // its pieces before it refuses a multi-key HD DVD. let files = match crate::whole_disc::content_files(reader) { @@ -473,7 +468,7 @@ pub(crate) fn read_unit(reader: &mut dyn SectorSource, lba: u32) -> Result bool { +pub(crate) fn fatal_read(e: &Error) -> bool { matches!(e, Error::Halted) || e.is_source_terminated() || (e.is_scsi_transport_failure() && !matches!(e, Error::IoError { .. })) diff --git a/src/keys/mod.rs b/src/keys/mod.rs index e5144ba8..fa020782 100644 --- a/src/keys/mod.rs +++ b/src/keys/mod.rs @@ -231,6 +231,8 @@ pub(crate) struct Inner { pub(crate) scope: KeyScope, // Base keys (pool slot = index); forensic index keys follow them in `decrypt_keys`. pub(crate) pool: Vec<[u8; 16]>, + // HD DVD: each pool key's Title Key number (`TITLE_KEY_PTR`); empty numbers slot + 1. + pub(crate) key_nums: Vec, pub(crate) fmts_keys: Vec<[u8; 16]>, pub(crate) fmts_phases: HashMap, pub(crate) map: Arc, @@ -263,6 +265,7 @@ impl Inner { n_decl: None, scope: KeyScope::None, pool: Vec::new(), + key_nums: Vec::new(), fmts_keys: Vec::new(), fmts_phases: HashMap::new(), map: Arc::new(AacsKeyMap::from_ranges(Vec::new())), @@ -477,7 +480,7 @@ impl KeyRing { .pool .iter() .enumerate() - .map(|(s, k)| (s as u32 + 1, *k)) + .map(|(s, k)| (i.key_nums.get(s).copied().unwrap_or(s as u32 + 1), *k)) .collect(); for (j, k) in i.fmts_keys.iter().enumerate() { let tag = crate::keys::fmts::FMTS_POOL_TAG_BASE.saturating_add(j as u32); @@ -690,6 +693,7 @@ impl KeyRing { i.capacity = capacity; if let Some(h) = held.filter(|h| h.is_aacs()) { i.pool = h.0.pool.clone(); + i.key_nums = h.0.key_nums.clone(); i.disc_hash = h.0.disc_hash.clone(); } let piece = resolve::loose_file_piece(capacity); diff --git a/src/keys/resolve.rs b/src/keys/resolve.rs index dee9668e..b32ae25b 100644 --- a/src/keys/resolve.rs +++ b/src/keys/resolve.rs @@ -97,7 +97,8 @@ impl Probed { // What one request to one source produced. enum Asked { - Keys(Vec<[u8; 16]>), + /// The keys, and each one's number (CPS unit / HD DVD Title Key entry). + Keys(Vec<[u8; 16]>, Vec), Empty, /// The source failed; the error is the run's `first_failure` if it came first. Failed, @@ -218,7 +219,10 @@ impl Run<'_> { return Ok(if keys.is_empty() { Asked::Empty } else { - Asked::Keys(keys.into_iter().map(|u| u.key).collect()) + Asked::Keys( + keys.iter().map(|u| u.key).collect(), + keys.iter().map(|u| u.idx).collect(), + ) }); } Err(Error::Halted) => { @@ -533,7 +537,7 @@ fn decide( return Ok(inner); } match ev.detector { - Detector::Unverified => resolve_hddvd(ev, sampler, disc_hash, n_decl, run), + Detector::PerPack => resolve_hddvd(ev, sampler, disc_hash, seed, run), Detector::Verified => resolve_bd(ev, scoped, sampler, disc_hash, n_decl, seed, run), } } @@ -559,40 +563,64 @@ fn in_clear( } } -// KU §2.6: HD DVD with encrypted content is best effort (its encrypted flag is unverified, -// KS-27): a single declared key is applied to every piece; several, or none parseable, refuse. +// `[HD]` §4.3: an HD DVD EVOBU is keyed by its CPI's `TITLE_KEY_PTR`, not by a CPS-unit map, +// so every Title Key a source gives is held under its entry number and proven on decrypted +// packs of the main title. Unprovable (no structural test applied) is best effort. fn resolve_hddvd( ev: &KeyEvidence, sampler: &mut Sampler, disc_hash: &str, - n_decl: Option, + seed: Option<&KeyRing>, run: &mut Run, ) -> Result { - if n_decl != Some(1) { - tracing::error!(target: "freemkv::keys", declared = ?n_decl, "multi-key HD DVD cannot be matched reliably"); - return Err(Error::NoDiscKey { - disc_hash: crate::hex::strip_hex_prefix(disc_hash).to_string(), - }); - } - if run.pool.is_empty() { + let mut keys: Vec<(u32, [u8; 16])> = seed + .map(|s| { + s.0.key_nums + .iter() + .copied() + .zip(s.0.pool.iter().copied()) + .collect() + }) + .unwrap_or_default(); + let mut origin = (!keys.is_empty()).then_some("seed"); + if keys.is_empty() { let samples = sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS); for i in 0..run.sources.len() { - if let Asked::Keys(k) = run.ask(i, &samples, false)? { - let who = run.sources[i].label(); - run.add_keys(&k[..1], who); + if let Asked::Keys(k, idx) = run.ask(i, &samples, false)? { + let nums = idx.iter().map(|&i| title_key_number(ev, i)); + keys = nums.zip(k).take(MAX_POOL_KEYS).collect(); + origin = Some(run.sources[i].label()); break; } } - if run.pool.is_empty() { - let failure = run.first_failure.take(); - return Err(failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash))); - } } + if keys.is_empty() { + let failure = run.first_failure.take(); + return Err(failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash))); + } + let proof = prove_hddvd(sampler.source(), ev.main.as_ref(), &keys, run.halt)?; + let proven = match proof { + HdProof::Wrong { + opened, + failed, + missing, + } => { + tracing::error!(target: "freemkv::keys", opened, failed, missing, code = crate::error::E_DECRYPT_FAILED, "the held Title Keys do not open the HD DVD's packs: wrong key"); + return Err(Error::DecryptFailed); + } + HdProof::Proven(slots) => slots, + HdProof::Unproven => { + tracing::warn!(target: "freemkv::keys", "no decrypted HD DVD pack could be checked: keys applied unproven"); + Vec::new() + } + }; let mut inner = aacs_inner(run); inner.no_stream_files = ev.no_stream_files; - inner.best_effort = true; - inner.origin = run.origin.first().copied(); - inner.proven = vec![0]; + inner.best_effort = proven.is_empty(); + inner.pool = keys.iter().map(|k| k.1).collect(); + inner.key_nums = keys.iter().map(|k| k.0).collect(); + inner.origin = origin; + inner.proven = proven; inner.keyed = ev.pieces.len(); let mut ranges = Vec::new(); for p in &ev.pieces { @@ -605,6 +633,118 @@ fn resolve_hddvd( Ok(inner) } +// A source's key `idx` is its position among the title-key file's present entries +// (`UnitKey::idx`); the VTKF entry number (`TITLE_KEY_PTR`) is that entry's slot. +fn title_key_number(ev: &KeyEvidence, idx: u32) -> u32 { + ev.aacs + .as_ref() + .and_then(|a| crate::aacs::inf::parse_vtkf(&a.unit_key_ro)) + .and_then(|f| f.encrypted_keys.get(idx as usize).map(|k| k.0)) + .unwrap_or(idx.saturating_add(1)) +} + +// Sectors read per HD DVD proof window, and the windows spread over the main title. +const HD_PROOF_SECTORS: u32 = 2048; +const HD_PROOF_WINDOWS: u64 = 3; + +// The verdict of decrypting sampled HD DVD packs under the held Title Keys. +enum HdProof { + /// Most checked packs verified; the pool slots that opened them. + Proven(Vec), + /// No decrypted pack could be checked (no structural test applied, or nothing encrypted). + Unproven, + /// Checked packs did not verify, or named a Title Key not held. + Wrong { + opened: usize, + failed: usize, + missing: usize, + }, +} + +// Decrypt the encrypted packs of a few windows of the main title under the CPI each EVOBU's +// NV_PCK gives (`[HD]` §4.3.4) and judge them by `hddvd::payload_check`. A wrong key leaves +// bytes 128.. random, so a structural test passes about once in 2^16. +fn prove_hddvd( + reader: &mut dyn crate::sector::SectorSource, + main: Option<&super::evidence::MainTitle>, + keys: &[(u32, [u8; 16])], + halt: &Halt, +) -> Result { + use crate::aacs::hddvd::{ + PACK_LEN, PackKind, classify, decrypt_pack, needs_key, payload_check, + }; + let Some(main) = main else { + return Ok(HdProof::Unproven); + }; + let total: u64 = main.extents.iter().map(|e| u64::from(e.sector_count)).sum(); + let (mut opened, mut failed, mut missing) = (0usize, 0usize, 0usize); + let mut slots = Vec::new(); + for w in 0..HD_PROOF_WINDOWS { + halt.check()?; + let Some((lba, left)) = + sector_at(&main.extents, total * (2 * w + 1) / (2 * HD_PROOF_WINDOWS)) + else { + continue; + }; + let n = left.min(HD_PROOF_SECTORS); + let mut buf = vec![0u8; n as usize * PACK_LEN]; + let got = match reader.read_sectors(lba, n as u16, &mut buf, false) { + Ok(got) => got.min(buf.len()), + Err(e) if super::evidence::fatal_read(&e) => return Err(e), + Err(_) => continue, + }; + let mut cpi = None; + for pack in buf[..got].as_chunks::().0 { + let kind = classify(pack); + if let PackKind::Nav(c) = kind { + cpi = c; + continue; + } + let Some(c) = cpi.filter(|c| needs_key(kind, Some(c)) && c.key_vf() == 0b10) else { + continue; + }; + let Some(slot) = keys.iter().position(|k| k.0 == c.title_key_ptr()) else { + missing += 1; + continue; + }; + let mut plain = pack.to_vec(); + decrypt_pack(&mut plain, &keys[slot].1, &c); + match payload_check(&plain) { + Some(true) => { + opened += 1; + if !slots.contains(&slot) { + slots.push(slot); + } + } + Some(false) => failed += 1, + None => {} + } + } + } + Ok(match (opened, failed + missing) { + (0, 0) => HdProof::Unproven, + (o, f) if o > f => HdProof::Proven(slots), + _ => HdProof::Wrong { + opened, + failed, + missing, + }, + }) +} + +// The sector `off` sectors into `extents`, and how many sectors of its extent follow it. +fn sector_at(extents: &[Extent], mut off: u64) -> Option<(u32, u32)> { + for e in extents { + let n = u64::from(e.sector_count); + if off < n { + let lba = u64::from(e.start_lba) + off; + return Some((u32::try_from(lba).ok()?, (n - off) as u32)); + } + off -= n; + } + None +} + // KU §2.3 steps 8–14 for BD/UHD content not in the clear, over `probe_scope`'s probes. fn resolve_bd( ev: &KeyEvidence, @@ -645,7 +785,7 @@ fn resolve_bd( if run.sources[i].answer_depends_on_samples() { continue; } - if let Asked::Keys(k) = run.ask(i, &samples, false)? { + if let Asked::Keys(k, _) = run.ask(i, &samples, false)? { let who = run.sources[i].label(); run.add_keys(&k, who); } @@ -683,7 +823,7 @@ fn resolve_bd( } budget -= 1; for s in dependent { - if let Asked::Keys(k) = run.ask(s, &samples, false)? { + if let Asked::Keys(k, _) = run.ask(s, &samples, false)? { let who = run.sources[s].label(); run.add_keys(&k, who); if run.judge(&ps[i]) != Verdict::Ask { @@ -835,7 +975,7 @@ fn resolve_forensic( // A failed request is kept as the run's first failure, not returned per batch. let mut ask = |batch: &[Vec]| -> Result>> { for i in 0..run.sources.len() { - if let Asked::Keys(k) = run.ask(i, batch, true)? { + if let Asked::Keys(k, _) = run.ask(i, batch, true)? { return Ok(Some(k)); } } diff --git a/src/keys/tests.rs b/src/keys/tests.rs index db50e538..96863fff 100644 --- a/src/keys/tests.rs +++ b/src/keys/tests.rs @@ -948,36 +948,115 @@ fn transient_source_gives_up_after_60s_without_an_answer() { assert_eq!(calls.len(), 2, "no attempt after the Stop"); } -/// LK16. KS-27 (evidence: the HD DVD book is withdrawn; the flag model is UNVERIFIED): a -/// single declared key is applied best-effort; several refuse (E7022). +/// LK16. `[HD]` §4.3: an HD DVD EVOBU names its Title Key by the CPI's `TITLE_KEY_PTR`, so +/// every key a source gives is held under its entry number, whatever the file declares, and +/// proven on decrypted packs; a key that opens no pack refuses (E7013). #[test] -fn hddvd_single_key_best_effort_multi_key_refuses() { - // DirImage lays out a BDMV or VIDEO_TS tree; the EVO is found under /HVDVD_TS. - let files = [ - BdFile::new("HVDVD_TS/FEATURE.EVO", 30, Some(K1)), - BdFile::new("BDMV/index.bdmv", 1, None), - ]; - for (declared, ok) in [(1, true), (2, false)] { +fn hddvd_title_keys_are_held_by_entry_and_proven_on_packs() { + for (declared, held, ok) in [ + (1, vec![K1], true), + (64, vec![WRONG, K1], true), + (64, vec![WRONG], false), + ] { + let ptr = held.len() as u16; + let files = [ + BdFile::new("HVDVD_TS/FEATURE.EVO", 0, None).with_clip(ps_clip_keyed(60, ptr)), + BdFile::new("BDMV/index.bdmv", 1, None), + ]; let uk_ro = unit_key_ro(AacsVersion::V10, &vec![[0xEE; 16]; declared], &[1]); - let img = encrypted_bd_image(&files, &uk_ro); + let mut img = encrypted_bd_image(&files, &uk_ro); + encrypt_ps(&mut img, 0, &K1); let disc = disc_over(&img, &uk_ro, &[&[0]], DiscFormat::HdDvd); let fx = Fx { img, disc }; let calls = Calls::default(); let r = resolve( &fx, KeyScope::Titles(vec![0]), - &[Spec::keydb(&[K1], &calls)], + &[Spec::keydb(&held, &calls)], ); - if ok { - let s = r.unwrap().status(); - assert!(s.best_effort); - assert_eq!(s.keyed, 1); - } else { - assert_eq!(code(r), E7022); + if !ok { + assert_eq!( + code(r), + E7013, + "{declared} declared: a wrong key must not pass" + ); + continue; } + let set = r.unwrap_or_else(|e| panic!("{declared} declared: refused: {e}")); + let s = set.status(); + assert_eq!((s.keyed, s.best_effort), (1, false), "{declared} declared"); + let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap(); + assert_eq!( + read(&mut r, &fx, 0, 0, 20).unwrap(), + fx.plain(fx.file(0).0, 20), + "{declared} declared: decrypted" + ); } } +/// A source numbers keys by position among the VTKF's present entries (`UnitKey::idx`); a +/// key is held under its entry's slot number, which is what `TITLE_KEY_PTR` names. +#[test] +fn hddvd_source_key_positions_map_to_vtkf_slots() { + // A VTKF with entries 2 and 3 present (slot 1 empty). + let mut vtkf = vec![0u8; 2480]; + vtkf[..12].copy_from_slice(crate::aacs::inf::VTKF_MAGIC); + vtkf[0x80 + 0x24] = 0x80; + vtkf[0x80 + 2 * 0x24] = 0x80; + let files = [ + BdFile::new("HVDVD_TS/FEATURE.EVO", 0, None).with_clip(ps_clip_keyed(60, 3)), + BdFile::new("BDMV/index.bdmv", 1, None), + ]; + let mut img = encrypted_bd_image(&files, &vtkf); + encrypt_ps(&mut img, 0, &K1); + let disc = disc_over(&img, &vtkf, &[&[0]], DiscFormat::HdDvd); + let fx = Fx { img, disc }; + let calls = Calls::default(); + let set = resolve( + &fx, + KeyScope::Titles(vec![0]), + &[Spec::keydb(&[WRONG, K1], &calls)], + ) + .expect("position 1 is entry 3"); + let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap(); + assert_eq!( + read(&mut r, &fx, 0, 0, 20).unwrap(), + fx.plain(fx.file(0).0, 20) + ); +} + +/// A read that starts mid-EVOBU (no NV_PCK before its first encrypted pack) takes the CPI +/// from the nearest NV_PCK before it, and a read that continues the last one carries it. +#[test] +fn hddvd_read_mid_evobu_finds_the_cpi_before_it() { + let files = [ + BdFile::new("HVDVD_TS/FEATURE.EVO", 0, None).with_clip(ps_clip(60)), + BdFile::new("BDMV/index.bdmv", 1, None), + ]; + let uk_ro = unit_key_ro(AacsVersion::V10, &[[0xEE; 16]], &[1]); + let mut img = encrypted_bd_image(&files, &uk_ro); + encrypt_ps(&mut img, 0, &K1); + let disc = disc_over(&img, &uk_ro, &[&[0]], DiscFormat::HdDvd); + let fx = Fx { img, disc }; + let calls = Calls::default(); + let set = resolve( + &fx, + KeyScope::Titles(vec![0]), + &[Spec::keydb(&[K1], &calls)], + ) + .unwrap(); + let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap(); + // Units 4..6 start at pack 12, two packs past the NV_PCK at pack 10; then 6..8 follows. + assert_eq!( + read(&mut r, &fx, 0, 4, 2).unwrap(), + fx.plain(fx.unit(0, 4), 2) + ); + assert_eq!( + read(&mut r, &fx, 0, 6, 2).unwrap(), + fx.plain(fx.unit(0, 6), 2) + ); +} + /// An HD DVD copy already in the clear (whole MPEG-PS packs, no scrambling flag) that kept its /// AACS directory needs no key, however many keys its title-key file declares: the set is /// clear, no source is asked, nothing is best effort. @@ -3255,35 +3334,35 @@ const MEDIA_UNITS: u32 = 10; // Whole MPEG-PS packs (pack start in every sector, no scrambling flag): an HD DVD feature in // the clear. fn ps_clip(sectors: usize) -> Vec { - let mut clip = vec![0u8; sectors * 2048]; - for (i, sector) in clip.chunks_mut(2048).enumerate() { - for (j, b) in sector.iter_mut().enumerate() { - *b = ((i * 31 + j) % 251) as u8 | 1; - } - sector[..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); - sector[20] &= !0x30; - } - clip + ps_clip_keyed(sectors, 1) } -// Encrypt every unit of file `f` (MPEG-PS) under `key`, its plain copy kept. A plaintext -// header byte is varied until the ciphertext carries the PES scrambling flag the reader -// gates on (that flag lies past the unit's clear seed). +// An HD DVD clip of whole packs: an NV_PCK every 10 packs whose CPI names Title Key `ptr`, +// then audio and video packs (`aacs::hddvd` fixtures), all in the clear. +fn ps_clip_keyed(sectors: usize, ptr: u16) -> Vec { + use crate::aacs::hddvd::tests::{audio_pack, cpi, nav_pack, video_pack}; + (0..sectors) + .flat_map(|i| match i % 10 { + 0 => nav_pack(&cpi(ptr, i as u32 + 1)), + n if n % 3 == 1 => audio_pack(i as u8 | 1), + _ => video_pack(i as u8), + }) + .collect() +} + +// Encrypt file `f` (an HD DVD clip) per pack under Title Key `key` (`[HD]` §4.3.2), each pack +// under the CPI of the NV_PCK before it; the plain copy keeps the clear packs. fn encrypt_ps(img: &mut EncryptedBdImage, f: usize, key: &[u8; 16]) { + use crate::aacs::hddvd::{PackKind, classify, encrypt_pack}; let (start, sectors) = img.files[f]; - for u in 0..sectors / 3 { - let at = (start + u * 3) as usize * 2048; - let mut plain = img.plain[at..at + ALIGNED_UNIT_LEN].to_vec(); - let ct = (0..=255u8) - .find_map(|t| { - plain[24] = t; - let mut ct = plain.clone(); - assert!(encrypt_unit(&mut ct, key)); - (ct[20] & 0x30 != 0).then_some(ct) - }) - .expect("a flagged ciphertext"); - img.plain[at..at + ALIGNED_UNIT_LEN].copy_from_slice(&plain); - img.image[at..at + ALIGNED_UNIT_LEN].copy_from_slice(&ct); + let mut cpi = None; + for s in 0..sectors as usize { + let at = (start as usize + s) * 2048; + let pack = &mut img.image[at..at + 2048]; + match classify(pack) { + PackKind::Nav(c) => cpi = c, + _ => encrypt_pack(pack, key, &cpi.expect("an NV_PCK leads the clip")), + } } } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 9c06423e..b0cdd05c 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -61,8 +61,16 @@ pub struct DecryptingSectorSource { watch_css: bool, /// The crack's verdict was scrambled-but-uncrackable: every read refuses (E7023). refused: bool, + /// HD DVD: the sector after the last read and the CPI in force there, so a read that + /// continues it decrypts the packs before its first NV_PCK. + hd_cpi: Option<(u32, Option)>, } +// HD DVD: how far back a read that starts mid-EVOBU looks for the NV_PCK leading it, and in +// what steps. An EVOBU lasts at most about a second (under 2000 packs at 30 Mbit/s). +const HD_LOOKBACK_SECTORS: u32 = 4096; +const HD_LOOKBACK_STEP: u32 = 64; + /// What the content-detected stage may do with what it finds (from `InputOptions`). #[derive(Clone, Default)] pub(crate) struct StageOptions { @@ -159,6 +167,7 @@ impl DecryptingSectorSource { stale_cpi: false, watch_css: false, refused: false, + hd_cpi: None, }; match keying.into().0 { KeyingKind::Keys(keys) => s.keys = keys, @@ -383,6 +392,29 @@ impl SectorSource for DecryptingSectorSource { // Proactive map path (storm-free mux): keys were resolved per unit up front, // so decrypt with the mapped key and trust it (no per-unit `is_clean`). A // resolver gap fails loud; bad TS and units outside content extents pass through. + if let Some(map) = self.key_map.clone() + && matches!( + self.keys, + DecryptKeys::Aacs { + format: crate::disc::ContentFormat::MpegPs, + .. + } + ) + { + let lead = self.hd_lead_cpi(lba, &buf[..n]); + let run = crate::decrypt::decrypt_hddvd_packs( + &mut buf[..n], + &self.keys, + lba, + &map, + content_ref, + lead, + )?; + let sectors = (n / crate::consts::SECTOR_BYTES) as u32; + self.hd_cpi = Some((lba.saturating_add(sectors), run.last_cpi)); + self.count_blanked(lba, run.blanked); + return Ok(n); + } if let Some(map) = self.key_map.clone() { let blanked = crate::decrypt::decrypt_sectors_mapped_in_content( &mut buf[..n], @@ -496,6 +528,53 @@ impl DecryptingSectorSource { self.count_blanked(lba, n); } + // The CPI in force at the first pack of a read at `lba`: carried from the read it continues, + // else from the nearest NV_PCK before it. `None` when no pack before the read's first + // NV_PCK needs a key, or when none is found within `HD_LOOKBACK_SECTORS`. + fn hd_lead_cpi(&mut self, lba: u32, buf: &[u8]) -> Option { + use crate::aacs::hddvd::{PackKind, classify}; + use crate::consts::SECTOR_BYTES; + if let Some((next, cpi)) = self.hd_cpi + && next == lba + { + return cpi; + } + let wanted = buf + .as_chunks::() + .0 + .iter() + .map(|p| classify(p)) + .take_while(|k| !matches!(k, PackKind::Nav(_))) + .any(|k| matches!(k, PackKind::Scrambled | PackKind::Highlight)); + if !wanted { + return None; + } + let floor = lba.saturating_sub(HD_LOOKBACK_SECTORS); + let mut end = lba; + let mut tmp = vec![0u8; HD_LOOKBACK_STEP as usize * SECTOR_BYTES]; + while end > floor { + let start = end.saturating_sub(HD_LOOKBACK_STEP).max(floor); + let len = (end - start) as usize * SECTOR_BYTES; + let got = self + .inner + .read_sectors(start, (end - start) as u16, &mut tmp[..len], false) + .ok()?; + for pack in tmp[..got.min(len)] + .as_chunks::() + .0 + .iter() + .rev() + { + if let PackKind::Nav(cpi) = classify(pack) { + return cpi; + } + } + end = start; + } + tracing::warn!(target: "freemkv::decrypt", lba, "no NV_PCK found before an HD DVD read"); + None + } + fn count_blanked(&self, lba: u32, n: usize) { self.blanked .0 diff --git a/src/spec/keys.rs b/src/spec/keys.rs index 1609d4ee..7ad9e32e 100644 --- a/src/spec/keys.rs +++ b/src/spec/keys.rs @@ -2,8 +2,9 @@ //! //! KS-1…KS-19 and KS-29 are the public AACS books (Final Revision 0.953, downloaded //! from aacsla.com); KS-20 and KS-21 are ECMA-167 3rd edition; KS-22…KS-24 are -//! libaacs `55be92be`, corroboration only; KS-25…KS-28 are evidence for what has no -//! public spec (AACS 2.x / UHD, FMTS, HD DVD). "PDF p." is the physical page. +//! libaacs `55be92be`, corroboration only; KS-25…KS-28 and KS-31 are evidence for what +//! has no public spec (AACS 2.x / UHD, FMTS, HD DVD). KS-30 is the AACS HD DVD book, Final +//! Revision 0.952, since withdrawn from aacsla.com (KS-27). "PDF p." is the physical page. use super::{QuoteKind, SpecQuote}; @@ -13,6 +14,7 @@ const CM: &str = "AACS Introduction and Common Cryptographic Elements, Final Rev const UDF: &str = "ECMA-167, 3rd edition, June 1997"; const LIBAACS: &str = "libaacs (VideoLAN) @55be92be, src/libaacs/aacs.c"; const EVIDENCE: &str = "freemkv evidence (no public spec)"; +const HD: &str = "AACS HD DVD and DVD Pre-recorded Book, Final Rev 0.952"; const AACS_URL: &str = "https://aacsla.com/aacs-specifications/"; const UDF_URL: &str = @@ -319,7 +321,7 @@ pub const KS_27_HDDVD_EVIDENCE: SpecQuote = SpecQuote { id: "KS-27", kind: QuoteKind::Evidence, source: EVIDENCE, - section: "HD DVD: AACS HD DVD book withdrawn; PES flag model UNVERIFIED", + section: "HD DVD: AACS HD DVD book withdrawn from aacsla.com", locator: "aacsla.com AACS Specifications page, “HD DVD and DVD Books”", url: AACS_URL, text: "have been removed from this AACS website due to inactivity", @@ -347,6 +349,30 @@ pub const KS_29_VID_FROM_MEDIA: SpecQuote = SpecQuote { in Section 4.3.", }; +pub const KS_30_HDDVD_PACK_ENCRYPTION: SpecQuote = SpecQuote { + id: "KS-30", + kind: QuoteKind::Normative, + source: HD, + section: "§4.3.2 Pack Encryption, Table 4-7", + locator: "PDF p.88", + url: "", + text: "For each encrypted Pack, the first 128 bytes are called the Unencrypted Portion and the \ + remaining 1920 bytes are called the Encrypted Portion. … Kc = AES-G (Kt, Dtk || \ + CPIlsb_96), … When a Pack is encrypted, the 2-bit PES_scrambling_control shall be 01₂. \ + Otherwise, the PES_scrambling_control shall be 00₂.", +}; + +pub const KS_31_HDDVD_CPI_EVIDENCE: SpecQuote = SpecQuote { + id: "KS-31", + kind: QuoteKind::Evidence, + source: EVIDENCE, + section: "HD DVD: CPI location in the NV_PCK GCI packet", + locator: "two encrypted 300 pressings, decrypted against KS-30", + url: "", + text: "the 16-byte CPI sits at offset 12 of the GCI data (after the 0x04 sub_stream_id); \ + Dtk at pack bytes 84..88 opens 185/185 E-AC-3 packs, 83 or 85 opens 0/185", +}; + /// Every `KS-n` quote, in ID order. pub const ALL: &[&SpecQuote] = &[ &KS_1_ENCRYPT_EVERY_UNIT, @@ -378,4 +404,6 @@ pub const ALL: &[&SpecQuote] = &[ &KS_27_HDDVD_EVIDENCE, &KS_28_FILE_GRID_EVIDENCE, &KS_29_VID_FROM_MEDIA, + &KS_30_HDDVD_PACK_ENCRYPTION, + &KS_31_HDDVD_CPI_EVIDENCE, ]; diff --git a/tests/goldens/parity_image_hddvd_clear.golden b/tests/goldens/parity_image_hddvd_clear.golden index a727432d..f9f99106 100644 --- a/tests/goldens/parity_image_hddvd_clear.golden +++ b/tests/goldens/parity_image_hddvd_clear.golden @@ -1,6 +1,6 @@ -resolve-whole status = KeySetStatus { proven: 1, declared: Some(1), keyed: 1, clear: 0, lazy: 0, forensic: None, origin: Some("keydb"), best_effort: true, requests: 1 } +resolve-whole status = KeySetStatus { proven: 0, declared: Some(1), keyed: 0, clear: 1, lazy: 0, forensic: None, origin: None, best_effort: false, requests: 0 } copy written = 8460288 -copy image = 8460288 bytes sha256:438eaf4106ce92a5550c5fa70574a2d2 -extract result = good=61440 unreadable=6144 complete=false halted=false files=[("BDMV/index.bdmv", 0, 6144, true), ("HVDVD_TS/FEATURE.EVO", 61440, 0, true)] -extract out BDMV/index.bdmv = 6144 bytes sha256:fd9243e1ba57263ed469c3bdbd7ade6e -extract out HVDVD_TS/FEATURE.EVO = 61440 bytes sha256:9be03259626dabeb7c985b6930729f99 +copy image = 8460288 bytes sha256:9b9a739e6f30ca537420be3c2cabdcb5 +extract result = good=67584 unreadable=0 complete=true halted=false files=[("BDMV/index.bdmv", 6144, 0, true), ("HVDVD_TS/FEATURE.EVO", 61440, 0, true)] +extract out BDMV/index.bdmv = 6144 bytes sha256:3a25ec8e4276a359fbd2da7b70b74436 +extract out HVDVD_TS/FEATURE.EVO = 61440 bytes sha256:86e86d1801294d6e82d91d2779c16cea diff --git a/tests/spec_guards.rs b/tests/spec_guards.rs index b425d488..cc873aa3 100644 --- a/tests/spec_guards.rs +++ b/tests/spec_guards.rs @@ -264,43 +264,59 @@ fn v20_stride_64_evidence() { ); } -/// KS-27: HD DVD `.evo` units carry the flag in PES_scrambling_control (bits 5-4 of byte -/// 20). Literal offsets here, so a drifted constant in the library fails this guard. -#[test] -fn hddvd_flag_is_bits_5_4_of_byte_20() { - let unit_with = |at: usize, b: u8| { - let mut u = vec![0u8; ALIGNED_UNIT_LEN]; - u[at] = b; - aacs_unit_encrypted(&u, ContentFormat::MpegPs) - }; - for b in [0x10, 0x20, 0x30, 0xFF] { - assert!(unit_with(20, b), "byte 20 = {b:#04x}"); - } - for b in [0x00, 0x0F, 0x40, 0x80, 0xC0] { - assert!(!unit_with(20, b), "byte 20 = {b:#04x}"); - } - for at in [0, 19, 21] { - assert!(!unit_with(at, 0x30), "byte {at}"); +// An MPEG-2 pack header, then one video PES whose flags byte (`10` marker, then +// PES_scrambling_control in bits 5-4) is `flags`; filler to 6144 bytes (3 packs). +fn ps_unit(flags_per_pack: [u8; 3]) -> Vec { + let mut u = Vec::new(); + for flags in flags_per_pack { + let mut p = vec![ + 0x00, 0x00, 0x01, 0xBA, 0x44, 0, 0x04, 0, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8, + ]; + p.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x07, 0xEC, flags, 0x00, 0x00]); + p.resize(2048, 0x5A); + u.extend_from_slice(&p); } + u } -/// per evidence (no public spec); do not change without evidence proving otherwise — -/// KS-27: the HD DVD book was "removed from this AACS website due to inactivity". +/// KS-30: an encrypted HD DVD pack has PES_scrambling_control `01₂`, judged per pack in the +/// PES header (byte 20 with no pack stuffing): a unit is encrypted when any pack is. #[test] -fn hddvd_flag_offset_is_unverified() { +fn hddvd_flag_is_pes_scrambling_control_of_every_pack() { assert_eq!( - KS_27_HDDVD_EVIDENCE.kind, - libfreemkv::spec::QuoteKind::Evidence + KS_30_HDDVD_PACK_ENCRYPTION.kind, + libfreemkv::spec::QuoteKind::Normative ); - let src = include_str!("../src/aacs/content.rs"); - let lines: Vec<&str> = src.lines().collect(); - let at = lines - .iter() - .position(|l| l.starts_with("const PS_SCRAMBLE_OFF")) - .expect("PS_SCRAMBLE_OFF is defined in aacs/content.rs"); - let context = lines[at.saturating_sub(3)..at].join("\n"); assert!( - context.contains("UNVERIFIED"), - "the UNVERIFIED marker left PS_SCRAMBLE_OFF" + KS_30_HDDVD_PACK_ENCRYPTION + .text + .contains("the 2-bit PES_scrambling_control shall be 01₂") + ); + let enc = |f: [u8; 3]| aacs_unit_encrypted(&ps_unit(f), ContentFormat::MpegPs); + assert!(!enc([0x80, 0x80, 0x80]), "every pack 00"); + assert!(enc([0x90, 0x80, 0x80]), "first pack 01"); + assert!(enc([0x80, 0x80, 0x90]), "only the last pack 01"); + assert!(!enc([0x8F, 0x80, 0x80]), "bits outside 5-4"); + let mut sys = ps_unit([0x80, 0x90, 0x80]); + sys[17] = 0xBB; // the first pack leads with a system header: its byte 20 is no flag + sys[20] = 0xB0; + sys[2048 + 20] = 0x80; + assert!( + !aacs_unit_encrypted(&sys, ContentFormat::MpegPs), + "a system header's byte 20" + ); +} + +/// KS-30: the boundary is the fixed 128th byte, not the end of the PES header. +#[test] +fn hddvd_clear_head_is_128_bytes() { + assert!( + KS_30_HDDVD_PACK_ENCRYPTION + .text + .contains("the first 128 bytes are called the Unencrypted Portion") + ); + assert_eq!( + KS_31_HDDVD_CPI_EVIDENCE.kind, + libfreemkv::spec::QuoteKind::Evidence ); } diff --git a/tests/spec_quotes.rs b/tests/spec_quotes.rs index a425f18b..01e3b99f 100644 --- a/tests/spec_quotes.rs +++ b/tests/spec_quotes.rs @@ -110,11 +110,11 @@ fn const_names_agree_with_ids() { ); } -/// `KS-1`…`KS-29` exist, in order, with no gap (KU design §3.6.1 rule 4). +/// `KS-1`…`KS-31` exist, in order, with no gap (KU design §3.6.1 rule 4). #[test] -fn keys_quotes_are_ks_1_to_29_in_order() { +fn keys_quotes_are_ks_1_to_31_in_order() { let ids: Vec<&str> = spec::keys::ALL.iter().map(|q| q.id).collect(); - let want: Vec = (1..=29).map(|n| format!("KS-{n}")).collect(); + let want: Vec = (1..=31).map(|n| format!("KS-{n}")).collect(); assert_eq!(ids, want); } diff --git a/tests/spec_quotes.txt b/tests/spec_quotes.txt index 71811d40..9a9accf1 100644 --- a/tests/spec_quotes.txt +++ b/tests/spec_quotes.txt @@ -34,6 +34,8 @@ KS-26 FMTS anchor contract: the decode server returns the whole set only for an KS-27 Please note: The AACS Specifications books for HD DVD and CBHD have been removed from this AACS website due to inactivity. KS-28 File-anchored grid on real discs: 32/32 sync on the file grid vs 0–1/32 on the disc-LBA grid (aacs-unit-grid.md §2 table). This corroborates KS-1. KS-29 The Licensed Drive reads Volume ID (Volume_ID) from the media and calculates a message authentication code (Dm) from the Volume ID and the Bus Key (BK) calculated in step 26 in Section 4.3. // test-only reference +KS-30 Table 4-7 shows the Pack encryption format. For each encrypted Pack, the first 128 bytes are called the Unencrypted Portion and the remaining 1920 bytes are called the Encrypted Portion. The Unencrypted Portion is unencrypted and the Encrypted Portion is encrypted. Each Encrypted Pack is encrypted by a 128-bit Content Key (Kc). The Content Key (Kc) is calculated by a 128-bit Title Key (Kt), a 32-bit Title Key Data (Dtk) and the least significant 96 bits of the CPI field in the GCI_PKT as follows: Kc = AES-G (Kt, Dtk || CPIlsb_96), where AES-G denotes the one-way function based on the AES algorithm defined in the AACS Introduction and Common Cryptographic Elements. Note that Title Key Data (Dtk) is just data between the 84th byte and the 87th byte of Pack, inclusive. The Encrypted Portion is encrypted as follows: Ce = AES-128CBCE(Kc, C), where AES-128CBCE denotes encryption by the AES algorithm in the CBC mode defined in the AACS Introduction and Common Cryptographic Elements. When a Pack is encrypted, the 2-bit PES_scrambling_control shall be 01₂. Otherwise, the PES_scrambling_control shall be 00₂. +KS-31 the 16-byte CPI sits at offset 12 of the GCI data (after the 0x04 sub_stream_id); Dtk at pack bytes 84..88 opens 185/185 E-AC-3 packs, 83 or 85 opens 0/185 SS-1 Table 18 Sense key specific field definitions Sense Key Sense Key Specific Field Definition Reference ILLEGAL REQUEST Field pointer 2.4.1.1.4.2 HARDWARE ERROR, MEDIUM ERROR, or RECOVERED ERROR Actual retry count 2.4.1.1.4.3 NO SENSE or NOT READY Progress indication 2.4.1.1.4.4 … 2.4.1.1.4.4 Progress indication sense key specific data If the sense key is NO SENSE or NOT READY, the SENSE KEY SPECIFIC field shall be as shown in table 21. … PROGRESS INDICATION field The PROGRESS INDICATION field is a percent complete indication in which the returned value is a numerator that has 65 536 (10000h) as its denominator. The progress indication shall be based upon the total operation. … SKSV (Sense-key Specific Valid) bit 1 A sense-key specific valid (SKSV) bit set to one indicates the SENSE KEY SPECIFIC field contains valid information as defined in this manual. SS-2 The descriptor format sense data for response codes 72h (current errors) and 73h (deferred errors) is defined in table 12. … The sense key specific sense data descriptor (see table 17) provides additional information about the exception condition. The format and content of the sense-key specific data depends on the value in the SENSE KEY field (see 2.4.1.1.1). SS-3 In the event that a command requires a level of readiness that does not currently exist, the Drive should be terminated with CHECK CONDITION status and sense bytes SK/ASC/ASCQ should be selected from those shown in Table F.3. Table F.3 — Readiness Errors SK ASC ASCQ Description 2 04 00 LOGICAL UNIT NOT READY, CAUSE NOT REPORTABLE 2 04 01 LOGICAL UNIT IS IN PROCESS OF BECOMING READY 2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED 2 04 03 LOGICAL UNIT NOT READY, MANUAL INTERVENTION REQUIRED 2 04 04 LOGICAL UNIT NOT READY, FORMAT IN PROGRESS 2 04 07 LOGICAL UNIT NOT READY, OPERATION IN PROGRESS 2 04 08 LOGICAL UNIT NOT READY, LONG WRITE IN PROGRESS 2 0C 07 WRITE ERROR RECOVERY NEEDED 2 0C 0F DEFECTS IN ERROR WINDOW 2 30 00 INCOMPATIBLE MEDIUM INSTALLED 2 30 01 CANNOT READ MEDIUM – UNKNOWN FORMAT 2 or 5 30 02 CANNOT READ MEDIUM – INCOMPATIBLE FORMAT 2 30 03 CLEANING CARTRIDGE INSTALLED 2 or 5 30 04 CANNOT WRITE MEDIUM – UNKNOWN FORMAT 2 or 5 30 05 CANNOT WRITE MEDIUM – INCOMPATIBLE FORMAT 2 or 5 30 06 CANNOT FORMAT MEDIUM – INCOMPATIBLE MEDIUM 2 30 07 CLEANING FAILURE 2 or 5 30 11 CANNOT WRITE MEDIUM – UNSUPPORTED MEDIUM VERSION 2 3A 00 MEDIUM NOT PRESENT 2 3A 01 MEDIUM NOT PRESENT – TRAY CLOSED 2 3A 02 MEDIUM NOT PRESENT – TRAY OPEN