mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04d79c593 | ||
|
|
b34af1fa74 | ||
|
|
a7317f8885 | ||
|
|
474273afc0 | ||
|
|
0a2bab5789 | ||
|
|
fa4d7ef871 | ||
|
|
36d1af1b7f | ||
|
|
b96f6206fe | ||
|
|
bb32fb993b | ||
|
|
c1eb74dfa5 | ||
|
|
46838c63ca | ||
|
|
1e60220ff6 | ||
|
|
080f03e8ed | ||
|
|
35de6101d4 | ||
|
|
9012101573 | ||
|
|
8bc1de6c9b | ||
|
|
d94a4d3444 | ||
|
|
1bd7e1de2a | ||
|
|
da62ee7cf2 | ||
|
|
575c76156f | ||
|
|
b518860d9c | ||
|
|
c8eb42b490 | ||
|
|
48570ac065 |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.27.3 (2026-06-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **The mapfile now persists resolved AACS unit keys, not just the Volume ID.**
|
||||
A keyed disc writes its decrypted unit keys as `# freemkv-uk: <cps>:<hex>`
|
||||
comment headers; an unresolved disc writes only the `# freemkv-vid:` marker.
|
||||
The two are **mutually exclusive** (`Mapfile::set_unit_keys` clears the VID):
|
||||
unit keys are the final answer, so a deferred-mux / resume decrypts directly
|
||||
with no key-service round-trip, while the VID alone means "still unresolved —
|
||||
retry the key service." `CopyOptions`/`SweepOptions` gain a `unit_keys` field
|
||||
(written when non-empty, else the VID), and `Disc::inject_unit_keys` applies
|
||||
mapfile-recovered keys to a scanned disc (marking the source `ExternalUk`).
|
||||
|
||||
## 0.27.0 (2026-06-03)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AACS unit-encryption detection now reads the raw MPEG-TS sync bytes
|
||||
instead of header flag bits.** `is_unit_encrypted` is renamed
|
||||
`is_aacs_scrambled`: a unit is encrypted iff its body TS packet sync bytes
|
||||
(`0x47` at offset 4 and every 192 bytes) are not intact — the encrypted
|
||||
body destroys them. The previous check read the TP_extra copy-control bits
|
||||
(byte 0) or TS transport_scrambling_control bits (byte 7), which discs and
|
||||
players do not set reliably. A single predicate now backs both the decrypt
|
||||
gate and out-of-band key validation, so all callers agree on what
|
||||
"encrypted" means. Decryption restores the syncs, so a decrypted unit reads
|
||||
as clear and there is no encryption flag to clear.
|
||||
|
||||
## 0.26.1 (2026-05-22)
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.8"
|
||||
version = "0.28.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+44
-23
@@ -69,16 +69,29 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
|
||||
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
///
|
||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
||||
/// across discs/players.
|
||||
///
|
||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
||||
/// service's validation gate all call THIS, so they always agree on what is
|
||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
|
||||
// then 188-byte TS packet, repeating every 192 bytes.
|
||||
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
|
||||
/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the
|
||||
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
|
||||
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
|
||||
/// scrambles all but the first (which lives in the clear 16-byte seed).
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
@@ -87,11 +100,15 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
}
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Expect at least most packets to have sync bytes
|
||||
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
|
||||
count > total / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||
/// Returns true if decryption succeeded (verified by TS sync bytes).
|
||||
///
|
||||
@@ -99,12 +116,14 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
||||
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
||||
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
||||
/// 4. Clear encryption flag bits
|
||||
///
|
||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||
/// there is no flag to clear.
|
||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true; // not encrypted
|
||||
}
|
||||
|
||||
@@ -124,16 +143,13 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Step 4: Clear encryption flag
|
||||
unit[0] &= !0xC0;
|
||||
|
||||
// Verify
|
||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
||||
verify_ts(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ pub fn decrypt_unit_full(
|
||||
unit_key: &[u8; 16],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
) -> bool {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rdk) = read_data_key {
|
||||
@@ -200,10 +216,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unit_unencrypted() {
|
||||
// Unit with 0xC0 bits clear should pass through unchanged
|
||||
// A clear unit (TS syncs intact) is not scrambled → passes through.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // not encrypted
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
unit[off] = TS_SYNC;
|
||||
off += TS_PACKET_LEN;
|
||||
}
|
||||
let key = [0u8; 16];
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &key));
|
||||
}
|
||||
|
||||
@@ -253,8 +274,8 @@ mod tests {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Set encryption flag
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
@@ -281,9 +302,9 @@ mod tests {
|
||||
|
||||
// Now plain contains encrypted data. Decrypt it.
|
||||
let mut unit = plain;
|
||||
assert!(is_unit_encrypted(&unit));
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
|
||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
||||
|
||||
// Verify TS sync bytes
|
||||
let mut count = 0;
|
||||
|
||||
+36
-14
@@ -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()
|
||||
}
|
||||
@@ -448,8 +455,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
|
||||
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||
// All-zero placeholders — synthetic; no real key material in code.
|
||||
let z40 = "00".repeat(20);
|
||||
let z32 = "00".repeat(16);
|
||||
let line = format!(
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
||||
);
|
||||
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(entry.title, "Sample Film");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
@@ -457,9 +469,14 @@ mod tests {
|
||||
assert_eq!(entry.unit_keys[0].0, 1);
|
||||
}
|
||||
|
||||
// NOTE: key fields below use obvious repeated-byte / zero placeholders
|
||||
// (0x01.., 0x02.., 0x03.., 0x00..). NEVER put real — or real-looking — host,
|
||||
// device, or processing key material in code; these tests exercise the
|
||||
// parser's field-splitting only, not any genuine key.
|
||||
|
||||
#[test]
|
||||
fn test_parse_device_key() {
|
||||
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||
assert_eq!(dk.node, 0x0800);
|
||||
assert_eq!(dk.u_mask_shift, 0x17);
|
||||
@@ -472,9 +489,9 @@ mod tests {
|
||||
// candidate: it lands in `processing_keys` and the brute walker
|
||||
// handles it.
|
||||
let cfg = r#"
|
||||
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
|
||||
| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0x03030303030303030303030303030303 ; legacy PK row still works
|
||||
"#;
|
||||
let db = KeyDb::parse(cfg);
|
||||
assert_eq!(
|
||||
@@ -488,29 +505,34 @@ mod tests {
|
||||
2,
|
||||
"orphan DK row + legacy PK row both belong in processing_keys"
|
||||
);
|
||||
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
|
||||
assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
|
||||
// The parser must NOT pick up a positioned DK row as an orphan
|
||||
// (that would double-count). parse_orphan_dk explicitly checks.
|
||||
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
assert!(
|
||||
KeyDb::parse_orphan_dk(positioned).is_none(),
|
||||
"positioned DK must not match orphan parser"
|
||||
);
|
||||
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
|
||||
let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101";
|
||||
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
|
||||
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_host_cert() {
|
||||
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
|
||||
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||
assert_eq!(hc.private_key[0], 0xDE);
|
||||
// 20-byte priv + 92-byte cert, all zeros — placeholders, not a key.
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
let hc = KeyDb::parse_host_cert(&line).unwrap();
|
||||
assert_eq!(hc.private_key, [0u8; 20]);
|
||||
assert_eq!(hc.certificate.len(), 92);
|
||||
}
|
||||
|
||||
|
||||
+87
-1
@@ -1103,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");
|
||||
}
|
||||
@@ -1268,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"
|
||||
);
|
||||
|
||||
@@ -1954,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
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ pub mod variants;
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
is_aacs_scrambled,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
|
||||
@@ -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.
|
||||
@@ -103,3 +120,42 @@ impl Providers<'_> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeyProvider`] backed by a single caller-supplied key's raw material —
|
||||
/// the bridge for [`crate::disc::Disc::decrypt_with`].
|
||||
///
|
||||
/// The application's key source did the lookup and handed in material at one
|
||||
/// level (DK / PK / MK / VUK). This exposes exactly that material to the
|
||||
/// version-dispatched resolver, which owns ALL derivation — so a source never
|
||||
/// derives, and the lib remains the single home for the AACS chain across
|
||||
/// 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// Each level fills only its own field; the rest stay empty, so the resolver
|
||||
/// naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a
|
||||
/// disc-keyed VUK hit). `decrypt_with` already knows the disc, so the
|
||||
/// `lookup_disc_by_*` hash/VID arguments are irrelevant — a present
|
||||
/// `disc_entry` is returned for any query.
|
||||
pub(crate) struct SuppliedKey {
|
||||
pub device_keys: Vec<DeviceKey>,
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
pub media_keys: Vec<[u8; 16]>,
|
||||
pub disc_entry: Option<DiscEntry>,
|
||||
}
|
||||
|
||||
impl KeyProvider for SuppliedKey {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.device_keys.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.media_keys.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -185,12 +185,13 @@ pub fn decrypt_sectors(
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
let nunits = chunks.len();
|
||||
|
||||
// Per-unit decrypt closure. The is_unit_encrypted check is
|
||||
// a byte-0 heuristic; on a misfire we snapshot+restore via
|
||||
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
|
||||
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
|
||||
// Per-unit decrypt closure. The is_aacs_scrambled check reads the
|
||||
// raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look
|
||||
// scrambled and trigger a decrypt attempt, so on a verify miss we
|
||||
// snapshot+restore the original bytes so it survives. See test
|
||||
// `nav_file_unit_survives_decrypt_attempt`.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) {
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
|
||||
chunk.copy_from_slice(&original);
|
||||
@@ -232,11 +233,11 @@ pub fn decrypt_sectors(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
|
||||
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
|
||||
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
|
||||
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
|
||||
/// and must be restored to its original bytes — not left scrambled.
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
+29
-252
@@ -179,54 +179,33 @@ impl Disc {
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::aacs;
|
||||
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
// Host certs come from the caller's DriveCredentials (e.g. the keydb's
|
||||
// host_certs(), sourced app-side) — the library does not load a keydb.
|
||||
// Absent ⇒ no cert auth: an unlocked / LibreDrive drive already returned
|
||||
// a Volume ID via the OEM path before reaching here, so this is the
|
||||
// locked-drive-without-credentials case.
|
||||
let host_certs: &[aacs::HostCert] = match &opts.credentials {
|
||||
Some(c) if !c.host_certs.is_empty() => &c.host_certs,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
phase = "handshake_no_credentials",
|
||||
"no drive credentials supplied; cert handshake skipped"
|
||||
);
|
||||
return (None, None);
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_keydb_load_failed",
|
||||
io_error_kind = ?e.kind(),
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return (
|
||||
None,
|
||||
Some(Error::KeydbLoad {
|
||||
path: keydb_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let host_cert_count = keydb.host_certs.len();
|
||||
let host_cert_count = host_certs.len();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
keydb = %keydb_path.display(),
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
if host_cert_count == 0 {
|
||||
// No host certs in keydb -> cert auth cannot proceed.
|
||||
// Surface as RawReadUnsupported so the caller knows
|
||||
// neither path is available on this configuration.
|
||||
return (None, Some(Error::AacsRawReadUnsupported));
|
||||
}
|
||||
|
||||
// v0.25.7 wedge fix. Pre-0.25.7 this loop fired up to 16 AACS
|
||||
// authenticate attempts back-to-back with no pause. Each attempt
|
||||
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
|
||||
@@ -246,7 +225,7 @@ impl Disc {
|
||||
const MAX_CERT_ATTEMPTS: usize = 3;
|
||||
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
if idx > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
||||
}
|
||||
@@ -317,219 +296,6 @@ impl Disc {
|
||||
(None, Some(Error::AacsHostCertRejected))
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorSource), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
pub(super) fn resolve_encryption(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
|
||||
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
||||
path: keydb_path.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Read AACS files from disc/image via UDF
|
||||
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)?;
|
||||
|
||||
// Log the disc hash so we can confirm whether it's present in KEYDB
|
||||
// when key resolution fails. The disc hash is SHA-1 of the full
|
||||
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
||||
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
||||
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_disc_hash",
|
||||
disc_hash = %dh_hex,
|
||||
uk_ro_len = uk_ro_data.len(),
|
||||
"disc hash computed (compare with keydb.cfg entries)"
|
||||
);
|
||||
|
||||
let cc_data = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
let mkb_data = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
let mkb_first_64_hex = mkb_data
|
||||
.as_deref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.take(64)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb_info",
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
||||
mkb_version = ?mkb_ver,
|
||||
mkb_first_64 = %mkb_first_64_hex,
|
||||
keydb_disc_count = keydb.disc_entries.len(),
|
||||
keydb_dk_count = keydb.device_keys.len(),
|
||||
keydb_pk_count = keydb.processing_keys.len(),
|
||||
"AACS resolution inputs"
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID;
|
||||
// paths 2/3/4 in `resolve_keys` short-circuit on the zero
|
||||
// sentinel and don't waste cycles trying to derive against
|
||||
// garbage input).
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
let vid_available = volume_id != [0u8; 16];
|
||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
||||
|
||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key.
|
||||
//
|
||||
// Distinguish "we had every input and still missed" from "we
|
||||
// never had VID so the derivation paths couldn't run." The
|
||||
// former points at a stale keydb / unsupported MKB; the
|
||||
// latter points at a failed handshake upstream. Path 1
|
||||
// (disc-hash lookup) ran without VID and missed -> disc isn't
|
||||
// in the keydb. If the caller has a handshake-failure reason
|
||||
// it overrides this in `scan_with`.
|
||||
let miss_error = if vid_available {
|
||||
Error::AacsMkUnavailable
|
||||
} else {
|
||||
Error::AacsVukNotInKeydb
|
||||
};
|
||||
|
||||
// Build a probe + context and let the dispatcher pick V10 / V20
|
||||
// / V21. CSS is impossible here (this function is only called
|
||||
// when /AACS exists), so we don't populate the DVD probe sector
|
||||
// or a CSS context.
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: cc_data.as_deref(),
|
||||
mkb: mkb_data.as_deref(),
|
||||
};
|
||||
let scheme = match DrmScheme::detect(&probe) {
|
||||
Some(s) => s,
|
||||
None => return Err(miss_error),
|
||||
};
|
||||
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
|
||||
let aacs_ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro_data,
|
||||
content_cert: cc_data.as_deref(),
|
||||
volume_id: &volume_id,
|
||||
providers,
|
||||
mkb: mkb_data.as_deref(),
|
||||
};
|
||||
let mut ctx = DrmContext {
|
||||
aacs: Some(aacs_ctx),
|
||||
css: None,
|
||||
};
|
||||
let resolved = match scheme.load(&mut ctx) {
|
||||
Some(ResolvedScheme::Aacs(r)) => r,
|
||||
// Resolution against /AACS inputs can only produce AACS
|
||||
// keys. Either the dispatcher returned None (load failed)
|
||||
// or — structurally impossible here — a CSS state. Both
|
||||
// surface as the upstream miss-error.
|
||||
_ => return Err(miss_error),
|
||||
};
|
||||
|
||||
Ok(AacsState {
|
||||
version: match resolved.version {
|
||||
aacs::AacsVersion::V10 => 1,
|
||||
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
|
||||
},
|
||||
bus_encryption: resolved.bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::DeviceKey,
|
||||
2 => KeySource::ProcessingKey,
|
||||
3 => KeySource::KeyDbDerived,
|
||||
4 => KeySource::KeyDb,
|
||||
5 => KeySource::KeyDbUnitKeys,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key,
|
||||
volume_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// volume ID and AACS 2.0 read-data key for bus decryption.
|
||||
pub(super) fn resolve_encryption_static(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
unit_key: [u8; 16],
|
||||
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,
|
||||
};
|
||||
|
||||
let mkb_ver = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::mkb_version);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_external_uk",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
"using caller-supplied unit key"
|
||||
);
|
||||
|
||||
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![(1, unit_key)],
|
||||
read_data_key: handshake.and_then(|h| h.read_data_key),
|
||||
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
|
||||
@@ -562,13 +328,22 @@ impl Disc {
|
||||
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
|
||||
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
||||
// the real record length (same as `read_aacs_inputs`). Without this the
|
||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
||||
// processing-key `decrypt_with` derivation consume, and which a key
|
||||
// source ships to an online service — is the full 128 MiB pad, not the
|
||||
// ~few-MB record stream.
|
||||
let mut mkb_bytes = 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);
|
||||
.unwrap_or_default();
|
||||
let n = aacs::mkb_content_len(&mkb_bytes);
|
||||
if n > 0 && n < mkb_bytes.len() {
|
||||
mkb_bytes.truncate(n);
|
||||
}
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -585,11 +360,13 @@ impl Disc {
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
key_source: KeySource::ExternalUk,
|
||||
key_source: KeyOrigin::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]),
|
||||
uk_ro: uk_ro_data,
|
||||
mkb: mkb_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+120
-2
@@ -134,7 +134,17 @@ pub struct Mapfile {
|
||||
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
|
||||
/// resume without altering the ISO payload or breaking ddrescue
|
||||
/// data-line parsing. `None` for unencrypted / non-AACS discs.
|
||||
///
|
||||
/// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved
|
||||
/// persists the keys (`unit_keys`) and NOT the VID — the keys are the final
|
||||
/// answer, so deferred-mux/resume decrypts directly with no key service. A
|
||||
/// disc that did NOT resolve persists only the VID, the retry-able "still
|
||||
/// need a key" marker (a future mux can re-ask the key service with it).
|
||||
vid: Option<[u8; 16]>,
|
||||
/// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:`
|
||||
/// comment headers when the disc was successfully keyed. Mutually exclusive
|
||||
/// with `vid` (see above). Empty when unresolved.
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -160,6 +170,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
@@ -175,6 +186,7 @@ impl Mapfile {
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
@@ -190,6 +202,12 @@ impl Mapfile {
|
||||
// ignored rather than failing the whole load.
|
||||
vid = parse_vid_hex(hex.trim());
|
||||
}
|
||||
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
|
||||
// `<cps>:<32hex>`. Best-effort: a malformed line is skipped.
|
||||
if let Some(entry) = parse_uk_line(uk.trim()) {
|
||||
unit_keys.push(entry);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
@@ -240,6 +258,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid,
|
||||
unit_keys,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +364,25 @@ impl Mapfile {
|
||||
self.vid
|
||||
}
|
||||
|
||||
/// Record the disc's decrypted AACS unit keys so they persist in the
|
||||
/// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux /
|
||||
/// resume decrypts directly from these with no key-service round-trip.
|
||||
/// Setting keys clears any VID — the mapfile holds keys XOR VID, never both
|
||||
/// (keys are the final answer; VID is only the "still unresolved" marker).
|
||||
pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) {
|
||||
self.unit_keys = keys.to_vec();
|
||||
if !self.unit_keys.is_empty() {
|
||||
self.vid = None;
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from
|
||||
/// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`).
|
||||
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
|
||||
&self.unit_keys
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -421,10 +459,22 @@ impl Mapfile {
|
||||
// `#`-prefixed line as a comment, so this round-trips through
|
||||
// our `load()` without affecting the `pos size status` data
|
||||
// parser. 16 bytes → 32 lowercase hex chars.
|
||||
if let Some(vid) = self.vid {
|
||||
// KEYS XOR VID: a keyed disc persists its unit keys (the final
|
||||
// answer — deferred-mux decrypts directly); an unresolved disc
|
||||
// persists only the VID (the retry marker, so a future mux can
|
||||
// re-ask the key service). Never both.
|
||||
use std::fmt::Write as _;
|
||||
if !self.unit_keys.is_empty() {
|
||||
for (cps, key) in &self.unit_keys {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in key {
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-uk: {cps}:{hex}")?;
|
||||
}
|
||||
} else if let Some(vid) = self.vid {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in vid {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-vid: {hex}")?;
|
||||
@@ -474,6 +524,15 @@ fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
/// `None` on any malformation so a corrupt line is ignored, never fatal.
|
||||
fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> {
|
||||
let (cps, hex) = s.split_once(':')?;
|
||||
let cps: u32 = cps.trim().parse().ok()?;
|
||||
let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser
|
||||
Some((cps, key))
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -640,6 +699,65 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() {
|
||||
let p = tmpfile("uk_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
// Set a VID first, then unit keys: keys must WIN and clear the VID.
|
||||
mf.set_vid([0xAA; 16]);
|
||||
let keys: Vec<(u32, [u8; 16])> = vec![
|
||||
(
|
||||
0,
|
||||
[
|
||||
0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1,
|
||||
0x35, 0x9b, 0xad,
|
||||
],
|
||||
),
|
||||
(1, [0x11; 16]),
|
||||
];
|
||||
mf.set_unit_keys(&keys);
|
||||
assert_eq!(
|
||||
mf.vid(),
|
||||
None,
|
||||
"set_unit_keys must clear vid (keys XOR vid)"
|
||||
);
|
||||
mf.flush().unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"),
|
||||
"uk comment format mismatch: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 1:11111111111111111111111111111111"),
|
||||
"second uk missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("# freemkv-vid:"),
|
||||
"VID must NOT be written when keys are present: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the unit keys (and no VID).
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.unit_keys(), keys.as_slice());
|
||||
assert_eq!(loaded.vid(), None);
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// VID-only path (no keys) still persists the VID as the retry marker.
|
||||
let p2 = tmpfile("uk_vid_only");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.set_vid([0xBB; 16]);
|
||||
mf2.flush().unwrap();
|
||||
let loaded2 = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded2.vid(), Some([0xBB; 16]));
|
||||
assert!(loaded2.unit_keys().is_empty());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vid_round_trips_and_data_lines_unaffected() {
|
||||
let p = tmpfile("vid_round_trips");
|
||||
|
||||
+601
-130
@@ -375,6 +375,79 @@ pub struct Extent {
|
||||
pub sector_count: u32,
|
||||
}
|
||||
|
||||
/// Correct the channel count of a title's TrueHD audio streams by probing the
|
||||
/// first decrypted access units. The MPLS `audio_format` field declares the
|
||||
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is
|
||||
/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is
|
||||
/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded
|
||||
/// window of the title's first extent. Also regenerates the stream's codec
|
||||
/// label when it was the basic descriptor for the (now corrected) count —
|
||||
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
|
||||
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
|
||||
use crate::mux::codec::truehd::truehd_channels_from_stream;
|
||||
|
||||
let pids: Vec<u16> = title
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if pids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(ext) = title.extents.first() else {
|
||||
return;
|
||||
};
|
||||
// Bounded probe: up to 8 MiB from the start of the title — enough for the
|
||||
// first interleaved TrueHD major sync of each stream.
|
||||
const PROBE_SECTORS: u32 = 4096;
|
||||
let n = ext.sector_count.min(PROBE_SECTORS) as u16;
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; n as usize * 2048];
|
||||
if reader
|
||||
.read_sectors(ext.start_lba, n, &mut buf, true)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
|
||||
let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
|
||||
for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
|
||||
payloads
|
||||
.entry(pes.pid)
|
||||
.or_default()
|
||||
.extend_from_slice(&pes.data);
|
||||
}
|
||||
|
||||
for s in title.streams.iter_mut() {
|
||||
let Stream::Audio(a) = s else { continue };
|
||||
if !matches!(a.codec, Codec::TrueHd) {
|
||||
continue;
|
||||
}
|
||||
let Some(payload) = payloads.get(&a.pid) else {
|
||||
continue;
|
||||
};
|
||||
let Some(count) = truehd_channels_from_stream(payload) else {
|
||||
continue;
|
||||
};
|
||||
let new_ch = AudioChannels::from_count(count);
|
||||
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
|
||||
continue;
|
||||
}
|
||||
let was_basic =
|
||||
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
|
||||
a.channels = new_ch;
|
||||
if was_basic {
|
||||
a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
|
||||
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
|
||||
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
|
||||
@@ -882,9 +955,9 @@ pub struct AacsState {
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
pub key_source: KeyOrigin,
|
||||
/// Volume Unique Key (16 bytes). `None` when keys were resolved
|
||||
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
|
||||
/// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
|
||||
/// pre-decrypted unit keys without a VUK to derive them from.
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Decrypted unit keys (CPS unit number, key)
|
||||
@@ -893,6 +966,13 @@ pub struct AacsState {
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Volume ID (16 bytes) -- from SCSI handshake
|
||||
pub volume_id: [u8; 16],
|
||||
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
|
||||
/// scan so an external resolver (key-resolver) can derive the unit keys
|
||||
/// from a VUK without re-reading the disc. Empty when not captured.
|
||||
pub uk_ro: Vec<u8>,
|
||||
/// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
|
||||
/// walk it (device/processing key → media key). Empty when not captured.
|
||||
pub mkb: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How AACS keys were resolved. Variants are ordered root-of-trust →
|
||||
@@ -900,7 +980,7 @@ pub struct AacsState {
|
||||
/// attempts derivation from the strongest input it has first and falls
|
||||
/// back toward pre-computed per-disc material.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum KeySource {
|
||||
pub enum KeyOrigin {
|
||||
/// MKB + device keys → subset-difference tree → VUK
|
||||
DeviceKey,
|
||||
/// MKB + processing keys → media key → VUK
|
||||
@@ -917,74 +997,46 @@ pub enum KeySource {
|
||||
ExternalUk,
|
||||
}
|
||||
|
||||
impl KeySource {
|
||||
impl KeyOrigin {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
KeySource::DeviceKey => "MKB + device key",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
KeySource::ExternalUk => "external UK",
|
||||
KeyOrigin::DeviceKey => "MKB + device key",
|
||||
KeyOrigin::ProcessingKey => "MKB + processing key",
|
||||
KeyOrigin::KeyDbDerived => "KEYDB (derived)",
|
||||
KeyOrigin::KeyDb => "KEYDB",
|
||||
KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
KeyOrigin::ExternalUk => "external UK",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Standard KEYDB.cfg search locations (compatible with libaacs).
|
||||
const KEYDB_SEARCH_PATHS: &[&str] = &[
|
||||
".config/aacs/KEYDB.cfg", // libaacs standard path
|
||||
".config/freemkv/keydb.cfg", // freemkv download path
|
||||
];
|
||||
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
|
||||
|
||||
/// Options for disc scanning.
|
||||
#[derive(Default)]
|
||||
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 — 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,
|
||||
/// AACS host credentials for the live-drive authenticated handshake.
|
||||
///
|
||||
/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM
|
||||
/// Volume-ID path and needs none, and an ISO scan has no handshake at all. The
|
||||
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
|
||||
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
|
||||
/// source: a locked drive needs the cert to unlock even when the decryption key
|
||||
/// comes from an online service.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct DriveCredentials {
|
||||
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
|
||||
pub host_certs: Vec<crate::aacs::HostCert>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
for relative in KEYDB_SEARCH_PATHS {
|
||||
let p = std::path::PathBuf::from(&home).join(relative);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Options for disc scanning.
|
||||
///
|
||||
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
|
||||
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
|
||||
/// only scan input is the optional drive credentials for the live-drive
|
||||
/// authenticated handshake.
|
||||
#[derive(Default)]
|
||||
pub struct ScanOptions {
|
||||
/// Host credentials for the live-drive AACS handshake. `None` for an
|
||||
/// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans.
|
||||
pub credentials: Option<DriveCredentials>,
|
||||
}
|
||||
|
||||
/// Quick disc identification — name, format, capacity. No title/stream parsing.
|
||||
@@ -1226,7 +1278,7 @@ impl Disc {
|
||||
capacity: u32,
|
||||
handshake: Option<HandshakeResult>,
|
||||
handshake_error: Option<Error>,
|
||||
opts: &ScanOptions,
|
||||
_opts: &ScanOptions,
|
||||
udf_fs: udf::UdfFs,
|
||||
) -> Result<Self> {
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
@@ -1235,69 +1287,16 @@ impl Disc {
|
||||
|
||||
let (aacs, aacs_error) = if !encrypted {
|
||||
(None, None)
|
||||
} else if let Some(unit_key) = opts.unit_key {
|
||||
// Second key source: caller supplied the Unit Key directly
|
||||
// (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).
|
||||
} else {
|
||||
// Lookup-free: capture the disc's AACS inputs (MKB, VID,
|
||||
// Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
|
||||
// from a key source and applies it via `Disc::decrypt_with`. The
|
||||
// disc reports "encrypted, no keys" until then.
|
||||
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) => {
|
||||
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
|
||||
{
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => {
|
||||
// When the handshake itself failed AND resolution
|
||||
// bottomed out at "no keys", surface the upstream
|
||||
// handshake failure — it's more actionable than
|
||||
// the generic AacsNoKeys.
|
||||
let final_err = match (&e, handshake_error.as_ref()) {
|
||||
(
|
||||
Error::AacsNoKeys
|
||||
| Error::AacsVukNotInKeydb
|
||||
| Error::AacsVidUnavailable,
|
||||
Some(_),
|
||||
) => handshake_error.unwrap(),
|
||||
_ => e,
|
||||
};
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_resolve_failed",
|
||||
error_code = final_err.code(),
|
||||
keydb = %keydb_path.display(),
|
||||
handshake_ok = handshake.is_some(),
|
||||
"AACS key resolution failed"
|
||||
);
|
||||
(None, Some(final_err))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_no_keydb",
|
||||
"encrypted disc but no KEYDB found in search paths"
|
||||
);
|
||||
// Sentinel path string lets autorip's message switch
|
||||
// distinguish "no keydb found anywhere" from "keydb at
|
||||
// <path> failed to parse".
|
||||
let final_err =
|
||||
handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad {
|
||||
path: String::from("<no keydb in search paths>"),
|
||||
});
|
||||
(None, Some(final_err))
|
||||
}
|
||||
// A handshake failure (no VID) is more actionable than the
|
||||
// generic capture error, so surface it when present.
|
||||
Err(e) => (None, Some(handshake_error.unwrap_or(e))),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1430,6 +1429,44 @@ impl Disc {
|
||||
.cmp(&b_oversize)
|
||||
.then_with(|| a.clips.len().cmp(&b.clips.len()))
|
||||
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
|
||||
// Same length + clip count = the same feature authored as multiple
|
||||
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
|
||||
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
|
||||
// Prefer the richer audio so we never rip a stereo-only variant over
|
||||
// the lossless-multichannel main feature.
|
||||
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
|
||||
}
|
||||
|
||||
/// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
|
||||
/// Higher is better: `(any lossless track, best channel count, audio count)`.
|
||||
fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
|
||||
let mut lossless = 0u8;
|
||||
let mut max_ch = 0u8;
|
||||
let mut count = 0usize;
|
||||
for s in &t.streams {
|
||||
if let Stream::Audio(a) = s {
|
||||
count += 1;
|
||||
if matches!(
|
||||
a.codec,
|
||||
Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
|
||||
) {
|
||||
lossless = 1;
|
||||
}
|
||||
let ch = match a.channels {
|
||||
AudioChannels::Surround71 => 8,
|
||||
AudioChannels::Surround61 => 7,
|
||||
AudioChannels::Surround51 => 6,
|
||||
AudioChannels::Surround50 => 5,
|
||||
AudioChannels::Quad => 4,
|
||||
AudioChannels::Stereo21 => 3,
|
||||
AudioChannels::Stereo => 2,
|
||||
AudioChannels::Mono => 1,
|
||||
AudioChannels::Unknown => 0,
|
||||
};
|
||||
max_ch = max_ch.max(ch);
|
||||
}
|
||||
}
|
||||
(lossless, max_ch, count)
|
||||
}
|
||||
|
||||
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
||||
@@ -1476,6 +1513,48 @@ 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`). Each variant is an entry
|
||||
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
|
||||
/// it to the per-CPS unit keys. New levels can be added without breaking
|
||||
/// callers.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum Key {
|
||||
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
|
||||
/// (subset-difference tree) to find the one that applies → media key →
|
||||
/// VUK → unit keys. A source hands in its FULL device-key set, because
|
||||
/// choosing which one applies *is* the MKB walk (derivation), and all
|
||||
/// derivation lives here — never in a source.
|
||||
Device(Vec<crate::aacs::keydb::DeviceKey>),
|
||||
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
|
||||
/// → media key → VUK → unit keys.
|
||||
Processing(Vec<[u8; 16]>),
|
||||
/// Media key candidate(s) (Km). A source hands its full pool because an MK
|
||||
/// is MKB-scoped (shared across a pressing/MKB family) — picking the one
|
||||
/// that applies is `km_verifies` against this disc's MKB, which is
|
||||
/// derivation, so it lives here. libfreemkv verifies, then derives the VUK
|
||||
/// via the Volume ID and the per-CPS-unit keys.
|
||||
Media(Vec<[u8; 16]>),
|
||||
/// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
|
||||
/// into the per-CPS-unit keys. NOT terminal — the chain continues to the
|
||||
/// unit keys.
|
||||
Volume([u8; 16]),
|
||||
/// 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. This
|
||||
/// is the terminal level every other variant derives down into.
|
||||
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.
|
||||
@@ -1501,6 +1580,152 @@ 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 = KeyOrigin::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: KeyOrigin::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
uk_ro: Vec::new(),
|
||||
mkb: Vec::new(),
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// The public AACS inputs for this disc, for a [`crate::KeySource`] to look
|
||||
/// a key up. `None` when the disc carries no AACS state (unencrypted, CSS,
|
||||
/// or AACS inputs not captured at scan). Contains no secrets — just disc
|
||||
/// identity plus the on-disc AACS structures.
|
||||
pub fn inputs(&self) -> Option<crate::keysource::DiscInputs> {
|
||||
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
|
||||
disc_hash: a.disc_hash.clone(),
|
||||
volume_id: a.volume_id,
|
||||
mkb: a.mkb.clone(),
|
||||
unit_key_ro: a.uk_ro.clone(),
|
||||
// Content samples need the disc reader, which scan does not retain;
|
||||
// the caller fills these for sources that validate against ciphertext.
|
||||
samples: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
// Unit keys are terminal — no derivation. Inject directly (resume /
|
||||
// mapfile cache / a source that already had the final UKs).
|
||||
if let Key::Unit(keys) = key {
|
||||
self.inject_unit_keys(keys);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Every higher level derives DOWN to the unit keys, reusing the
|
||||
// version-dispatched resolver — the single home for all AACS
|
||||
// derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
|
||||
// (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
|
||||
let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
|
||||
if aacs.uk_ro.is_empty() {
|
||||
// Scan captured no Unit_Key_RO.inf — nothing to derive into.
|
||||
return Err(crate::error::Error::AacsNoKeys);
|
||||
}
|
||||
|
||||
// Map the supplied Key -> raw material. The source handed in material
|
||||
// at exactly one level; choosing/applying it is the resolver's job.
|
||||
let mut supplied = crate::aacs::provider::SuppliedKey {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: None,
|
||||
};
|
||||
match key {
|
||||
Key::Device(dks) => supplied.device_keys = dks,
|
||||
Key::Processing(pks) => supplied.processing_keys = pks,
|
||||
Key::Media(mks) => supplied.media_keys = mks,
|
||||
Key::Volume(vuk) => {
|
||||
supplied.disc_entry = Some(crate::aacs::DiscEntry {
|
||||
disc_hash: aacs.disc_hash.clone(),
|
||||
title: String::new(),
|
||||
media_key: None,
|
||||
disc_id: None,
|
||||
vuk: Some(vuk),
|
||||
unit_keys: Vec::new(),
|
||||
});
|
||||
}
|
||||
Key::Unit(_) => unreachable!("Key::Unit handled above"),
|
||||
}
|
||||
|
||||
// Snapshot inputs (releases the &self borrow before the &mut below).
|
||||
let volume_id = aacs.volume_id;
|
||||
let mkb = aacs.mkb.clone();
|
||||
let uk_ro = aacs.uk_ro.clone();
|
||||
let version_u8 = aacs.version;
|
||||
|
||||
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
|
||||
let ctx = crate::aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &volume_id,
|
||||
providers: &provider_refs,
|
||||
mkb: if mkb.is_empty() { None } else { Some(&mkb) },
|
||||
};
|
||||
|
||||
// Version dispatch — V10 uses the classical resolver at 48-byte
|
||||
// stride; V20/V21 share the 64-byte stride, so try the classical V20
|
||||
// paths first and fall back to the 2.1 variant chain.
|
||||
let resolved = match version_u8 {
|
||||
1 => crate::aacs::resolve_keys_v1(&ctx),
|
||||
_ => crate::aacs::resolve_keys_v2(&ctx).or_else(|| crate::aacs::resolve_keys_v21(&ctx)),
|
||||
}
|
||||
.ok_or(crate::error::Error::AacsKeyRejected)?;
|
||||
|
||||
if resolved.unit_keys.is_empty() {
|
||||
return Err(crate::error::Error::AacsKeyRejected);
|
||||
}
|
||||
|
||||
let a = self.aacs.as_mut().expect("aacs present (checked above)");
|
||||
a.unit_keys = resolved.unit_keys;
|
||||
if resolved.vuk.is_some() {
|
||||
a.vuk = resolved.vuk;
|
||||
}
|
||||
a.key_source = KeyOrigin::ExternalUk;
|
||||
// A prior scan-time resolution error (e.g. keyless scan) is now moot.
|
||||
self.aacs_error = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy disc sectors to an ISO image file.
|
||||
///
|
||||
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
|
||||
@@ -1586,6 +1811,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)
|
||||
}
|
||||
@@ -1684,12 +1910,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);
|
||||
}
|
||||
|
||||
@@ -2168,7 +2396,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)]
|
||||
@@ -2191,8 +2427,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).
|
||||
@@ -2687,6 +2927,229 @@ 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,
|
||||
KeyOrigin::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: KeyOrigin::DeviceKey,
|
||||
vuk: None,
|
||||
unit_keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
uk_ro: Vec::new(),
|
||||
mkb: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
KeyOrigin::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted
|
||||
/// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only
|
||||
/// the key-storage area matters for `parse_unit_key_ro`.
|
||||
fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let keys_start = uk_pos + 48;
|
||||
let stride = 64usize;
|
||||
let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
|
||||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
|
||||
for (i, k) in enc_keys.iter().enumerate() {
|
||||
let off = keys_start + i * stride;
|
||||
data[off..off + 16].copy_from_slice(k);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_volume_derives_per_cps_unit_keys() {
|
||||
// A Volume key (VUK) is NOT terminal — the lib must decrypt
|
||||
// Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
|
||||
// own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
|
||||
// per-CPS Unit), not the cipher.
|
||||
let vuk = [0x5au8; 16];
|
||||
let enc0 = [0x12u8; 16];
|
||||
let enc1 = [0x34u8; 16];
|
||||
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
|
||||
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
|
||||
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
let mut a = aacs_with(Vec::new());
|
||||
a.uk_ro = uk_ro_v20(&[enc0, enc1]);
|
||||
disc.aacs = Some(a);
|
||||
|
||||
disc.decrypt_with(Key::Volume(vuk)).unwrap();
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||
assert_eq!(
|
||||
unit_keys,
|
||||
vec![(1u32, exp0), (2u32, exp1)],
|
||||
"VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
|
||||
}
|
||||
assert_eq!(
|
||||
disc.aacs.as_ref().unwrap().key_source,
|
||||
KeyOrigin::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_higher_key_without_inputs_errors() {
|
||||
// A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
|
||||
// scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
|
||||
// panic and not a silent keyless "success".
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
|
||||
assert!(matches!(
|
||||
disc.decrypt_with(Key::Volume([0x11u8; 16])).unwrap_err(),
|
||||
crate::error::Error::AacsNoKeys
|
||||
));
|
||||
|
||||
// No AACS state at all → same.
|
||||
let mut disc2 = make_test_disc(1000, "UHD");
|
||||
disc2.encrypted = true;
|
||||
assert!(matches!(
|
||||
disc2
|
||||
.decrypt_with(Key::Media(vec![[0x22u8; 16]]))
|
||||
.unwrap_err(),
|
||||
crate::error::Error::AacsNoKeys
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_volume_yielding_no_units_is_rejected() {
|
||||
// A key that produces zero unit keys (here: an empty key-storage area)
|
||||
// is a rejection, not a silent empty success.
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
let mut a = aacs_with(Vec::new());
|
||||
a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
|
||||
disc.aacs = Some(a);
|
||||
assert!(matches!(
|
||||
disc.decrypt_with(Key::Volume([0x11u8; 16])).unwrap_err(),
|
||||
crate::error::Error::AacsKeyRejected
|
||||
));
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -2704,6 +3167,7 @@ mod tests {
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -2729,6 +3193,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!(
|
||||
@@ -2760,6 +3225,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!(
|
||||
@@ -2790,6 +3256,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!(
|
||||
@@ -2808,6 +3275,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!(
|
||||
@@ -2841,6 +3309,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();
|
||||
|
||||
@@ -2854,6 +3323,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!(
|
||||
@@ -2887,6 +3357,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");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Key sources — the lookup layer that hands libfreemkv a [`Key`].
|
||||
//!
|
||||
//! libfreemkv performs NO key lookup. An application resolves a key for a disc
|
||||
//! through one or more [`KeySource`]s, each a dumb adapter over a backing store
|
||||
//! (a keydb file, a key server, the mapfile cache): given the disc's
|
||||
//! [`DiscInputs`] it returns the raw [`Key`] at whatever level it holds. The
|
||||
//! library then derives down and decrypts via `Disc::decrypt_with`.
|
||||
//!
|
||||
//! Source implementations are published in the companion `freemkv-keysources`
|
||||
//! crate — keeping all key *policy* (which store, which order, online vs local)
|
||||
//! out of the library while all key *mechanism* (the AACS derivation chain)
|
||||
//! stays in it.
|
||||
|
||||
use crate::disc::Key;
|
||||
use crate::error::Result;
|
||||
|
||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscInputs {
|
||||
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
|
||||
/// its per-disc entries by, and a key server identifies the disc with.
|
||||
pub disc_hash: String,
|
||||
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
|
||||
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
|
||||
pub volume_id: [u8; 16],
|
||||
/// Raw MKB bytes. Empty when not captured.
|
||||
pub mkb: Vec<u8>,
|
||||
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
|
||||
pub unit_key_ro: Vec<u8>,
|
||||
/// Encrypted on-disc content sample units (each a 6144-byte aligned unit),
|
||||
/// for sources that validate a key server-side against real ciphertext
|
||||
/// (e.g. an online key service). Empty for sources that don't need them
|
||||
/// (a local keydb). Populated by the application — reading content requires
|
||||
/// the disc reader, which the library's scan does not retain — so
|
||||
/// [`crate::Disc::inputs`] leaves it empty for the caller to fill.
|
||||
pub samples: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A key source: given a disc's [`DiscInputs`], offer candidate [`Key`]s.
|
||||
///
|
||||
/// Dumb by contract — a source queries its backing store and enumerates the raw
|
||||
/// material it holds as candidate keys at whatever level it has (device /
|
||||
/// processing / media / volume / unit). It performs NO AACS derivation and NO
|
||||
/// validation; `Disc::decrypt_with` derives down, and the caller validates by
|
||||
/// decrypting a sample. That keeps every derivation step in one place (the
|
||||
/// library) across AACS 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// A source returns *multiple ordered candidates* because a single store can
|
||||
/// hold material for several derivation paths (a keydb has a per-disc VUK *and*
|
||||
/// a device-key pool *and* a media-key pool — the source can't know which
|
||||
/// applies without the MKB walk, which is derivation). The caller tries the
|
||||
/// candidates in order and keeps the first that decrypts (validate-before-
|
||||
/// return). A source that resolves server-side (an online key service) or holds
|
||||
/// a cached final key (the mapfile) simply returns one candidate.
|
||||
pub trait KeySource {
|
||||
/// Candidate keys for this disc, most-specific first. Empty = this source
|
||||
/// has nothing; `Err(_)` = the source itself failed (I/O, network, parse).
|
||||
fn resolve(&self, inputs: &DiscInputs) -> Result<Vec<Key>>;
|
||||
|
||||
/// Whether this source needs [`DiscInputs::samples`] populated (encrypted
|
||||
/// content samples) — true for a source that validates against ciphertext
|
||||
/// server-side, false for one that keys purely on disc identity. The caller
|
||||
/// reads samples (an extra disc read) only when some source needs them.
|
||||
fn needs_samples(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
+185
-5
@@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
|
||||
// Structured fields — callers translate purpose to UI text.
|
||||
a.purpose = label.purpose;
|
||||
|
||||
// Codec descriptor: trust the parser's `codec_hint` ONLY
|
||||
// when it's consistent with the stream's actual codec — it
|
||||
// may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD
|
||||
// stream, which the raw spec codec can't express). If the
|
||||
// hint CONTRADICTS the stream (a mis-bound / shuffled label,
|
||||
// e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+
|
||||
// track), discard it and derive the descriptor from the
|
||||
// stream itself — that's correct per-stream and can never be
|
||||
// shuffled. An empty hint is left for `fill_defaults`.
|
||||
let codec_desc = if label.codec_hint.is_empty() {
|
||||
// No codec hint — leave for fill_defaults.
|
||||
String::new()
|
||||
} else if !codec_hint_consistent(&label.codec_hint, &a.codec) {
|
||||
// Hint contradicts the stream (mis-bound / shuffled):
|
||||
// derive from the stream itself.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
} else if codec_hint_adds_detail(&label.codec_hint) {
|
||||
// Consistent AND richer than the spec codec can express
|
||||
// (e.g. "Dolby Atmos", "DTS:X") — keep the parser's hint.
|
||||
label.codec_hint.clone()
|
||||
} else {
|
||||
// Consistent but a plain codec/channel restatement —
|
||||
// normalize to the stream's own marketing descriptor so
|
||||
// styling is uniform across tracks.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
};
|
||||
|
||||
// a.label only carries codec/variant info. NEVER any
|
||||
// English purpose text — the CLI handles that via i18n.
|
||||
let mut parts = Vec::new();
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
if !codec_desc.is_empty() {
|
||||
parts.push(codec_desc);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
@@ -322,7 +349,70 @@ fn generate_video_label(
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn generate_audio_label(
|
||||
/// Does the parser's `codec_hint` name a codec consistent with the stream's
|
||||
/// actual `codec`? [`apply_labels`] uses this to keep richer-but-consistent
|
||||
/// hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension
|
||||
/// the raw spec codec can't express) while rejecting mis-bound ones (e.g.
|
||||
/// "AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec
|
||||
/// FAMILY parsed out of the hint string. "Atmos" with no carrier named is
|
||||
/// treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint
|
||||
/// naming no recognizable codec family (pure editorial, e.g. "Commentary") is
|
||||
/// consistent — it isn't asserting a codec.
|
||||
fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
|
||||
use crate::disc::Codec;
|
||||
let h = hint.to_ascii_lowercase();
|
||||
|
||||
let says_truehd = h.contains("truehd") || h.contains("true hd");
|
||||
let says_ddp = h.contains("ac-3+")
|
||||
|| h.contains("ac3+")
|
||||
|| h.contains("e-ac-3")
|
||||
|| h.contains("eac-3")
|
||||
|| h.contains("eac3")
|
||||
|| h.contains("digital plus")
|
||||
|| h.contains("dd+");
|
||||
let says_ac3 =
|
||||
!says_ddp && (h.contains("ac-3") || h.contains("ac3") || h.contains("dolby digital"));
|
||||
let says_dts_ma = h.contains("master audio") || h.contains("hd ma");
|
||||
let says_dts_hr = h.contains("high resolution") || h.contains("hd hr");
|
||||
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
|
||||
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
|
||||
let says_atmos = h.contains("atmos");
|
||||
|
||||
let names_family =
|
||||
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
|
||||
|
||||
// Pure-editorial hint (no codec family named) isn't asserting a codec →
|
||||
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
|
||||
if !names_family {
|
||||
return if says_atmos {
|
||||
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
}
|
||||
|
||||
match codec {
|
||||
Codec::TrueHd => says_truehd || says_atmos,
|
||||
Codec::Ac3Plus => says_ddp || says_atmos,
|
||||
Codec::Ac3 => says_ac3,
|
||||
Codec::DtsHdMa => says_dts_ma,
|
||||
Codec::DtsHdHr => says_dts_hr,
|
||||
Codec::Dts => says_dts,
|
||||
Codec::Lpcm => says_lpcm,
|
||||
// Unknown / other stream codec — don't second-guess the parser's hint.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the hint carry object-audio detail the spec codec can't express
|
||||
/// (Atmos / DTS:X)? Such hints are kept verbatim; plain codec/channel hints are
|
||||
/// normalized to the stream's own descriptor for uniform styling across tracks.
|
||||
fn codec_hint_adds_detail(hint: &str) -> bool {
|
||||
let h = hint.to_ascii_lowercase();
|
||||
h.contains("atmos") || h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx")
|
||||
}
|
||||
|
||||
pub(crate) fn generate_audio_label(
|
||||
codec: &crate::disc::Codec,
|
||||
channels: &crate::disc::AudioChannels,
|
||||
_secondary: bool,
|
||||
@@ -1182,6 +1272,94 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_mismatched_codec_hint_and_uses_stream_codec() {
|
||||
// Paddington case: a TrueHD+Atmos main track the parser mislabeled
|
||||
// "AC-3 2.0" (a compat-core hint bound to the wrong stream). The hint
|
||||
// contradicts the stream's real codec → discard it, use the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::TrueHd,
|
||||
AudioChannels::Surround71,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "AC-3 2.0", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby TrueHD 7.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unshuffles_cross_labeled_streams() {
|
||||
// Wicked case: hints fully cross-bound — a TrueHD stream wears "AC-3 5.1"
|
||||
// and a DD+ stream wears "TrueHD 5.1". Each is corrected from its own
|
||||
// stream codec, eliminating the shuffle.
|
||||
let mut titles = vec![title_with(vec![
|
||||
audio(0x1100, Codec::TrueHd, AudioChannels::Surround51, "eng"),
|
||||
audio(0x1101, Codec::Ac3Plus, AudioChannels::Surround51, "spa"),
|
||||
])];
|
||||
let labels = vec![
|
||||
audio_label(1, "eng", "AC-3 5.1", ""),
|
||||
audio_label(2, "spa", "TrueHD 5.1", ""),
|
||||
];
|
||||
apply_labels(&labels, &mut titles);
|
||||
let got: Vec<String> = titles[0]
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if let Stream::Audio(a) = s {
|
||||
Some(a.label.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, vec!["Dolby TrueHD 5.1", "Dolby Digital Plus 5.1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_consistent_richer_atmos_hint() {
|
||||
// A DD+ Atmos stream legitimately labeled "Dolby Atmos" — the hint is
|
||||
// richer than the spec codec yet consistent with it, so it's kept.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "Dolby Atmos", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_normalizes_plain_consistent_hint_to_marketing() {
|
||||
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
|
||||
// but short-form. A sibling DD+ track that fell back uses the marketing
|
||||
// form — keeping the short form here would read inconsistently, so a
|
||||
// plain (non-richer) consistent hint is normalized to the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"fra",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "fra", "AC-3+ 5.1", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Digital Plus 5.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sets_purpose_on_audio_commentary() {
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
@@ -1342,9 +1520,11 @@ mod apply_tests {
|
||||
if let Stream::Audio(a) = &titles[0].streams[1] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
}
|
||||
// Audio #2 (4th stream overall)
|
||||
// Audio #2 (4th stream overall). The plain "Dolby Digital" hint is
|
||||
// consistent with the AC-3 stream but carries no channel info, so it's
|
||||
// normalized to the stream's own uniform descriptor.
|
||||
if let Stream::Audio(a) = &titles[0].streams[3] {
|
||||
assert_eq!(a.label, "Dolby Digital");
|
||||
assert_eq!(a.label, "Dolby Digital 2.0");
|
||||
}
|
||||
// Subtitle #1
|
||||
if let Stream::Subtitle(s) = &titles[0].streams[2] {
|
||||
|
||||
+5
-3
@@ -85,6 +85,7 @@ pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub mod keysource;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
@@ -170,10 +171,11 @@ 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, DriveCredentials, Extent, FrameRate, HdrFormat, Key,
|
||||
KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
};
|
||||
pub use keysource::{DiscInputs, KeySource};
|
||||
|
||||
// ─── Streams ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
+15
-3
@@ -301,9 +301,21 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
return None;
|
||||
}
|
||||
|
||||
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
|
||||
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
|
||||
// PID location depends on the stream-entry type (BD spec stream_entry()):
|
||||
// type 1 (stream in the PlayItem's Clip): PID at +2
|
||||
// type 2 (stream in a SubPath SubClip): +subpath_id(1)+subclip_id(1) → PID at +4
|
||||
// type 3 / 4 (SubPath clip; type 4 = Dolby Vision +subpath_id(1) → PID at +3
|
||||
// enhancement layer, e.g. PID 0x1015):
|
||||
// Previously only type 1 was handled, so the DV EL (type 4) and any
|
||||
// sub-path stream fell through to PID 0 and were dropped by the mux.
|
||||
let pid_off = match item[pos + 1] {
|
||||
0x01 => 2,
|
||||
0x02 => 4,
|
||||
0x03 | 0x04 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let pid = if pid_off != 0 && pos + pid_off + 2 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + pid_off], item[pos + pid_off + 1]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
+74
-16
@@ -64,23 +64,40 @@ impl CodecParser for DtsParser {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for DTS-HD extension after core
|
||||
let mut total_size = core_size;
|
||||
if start + core_size + 4 <= data.len() {
|
||||
if let Some(0) = find_sync(
|
||||
&data[start + core_size..start + core_size + 4],
|
||||
&DTS_HD_EXT_SYNC,
|
||||
) {
|
||||
let ext = &data[start + core_size..];
|
||||
if ext.len() >= 9 {
|
||||
let ext_size = dts_hd_ext_frame_size(ext);
|
||||
if start + core_size + ext_size <= data.len() {
|
||||
total_size = core_size + ext_size;
|
||||
}
|
||||
// If ext incomplete, just emit core
|
||||
}
|
||||
// Include the DTS-HD extension substream if one immediately follows
|
||||
// the core. The extension carries the LOSSLESS (DTS-HD MA / HRA)
|
||||
// data; emitting a core-only frame and dropping the trailing
|
||||
// extension silently downgrades the track to lossy DTS core. If we
|
||||
// can't yet tell whether an extension follows, or it's present but
|
||||
// not fully buffered, WAIT for more PES data (break, leaving `pos`
|
||||
// at this access unit's core sync so the buffer keeps the partial
|
||||
// unit) rather than splitting the extension off and losing it.
|
||||
let after = start + core_size;
|
||||
let avail = data.len() - after;
|
||||
// Does a DTS-HD extension substream follow the core? Match the full
|
||||
// sync when it's buffered, or a partial PREFIX when the buffer ends
|
||||
// mid-sync — so we wait for the rest instead of splitting the
|
||||
// extension off and losing the lossless data. Nothing after the core
|
||||
// (e.g. the final access unit / EOF, with no parser flush) is taken
|
||||
// as a legitimate lossy core-only unit.
|
||||
let ext_follows = if avail >= 4 {
|
||||
data[after..after + 4] == DTS_HD_EXT_SYNC
|
||||
} else if avail > 0 {
|
||||
DTS_HD_EXT_SYNC[..avail] == data[after..]
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let total_size = if !ext_follows {
|
||||
core_size
|
||||
} else if avail < 9 {
|
||||
break; // extension present but its size header isn't buffered — wait
|
||||
} else {
|
||||
let ext_size = dts_hd_ext_frame_size(&data[after..]);
|
||||
if ext_size == 0 || avail < ext_size {
|
||||
break; // extension known but not fully buffered — wait
|
||||
}
|
||||
}
|
||||
core_size + ext_size
|
||||
};
|
||||
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
@@ -204,6 +221,47 @@ mod tests {
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
}
|
||||
|
||||
/// Build a DTS-HD extension substream of `size` bytes with a valid sync +
|
||||
/// size header (matching `dts_hd_ext_frame_size`).
|
||||
fn make_dts_ext(size: usize) -> Vec<u8> {
|
||||
let raw = size - 1;
|
||||
let mut e = vec![0u8; size];
|
||||
e[0..4].copy_from_slice(&DTS_HD_EXT_SYNC);
|
||||
e[6] = ((raw >> 11) & 0x1F) as u8;
|
||||
e[7] = ((raw >> 3) & 0xFF) as u8;
|
||||
e[8] = (((raw & 0x07) << 5) as u8) | (e[8] & 0x1F);
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_dts_hd_extension_across_pes_boundary() {
|
||||
// DTS-HD MA access unit = core + extension substream. When the
|
||||
// extension straddles a PES boundary, the parser must WAIT and emit the
|
||||
// full unit — not a core-only frame (which would drop the lossless
|
||||
// data, the Dunkirk lossy-core bug).
|
||||
let mut parser = DtsParser::new();
|
||||
let core = make_dts_core(512);
|
||||
let ext = make_dts_ext(256);
|
||||
let mut au = core;
|
||||
au.extend_from_slice(&ext); // 768-byte access unit
|
||||
|
||||
// Split mid-extension: first PES carries the core + 100 ext bytes.
|
||||
let split = 512 + 100;
|
||||
let f1 = parser.parse(&make_pes(au[..split].to_vec(), Some(90000)));
|
||||
assert!(
|
||||
f1.is_empty(),
|
||||
"must wait for the full extension, not emit a core-only frame"
|
||||
);
|
||||
|
||||
let f2 = parser.parse(&make_pes(au[split..].to_vec(), Some(90000)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(
|
||||
f2[0].data.len(),
|
||||
768,
|
||||
"frame must include core + extension (lossless preserved)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = DtsParser::new();
|
||||
|
||||
@@ -153,6 +153,52 @@ impl CodecParser for TrueHdParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
||||
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some
|
||||
/// bits denote a stereo pair (2), others a single channel (1).
|
||||
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
||||
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
||||
|
||||
/// Decode the true channel count from a TrueHD major-sync `format_info` word
|
||||
/// (the 32 bits immediately after the 0xF8726FBA sync). Returns the richest
|
||||
/// presentation's channel count — the 8-channel (e.g. 7.1) presentation when
|
||||
/// present, else the 6-channel (5.1) one. This is the real layout that the MPLS
|
||||
/// `audio_format` base field (often 5.1 even on a 7.1/Atmos track) understates.
|
||||
pub fn truehd_channels(format_info: u32) -> Option<u8> {
|
||||
let ch8 = (format_info & 0x1FFF) as u16; // 8ch_presentation_channel_assignment (13 bits)
|
||||
let ch6 = ((format_info >> 15) & 0x1F) as u16; // 6ch_presentation_channel_assignment (5 bits)
|
||||
let count = |mask: u16, tbl: &[u8]| -> u8 {
|
||||
tbl.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| mask & (1 << i) != 0)
|
||||
.map(|(_, &c)| c)
|
||||
.sum()
|
||||
};
|
||||
if ch8 != 0 {
|
||||
Some(count(ch8, &THD_8CH))
|
||||
} else if ch6 != 0 {
|
||||
Some(count(ch6, &THD_6CH))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a demuxed TrueHD elementary-stream chunk for the first major sync and
|
||||
/// decode its true channel count. The stream may interleave AC-3; we scan for
|
||||
/// the major-sync word anywhere and read the following `format_info`.
|
||||
pub fn truehd_channels_from_stream(data: &[u8]) -> Option<u8> {
|
||||
let mut p = 0;
|
||||
while p + 8 <= data.len() {
|
||||
let w = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
|
||||
if (w & 0xFFFF_FFFE) == 0xF872_6FBA {
|
||||
let fi = u32::from_be_bytes([data[p + 4], data[p + 5], data[p + 6], data[p + 7]]);
|
||||
return truehd_channels(fi);
|
||||
}
|
||||
p += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -247,4 +293,27 @@ mod tests {
|
||||
let parser = TrueHdParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_71_from_8ch_presentation() {
|
||||
// 8ch presentation assignment bits 0-4 (LR,C,LFE,LsRs,back-LR) = 2+1+1+2+2 = 8.
|
||||
let format_info = 0x1F; // low 13 bits = 0x1F
|
||||
assert_eq!(truehd_channels(format_info), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_51_from_6ch_presentation() {
|
||||
// No 8ch presentation; 6ch bits 0-3 (LR,C,LFE,LsRs) = 2+1+1+2 = 6.
|
||||
let format_info = 0xF << 15; // 6ch field = 0xF, 8ch field = 0
|
||||
assert_eq!(truehd_channels(format_info), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_scan_finds_major_sync() {
|
||||
// [junk][major sync 0xF8726FBA][format_info: 8ch=0x1F -> 7.1]
|
||||
let mut data = vec![0xAA, 0xBB];
|
||||
data.extend_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||
data.extend_from_slice(&0x0000_001Fu32.to_be_bytes());
|
||||
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,12 @@ pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
|
||||
// Dolby Vision — BlockAdditionMapping carries the DOVIDecoderConfigurationRecord
|
||||
// (dvcC) so players / mediainfo recognise the track as Dolby Vision.
|
||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
|
||||
@@ -34,6 +34,24 @@ pub struct MkvTrack {
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
pub bit_depth: u8,
|
||||
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
|
||||
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
|
||||
pub dv_config: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
||||
/// BlockAdditionMapping. For disc Profile 7 dual-layer the base, enhancement,
|
||||
/// and RPU are all present (lossless FEL/MEL preserved as a second track).
|
||||
pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 24];
|
||||
v[0] = 1; // dv_version_major
|
||||
v[1] = 0; // dv_version_minor
|
||||
// profile(7) | level(6) | rpu_present(1) | el_present(1) | bl_present(1)
|
||||
v[2] = ((profile & 0x7F) << 1) | ((level >> 5) & 0x01);
|
||||
v[3] = ((level & 0x1F) << 3) | (1 << 2) | (1 << 1) | 1; // rpu = el = bl = 1
|
||||
v[4] = (bl_compat_id & 0x0F) << 4;
|
||||
// v[5..24] reserved = 0
|
||||
v
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
@@ -83,6 +101,13 @@ impl MkvTrack {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
// The DV layer (hdr=DolbyVision) carries the dvcC so the track is
|
||||
// recognised as Dolby Vision (disc Profile 7 dual-layer).
|
||||
dv_config: if matches!(v.hdr, HdrFormat::DolbyVision) {
|
||||
Some(dolby_vision_config(7, 6, 0))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +153,7 @@ impl MkvTrack {
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +182,7 @@ impl MkvTrack {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +360,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||
// mediainfo recognise the track as Dolby Vision.
|
||||
if let Some(ref dvcc) = track.dv_config {
|
||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, 0x6476_6343)?;
|
||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
|
||||
ebml::end_master(&mut writer, map_pos)?;
|
||||
}
|
||||
|
||||
// Audio-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
|
||||
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
|
||||
@@ -612,6 +650,7 @@ mod tests {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,9 +675,24 @@ mod tests {
|
||||
sample_rate: 48000.0,
|
||||
channels: 6,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dolby_vision_config_profile7() {
|
||||
// dvcC for disc Profile 7 dual-layer: version 1.0, profile 7, all of
|
||||
// bl/el/rpu present. 24 bytes.
|
||||
let c = dolby_vision_config(7, 6, 0);
|
||||
assert_eq!(c.len(), 24);
|
||||
assert_eq!(c[0], 1); // dv_version_major
|
||||
assert_eq!(c[1], 0); // dv_version_minor
|
||||
// profile in the top 7 bits of byte 2
|
||||
assert_eq!(c[2] >> 1, 7, "dv_profile must be 7");
|
||||
// rpu/el/bl present flags in byte 3 (low 3 bits after level)
|
||||
assert_eq!(c[3] & 0b0000_0111, 0b0000_0111, "rpu+el+bl all present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_writes_ebml_header() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
|
||||
+26
-12
@@ -162,10 +162,11 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Default)]
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
|
||||
/// exclusive key source. Takes precedence over `keydb_path`.
|
||||
pub unit_key: Option<[u8; 16]>,
|
||||
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
|
||||
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
|
||||
/// caller has no key. The library does no lookup — a key source resolves
|
||||
/// these and the caller passes them here.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
@@ -184,11 +185,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
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
|
||||
// (so the kernel readahead window widens) and the periodic
|
||||
@@ -196,8 +192,19 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// when the mux output is being written to the same disk.
|
||||
let mut reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
|
||||
let capacity = reader.capacity_sectors();
|
||||
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
let mut disc = crate::disc::Disc::scan_image(
|
||||
&mut reader,
|
||||
capacity,
|
||||
&crate::disc::ScanOptions::default(),
|
||||
)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Apply the caller-resolved keys (lookup-free); decrypt_keys() then
|
||||
// yields them for the stream below. Propagate a failed application
|
||||
// rather than silently muxing an undecryptable stream.
|
||||
if !opts.unit_keys.is_empty() {
|
||||
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()))
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
if disc.titles.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
@@ -209,8 +216,15 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below.
|
||||
let keys = disc.decrypt_keys();
|
||||
if let Ok(probe) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(probe, keys.clone());
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 16 MiB batch — sequential read from fast
|
||||
// storage, no bad sectors. Measured optimum on the rip1
|
||||
|
||||
+55
-53
@@ -89,8 +89,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
plain[offset] = 0x47; // TS sync byte
|
||||
offset += 192;
|
||||
}
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS
|
||||
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Save original plaintext for comparison
|
||||
let expected = plain.clone();
|
||||
@@ -126,8 +126,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
}
|
||||
|
||||
// Verify it looks encrypted
|
||||
assert!(aacs::is_unit_encrypted(&plain));
|
||||
// Verify it looks encrypted (body TS syncs scrambled)
|
||||
assert!(aacs::is_aacs_scrambled(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
||||
@@ -136,8 +136,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&plain),
|
||||
"encryption flag should be cleared"
|
||||
!aacs::is_aacs_scrambled(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
);
|
||||
|
||||
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
|
||||
@@ -156,17 +156,11 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
sync_count, expected_syncs
|
||||
);
|
||||
|
||||
// Compare all bytes except byte 0 (encryption flag cleared)
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plain[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected[1..aacs::ALIGNED_UNIT_LEN],
|
||||
"decrypted unit body does not match original"
|
||||
);
|
||||
// Byte 0: original had 0xC0 set, decrypted has it cleared
|
||||
assert_eq!(
|
||||
plain[0] & !0xC0,
|
||||
expected[0] & !0xC0,
|
||||
"byte 0 mismatch ignoring flag"
|
||||
&plain[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,49 +254,65 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||
}
|
||||
|
||||
/// Test: aacs_is_unit_encrypted detects encryption flags correctly.
|
||||
/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs.
|
||||
#[test]
|
||||
fn aacs_is_unit_encrypted_detection() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
fn aacs_is_aacs_scrambled_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&unit),
|
||||
"zero unit should not be encrypted"
|
||||
!aacs::is_aacs_scrambled(&clear),
|
||||
"clear unit (syncs intact) must not be scrambled"
|
||||
);
|
||||
|
||||
unit[0] = 0x40; // bit 6 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
// Flag bits do NOT make a synced unit look scrambled.
|
||||
let mut flagged = clear.clone();
|
||||
flagged[0] = 0xC0; // copy-control bits
|
||||
flagged[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
unit[0] = 0x80; // bit 7 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0xC0; // both bits set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0x3F; // bits 6-7 clear
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&scrambled),
|
||||
"unit with no intact TS syncs must read as scrambled"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xC0u8; 100];
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&short),
|
||||
!aacs::is_aacs_scrambled(&short),
|
||||
"short buffer should not be detected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
||||
///
|
||||
/// A unit without encryption flags should pass through decrypt_unit unchanged.
|
||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // no encryption flag
|
||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
let result = aacs::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "unencrypted unit should return true");
|
||||
assert_eq!(unit, original, "unencrypted unit should be unchanged");
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
}
|
||||
|
||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||
@@ -369,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
}
|
||||
// Set encryption flag
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
|
||||
let expected = plaintext.clone();
|
||||
|
||||
@@ -401,14 +410,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
let mut expected_cleared = expected.clone();
|
||||
expected_cleared[0] &= !0xC0;
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&plaintext[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
@@ -428,7 +434,7 @@ fn aacs_cross_validation_alternate_key() {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
let expected = plaintext.clone();
|
||||
|
||||
let mut header = [0u8; 16];
|
||||
@@ -446,12 +452,8 @@ fn aacs_cross_validation_alternate_key() {
|
||||
|
||||
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
||||
|
||||
let mut expected_cleared = expected;
|
||||
expected_cleared[0] &= !0xC0;
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
}
|
||||
|
||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
||||
|
||||
+15
-32
@@ -126,31 +126,19 @@ fn disc_title_total_sectors() {
|
||||
// ── ScanOptions tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_options_default() {
|
||||
fn scan_options_default_has_no_credentials() {
|
||||
// Lookup-free: the only scan input is the optional live-drive credentials.
|
||||
let opts = ScanOptions::default();
|
||||
assert!(opts.keydb_path.is_none());
|
||||
assert!(opts.credentials.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb() {
|
||||
fn scan_options_with_credentials() {
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
credentials: Some(libfreemkv::DriveCredentials::default()),
|
||||
};
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
"/tmp/KEYDB.cfg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(path.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
assert!(opts.credentials.is_some());
|
||||
assert!(opts.credentials.unwrap().host_certs.is_empty());
|
||||
}
|
||||
|
||||
// ── detect_format integration tests ───────────────────────────────────────
|
||||
@@ -511,21 +499,16 @@ fn batch_count_max_batch_sizes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_encryption_no_keydb() {
|
||||
// A UDF image with /AACS directory but no keydb path -> aacs is None
|
||||
fn scan_encrypted_resolves_no_keys() {
|
||||
// A UDF image with an /AACS directory: the lookup-free scan detects
|
||||
// encryption and captures inputs, but resolves NO key on its own — a
|
||||
// caller applies one later via Disc::decrypt_with.
|
||||
let mut reader = MockSectorReader::new();
|
||||
build_udf_with_aacs_dir(&mut reader);
|
||||
|
||||
// No keydb configured and no standard keydb on the system
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
};
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap();
|
||||
|
||||
// The disc detects encryption but can't resolve keys without a keydb
|
||||
assert!(
|
||||
disc.aacs.is_none(),
|
||||
"aacs should be None when keydb is unavailable"
|
||||
);
|
||||
// No unit keys without an external key (the mock has no Unit_Key_RO.inf to
|
||||
// capture, so the keyless state isn't even built) — either way, no keys.
|
||||
assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None));
|
||||
}
|
||||
|
||||
+15
-13
@@ -82,22 +82,24 @@ fn decrypt_sectors_with_css_keys_works() {
|
||||
/// Test: AACS unit encryption detection works.
|
||||
#[test]
|
||||
fn aacs_encryption_flag_detection() {
|
||||
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// No encryption flag
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 6
|
||||
unit[0] |= 0x40;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 7
|
||||
unit[0] = 0x80;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Both bits set
|
||||
// Flag bits on a synced unit do not make it look encrypted.
|
||||
unit[0] = 0xC0;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
unit[7] = 0xC0;
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// Scrambled body (syncs gone) → encrypted.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::is_aacs_scrambled(&scrambled));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
|
||||
Reference in New Issue
Block a user