Compare commits

...
11 Commits
Author SHA1 Message Date
MattJackson c8eb42b490 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.
2026-06-02 20:37:56 -07:00
MattJackson 48570ac065 Local resolve: MK-pool brute via km_verifies (path 2.5)
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. New path 2.5 (between PK and the
VID lookup) collects the distinct MK pool from the providers, km_verifies each
against the disc MKB, and on a UNIQUE pass derives VUK (with the disc VID) then
the UK — matching the online resolver's behavior so local keydb mode resolves
the same discs (e.g. an MK present in keydb under a sibling pressing).
km_verifies is one AES-D + magic check per candidate (cheap). Adds
KeyProvider::media_keys() + a path-2.5 unit test.
2026-06-02 16:14:09 -07:00
MattJackson 08e46640fd v0.26.8: bump version 2026-06-02 15:41:38 -07:00
MattJackson a9195824ff Trim MKB to real record length; empty unit-keys means no keys
- mkb_content_len walks the MKB record stream and returns where it ends,
  so callers can drop the trailing zero padding. MKB files are allocated
  to a fixed size (~128 MiB) with records at the front; both MKB_RO and
  MKB_RW can be padded. read_aacs_inputs* now trim to the real records
  (~few MB) instead of shipping ~128 MiB of zeros.
- decrypt_keys() returns None when unit_keys is empty: an AACS state that
  carries only a VID (out-of-band resolution, pre-key) is 'encrypted, no
  keys', not a usable empty key set.
2026-06-02 15:41:27 -07:00
MattJackson 401fe23988 v0.26.7: bump version 2026-06-02 15:15:58 -07:00
MattJackson f89bce5851 Read MKB_RO not the padded MKB_RW; keep VID when keydb is disabled
- read_aacs_inputs / read_aacs_inputs_from_drive now read MKB_RO.inf first.
  MKB_RW.inf is a fixed ~128 MiB rewritable region that is mostly zero
  padding; reading it shipped 124 MiB of nothing. MKB_RO is the real,
  correctly-sized MKB (a few MB). Fall back to RW only if RO is absent.
- disable_keydb no longer drops the Volume ID. A caller resolving Unit Keys
  out-of-band needs the VID (on-disc content read during the handshake).
  New resolve_vid_only builds a keys-free AacsState carrying just the VID +
  version metadata, so the disc reports 'encrypted, no keys' (resolved
  out-of-band) instead of discarding the VID.
2026-06-02 15:15:45 -07:00
MattJackson dfccb85e15 v0.26.6: bump version 2026-06-02 14:11:13 -07:00
MattJackson ebedffb762 ScanOptions: add disable_keydb to skip all keydb lookup
A caller that resolves Unit Keys out-of-band can now set disable_keydb
so the scan consults no keydb at all — neither an explicit keydb_path
nor the standard search locations. Without it, a keydb that merely sits
in a default location ('~/.config/...') silently shadows the out-of-band
path. unit_key still takes precedence over everything.
2026-06-02 14:09:59 -07:00
MattJackson fdf63ccb7f v0.26.5: bump version 2026-06-02 13:37:32 -07:00
MattJackson c735d284da v0.26.4: bump version 2026-06-02 13:06:10 -07:00
MattJackson 94ab7bc73c scan: genericize the caller-supplied unit-key docs (no external-source naming)
The ScanOptions.unit_key path is a generic primitive — a caller-supplied Unit
Key that bypasses keydb lookup. Doc comments + a tracing log named a specific
external source; reworded to neutral 'out-of-band / external key service' so
the library makes no assumptions about where the key came from.
2026-06-02 13:00:11 -07:00
11 changed files with 365 additions and 75 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.26.3"
version = "0.26.11"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+27 -6
View File
@@ -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();
+7
View File
@@ -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()
}
+127
View File
@@ -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");
}
@@ -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
+2 -2
View File
@@ -31,8 +31,8 @@ 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::{
+18 -1
View File
@@ -15,7 +15,7 @@
//! fastest/closest first.
//!
//! Default impls return empty / `None` so backends only override
//! the methods they actually support — an HTTP keyserver might
//! the methods they actually support — an external key service might
//! implement only `lookup_disc_by_hash`, while a local file might
//! implement all five.
//!
@@ -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.
+65 -2
View File
@@ -468,7 +468,7 @@ impl Disc {
})
}
/// Resolve encryption from a caller-supplied Unit Key (the keyserver
/// Resolve encryption from a caller-supplied Unit Key (the external key service
/// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc
/// hash + version/bus-encryption flags, then use `unit_key` directly as
/// CPS unit 1's decryption key. The handshake (if any) still supplies the
@@ -514,7 +514,7 @@ impl Disc {
disc_hash = %aacs::disc_hash_hex(&dh),
version,
bus_encryption,
"using caller-supplied unit key (keyserver path)"
"using caller-supplied unit key"
);
Ok(AacsState {
@@ -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]),
})
}
}
+62 -22
View File
@@ -912,7 +912,7 @@ pub enum KeySource {
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the keyserver path).
/// Unit key supplied directly by the caller (the external Unit Key path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
}
@@ -945,18 +945,27 @@ pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
/// Caller-supplied Unit Key — the second, mutually-exclusive key source
/// (the online-keyserver path). When set, libfreemkv skips keydb lookup
/// and all derivation and uses this key directly to decrypt. Takes
/// precedence over `keydb_path` if both are set. The caller obtains it
/// however it likes (e.g. POSTing the disc's `Unit_Key_RO.inf` + MKB to a
/// keyserver); libfreemkv stays free of any network dependency.
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
/// libfreemkv skips keydb lookup and all derivation and uses this key
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
/// 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());
@@ -1152,10 +1161,10 @@ impl Disc {
}
/// Read a disc's AACS key-input files from an ISO image: returns
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve keys
/// out-of-band (the keyserver path) — POST these to the keyserver, get the
/// Unit Key, then scan with `ScanOptions { unit_key: Some(uk), .. }`.
/// libfreemkv itself never makes the network call.
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
/// out-of-band: obtain the key however you like, then scan with
/// `ScanOptions { unit_key: Some(uk), .. }`. libfreemkv never makes a
/// network call.
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)
.map_err(|_| Error::AacsNoKeys)?;
@@ -1164,17 +1173,24 @@ 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))
}
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. Keys are
/// needed *during* scan and the ISO only exists post-rip, so the keyserver
/// path fetches the disc's key files from the live drive before
/// [`Disc::scan`], POSTs them, then scans with
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
/// out-of-band Unit Key path fetches the disc's key files from the drive,
/// resolves a key from them however it likes, then scans with
/// `ScanOptions { unit_key: Some(uk), .. }`. These files are plaintext UDF
/// metadata — no AACS handshake or keys are required to read them.
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>)> {
@@ -1183,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))
}
@@ -1213,11 +1237,20 @@ impl Disc {
(None, None)
} else if let Some(unit_key) = opts.unit_key {
// Second key source: caller supplied the Unit Key directly
// (keyserver path). Skip keydb entirely.
// (external Unit Key). Skip keydb entirely.
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
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) => {
@@ -1448,6 +1481,13 @@ impl 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,
+2 -1
View File
@@ -163,7 +163,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
#[derive(Default)]
pub struct InputOptions {
pub keydb_path: Option<String>,
/// Caller-supplied Unit Key (keyserver path) — the second, mutually
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
/// exclusive key source. Takes precedence over `keydb_path`.
pub unit_key: Option<[u8; 16]>,
pub title_index: Option<usize>,
@@ -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
+44 -33
View File
@@ -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
+10 -7
View File
@@ -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.