From 8d39ccc61385b9d15a543b8deb397be1bf9fc786 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:44:37 -0700 Subject: [PATCH] fix(decrypt): an encrypted unit outside every key range fails, not passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decrypt_sectors_mapped returned early for any LBA no map range covers, on the reasoning that unmapped means clear filesystem or nav. "The map has no key here" and "there is nothing to decrypt here" are different statements, and only the second makes passing the unit through correct. On a multi-CPS disc an orphan clip — referenced by no playlist, so in no title extent and therefore in no range — hits the first and was treated as the second. The early return fired BEFORE the aacs_unit_encrypted gate below it, so nothing ever asked whether those bytes were ciphertext. extract_tree then counted them as bytes_good, dropped the .partial suffix, set complete = true and exited 0: a scrambled file on disk with a clean bill of health. FileResult's own doc already stated the intended contract — "unreadable sectors AND undecryptable units both land here (extract fails a bad decrypt loud)". The code did not implement it on this path. The fix is one line of symmetry. The split-unit branch immediately above already makes exactly this distinction, checking aacs_unit_seed_encrypted before refusing. This branch did not, so the same question got two answers eight lines apart — the duplication shape this release keeps finding. Deliberately NOT changed: the decrypt decision on an orphan is still to refuse rather than guess. Blind trial-decrypt is what the keymap-only model exists to remove, and extract has no CPS/forensic fetch source. The defect was never that we declined to key it; it was that declining looked like success. The test asserts BOTH directions — a clear out-of-range unit must still pass through byte-identical, because that is the ordinary whole-disc read and breaking it would trade one silent defect for a loud one. --- src/decrypt.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index b9155ad..6da4ac8 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -441,9 +441,24 @@ pub(crate) fn decrypt_sectors_mapped( return; } let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); - // No range covers this LBA → the map keys no content here, so pass the - // unit through untouched (clear filesystem / nav on a whole-disc read). + // No range covers this LBA. That is expected for clear filesystem / nav + // on a whole-disc read — but "the map has no key here" and "there is + // nothing to decrypt here" are different statements, and only the + // second makes passing the unit through correct. + // + // An ENCRYPTED unit outside every range is content we cannot key: on a + // multi-CPS disc that is an orphan clip referenced by no playlist, so + // it sits in no title extent and therefore in no range. Emitting it + // verbatim ships ciphertext where plaintext is meant to be, and extract + // then counts those bytes as good and reports the file complete. + // + // The split-unit branch immediately above already draws exactly this + // distinction. This one did not, so it was reached before the + // `aacs_unit_encrypted` gate below ever ran. let Some((key_idx, phase, range_start)) = map.entry_for(unit_lba) else { + if aacs::content::aacs_unit_seed_encrypted(chunk, format) { + verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + } return; }; // PHASE GATE (FMTS forensic segment): the segment interleaves two variants @@ -1404,6 +1419,68 @@ mod tests { ); } + /// An ENCRYPTED unit that falls outside every key-map range must fail, not + /// pass through as ciphertext. + /// + /// "The map has no key here" and "there is nothing to decrypt here" are + /// different statements, and only the second makes passing the unit through + /// correct. On a multi-CPS disc an orphan clip — referenced by no playlist, + /// so in no title extent and therefore in no range — hits the first and was + /// treated as the second. `extract_tree` then counted those bytes as GOOD, + /// dropped the `.partial` suffix, and reported `complete: true`, exit 0: + /// a scrambled file on disk with a clean bill of health. + /// + /// A CLEAR unit outside every range is the ordinary case (filesystem and + /// nav on a whole-disc read) and must still pass through untouched — so + /// this asserts both directions. + #[test] + fn an_encrypted_unit_outside_every_key_range_fails_instead_of_passing_through() { + use crate::disc::ContentFormat; + let key = [0xAAu8; 16]; + let ul = aacs::content::ALIGNED_UNIT_LEN; + let usz = (ul / 2048) as u32; + + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + // The map covers unit 0 only. Unit 1 is the orphan. + let map = AacsKeyMap::from_ranges(vec![(0, usz, 0)]); + + // Clear orphan: untouched, no error. This is nav/filesystem. + let mut clear_buf = vec![0u8; 2 * ul]; + let mut u0 = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u0, &key); + clear_buf[..ul].copy_from_slice(&u0); + clear_buf[ul..].copy_from_slice(&clear_ts_unit()); + let orphan_before = clear_buf[ul..].to_vec(); + decrypt_sectors_mapped(&mut clear_buf, &keys, 0, &map) + .expect("a CLEAR unit outside the map is ordinary nav and must pass"); + assert_eq!( + &clear_buf[ul..], + &orphan_before[..], + "a clear out-of-range unit must be left byte-identical" + ); + + // Encrypted orphan: must fail loud. + let mut enc_buf = vec![0u8; 2 * ul]; + let mut v0 = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut v0, &key); + enc_buf[..ul].copy_from_slice(&v0); + let mut orphan = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut orphan, &[0xCCu8; 16]); + enc_buf[ul..].copy_from_slice(&orphan); + + let err = decrypt_sectors_mapped(&mut enc_buf, &keys, 0, &map) + .expect_err("an encrypted unit we hold no key for must not be emitted"); + assert_eq!( + err.code(), + crate::error::Error::DecryptFailed.code(), + "same verdict CSS and the split-unit branch give for 'no provable key'" + ); + } + /// Phase::Even → only even-index units in the range are decrypted; the odd /// (alternate variant) half is left BYTE-FOR-BYTE as ciphertext for the muxer. #[test]