mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd7e1de2a | ||
|
|
da62ee7cf2 | ||
|
|
575c76156f | ||
|
|
b518860d9c | ||
|
|
c8eb42b490 | ||
|
|
48570ac065 | ||
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 | ||
|
|
dfccb85e15 | ||
|
|
ebedffb762 |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.27.3 (2026-06-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **The mapfile now persists resolved AACS unit keys, not just the Volume ID.**
|
||||
A keyed disc writes its decrypted unit keys as `# freemkv-uk: <cps>:<hex>`
|
||||
comment headers; an unresolved disc writes only the `# freemkv-vid:` marker.
|
||||
The two are **mutually exclusive** (`Mapfile::set_unit_keys` clears the VID):
|
||||
unit keys are the final answer, so a deferred-mux / resume decrypts directly
|
||||
with no key-service round-trip, while the VID alone means "still unresolved —
|
||||
retry the key service." `CopyOptions`/`SweepOptions` gain a `unit_keys` field
|
||||
(written when non-empty, else the VID), and `Disc::inject_unit_keys` applies
|
||||
mapfile-recovered keys to a scanned disc (marking the source `ExternalUk`).
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.5"
|
||||
version = "0.27.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+44
-23
@@ -69,16 +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 (copy_permission_indicator bits).
|
||||
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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() {
|
||||
@@ -87,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).
|
||||
///
|
||||
@@ -99,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
|
||||
}
|
||||
|
||||
@@ -124,16 +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 encryption flag
|
||||
unit[0] &= !0xC0;
|
||||
|
||||
// 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<usize> {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
@@ -173,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 {
|
||||
@@ -200,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));
|
||||
}
|
||||
|
||||
@@ -253,8 +274,8 @@ mod tests {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Set encryption flag
|
||||
plain[0] |= 0xC0;
|
||||
// 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();
|
||||
@@ -281,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;
|
||||
|
||||
@@ -223,6 +223,13 @@ impl super::provider::KeyProvider for KeyDb {
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
// Every per-disc Media Key in the db. The resolver dedups; MKs are
|
||||
// MKB-scoped so the same value recurs across a pressing's discs.
|
||||
self.iter_disc_entries()
|
||||
.filter_map(|e| e.media_key)
|
||||
.collect()
|
||||
}
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.host_certs.clone()
|
||||
}
|
||||
|
||||
+128
-1
@@ -532,6 +532,27 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
// A zero type, a zero/short length, or an overrun = records done, padding begun.
|
||||
if rec_type == 0x00 || rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
@@ -1082,6 +1103,30 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 2));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
|
||||
|
||||
// Path 2.5: MK-pool brute. keydb stores Media Keys per-disc, but an
|
||||
// MK is MKB-scoped (shared across a pressing/MKB-family). A disc
|
||||
// whose own hash/VID isn't keyed can still resolve if ANY stored MK
|
||||
// verifies against its MKB. Try every distinct MK via km_verifies;
|
||||
// a UNIQUE pass is this disc's Km → derive VUK (needs VID) → UK.
|
||||
// km_verifies is one AES-D + magic check per candidate (cheap).
|
||||
let mks = providers.media_keys();
|
||||
let mut mk_hits: Vec<[u8; 16]> = Vec::new();
|
||||
for mk in &mks {
|
||||
if probe::km_verifies(mkb, mk) && !mk_hits.contains(mk) {
|
||||
mk_hits.push(*mk);
|
||||
if mk_hits.len() > 1 {
|
||||
break; // ambiguous — bail to avoid a wrong key
|
||||
}
|
||||
}
|
||||
}
|
||||
if mk_hits.len() == 1 {
|
||||
let vuk = derive_vuk(&mk_hits[0], ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
|
||||
// Same class as path 3 (KEYDB MK → derived VUK).
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
|
||||
}
|
||||
@@ -1247,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"
|
||||
);
|
||||
|
||||
@@ -1374,6 +1419,26 @@ mod tests {
|
||||
assert_eq!(mkb_version(&mkb), Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_content_len_trims_trailing_padding() {
|
||||
// Two real records (0x10 type/version + 0x86 verify), then 128 KiB of
|
||||
// zero padding (the fixed-region tail). Content length must stop at the
|
||||
// end of the records, not include the padding.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
let records_len = mkb.len();
|
||||
mkb.extend(std::iter::repeat(0u8).take(128 * 1024)); // padding
|
||||
assert_eq!(mkb_content_len(&mkb), records_len);
|
||||
// No padding → returns the full length.
|
||||
assert_eq!(mkb_content_len(&mkb[..records_len]), records_len);
|
||||
// Empty → 0.
|
||||
assert_eq!(mkb_content_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_version_returns_none_on_empty() {
|
||||
assert_eq!(mkb_version(&[]), None);
|
||||
@@ -1913,6 +1978,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path2_5_mk_pool_brute_resolves_unkeyed_disc() {
|
||||
// The Dunkirk case: this disc's own hash/VID are NOT in keydb, but its
|
||||
// Media Key IS — filed under a sibling disc that shares its MKB. Path
|
||||
// 2.5 must km_verifies that MK against the MKB and resolve.
|
||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||
|
||||
let km = [0x11u8; 16];
|
||||
let vid = [0x22u8; 16];
|
||||
|
||||
// MKB: 0x10 type/version + 0x86 verify record whose mk_dv decrypts under
|
||||
// km to the AACS verify magic, so km_verifies(mkb, km) == true.
|
||||
let mut vd = [0u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = enc(&km, &vd);
|
||||
let mut mkb = mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x4D]);
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
assert!(
|
||||
probe::km_verifies(&mkb, &km),
|
||||
"fixture: km must verify the MKB"
|
||||
);
|
||||
|
||||
// This disc's inf (its hash will NOT be in keydb).
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
|
||||
// keydb: a SIBLING disc carries our km, keyed by the sibling's own
|
||||
// hash + VID (neither matches THIS disc) — so only the MK-pool brute
|
||||
// (km_verifies) can find it.
|
||||
let mut keydb = KeyDb::empty();
|
||||
keydb.disc_entries.insert(
|
||||
"0xsibling".to_string(),
|
||||
DiscEntry {
|
||||
disc_hash: "0xsibling".to_string(),
|
||||
title: "sibling".to_string(),
|
||||
media_key: Some(km),
|
||||
disc_id: Some([0x99u8; 16]),
|
||||
vuk: None,
|
||||
unit_keys: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
providers,
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
let resolved = resolve_keys_v1(&ctx)
|
||||
.expect("MK-pool brute (path 2.5) must resolve a disc whose MK is in keydb");
|
||||
assert_eq!(
|
||||
resolved.key_source, 3,
|
||||
"MK-pool brute is the KEYDB-derived class"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.vuk,
|
||||
Some(derive_vuk(&km, &vid)),
|
||||
"VUK must derive from the verified Km + this disc's VID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert
|
||||
|
||||
+3
-3
@@ -24,15 +24,15 @@ 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;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use variants::{
|
||||
|
||||
@@ -48,6 +48,14 @@ pub trait KeyProvider: Send + Sync {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Every Media Key this provider holds, regardless of which disc it was
|
||||
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
|
||||
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
|
||||
/// and resolve a disc whose own hash/VID isn't directly keyed.
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// AACS host certificates (with their private keys) for drive
|
||||
/// authentication. Multiple in case some are revoked.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
@@ -85,6 +93,15 @@ impl Providers<'_> {
|
||||
self.0.iter().flat_map(|p| p.processing_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union of distinct Media Keys across every provider, for the MK-pool
|
||||
/// brute (`km_verifies` against the disc's MKB).
|
||||
pub fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union — gather host certs from every provider. Not yet wired into
|
||||
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
|
||||
/// kept here so a provider-aware handshake refactor is a drop-in.
|
||||
|
||||
+11
-10
@@ -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<u8> = 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];
|
||||
|
||||
@@ -529,4 +529,67 @@ impl Disc {
|
||||
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
||||
/// metadata), for callers that resolve Unit Keys out-of-band and have
|
||||
/// disabled the local keydb. The VID is on-disc content read during the
|
||||
/// handshake; preserving it here lets the out-of-band path use it. No keys
|
||||
/// are present (`unit_keys` empty, `vuk` None), so the disc reports as
|
||||
/// "encrypted, no keys" until the caller re-scans with a resolved Unit Key.
|
||||
pub(super) fn resolve_vid_only(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
let version = match cc.as_ref().map(|c| c.version) {
|
||||
Some(aacs::AacsVersion::V10) => 1,
|
||||
Some(_) => 2,
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
// MKB_RO is the correctly-sized copy; avoid reading the padded RW region.
|
||||
let mkb_ver = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::mkb_version);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
has_vid = handshake.is_some(),
|
||||
"keydb disabled — carrying VID only, keys resolved out-of-band"
|
||||
);
|
||||
|
||||
Ok(AacsState {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
key_source: KeySource::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![],
|
||||
read_data_key: handshake.and_then(|h| h.read_data_key),
|
||||
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+120
-2
@@ -134,7 +134,17 @@ pub struct Mapfile {
|
||||
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
|
||||
/// resume without altering the ISO payload or breaking ddrescue
|
||||
/// data-line parsing. `None` for unencrypted / non-AACS discs.
|
||||
///
|
||||
/// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved
|
||||
/// persists the keys (`unit_keys`) and NOT the VID — the keys are the final
|
||||
/// answer, so deferred-mux/resume decrypts directly with no key service. A
|
||||
/// disc that did NOT resolve persists only the VID, the retry-able "still
|
||||
/// need a key" marker (a future mux can re-ask the key service with it).
|
||||
vid: Option<[u8; 16]>,
|
||||
/// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:`
|
||||
/// comment headers when the disc was successfully keyed. Mutually exclusive
|
||||
/// with `vid` (see above). Empty when unresolved.
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -160,6 +170,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
@@ -175,6 +186,7 @@ impl Mapfile {
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
@@ -190,6 +202,12 @@ impl Mapfile {
|
||||
// ignored rather than failing the whole load.
|
||||
vid = parse_vid_hex(hex.trim());
|
||||
}
|
||||
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
|
||||
// `<cps>:<32hex>`. Best-effort: a malformed line is skipped.
|
||||
if let Some(entry) = parse_uk_line(uk.trim()) {
|
||||
unit_keys.push(entry);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
@@ -240,6 +258,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid,
|
||||
unit_keys,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +364,25 @@ impl Mapfile {
|
||||
self.vid
|
||||
}
|
||||
|
||||
/// Record the disc's decrypted AACS unit keys so they persist in the
|
||||
/// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux /
|
||||
/// resume decrypts directly from these with no key-service round-trip.
|
||||
/// Setting keys clears any VID — the mapfile holds keys XOR VID, never both
|
||||
/// (keys are the final answer; VID is only the "still unresolved" marker).
|
||||
pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) {
|
||||
self.unit_keys = keys.to_vec();
|
||||
if !self.unit_keys.is_empty() {
|
||||
self.vid = None;
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from
|
||||
/// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`).
|
||||
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
|
||||
&self.unit_keys
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -421,10 +459,22 @@ impl Mapfile {
|
||||
// `#`-prefixed line as a comment, so this round-trips through
|
||||
// our `load()` without affecting the `pos size status` data
|
||||
// parser. 16 bytes → 32 lowercase hex chars.
|
||||
if let Some(vid) = self.vid {
|
||||
// KEYS XOR VID: a keyed disc persists its unit keys (the final
|
||||
// answer — deferred-mux decrypts directly); an unresolved disc
|
||||
// persists only the VID (the retry marker, so a future mux can
|
||||
// re-ask the key service). Never both.
|
||||
use std::fmt::Write as _;
|
||||
if !self.unit_keys.is_empty() {
|
||||
for (cps, key) in &self.unit_keys {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in key {
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-uk: {cps}:{hex}")?;
|
||||
}
|
||||
} else if let Some(vid) = self.vid {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in vid {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-vid: {hex}")?;
|
||||
@@ -474,6 +524,15 @@ fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
/// `None` on any malformation so a corrupt line is ignored, never fatal.
|
||||
fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> {
|
||||
let (cps, hex) = s.split_once(':')?;
|
||||
let cps: u32 = cps.trim().parse().ok()?;
|
||||
let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser
|
||||
Some((cps, key))
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -640,6 +699,65 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() {
|
||||
let p = tmpfile("uk_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
// Set a VID first, then unit keys: keys must WIN and clear the VID.
|
||||
mf.set_vid([0xAA; 16]);
|
||||
let keys: Vec<(u32, [u8; 16])> = vec![
|
||||
(
|
||||
0,
|
||||
[
|
||||
0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1,
|
||||
0x35, 0x9b, 0xad,
|
||||
],
|
||||
),
|
||||
(1, [0x11; 16]),
|
||||
];
|
||||
mf.set_unit_keys(&keys);
|
||||
assert_eq!(
|
||||
mf.vid(),
|
||||
None,
|
||||
"set_unit_keys must clear vid (keys XOR vid)"
|
||||
);
|
||||
mf.flush().unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"),
|
||||
"uk comment format mismatch: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 1:11111111111111111111111111111111"),
|
||||
"second uk missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("# freemkv-vid:"),
|
||||
"VID must NOT be written when keys are present: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the unit keys (and no VID).
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.unit_keys(), keys.as_slice());
|
||||
assert_eq!(loaded.vid(), None);
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// VID-only path (no keys) still persists the VID as the retry marker.
|
||||
let p2 = tmpfile("uk_vid_only");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.set_vid([0xBB; 16]);
|
||||
mf2.flush().unwrap();
|
||||
let loaded2 = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded2.vid(), Some([0xBB; 16]));
|
||||
assert!(loaded2.unit_keys().is_empty());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vid_round_trips_and_data_lines_unaffected() {
|
||||
let p = tmpfile("vid_round_trips");
|
||||
|
||||
+285
-13
@@ -951,11 +951,21 @@ pub struct ScanOptions {
|
||||
/// obtains the key however it likes; libfreemkv stays free of any network
|
||||
/// dependency.
|
||||
pub unit_key: Option<[u8; 16]>,
|
||||
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
|
||||
/// standard-location search, so no keydb is loaded for this scan. A caller
|
||||
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
|
||||
/// a keydb that merely happens to sit in a default location does not shadow
|
||||
/// the out-of-band path. `unit_key` still takes precedence over everything.
|
||||
pub disable_keydb: bool,
|
||||
}
|
||||
|
||||
impl ScanOptions {
|
||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
||||
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
|
||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
||||
if self.disable_keydb {
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = &self.keydb_path {
|
||||
if p.exists() {
|
||||
return Some(p.clone());
|
||||
@@ -1163,10 +1173,18 @@ impl Disc {
|
||||
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RO.inf"))
|
||||
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
|
||||
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
|
||||
// so reading either ships up to ~124 MiB of nothing — trim to the
|
||||
// record stream so callers send/store a few MB, not 128 MiB.
|
||||
let mut mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
Ok((inf, mkb))
|
||||
}
|
||||
|
||||
@@ -1181,10 +1199,18 @@ impl Disc {
|
||||
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RO.inf"))
|
||||
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
|
||||
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
|
||||
// so reading either ships up to ~124 MiB of nothing — trim to the
|
||||
// record stream so callers send/store a few MB, not 128 MiB.
|
||||
let mut mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
Ok((inf, mkb))
|
||||
}
|
||||
|
||||
@@ -1216,6 +1242,15 @@ impl Disc {
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
} else if opts.disable_keydb {
|
||||
// Keydb disabled: keys are resolved out-of-band. Still capture the
|
||||
// VID (read during the handshake) so the out-of-band path has it;
|
||||
// carry no keys (disc reports "encrypted, no keys" until re-scanned
|
||||
// with a resolved Unit Key).
|
||||
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
} else {
|
||||
match opts.resolve_keydb() {
|
||||
Some(keydb_path) => {
|
||||
@@ -1441,11 +1476,40 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// A decryption key handed to libfreemkv by the caller.
|
||||
///
|
||||
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
|
||||
/// server, never searches paths. The application resolves a key from whatever
|
||||
/// source it likes (a local keydb, an online key service, a mapfile cache) and
|
||||
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
|
||||
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
|
||||
///
|
||||
/// `#[non_exhaustive]`: AACS is a derivation chain
|
||||
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
|
||||
/// [`Key::Unit`] entry point is wired; the higher entry points
|
||||
/// (device / media / volume key) land as the derivation is lifted out of the
|
||||
/// keydb-coupled scan path. Adding them is non-breaking thanks to
|
||||
/// `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub enum Key {
|
||||
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
|
||||
/// (keydb / key server) resolved these, or they were cached in the mapfile
|
||||
/// at sweep; libfreemkv decrypts directly with no further derivation.
|
||||
Unit(Vec<(u32, [u8; 16])>),
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Get the resolved decryption keys for this disc.
|
||||
/// Used by disc-to-ISO and other full-disc operations.
|
||||
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
|
||||
if let Some(ref aacs) = self.aacs {
|
||||
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
|
||||
// the VID-only state from out-of-band resolution before a Unit Key
|
||||
// is supplied. Report None so callers treat it as missing keys
|
||||
// (not a usable, empty key set).
|
||||
if aacs.unit_keys.is_empty() {
|
||||
return crate::decrypt::DecryptKeys::None;
|
||||
}
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: aacs.unit_keys.clone(),
|
||||
read_data_key: aacs.read_data_key,
|
||||
@@ -1459,6 +1523,61 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
||||
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
|
||||
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
|
||||
///
|
||||
/// If the scan built no AACS state (`self.aacs == None`) — which happens
|
||||
/// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
|
||||
/// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
|
||||
/// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
|
||||
/// decryption key; the keydb is only needed to *derive* it, and that
|
||||
/// derivation already happened at sweep (the UK is in the mapfile). So a UK
|
||||
/// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
|
||||
/// was applied by the drive at read time, so `read_data_key` is unused for
|
||||
/// file-backed mux. Without this, a keyed disc swept without a keydb would
|
||||
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
|
||||
/// remux. No-op for an unencrypted or CSS (DVD) disc.
|
||||
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
||||
if let Some(aacs) = self.aacs.as_mut() {
|
||||
aacs.unit_keys = keys;
|
||||
aacs.key_source = KeySource::ExternalUk;
|
||||
} else if self.encrypted && self.css.is_none() {
|
||||
self.aacs = Some(AacsState {
|
||||
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
|
||||
bus_encryption: self.format == DiscFormat::Uhd,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: KeySource::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
});
|
||||
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
|
||||
// the decryption key. Clear it so callers don't treat the disc as
|
||||
// keyless on the stale error.
|
||||
self.aacs_error = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
|
||||
/// decryption state. **Lookup-free**: no keydb, no network — the caller
|
||||
/// (an application, via a key source) does all resolution and hands the key
|
||||
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
|
||||
/// unit keys came from a key source or the mapfile cache, and libfreemkv
|
||||
/// decrypts directly (see [`Self::inject_unit_keys`]).
|
||||
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
|
||||
match key {
|
||||
Key::Unit(keys) => {
|
||||
self.inject_unit_keys(keys);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy disc sectors to an ISO image file.
|
||||
///
|
||||
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
|
||||
@@ -1544,6 +1663,7 @@ impl Disc {
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
vid: opts.vid,
|
||||
unit_keys: opts.unit_keys.clone(),
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
@@ -1642,12 +1762,14 @@ impl Disc {
|
||||
)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
// Persist the disc's AACS Volume ID into the mapfile header so it
|
||||
// survives to deferred-mux / resume. ddrescue-safe (comment line);
|
||||
// does not touch the ISO payload. On a resume-load the VID is
|
||||
// already present, but re-setting it (idempotent) covers the case
|
||||
// where Pass 1 created the mapfile before the VID was known.
|
||||
if let Some(vid) = opts.vid {
|
||||
// Persist the disc's decryption state into the mapfile header so it
|
||||
// survives to deferred-mux / resume. ddrescue-safe (comment lines);
|
||||
// does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
|
||||
// unit keys (the final answer — deferred-mux decrypts directly, no key
|
||||
// service); an unresolved disc writes only the VID (the retry marker).
|
||||
if !opts.unit_keys.is_empty() {
|
||||
map.set_unit_keys(&opts.unit_keys);
|
||||
} else if let Some(vid) = opts.vid {
|
||||
map.set_vid(vid);
|
||||
}
|
||||
|
||||
@@ -2126,7 +2248,15 @@ pub struct CopyOptions<'a> {
|
||||
/// Pass 1 so it survives to deferred-mux / resume. `None` for
|
||||
/// unencrypted / non-AACS discs. Caller wires this from
|
||||
/// `Disc::aacs.volume_id`.
|
||||
///
|
||||
/// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a
|
||||
/// key): the VID is the "still unresolved, retry-able" marker.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile
|
||||
/// during Pass 1. When non-empty these are written (the final answer, so
|
||||
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
|
||||
/// Caller wires this from `Disc::aacs.unit_keys`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -2149,8 +2279,12 @@ pub struct SweepOptions<'a> {
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
|
||||
/// sweep creates / opens it. `None` for unencrypted discs.
|
||||
/// sweep creates / opens it. `None` for unencrypted discs. Written ONLY
|
||||
/// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker).
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Resolved AACS unit keys persisted into the mapfile when the sweep
|
||||
/// creates / opens it. When non-empty these win over `vid`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
|
||||
@@ -2645,6 +2779,136 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
|
||||
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
|
||||
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
|
||||
// in the mapfile. At remux the UK is recovered and injected — that MUST
|
||||
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
|
||||
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
|
||||
// forever with "No keys available (E8005)" despite holding the UK.
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
|
||||
path: "<no keydb in search paths>".into(),
|
||||
});
|
||||
assert!(
|
||||
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
|
||||
"precondition: encrypted disc with no aacs state => no decrypt keys"
|
||||
);
|
||||
|
||||
let uk = vec![(0u32, [0x11u8; 16])];
|
||||
disc.inject_unit_keys(uk.clone());
|
||||
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
} => {
|
||||
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
||||
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
|
||||
}
|
||||
assert!(
|
||||
disc.aacs_error.is_none(),
|
||||
"stale KeydbLoad must be cleared once a UK is in hand"
|
||||
);
|
||||
assert_eq!(
|
||||
disc.aacs.as_ref().unwrap().key_source,
|
||||
KeySource::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||||
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||||
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||||
AacsState {
|
||||
version: 2,
|
||||
bus_encryption: true,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: KeySource::DeviceKey,
|
||||
vuk: None,
|
||||
unit_keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
|
||||
// VID-only state (resolved but no Unit Key yet) must read as None, not
|
||||
// an empty-but-usable key set — callers treat it as "keys missing".
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs = Some(aacs_with(Vec::new()));
|
||||
assert!(matches!(
|
||||
disc.decrypt_keys(),
|
||||
crate::decrypt::DecryptKeys::None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
|
||||
// When scan DID build an AACS state, decrypt_with must overwrite its
|
||||
// unit keys (not append) and mark the source ExternalUk.
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
|
||||
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
|
||||
disc.decrypt_with(Key::Unit(new.clone())).unwrap();
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys"),
|
||||
}
|
||||
assert_eq!(
|
||||
disc.aacs.as_ref().unwrap().key_source,
|
||||
KeySource::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_unit_key_yields_decrypt_keys() {
|
||||
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
|
||||
// decrypt_keys() must return usable AACS state (same path as the
|
||||
// deferred-mux resume — autorip resolves the UK and passes it in).
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
let uk = vec![(0u32, [0x44u8; 16])];
|
||||
disc.decrypt_with(Key::Unit(uk.clone())).unwrap();
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||
assert_eq!(unit_keys, uk);
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
|
||||
// Unencrypted disc: nothing to inject into, stays None.
|
||||
let mut plain = make_test_disc(1000, "PLAIN");
|
||||
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
|
||||
assert!(plain.aacs.is_none());
|
||||
assert!(matches!(
|
||||
plain.decrypt_keys(),
|
||||
crate::decrypt::DecryptKeys::None
|
||||
));
|
||||
|
||||
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
|
||||
let mut dvd = make_test_disc(1000, "DVD");
|
||||
dvd.format = DiscFormat::Dvd;
|
||||
dvd.encrypted = true;
|
||||
dvd.css = Some(crate::css::CssState {
|
||||
title_key: [0u8; 5],
|
||||
});
|
||||
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
|
||||
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_to_dev_null_no_enodev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2662,6 +2926,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -2687,6 +2952,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2718,6 +2984,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2748,6 +3015,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
|
||||
assert!(
|
||||
@@ -2766,6 +3034,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
|
||||
assert!(
|
||||
@@ -2799,6 +3068,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
|
||||
|
||||
@@ -2812,6 +3082,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
|
||||
assert!(
|
||||
@@ -2845,6 +3116,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
let r = result.expect("100-batch clean sweep should succeed");
|
||||
|
||||
+3
-3
@@ -170,9 +170,9 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
|
||||
SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeySource,
|
||||
LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions,
|
||||
Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
};
|
||||
|
||||
// ─── Streams ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,6 +187,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let scan_opts = crate::disc::ScanOptions {
|
||||
keydb_path: opts.keydb_path.as_ref().map(Into::into),
|
||||
unit_key: opts.unit_key,
|
||||
..Default::default()
|
||||
};
|
||||
// FileSectorSource is the sole file-backed sector source.
|
||||
// It carries the platform-tuned SEQUENTIAL fadvise hint
|
||||
|
||||
+55
-53
@@ -89,8 +89,8 @@ 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;
|
||||
// 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();
|
||||
@@ -126,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);
|
||||
@@ -136,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)
|
||||
@@ -156,17 +156,11 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
sync_count, expected_syncs
|
||||
);
|
||||
|
||||
// Compare all bytes except byte 0 (encryption flag cleared)
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plain[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected[1..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"
|
||||
&plain[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,49 +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"
|
||||
);
|
||||
|
||||
unit[0] = 0x40; // bit 6 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
// 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_aacs_scrambled(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
unit[0] = 0x80; // bit 7 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0xC0; // both bits set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0x3F; // bits 6-7 clear
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
// 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![0xC0u8; 100];
|
||||
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[0] = 0x00; // no encryption flag
|
||||
// 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 ──────────────
|
||||
@@ -369,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
}
|
||||
// Set encryption flag
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
|
||||
let expected = plaintext.clone();
|
||||
|
||||
@@ -401,14 +410,11 @@ 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");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
let mut expected_cleared = expected.clone();
|
||||
expected_cleared[0] &= !0xC0;
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&plaintext[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
@@ -428,7 +434,7 @@ fn aacs_cross_validation_alternate_key() {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
let expected = plaintext.clone();
|
||||
|
||||
let mut header = [0u8; 16];
|
||||
@@ -446,12 +452,8 @@ 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],
|
||||
);
|
||||
// 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
|
||||
|
||||
+15
-13
@@ -82,22 +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));
|
||||
|
||||
// No encryption flag
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 6
|
||||
unit[0] |= 0x40;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 7
|
||||
unit[0] = 0x80;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Both bits set
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user