mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8eb42b490 | ||
|
|
48570ac065 | ||
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 | ||
|
|
dfccb85e15 | ||
|
|
ebedffb762 | ||
|
|
fdf63ccb7f | ||
|
|
c735d284da | ||
|
|
94ab7bc73c |
+1
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user