diff --git a/CHANGELOG.md b/CHANGELOG.md index 985bf74..b82f813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now + buffered, re-aligned to its clip-file 6144-byte unit grid, and verified + (CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`) + before it is signed off as good. A unit that neither a held nor a + freshly-fetched key decrypts is treated exactly like a bad read — re-read by + the patch pass, terminal loss only if truly unrecoverable — closing the + "silent bad read" class where a sector reads OK but its ciphertext is subtly + wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad; + every uncertainty (no keys, a merely-missing key, an unread/zero-filled sector, + a non-AACS disc) leaves the read byte-for-byte as before. Gated by a + compile-time kill-switch (`POST_READ_VERIFY`), and container-pluggable (BD/UHD + transport stream today, with an HD-DVD program-stream seam in place). - **Every error is now `Error: E `, with an Error Codes reference.** User-facing errors show their code so you can look it up, and a new **Error Codes** page lists every code with its message, cause, and next @@ -13,11 +25,22 @@ ### Changed +- **AACS decrypt acceptance is now standards-strict.** A key is accepted only + when the decrypted unit has the TS sync byte on *all* 32 source packets + (libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key + could coincidentally restore enough syncs to pass and silently corrupt a unit. - keydb download/save moved out of the library into freemkv-keysources; libfreemkv no longer has any keydb I/O (it already held no keys). ### Fixed +- **AACS content-certificate bus-encryption flag read from the wrong bit.** The + flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a + bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud + guard that refuses to decrypt bus-wrapped data to garbage when no bus key was + obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker + (`0x10`). Confirmed against real retail content certificates. + - **DVD rips now start on the movie, not the disc menu.** A VTS title VOB's start sector was read from the IFO as a VTS-relative pointer but used as an absolute disc address, so a DVD title's read extents began `ifo_lba` sectors diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 7dfbef6..b837ea0 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -2521,15 +2521,19 @@ mod tests { #[test] fn parse_unit_key_ro_title_cps_mapping_first_play_top_menu_then_titles() { // [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then - // per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. - let mut data = build_unit_key_ro(2, 64); - data[20..22].copy_from_slice(&7u16.to_be_bytes()); // first_play - data[22..24].copy_from_slice(&9u16.to_be_bytes()); // top_menu - data[24..26].copy_from_slice(&2u16.to_be_bytes()); // num_titles - data[28..30].copy_from_slice(&3u16.to_be_bytes()); // title 0 CPS - data[32..34].copy_from_slice(&4u16.to_be_bytes()); // title 1 CPS + // per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc + // 1-based CPS number in `1..=num_uk` is validated and converted to a + // 0-based key index (libaacs unit_key.c); an out-of-range number → 0. + let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid + data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1 + data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2 + data[24..26].copy_from_slice(&3u16.to_be_bytes()); // num_titles = 3 + data[28..30].copy_from_slice(&3u16.to_be_bytes()); // title 0 CPS 3 + data[32..34].copy_from_slice(&4u16.to_be_bytes()); // title 1 CPS 4 + data[36..38].copy_from_slice(&9u16.to_be_bytes()); // title 2 CPS 9 (> num_uk) let p = parse_unit_key_ro(&data, AacsVersion::V20).unwrap(); - assert_eq!(p.title_cps_unit, vec![7, 9, 3, 4]); + // 1-based CPS {1,2,3,4} → 0-based {0,1,2,3}; out-of-range 9 → 0. + assert_eq!(p.title_cps_unit, vec![0, 1, 2, 3, 0]); } // ── MKB record framing: rec_len is BE24 incl. 4-byte header ──────────── #[test] diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 24b7be8..0fd963e 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -32,8 +32,8 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs. pub use decrypt::{ ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, - aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, - decrypt_unit_checked, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, + aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full, + decrypt_unit_try_keys, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps, unit_is_clean_ts, unit_key_validates, }; // `probe` is a reproduction-harness helper (see keys.rs), not part of the diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index f0d7b4b..2dbd692 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -547,11 +547,12 @@ mod tests { } /// A content certificate: type byte@0 (0x00 = V10, else V20), - /// bus_encryption bit0@1, cc_id@2..8 (aacs/keys.rs parse_content_cert). + /// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert, + /// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`). fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec { - let mut v = vec![0u8; 8]; + let mut v = vec![0u8; 20]; v[0] = cert_type; - v[1] = if bus_encryption { 0x01 } else { 0x00 }; + v[1] = if bus_encryption { 0x80 } else { 0x00 }; v } diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 25994ea..ea15a61 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -304,7 +304,6 @@ impl Disc { None => base_keys.clone(), } } - } /// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF @@ -1187,7 +1186,6 @@ mod tests { std::fs::read(dir.join(rel)).ok() } - // ── Tests ───────────────────────────────────────────────────────────── /// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via diff --git a/src/disc/verify.rs b/src/disc/verify.rs index 7836b29..0262d1e 100644 --- a/src/disc/verify.rs +++ b/src/disc/verify.rs @@ -439,7 +439,10 @@ impl UnitVerifier { } let accept = self.accept_for(clip); if readable - && matches!(self.decryptability(&raw, accept), Decryptability::Undecryptable) + && matches!( + self.decryptability(&raw, accept), + Decryptability::Undecryptable + ) { push_ranges(&mut bad, &lbas); } @@ -550,7 +553,10 @@ mod tests { /// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors). fn one_clip(lba: u32) -> Vec { - vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(lba, ALIGNED_UNIT_LEN as u32)])] + vec![ts_clip( + ALIGNED_UNIT_LEN as u64, + vec![(lba, ALIGNED_UNIT_LEN as u32)], + )] } /// Build a BD-TS `ClipLayout` (the only container current enumeration emits). @@ -615,7 +621,10 @@ mod tests { u[4] = 0x00; // break the first packet's sync let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); let bad = v.observe(100, &u); - assert!(bad.is_empty(), "clear-but-not-clean unit -> skip, never false-bad"); + assert!( + bad.is_empty(), + "clear-but-not-clean unit -> skip, never false-bad" + ); } // ── encrypted (CPI set) content ──────────────────────────────────────── @@ -699,7 +708,10 @@ mod tests { c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); vec![real] }); - let clips = vec![ts_clip(2 * ALIGNED_UNIT_LEN as u64, vec![(200, 2 * ALIGNED_UNIT_LEN as u32)])]; + let clips = vec![ts_clip( + 2 * ALIGNED_UNIT_LEN as u64, + vec![(200, 2 * ALIGNED_UNIT_LEN as u32)], + )]; let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); let mut u0 = clear_unit(); encrypt_unit(&mut u0, &real); @@ -724,7 +736,10 @@ mod tests { let real = [0x42; 16]; let mut u = clear_unit(); encrypt_unit(&mut u, &real); - let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(10, 4096), (5000, 2048)])]; + let clips = vec![ts_clip( + ALIGNED_UNIT_LEN as u64, + vec![(10, 4096), (5000, 2048)], + )]; // Wrong key + a fetch that yields wrong keys => confident bad, fragmented. let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); @@ -748,7 +763,10 @@ mod tests { let key = [0x5a; 16]; let mut u0 = clear_unit(); encrypt_unit(&mut u0, &key); - let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64 + 2048, vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)])]; + let clips = vec![ts_clip( + ALIGNED_UNIT_LEN as u64 + 2048, + vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)], + )]; let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); // Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is // judged; the tail is never a verdict. @@ -843,7 +861,10 @@ mod tests { let key = [0x5a; 16]; let mut u = clear_unit(); encrypt_unit(&mut u, &key); - let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + 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); @@ -855,15 +876,23 @@ mod tests { let real = [0x11; 16]; let mut u = clear_unit(); encrypt_unit(&mut u, &real); - let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + let mut iso = MockIso { + sectors: Default::default(), + err_lba: None, + }; place_unit(&mut iso, [100, 101, 102], &u); // Wrong held key + a fetch that yields a wrong key => confident bad. let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); // A range covering only ONE sector of the unit still re-reads the WHOLE // unit from the ISO (patch re-reads partial units). let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)], &|_| true); - assert_eq!(bad, vec![(100, 3)], "undecryptable unit -> full 3-sector range"); + assert_eq!( + bad, + vec![(100, 3)], + "undecryptable unit -> full 3-sector range" + ); } #[test] @@ -872,8 +901,14 @@ mod tests { let mut u = clear_unit(); encrypt_unit(&mut u, &key); // Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B). - let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(10, 4096), (5000, 2048)])]; - let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + let clips = vec![ts_clip( + ALIGNED_UNIT_LEN as u64, + vec![(10, 4096), (5000, 2048)], + )]; + let mut iso = MockIso { + sectors: Default::default(), + err_lba: None, + }; place_unit(&mut iso, [10, 11, 5000], &u); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); // Range touches only the distant fragment; whole unit still assembled. @@ -892,9 +927,13 @@ mod tests { }; place_unit(&mut iso, [100, 101, 102], &u); let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true); - assert!(bad.is_empty(), "ISO read error on a sector -> skip (fail-safe)"); + assert!( + bad.is_empty(), + "ISO read error on a sector -> skip (fail-safe)" + ); } #[test] @@ -906,14 +945,22 @@ mod tests { let real = [0x11; 16]; let mut u = clear_unit(); encrypt_unit(&mut u, &real); - let mut iso = MockIso { sectors: Default::default(), err_lba: None }; + let mut iso = MockIso { + sectors: Default::default(), + err_lba: None, + }; place_unit(&mut iso, [100, 101, 102], &u); - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| panic!("must NOT key-fetch an unread unit")); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); + let fetch: KeyFetch = + Arc::new(|_s: &[Vec]| panic!("must NOT key-fetch an unread unit")); + let mut v = + UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); // 102 not Finished -> the whole unit is skipped. let is_finished = |lba: u32| lba != 102; let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &is_finished); - assert!(bad.is_empty(), "unit with an unread sector is skipped, not flagged or fetched"); + assert!( + bad.is_empty(), + "unit with an unread sector is skipped, not flagged or fetched" + ); } #[test] @@ -951,7 +998,10 @@ mod tests { // Open more partials than the cap with single-sector feeds; the map must // never exceed the cap (oldest evicted, unverified — fail-safe). let mut v = UnitVerifier::new( - &vec![ts_clip((MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64, vec![(0, u32::MAX / 2)])], + &vec![ts_clip( + (MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64, + vec![(0, u32::MAX / 2)], + )], &aacs_keys(&[[1; 16]]), None, )