From 575c76156f4db0a46b3f7bd2e5436e30f0b010ea Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:45:37 -0700 Subject: [PATCH] mapfile: persist resolved unit keys (keys XOR VID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keyed disc now writes its decrypted AACS unit keys to the mapfile header (# freemkv-uk: :); 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. --- CHANGELOG.md | 14 +++++ src/disc/mapfile.rs | 122 +++++++++++++++++++++++++++++++++++++++++++- src/disc/mod.rs | 50 +++++++++++++++--- 3 files changed, 177 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb0140..7575f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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: :` + 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 diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs index 4afc50d..4c4974f 100644 --- a/src/disc/mapfile.rs +++ b/src/disc/mapfile.rs @@ -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:") { + // `:<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 `:<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 { 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"); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index fc41933..bde1e40 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -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. /// /// NOT a stream operation. Copies sectors byte-for-byte producing a valid @@ -1586,6 +1599,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 +1698,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 +2184,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 +2215,12 @@ pub struct SweepOptions<'a> { pub progress: Option<&'a dyn crate::progress::Progress>, pub halt: Option>, /// 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). @@ -2704,6 +2732,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 +2758,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 +2790,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 +2821,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 +2840,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 +2874,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 +2888,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 +2922,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");