mapfile: persist resolved unit keys (keys XOR VID)

A keyed disc now writes its decrypted AACS unit keys to the mapfile header
(# freemkv-uk: <cps>:<hex>); an unresolved disc writes only the VID. The two are
mutually exclusive (set_unit_keys clears the VID) — unit keys are the final
answer, so deferred-mux / resume decrypts directly with no key lookup, while the
VID alone is the 'still unresolved, retry' marker. CopyOptions/SweepOptions carry
the keys (written when present, else the VID); Disc::inject_unit_keys applies
mapfile-recovered keys to a scanned disc. Round-trip test added.
This commit is contained in:
MattJackson
2026-06-03 21:45:37 -07:00
parent b518860d9c
commit 575c76156f
3 changed files with 177 additions and 9 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # 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) ## 0.27.0 (2026-06-03)
### Changed ### Changed
+120 -2
View File
@@ -134,7 +134,17 @@ pub struct Mapfile {
/// `# freemkv-vid:` comment header so it survives to deferred-mux / /// `# freemkv-vid:` comment header so it survives to deferred-mux /
/// resume without altering the ISO payload or breaking ddrescue /// resume without altering the ISO payload or breaking ddrescue
/// data-line parsing. `None` for unencrypted / non-AACS discs. /// 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]>, 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 { impl Mapfile {
@@ -160,6 +170,7 @@ impl Mapfile {
dirty: false, dirty: false,
last_flushed: Instant::now(), last_flushed: Instant::now(),
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
// Eager initial persist so a resume can pick this up even if // Eager initial persist so a resume can pick this up even if
// `record()` is never called. // `record()` is never called.
@@ -175,6 +186,7 @@ impl Mapfile {
let mut saw_current_line = false; let mut saw_current_line = false;
let mut version = String::from("unknown"); let mut version = String::from("unknown");
let mut vid: Option<[u8; 16]> = None; let mut vid: Option<[u8; 16]> = None;
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
for line in text.lines() { for line in text.lines() {
let t = line.trim(); let t = line.trim();
if t.is_empty() { if t.is_empty() {
@@ -190,6 +202,12 @@ impl Mapfile {
// ignored rather than failing the whole load. // ignored rather than failing the whole load.
vid = parse_vid_hex(hex.trim()); 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; continue;
} }
// First non-comment line is the "current" state line (pos status [pass] [pass_time]). // First non-comment line is the "current" state line (pos status [pass] [pass_time]).
@@ -240,6 +258,7 @@ impl Mapfile {
dirty: false, dirty: false,
last_flushed: Instant::now(), last_flushed: Instant::now(),
vid, vid,
unit_keys,
}) })
} }
@@ -345,6 +364,25 @@ impl Mapfile {
self.vid 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] { pub fn entries(&self) -> &[MapEntry] {
&self.entries &self.entries
} }
@@ -421,10 +459,22 @@ impl Mapfile {
// `#`-prefixed line as a comment, so this round-trips through // `#`-prefixed line as a comment, so this round-trips through
// our `load()` without affecting the `pos size status` data // our `load()` without affecting the `pos size status` data
// parser. 16 bytes → 32 lowercase hex chars. // 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); let mut hex = String::with_capacity(32);
for b in vid { for b in vid {
use std::fmt::Write as _;
let _ = write!(hex, "{b:02x}"); let _ = write!(hex, "{b:02x}");
} }
writeln!(w, "# freemkv-vid: {hex}")?; writeln!(w, "# freemkv-vid: {hex}")?;
@@ -474,6 +524,15 @@ fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
Some(out) 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> { fn parse_hex(s: &str) -> io::Result<u64> {
let s = s.strip_prefix("0x").unwrap_or(s); let s = s.strip_prefix("0x").unwrap_or(s);
u64::from_str_radix(s, 16).map_err(|_| { u64::from_str_radix(s, 16).map_err(|_| {
@@ -640,6 +699,65 @@ mod tests {
let _ = std::fs::remove_file(&p); 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] #[test]
fn vid_round_trips_and_data_lines_unaffected() { fn vid_round_trips_and_data_lines_unaffected() {
let p = tmpfile("vid_round_trips"); let p = tmpfile("vid_round_trips");
+43 -7
View File
@@ -1501,6 +1501,19 @@ 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`.
/// No-op for a disc with no AACS state (unencrypted / non-AACS).
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
if let Some(aacs) = self.aacs.as_mut() {
aacs.unit_keys = keys;
aacs.key_source = KeySource::ExternalUk;
}
}
/// Copy disc sectors to an ISO image file. /// Copy disc sectors to an ISO image file.
/// ///
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid /// NOT a stream operation. Copies sectors byte-for-byte producing a valid
@@ -1586,6 +1599,7 @@ impl Disc {
progress: opts.progress, progress: opts.progress,
halt: opts.halt.clone(), halt: opts.halt.clone(),
vid: opts.vid, vid: opts.vid,
unit_keys: opts.unit_keys.clone(),
}; };
self.sweep(reader, path, &sweep_opts) self.sweep(reader, path, &sweep_opts)
} }
@@ -1684,12 +1698,14 @@ impl Disc {
) )
.map_err(|e| Error::IoError { source: e })?; .map_err(|e| Error::IoError { source: e })?;
// Persist the disc's AACS Volume ID into the mapfile header so it // Persist the disc's decryption state into the mapfile header so it
// survives to deferred-mux / resume. ddrescue-safe (comment line); // survives to deferred-mux / resume. ddrescue-safe (comment lines);
// does not touch the ISO payload. On a resume-load the VID is // does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
// already present, but re-setting it (idempotent) covers the case // unit keys (the final answer — deferred-mux decrypts directly, no key
// where Pass 1 created the mapfile before the VID was known. // service); an unresolved disc writes only the VID (the retry marker).
if let Some(vid) = opts.vid { if !opts.unit_keys.is_empty() {
map.set_unit_keys(&opts.unit_keys);
} else if let Some(vid) = opts.vid {
map.set_vid(vid); map.set_vid(vid);
} }
@@ -2168,7 +2184,15 @@ pub struct CopyOptions<'a> {
/// Pass 1 so it survives to deferred-mux / resume. `None` for /// Pass 1 so it survives to deferred-mux / resume. `None` for
/// unencrypted / non-AACS discs. Caller wires this from /// unencrypted / non-AACS discs. Caller wires this from
/// `Disc::aacs.volume_id`. /// `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]>, 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)] #[derive(Debug, Clone, Copy)]
@@ -2191,8 +2215,12 @@ pub struct SweepOptions<'a> {
pub progress: Option<&'a dyn crate::progress::Progress>, pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) persisted into the mapfile when the /// 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]>, 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). /// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
@@ -2704,6 +2732,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
assert!( assert!(
@@ -2729,6 +2758,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!( assert!(
@@ -2760,6 +2790,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!( assert!(
@@ -2790,6 +2821,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts); let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!( assert!(
@@ -2808,6 +2840,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts); let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!( assert!(
@@ -2841,6 +2874,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap(); let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
@@ -2854,6 +2888,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts); let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!( assert!(
@@ -2887,6 +2922,7 @@ mod tests {
progress: None, progress: None,
halt: None, halt: None,
vid: None, vid: None,
unit_keys: Vec::new(),
}; };
let result = disc.copy(&mut reader, &iso_path, &opts); let result = disc.copy(&mut reader, &iso_path, &opts);
let r = result.expect("100-batch clean sweep should succeed"); let r = result.expect("100-batch clean sweep should succeed");