Compare commits

...
12 Commits
Author SHA1 Message Date
MattJackson 1bd7e1de2a 0.27.4: lookup-free Key API (Disc::decrypt_with) + E8005 deferred-mux fix
Add libfreemkv::Key (non_exhaustive; Unit wired) + Disc::decrypt_with(Key) — the
caller resolves a key from any source and hands it in; the library decrypts with
no keydb/network of its own. inject_unit_keys now synthesizes a minimal
ExternalUk AACS state when a scan built none (no keydb at scan time), so a disc
keyed at sweep but scanned without a keydb still decrypts from its mapfile UK
instead of reporting E8005 and deferring the mux forever. Tests cover the
synthesize, replace, empty->None, and decrypt_with paths.
2026-06-04 11:08:33 -07:00
MattJackson da62ee7cf2 v0.27.3: bump version 2026-06-03 21:47:43 -07:00
MattJackson 575c76156f mapfile: persist resolved unit keys (keys XOR VID)
A keyed disc now writes its decrypted AACS unit keys to the mapfile header
(# freemkv-uk: <cps>:<hex>); an unresolved disc writes only the VID. The two are
mutually exclusive (set_unit_keys clears the VID) — unit keys are the final
answer, so deferred-mux / resume decrypts directly with no key lookup, while the
VID alone is the 'still unresolved, retry' marker. CopyOptions/SweepOptions carry
the keys (written when present, else the VID); Disc::inject_unit_keys applies
mapfile-recovered keys to a scanned disc. Round-trip test added.
2026-06-03 21:45:37 -07:00
MattJackson b518860d9c v0.27.0: detect AACS-scrambled units by raw TS sync, not flag bits
Rename is_unit_encrypted -> is_aacs_scrambled and decide encryption from the unit's MPEG-TS sync bytes (destroyed by the encrypted body) instead of the TP_extra copy-control (byte 0) or TS scrambling-control (byte 7) flags, which discs do not set reliably. One shared predicate now backs the decrypt gate and out-of-band key validation, so callers agree on what 'encrypted' means. Decryption restores the syncs, so a decrypted unit reads as clear and there is no flag to clear.
2026-06-03 07:35:50 -07:00
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
15 changed files with 782 additions and 122 deletions
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+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()
}
+128 -1
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");
}
@@ -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
View File
@@ -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::{
+17
View File
@@ -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
View File
@@ -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];
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ────────────────────────────────────────────────────────────────
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.