keys: refuse unparseable FMTS segment table; cap keys per source answer

This commit is contained in:
Matthew Jackson
2026-09-29 19:08:55 -07:00
parent ea418670b2
commit 76b6bf2022
3 changed files with 60 additions and 8 deletions
+3 -2
View File
@@ -31,7 +31,7 @@ pub(crate) struct Layout {
// Bytes per source packet (the SPN unit).
const SPN_BYTES: u64 = 192;
/// Read the disc's forensic layout. `Ok(None)`: not FMTS (no or empty segment table).
/// Read the disc's forensic layout. `Ok(None)`: not FMTS (no or empty segment table); an unparseable table is refused.
pub(crate) fn layout(
fs: &crate::udf::UdfFs,
reader: &mut dyn SectorSource,
@@ -43,7 +43,8 @@ pub(crate) fn layout(
};
let Some(segments) = parse_individual_segments(&tbl) else {
tracing::warn!(target: "freemkv::keys", "fmts: IndividualSegment.tbl present but unparseable");
return Ok(None);
// Ok(None) would rip forensic units as base content: refuse.
return Err(Error::FmtsKeyMissing);
};
if segments.is_empty() {
return Ok(None);
+6 -6
View File
@@ -27,7 +27,8 @@ const RETRY_CAP: Duration = Duration::from_secs(8);
const NO_ANSWER_WINDOW: Duration = Duration::from_secs(60);
/// Encrypted units sent in one per-piece request, at most.
const SAMPLE_CAP: usize = 32;
// Most keys a run holds: real discs declare few CPS units; a service cannot flood the pool.
// Most keys taken from one source answer: real discs declare few CPS units; a source cannot
// flood the pool or starve a later source.
const MAX_POOL_KEYS: usize = 256;
/// Time for the J13 retry: injected so tests use a fake clock and never sleep.
@@ -145,11 +146,10 @@ impl Run<'_> {
// Add keys to the pool (deduplicated): the pool only grows during `resolve`.
fn add_keys(&mut self, keys: &[[u8; 16]], who: &'static str) {
for k in keys {
if self.pool.len() >= MAX_POOL_KEYS {
tracing::warn!(target: "freemkv::keys", who, "key pool full: extra keys ignored");
break;
}
if keys.len() > MAX_POOL_KEYS {
tracing::warn!(target: "freemkv::keys", who, "source answer truncated: extra keys ignored");
}
for k in keys.iter().take(MAX_POOL_KEYS) {
if !self.pool.contains(k) {
self.pool.push(*k);
self.origin.push(who);
+51
View File
@@ -1502,6 +1502,23 @@ fn fmts_fixture() -> Fx {
fx
}
/// A present but unparseable segment table is a refusal, never "not FMTS" (which would rip
/// forensic units as base content).
#[test]
fn fmts_malformed_segment_table_is_refused() {
let mut fx = fmts_fixture();
let at = fx.file(2).0 as usize * 2048;
// record_size (bytes 6..8) no longer matches the record length.
fx.img.image[at + 6..at + 8].copy_from_slice(&15u16.to_be_bytes());
let calls = Calls::default();
let r = resolve(
&fx,
KeyScope::Titles(vec![2]),
&[Spec::keydb(&[K1, K2], &calls)],
);
assert!(matches!(r, Err(Error::FmtsKeyMissing)), "{:?}", r.err());
}
fn fmts_online(calls: &Calls) -> Spec {
let mut s = Spec::online(&[K2], calls);
s.fmts = vec![F1, F2];
@@ -2784,6 +2801,40 @@ fn pieces_survive_extents_near_the_lba_limit() {
assert!(!spans.is_empty());
}
/// The push-closure path (a title extent no file covers) survives an extent near u32::MAX.
#[test]
fn uncovered_title_extent_near_the_lba_limit_survives() {
let mut fx = two_units();
fx.disc.titles[0].extents = vec![Extent {
start_lba: u32::MAX - 4,
sector_count: 10,
}];
let spans = crate::keys::resolve::whole_disc_pieces(&fx.disc, &[]);
assert!(!spans.is_empty());
}
/// One source flooding the pool cannot starve a later source's real key.
#[test]
fn a_flooding_source_does_not_starve_a_later_source() {
let fx = two_units();
let junk: Vec<[u8; 16]> = (0..300u32)
.map(|i| {
let mut k = [0u8; 16];
k[..4].copy_from_slice(&(i + 1).to_be_bytes());
k
})
.collect();
let calls = Calls::default();
let mut later = Spec::keydb(&[K1, K2], &calls);
later.who = "later";
let r = resolve(
&fx,
KeyScope::WholeDisc,
&[Spec::keydb(&junk, &calls), later],
);
assert!(r.is_ok(), "{:?}", r.err());
}
/// A key service cannot flood the pool: keys past the cap are ignored.
#[test]
fn key_pool_is_capped() {