From c8eb42b490e51bb654ea4370d2248400d9ad1bab Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:37:56 -0700 Subject: [PATCH] v0.26.11: detect AACS unit encryption via TS scrambling-control bits is_unit_encrypted read the TP_extra copy-control bits (byte 0), which are a copy-permission flag, not an encryption flag. On discs whose sampled units are clear navigation packets (PAT/PMT) those bits can be set while the unit is not scrambled, so a correct Unit Key was used to 'decrypt' already-plaintext data, produced garbage, and the key was wrongly treated as failing. Read the actual flag instead: the TS transport_scrambling_control bits (top two of TS-header byte 3 = byte 7 of the aligned unit, inside the clear seed). AACS encrypts whole aligned units, so this one packet's TSC reflects the unit. decrypt_unit now clears the TSC bits of every packet on the way out so the result is valid unscrambled TS. Tests updated to the TSC flag. --- Cargo.toml | 2 +- src/aacs/decrypt.rs | 33 ++++++++++++++--- tests/crypto_tests.rs | 77 ++++++++++++++++++++++----------------- tests/pass_n_patch_fix.rs | 17 +++++---- 4 files changed, 82 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6d75818..c26e0bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.26.8" +version = "0.26.11" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index d561d29..470d402 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -69,9 +69,21 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { // ── Content decryption ────────────────────────────────────────────────────── -/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits). +/// Check if a 6144-byte aligned unit is encrypted. +/// +/// AACS encrypts at aligned-unit granularity (all-or-nothing per unit) and +/// signals it via the TS `transport_scrambling_control` (TSC) bits — the top +/// two bits of TS-header byte 3, which is byte 7 of the unit (4-byte +/// TP_extra_header + sync + PID/flags). TSC `00` = clear, non-zero = scrambled. +/// Byte 7 sits inside the clear 16-byte seed, so this is readable without the +/// key. +/// +/// NOTE: the earlier check read byte 0's TP_extra copy-control bits (`& 0xC0`), +/// which are copy-permission, NOT encryption status — they false-positive on +/// clear navigation units (PAT/PMT at a clip's start), causing a correct key to +/// be decrypted against clear data and wrongly rejected. pub fn is_unit_encrypted(unit: &[u8]) -> bool { - unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0 + unit.len() >= ALIGNED_UNIT_LEN && (unit[7] >> 6) & 0x03 != 0 } /// Verify decrypted unit by checking TS sync bytes at expected offsets. @@ -124,8 +136,16 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { // Step 3: Decrypt bytes 16..6143 with AES-CBC aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - // Step 4: Clear encryption flag - unit[0] &= !0xC0; + // Step 4: Clear the encryption flag — the TS transport_scrambling_control + // bits (top two of TS-header byte 3) of every packet, so the output is + // valid unscrambled TS. Each 192-byte cell's TS header byte 3 sits at + // offset 7 within the cell. (The old code cleared byte 0's TP_extra + // copy-control bits, which are NOT the scrambling flag.) + let mut off = 7; + while off < ALIGNED_UNIT_LEN { + unit[off] &= 0x3F; + off += TS_PACKET_LEN; + } // Verify verify_ts(unit) @@ -253,8 +273,9 @@ mod tests { plain[offset] = TS_SYNC; offset += TS_PACKET_LEN; } - // Set encryption flag - plain[0] |= 0xC0; + // Set the encryption flag: TS transport_scrambling_control (top two + // bits of byte 7), inside the clear seed. + plain[7] |= 0x80; // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt) let header: [u8; 16] = plain[..16].try_into().unwrap(); diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index c1aa6a4..44af407 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -89,8 +89,9 @@ fn aacs_decrypt_unit_roundtrip() { plain[offset] = 0x47; // TS sync byte offset += 192; } - // Set encryption flag (bits 6-7 of byte 0) - plain[0] |= 0xC0; + // Set encryption flag: TS transport_scrambling_control (top two bits of + // byte 7), inside the clear seed. + plain[7] |= 0x80; // Save original plaintext for comparison let expected = plain.clone(); @@ -156,18 +157,15 @@ fn aacs_decrypt_unit_roundtrip() { sync_count, expected_syncs ); - // Compare all bytes except byte 0 (encryption flag cleared) + // decrypt clears the TSC bits (byte 7, top two) — the only change from the + // original plaintext. Everything else round-trips exactly. + assert_eq!(plain[7], expected[7] & 0x3F, "TSC bits should be cleared"); + assert_eq!(&plain[..7], &expected[..7], "bytes 0..7 mismatch"); assert_eq!( - &plain[1..aacs::ALIGNED_UNIT_LEN], - &expected[1..aacs::ALIGNED_UNIT_LEN], + &plain[8..aacs::ALIGNED_UNIT_LEN], + &expected[8..aacs::ALIGNED_UNIT_LEN], "decrypted unit body does not match original" ); - // Byte 0: original had 0xC0 set, decrypted has it cleared - assert_eq!( - plain[0] & !0xC0, - expected[0] & !0xC0, - "byte 0 mismatch ignoring flag" - ); } /// Test 7: aacs_disc_hash_deterministic @@ -270,20 +268,27 @@ fn aacs_is_unit_encrypted_detection() { "zero unit should not be encrypted" ); - unit[0] = 0x40; // bit 6 set + // The encryption flag is the TS transport_scrambling_control (top two bits + // of byte 7). Any non-zero TSC = encrypted. + unit[7] = 0x40; // TSC = 01 assert!(aacs::is_unit_encrypted(&unit)); - - unit[0] = 0x80; // bit 7 set + unit[7] = 0x80; // TSC = 10 assert!(aacs::is_unit_encrypted(&unit)); - - unit[0] = 0xC0; // both bits set + unit[7] = 0xC0; // TSC = 11 assert!(aacs::is_unit_encrypted(&unit)); - - unit[0] = 0x3F; // bits 6-7 clear + unit[7] = 0x3F; // top two bits clear assert!(!aacs::is_unit_encrypted(&unit)); + // Byte 0's TP_extra copy-control bits are NOT the encryption flag. + unit[7] = 0x00; + unit[0] = 0xC0; + assert!( + !aacs::is_unit_encrypted(&unit), + "byte-0 copy-control bits must not be read as encryption" + ); + // Too short - let short = vec![0xC0u8; 100]; + let short = vec![0xFFu8; 100]; assert!( !aacs::is_unit_encrypted(&short), "short buffer should not be detected" @@ -296,7 +301,7 @@ fn aacs_is_unit_encrypted_detection() { #[test] fn aacs_decrypt_unit_unencrypted_passthrough() { let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN]; - unit[0] = 0x00; // no encryption flag + unit[7] &= 0x3F; // TSC = 0 → clear/unencrypted unit let original = unit.clone(); let key = [0xAA; 16]; @@ -369,8 +374,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() { plaintext[i] = (i % 251) as u8; } } - // Set encryption flag - plaintext[0] = 0xC0; + // Set encryption flag: TSC bits of packet 0 (byte 7). + plaintext[7] |= 0x80; let expected = plaintext.clone(); @@ -401,14 +406,19 @@ fn aacs_cross_validation_encrypt_then_decrypt() { ok, "decrypt_unit returned false (TS sync verification failed)" ); - assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared"); + assert_eq!(plaintext[7] >> 6, 0, "TSC bits not cleared"); - // Compare (byte 0 flag was cleared) + // decrypt clears the TSC bits of every packet (byte 7 of each 192-byte + // cell). Clear the same positions in the expected copy before comparing. let mut expected_cleared = expected.clone(); - expected_cleared[0] &= !0xC0; + let mut o = 7; + while o < aacs::ALIGNED_UNIT_LEN { + expected_cleared[o] &= 0x3F; + o += 192; + } assert_eq!( - &plaintext[1..aacs::ALIGNED_UNIT_LEN], - &expected_cleared[1..aacs::ALIGNED_UNIT_LEN], + &plaintext[..], + &expected_cleared[..], "decrypted unit does not match original plaintext" ); } @@ -428,7 +438,7 @@ fn aacs_cross_validation_alternate_key() { plaintext[off] = 0x47; off += 192; } - plaintext[0] = 0xC0; + plaintext[7] |= 0x80; // TSC encryption flag let expected = plaintext.clone(); let mut header = [0u8; 16]; @@ -447,11 +457,12 @@ fn aacs_cross_validation_alternate_key() { assert!(aacs::decrypt_unit(&mut plaintext, &unit_key)); let mut expected_cleared = expected; - expected_cleared[0] &= !0xC0; - assert_eq!( - &plaintext[1..aacs::ALIGNED_UNIT_LEN], - &expected_cleared[1..aacs::ALIGNED_UNIT_LEN], - ); + let mut o = 7; + while o < aacs::ALIGNED_UNIT_LEN { + expected_cleared[o] &= 0x3F; + o += 192; + } + assert_eq!(&plaintext[..], &expected_cleared[..]); } /// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 9cc4e13..807f05a 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -84,20 +84,23 @@ fn decrypt_sectors_with_css_keys_works() { fn aacs_encryption_flag_detection() { let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; - // No encryption flag + // The encryption flag is the TS transport_scrambling_control (top two bits + // of byte 7), not byte 0's copy-control bits. assert!(!aacs::is_unit_encrypted(&unit)); - // Set bit 6 - unit[0] |= 0x40; + unit[7] = 0x40; // TSC = 01 assert!(aacs::is_unit_encrypted(&unit)); - // Set bit 7 - unit[0] = 0x80; + unit[7] = 0x80; // TSC = 10 assert!(aacs::is_unit_encrypted(&unit)); - // Both bits set + unit[7] = 0xC0; // TSC = 11 + assert!(aacs::is_unit_encrypted(&unit)); + + // Byte 0 copy-control bits must NOT count as encryption. + unit[7] = 0x00; unit[0] = 0xC0; - assert!(aacs::is_unit_encrypted(&unit)); + assert!(!aacs::is_unit_encrypted(&unit)); } /// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.