diff --git a/src/keysource.rs b/src/keysource.rs index 4993b81..7ba7e2c 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -23,6 +23,22 @@ use crate::aacs::types::{UnitKey, Vid}; use crate::disc::Key; use crate::error::Error; +/// Minimum encrypted-content unit samples a single online key request must carry. +/// +/// The key service identifies a key by which of the submitted units it decrypts, +/// so too few samples — especially on FMTS, where a segment interleaves several +/// variants at the unit level — can return a key that matches an incidental unit +/// rather than the one asked about (a false positive). This many distinct units +/// make the request unambiguous. +/// +/// Canonical here (the base crate) so BOTH consumers agree on one value: the +/// online source in `freemkv-keysources` (which refuses to send an under-sampled +/// request) re-exports it, and libfreemkv's own FMTS forensic query +/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the +/// reverse import (keysources depends on libfreemkv, not vice versa), so the +/// value lives at the lower layer both share. +pub const MIN_SAMPLE_UNITS: usize = 8; + /// 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. diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 16572e7..44f3567 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -691,17 +691,37 @@ fn resolve_fmts_key_map( // decrypt-and-check: the array position IS the index. The first readable // segment whose batch yields the full set wins; a short (e.g. 1-key, // base-UK-shaped) response means that batch wasn't forensic (a wrong - // feature-title mapping), so try the next segment. ───────────────────────── + // feature-title mapping), so try the next segment. + // + // ANCHOR RULE: the query MUST sample an INDEX-1 segment. The key service only + // returns the full set for the canonical anchor sample (a unit that decrypts + // under the index-1 key); a batch from any other forensic index is rejected + // (a base-UK-shaped miss). The `index == 1` filter guarantees every batch we + // send is an anchor — and the forensic tag cycles 1..32 in file order, so + // ~1-in-32 segments qualify (~25 across the feature), leaving ample read-fault + // fallback within the `MAX_ANCHOR_ATTEMPTS` budget. ───────────────────────── const N_INDEX: usize = 32; + // Each forensic batch carries the server's minimum-samples count (the same + // disambiguation floor the online source enforces), drawn as even-phase units + // to land one clean variant half. + const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS; + // Read-fault fallback budget: how many INDEX-1 (anchor) segments to attempt + // before giving up. Only matters when the leading anchor segments are + // unreadable; each attempt is one server round-trip, so it is bounded. + const MAX_ANCHOR_ATTEMPTS: usize = 16; let mut index_keys: Vec<[u8; 16]> = Vec::new(); - for seg in segments.iter().take(16) { + for seg in segments + .iter() + .filter(|s| s.index == 1) + .take(MAX_ANCHOR_ATTEMPTS) + { let mut batch: Vec> = Vec::new(); - for p in 0..8usize { + for p in 0..BATCH_UNITS { if let Some(c) = read_unit(reader, seg, p * 2) { batch.push(c); } } - if batch.len() < 8 { + if batch.len() < BATCH_UNITS { continue; // read fault / short tail } let fresh = fetch(&batch);