From b518860d9cd7e97ebde588f3f4088e8442471ecc Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:35:50 -0700 Subject: [PATCH] v0.27.0: detect AACS-scrambled units by raw TS sync, not flag bits Rename is_unit_encrypted -> is_aacs_scrambled and decide encryption from the unit's MPEG-TS sync bytes (destroyed by the encrypted body) instead of the TP_extra copy-control (byte 0) or TS scrambling-control (byte 7) flags, which discs do not set reliably. One shared predicate now backs the decrypt gate and out-of-band key validation, so callers agree on what 'encrypted' means. Decryption restores the syncs, so a decrypted unit reads as clear and there is no flag to clear. --- CHANGELOG.md | 15 +++++ Cargo.toml | 2 +- src/aacs/decrypt.rs | 84 ++++++++++++++-------------- src/aacs/keys.rs | 2 +- src/aacs/mod.rs | 2 +- src/decrypt.rs | 21 +++---- tests/crypto_tests.rs | 113 ++++++++++++++++++-------------------- tests/pass_n_patch_fix.rs | 31 +++++------ 8 files changed, 138 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 483b8e1..fbb0140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.27.0 (2026-06-03) + +### Changed + +- **AACS unit-encryption detection now reads the raw MPEG-TS sync bytes + instead of header flag bits.** `is_unit_encrypted` is renamed + `is_aacs_scrambled`: a unit is encrypted iff its body TS packet sync bytes + (`0x47` at offset 4 and every 192 bytes) are not intact — the encrypted + body destroys them. The previous check read the TP_extra copy-control bits + (byte 0) or TS transport_scrambling_control bits (byte 7), which discs and + players do not set reliably. A single predicate now backs both the decrypt + gate and out-of-band key validation, so all callers agree on what + "encrypted" means. Decryption restores the syncs, so a decrypted unit reads + as clear and there is no encryption flag to clear. + ## 0.26.1 (2026-05-22) ### Added diff --git a/Cargo.toml b/Cargo.toml index c26e0bd..f805303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.26.11" +version = "0.27.0" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 470d402..23bbf19 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -69,28 +69,29 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { // ── Content decryption ────────────────────────────────────────────────────── -/// Check if a 6144-byte aligned unit is encrypted. +/// True if a 6144-byte aligned unit is AACS-scrambled on disc. /// -/// 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. +/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`) +/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source +/// packet). So "scrambled" = "the TS syncs are NOT intact". This is +/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) +/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably +/// across discs/players. /// -/// 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[7] >> 6) & 0x03 != 0 +/// This is the single shared definition of "encrypted" for the whole ecosystem +/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key +/// service's validation gate all call THIS, so they always agree on what is +/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`, +/// so the decrypt path never double-decrypts and there is no flag to clear. +pub fn is_aacs_scrambled(unit: &[u8]) -> bool { + unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit) } -/// Verify decrypted unit by checking TS sync bytes at expected offsets. -fn verify_ts(unit: &[u8]) -> bool { - // In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header - // then 188-byte TS packet, repeating every 192 bytes. - // Sync byte 0x47 should appear at offset 4, 196, 388, ... +/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the +/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes +/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body +/// scrambles all but the first (which lives in the clear 16-byte seed). +fn ts_syncs_intact(unit: &[u8]) -> bool { let mut count = 0; let mut offset = 4; while offset < unit.len() { @@ -99,11 +100,15 @@ fn verify_ts(unit: &[u8]) -> bool { } offset += TS_PACKET_LEN; } - // Expect at least most packets to have sync bytes let total = (unit.len() - 4) / TS_PACKET_LEN + 1; count > total / 2 } +/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact). +fn verify_ts(unit: &[u8]) -> bool { + ts_syncs_intact(unit) +} + /// Decrypt one AACS aligned unit (6144 bytes) in-place. /// Returns true if decryption succeeded (verified by TS sync bytes). /// @@ -111,12 +116,14 @@ fn verify_ts(unit: &[u8]) -> bool { /// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived /// 2. XOR derived with original 16 bytes → unit_decrypt_key /// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV -/// 4. Clear encryption flag bits +/// +/// Decryption restores the TS sync bytes, so the unit reads as clear afterward; +/// there is no flag to clear. pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { if unit.len() < ALIGNED_UNIT_LEN { return false; } - if !is_unit_encrypted(unit) { + if !is_aacs_scrambled(unit) { return true; // not encrypted } @@ -136,24 +143,13 @@ 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 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 + // Decryption restored the TS syncs; verify the unit now looks like clear TS. verify_ts(unit) } /// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked. pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option { - if !is_unit_encrypted(unit) { + if !is_aacs_scrambled(unit) { return Some(0); } @@ -193,7 +189,7 @@ pub fn decrypt_unit_full( unit_key: &[u8; 16], read_data_key: Option<&[u8; 16]>, ) -> bool { - if !is_unit_encrypted(unit) { + if !is_aacs_scrambled(unit) { return true; } if let Some(rdk) = read_data_key { @@ -220,10 +216,15 @@ mod tests { #[test] fn test_decrypt_unit_unencrypted() { - // Unit with 0xC0 bits clear should pass through unchanged + // A clear unit (TS syncs intact) is not scrambled → passes through. let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; - unit[0] = 0x00; // not encrypted + let mut off = 4; + while off < ALIGNED_UNIT_LEN { + unit[off] = TS_SYNC; + off += TS_PACKET_LEN; + } let key = [0u8; 16]; + assert!(!is_aacs_scrambled(&unit)); assert!(decrypt_unit(&mut unit, &key)); } @@ -273,9 +274,8 @@ mod tests { plain[offset] = TS_SYNC; offset += TS_PACKET_LEN; } - // Set the encryption flag: TS transport_scrambling_control (top two - // bits of byte 7), inside the clear seed. - plain[7] |= 0x80; + // No flag set: CBC-encrypting the body below scrambles packets 1..31's + // TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt) let header: [u8; 16] = plain[..16].try_into().unwrap(); @@ -302,9 +302,9 @@ mod tests { // Now plain contains encrypted data. Decrypt it. let mut unit = plain; - assert!(is_unit_encrypted(&unit)); + assert!(is_aacs_scrambled(&unit)); assert!(decrypt_unit(&mut unit, &unit_key)); - assert!(!is_unit_encrypted(&unit)); // flag should be cleared + assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored // Verify TS sync bytes let mut count = 0; diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index bb9eb72..26e01b7 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -1292,7 +1292,7 @@ mod tests { let original = std::fs::read(unit_path).unwrap(); assert_eq!(original.len(), ALIGNED_UNIT_LEN); assert!( - super::super::decrypt::is_unit_encrypted(&original), + super::super::decrypt::is_aacs_scrambled(&original), "Unit should be encrypted" ); diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 1d67836..5a0cf4f 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -24,7 +24,7 @@ pub mod variants; // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs. pub use decrypt::{ ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, - is_unit_encrypted, + is_aacs_scrambled, }; pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; pub use keys::probe; diff --git a/src/decrypt.rs b/src/decrypt.rs index b84d479..b40f255 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -185,12 +185,13 @@ pub fn decrypt_sectors( let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect(); let nunits = chunks.len(); - // Per-unit decrypt closure. The is_unit_encrypted check is - // a byte-0 heuristic; on a misfire we snapshot+restore via - // the original bytes so non-m2ts (e.g. MPLS/CLPI nav files) - // survive. See test `nav_file_unit_survives_decrypt_attempt`. + // Per-unit decrypt closure. The is_aacs_scrambled check reads the + // raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look + // scrambled and trigger a decrypt attempt, so on a verify miss we + // snapshot+restore the original bytes so it survives. See test + // `nav_file_unit_survives_decrypt_attempt`. let decrypt_one = |chunk: &mut [u8]| { - if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) { + if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) { let original: Vec = chunk.to_vec(); if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) { chunk.copy_from_slice(&original); @@ -232,11 +233,11 @@ pub fn decrypt_sectors( mod tests { use super::*; - /// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose - /// first byte has the top 2 bits set (here: the ASCII letter 'M' that - /// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`, - /// gets AES-decrypted with the unit key, fails the TS-sync verification, - /// and must be restored to its original bytes — not left scrambled. + /// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here + /// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled + /// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails + /// the TS-sync verification, and must be restored to its original bytes — + /// not left scrambled. #[test] fn nav_file_unit_survives_decrypt_attempt() { let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 44af407..d0ae377 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -89,9 +89,8 @@ fn aacs_decrypt_unit_roundtrip() { plain[offset] = 0x47; // TS sync byte offset += 192; } - // Set encryption flag: TS transport_scrambling_control (top two bits of - // byte 7), inside the clear seed. - plain[7] |= 0x80; + // No flag set: CBC-encrypting the body below scrambles packets 1..31's TS + // syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. // Save original plaintext for comparison let expected = plain.clone(); @@ -127,8 +126,8 @@ fn aacs_decrypt_unit_roundtrip() { prev.copy_from_slice(&plain[off..off + 16]); } - // Verify it looks encrypted - assert!(aacs::is_unit_encrypted(&plain)); + // Verify it looks encrypted (body TS syncs scrambled) + assert!(aacs::is_aacs_scrambled(&plain)); // Now decrypt let result = aacs::decrypt_unit(&mut plain, &unit_key); @@ -137,8 +136,8 @@ fn aacs_decrypt_unit_roundtrip() { "decrypt_unit should return true on valid encrypted unit" ); assert!( - !aacs::is_unit_encrypted(&plain), - "encryption flag should be cleared" + !aacs::is_aacs_scrambled(&plain), + "decrypted unit should read as clear (TS syncs restored)" ); // Verify TS sync bytes at expected positions (flag byte is cleared by decrypt) @@ -157,14 +156,11 @@ fn aacs_decrypt_unit_roundtrip() { sync_count, expected_syncs ); - // 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"); + // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!( - &plain[8..aacs::ALIGNED_UNIT_LEN], - &expected[8..aacs::ALIGNED_UNIT_LEN], - "decrypted unit body does not match original" + &plain[..], + &expected[..], + "decrypted unit does not match original plaintext" ); } @@ -258,56 +254,65 @@ fn aacs_vuk_derivation_roundtrip() { assert_eq!(vuk, vuk2, "derive_vuk not deterministic"); } -/// Test: aacs_is_unit_encrypted detects encryption flags correctly. +/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs. #[test] -fn aacs_is_unit_encrypted_detection() { - let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; - +fn aacs_is_aacs_scrambled_detection() { + // A clear unit: TS sync (0x47) intact at every 192-byte packet → not + // scrambled. (Flag bits play no role.) + let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN]; + let mut off = 4; + while off < aacs::ALIGNED_UNIT_LEN { + clear[off] = 0x47; + off += 192; + } assert!( - !aacs::is_unit_encrypted(&unit), - "zero unit should not be encrypted" + !aacs::is_aacs_scrambled(&clear), + "clear unit (syncs intact) must not be scrambled" ); - // 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[7] = 0x80; // TSC = 10 - assert!(aacs::is_unit_encrypted(&unit)); - unit[7] = 0xC0; // TSC = 11 - assert!(aacs::is_unit_encrypted(&unit)); - 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; + // Flag bits do NOT make a synced unit look scrambled. + let mut flagged = clear.clone(); + flagged[0] = 0xC0; // copy-control bits + flagged[7] = 0xC0; // TSC bits assert!( - !aacs::is_unit_encrypted(&unit), - "byte-0 copy-control bits must not be read as encryption" + !aacs::is_aacs_scrambled(&flagged), + "flag bits must not be read as encryption" + ); + + // A scrambled body (syncs destroyed) → scrambled. + let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; + assert!( + aacs::is_aacs_scrambled(&scrambled), + "unit with no intact TS syncs must read as scrambled" ); // Too short let short = vec![0xFFu8; 100]; assert!( - !aacs::is_unit_encrypted(&short), + !aacs::is_aacs_scrambled(&short), "short buffer should not be detected" ); } /// Test: aacs_decrypt_unit_unencrypted_passthrough /// -/// A unit without encryption flags should pass through decrypt_unit unchanged. +/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged. #[test] fn aacs_decrypt_unit_unencrypted_passthrough() { let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN]; - unit[7] &= 0x3F; // TSC = 0 → clear/unencrypted unit + // Intact TS syncs every 192 bytes → not scrambled → passthrough. + let mut off = 4; + while off < aacs::ALIGNED_UNIT_LEN { + unit[off] = 0x47; + off += 192; + } let original = unit.clone(); let key = [0xAA; 16]; + assert!(!aacs::is_aacs_scrambled(&unit)); let result = aacs::decrypt_unit(&mut unit, &key); - assert!(result, "unencrypted unit should return true"); - assert_eq!(unit, original, "unencrypted unit should be unchanged"); + assert!(result, "clear unit should return true"); + assert_eq!(unit, original, "clear unit should be unchanged"); } // ── AACS cross-validation with independent AES implementation ────────────── @@ -374,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() { plaintext[i] = (i % 251) as u8; } } - // Set encryption flag: TSC bits of packet 0 (byte 7). - plaintext[7] |= 0x80; + // No flag set: the CBC-encrypted body scrambles the packet syncs. let expected = plaintext.clone(); @@ -406,19 +410,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() { ok, "decrypt_unit returned false (TS sync verification failed)" ); - assert_eq!(plaintext[7] >> 6, 0, "TSC bits not 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(); - let mut o = 7; - while o < aacs::ALIGNED_UNIT_LEN { - expected_cleared[o] &= 0x3F; - o += 192; - } + // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!( &plaintext[..], - &expected_cleared[..], + &expected[..], "decrypted unit does not match original plaintext" ); } @@ -438,7 +434,7 @@ fn aacs_cross_validation_alternate_key() { plaintext[off] = 0x47; off += 192; } - plaintext[7] |= 0x80; // TSC encryption flag + // No flag set: the CBC-encrypted body scrambles the packet syncs. let expected = plaintext.clone(); let mut header = [0u8; 16]; @@ -456,13 +452,8 @@ fn aacs_cross_validation_alternate_key() { assert!(aacs::decrypt_unit(&mut plaintext, &unit_key)); - let mut expected_cleared = expected; - let mut o = 7; - while o < aacs::ALIGNED_UNIT_LEN { - expected_cleared[o] &= 0x3F; - o += 192; - } - assert_eq!(&plaintext[..], &expected_cleared[..]); + // Decryption clears no flag, so the unit round-trips byte-for-byte. + assert_eq!(&plaintext[..], &expected[..]); } /// 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 807f05a..451b589 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -82,25 +82,24 @@ fn decrypt_sectors_with_css_keys_works() { /// Test: AACS unit encryption detection works. #[test] fn aacs_encryption_flag_detection() { + // A clear unit: TS syncs (0x47) intact at every 192-byte packet. let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; + let mut off = 4; + while off < aacs::ALIGNED_UNIT_LEN { + unit[off] = 0x47; + off += 192; + } + // Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit. + assert!(!aacs::is_aacs_scrambled(&unit)); - // 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)); - - unit[7] = 0x40; // TSC = 01 - assert!(aacs::is_unit_encrypted(&unit)); - - unit[7] = 0x80; // TSC = 10 - assert!(aacs::is_unit_encrypted(&unit)); - - unit[7] = 0xC0; // TSC = 11 - assert!(aacs::is_unit_encrypted(&unit)); - - // Byte 0 copy-control bits must NOT count as encryption. - unit[7] = 0x00; + // Flag bits on a synced unit do not make it look encrypted. unit[0] = 0xC0; - assert!(!aacs::is_unit_encrypted(&unit)); + unit[7] = 0xC0; + assert!(!aacs::is_aacs_scrambled(&unit)); + + // Scrambled body (syncs gone) → encrypted. + let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN]; + assert!(aacs::is_aacs_scrambled(&scrambled)); } /// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.