keys: one encryption decision for every AACS format, the HD DVD clear case folded into it

KeyRing::acquire probes the pieces in scope once, each unit judged by its format's detector
(BD/UHD: the CPI flag; HD DVD: flagged or not whole MPEG-PS). Content read in the clear asks
no source, whatever key files the disc declares; for FMTS the forensic segments must read clear
too. Encrypted content goes to the format's keying (BD/UHD per piece, HD DVD best effort) and is
refused E7022 / E7032 when no source has the key. Replaces 9e9ff21's HD DVD-only check.
This commit is contained in:
Matthew Jackson
2026-10-01 22:46:14 -07:00
parent 6727fe893a
commit dc6d0fe693
6 changed files with 351 additions and 85 deletions
+1
View File
@@ -96,6 +96,7 @@
### Fixed
- Every AACS format takes one encryption decision in `KeyRing::acquire`: content its own detector reads in the clear needs no key and asks no source, whatever key files the disc declares (an HD DVD copy in the clear declaring several keys, and an FMTS disc whose forensic segments are also clear, no longer ask for or refuse on keys); encrypted content is keyed by the format's rules and refused (E7022 / E7032) when no source has the key.
- A zero read batch (`MuxOptions::default()` carries `batch_sectors` 0) is refused with `Error::MuxBatchSectorsZero` (E9085) on every path (live, session, ISO, URL) instead of spinning until Stop.
- A stopped or failed read can no longer finalise a truncated MKV / M2TS as a complete title; `extract` after a drive Stop leaves the file `.partial` instead of finalising zeros.
- DVDs with MPEG audio (common on PAL) ripped an empty track; anamorphic DVDs got wrong or missing subtitle languages; a partial angle-cell filter dropped cells.
+19 -24
View File
@@ -55,10 +55,24 @@ impl Disc {
pub enum Detector {
/// BD/UHD CPI flags: keys are proven on ciphertext.
Verified,
/// HD DVD: the test is unverified, so a single declared key is applied without proof.
/// HD DVD: the flag is unverified, so a unit reads clear only when it is also whole
/// content; encrypted content gets a single declared key applied without proof.
Unverified,
}
impl Detector {
// The format's own test of one aligned unit, feeding the one encryption decision
// (`resolve::acquire`). Verified: the CPI flag. Unverified: encrypted unless the unit is
// unflagged and structurally whole content (an AACS unit's ciphertext is not).
pub(crate) fn unit_encrypted(self, unit: &[u8], format: ContentFormat) -> bool {
use crate::aacs::content::{aacs_unit_encrypted, is_clean};
match self {
Detector::Verified => aacs_unit_encrypted(unit, format),
Detector::Unverified => aacs_unit_encrypted(unit, format) || !is_clean(unit, format),
}
}
}
/// One unit of key proof (design §2.2): a stream file, a title extent no file covers, or a
/// whole loose file. Spans are sectors `(start, count, unit anchor)`, sorted pieces by
/// first sector.
@@ -393,12 +407,14 @@ impl<'a> Sampler<'a> {
read_unit(self.reader, lba)
}
// Up to 32 units on `p`'s grid (KU §2.3 step 7), skipping FMTS segment units.
// Up to 32 units on `p`'s grid (KU §2.3 step 7), skipping FMTS segment units, each
// judged by `detector`.
pub(crate) fn probe(
&mut self,
p: &Piece,
segments: &[(u32, u32)],
format: ContentFormat,
detector: Detector,
halt: &Halt,
) -> Result<(u64, Vec<Sample>)> {
let (units, lbas) = probe_lbas(p, segments);
@@ -406,7 +422,7 @@ impl<'a> Sampler<'a> {
for lba in lbas {
halt.check()?;
out.push(match self.unit(lba)? {
Some(u) if crate::aacs::content::aacs_unit_encrypted(&u, format) => Sample::Enc(u),
Some(u) if detector.unit_encrypted(&u, format) => Sample::Enc(u),
Some(_) => Sample::Clear,
None => Sample::Fault,
});
@@ -414,27 +430,6 @@ impl<'a> Sampler<'a> {
Ok((units, out))
}
// Whether `p` is MPEG-PS content already in the clear: every unit `probe` would sample is
// readable, unflagged and structurally whole packs (the ciphertext of an AACS unit is not).
// `false` when no unit was sampled.
pub(crate) fn ps_in_clear(&mut self, p: &Piece, halt: &Halt) -> Result<bool> {
use crate::aacs::content::{aacs_unit_encrypted, is_clean};
let (_, lbas) = probe_lbas(p, &[]);
if lbas.is_empty() {
return Ok(false);
}
for lba in lbas {
halt.check()?;
match self.unit(lba)? {
Some(u)
if !aacs_unit_encrypted(&u, ContentFormat::MpegPs)
&& is_clean(&u, ContentFormat::MpegPs) => {}
_ => return Ok(false),
}
}
Ok(true)
}
// Up to `n` encrypted units spread over the main feature.
pub(crate) fn main_samples(&mut self, main: Option<&MainTitle>, n: usize) -> Vec<Vec<u8>> {
main.map(|m| crate::keysource::encrypted_units_in(self.reader, &m.extents, m.format, n))
+22
View File
@@ -110,6 +110,28 @@ fn read_unit(
super::evidence::read_unit(reader, lba).ok().flatten()
}
/// Whether the forensic segments read in the clear: the first unit of each of up to
/// [`MAX_ANCHOR_ATTEMPTS`] segments is readable and unflagged. `false` when a segment has no
/// sector range or no unit was read.
pub(crate) fn segments_clear(
reader: &mut dyn SectorSource,
layout: &Layout,
format: ContentFormat,
halt: &Halt,
) -> Result<bool> {
if layout.unresolved || layout.segments.is_empty() {
return Ok(false);
}
for seg in layout.segments.iter().take(MAX_ANCHOR_ATTEMPTS) {
halt.check()?;
match read_unit(reader, &layout.clip, seg, 0) {
Some(u) if !crate::aacs::content::aacs_unit_encrypted(&u, format) => {}
_ => return Ok(false),
}
}
Ok(true)
}
/// Sends one anchor batch to the sources: `Ok(None)` for an empty answer.
pub(crate) type AskFn<'a> = dyn FnMut(&[Vec<u8>]) -> Result<Option<Vec<[u8; 16]>>> + 'a;
+4 -4
View File
@@ -331,10 +331,10 @@ fn sha256(tag: &[u8], bytes: &[u8]) -> [u8; 32] {
}
impl KeyRing {
/// Acquire the keys `ev.scope()` needs, once, before any output (KU §2.3). The only
/// code in the product that asks a [`crate::KeySource`] for keys; `sources` is called
/// once and every source it builds is dropped before this returns (LK7). `sampler`
/// reads the evidence's pieces from the same raw source; `ctx.halt` stops it.
/// Acquire the keys `ev.scope()` needs, once, before any output (KU §2.3): the only code
/// that asks a [`crate::KeySource`]; every source `sources` builds is dropped before this
/// returns (LK7). `sampler` reads the pieces from the same raw source. Content the
/// format's own detector reads in the clear asks no source, whatever key files it declares.
///
/// `Err` refuses the rip before any output: E7022 (a title piece is Missing), E7032 (an
/// image or folder piece is Missing), E7013 (a single declared key opens none of the
+99 -57
View File
@@ -333,9 +333,10 @@ fn probe(
p: &mut Probed,
segments: &[(u32, u32)],
format: ContentFormat,
detector: Detector,
halt: &Halt,
) -> Result<()> {
let (units, samples) = sampler.probe(&p.piece, segments, format, halt)?;
let (units, samples) = sampler.probe(&p.piece, segments, format, detector, halt)?;
p.units = units;
for s in samples {
match s {
@@ -420,10 +421,7 @@ pub(crate) fn acquire(
if let Some(s) = seed {
run.add_keys(&s.0.pool, "seed");
}
let result = match ev.detector {
Detector::Unverified => resolve_hddvd(ev, sampler, &disc_hash, n_decl, &mut run),
Detector::Verified => resolve_bd(ev, sampler, &disc_hash, n_decl, seed, &mut run),
};
let result = decide(ev, sampler, &disc_hash, n_decl, seed, &mut run);
// Every source is dropped here: nothing can ask after `acquire` (LK7).
let trace = std::mem::take(&mut run.trace);
drop(run.sources);
@@ -474,8 +472,95 @@ fn aacs_inner(run: &Run) -> Inner {
inner
}
// KU §2.6: HD DVD is best effort. Not probed (its encrypted flag is unverified, KS-27):
// a single declared key is applied to every piece; several, or none parseable, refuse.
// The pieces in scope with their probes, and the forensic layout when its clip is in scope.
struct Scoped<'e> {
ps: Vec<Probed>,
layout: Option<&'e super::fmts::Layout>,
clip: Vec<(u32, u32)>,
}
// KU §2.3 steps 5–7, every AACS format: the evidence's pieces, the forensic layout when its
// clip is in scope, and each piece probed by the format's own detector.
fn probe_scope<'e>(ev: &'e KeyEvidence, sampler: &mut Sampler, halt: &Halt) -> Result<Scoped<'e>> {
let mut ps: Vec<Probed> = ev.pieces.iter().cloned().map(Probed::new).collect();
let layout = ev.fmts.as_ref();
let clip: Vec<(u32, u32)> = layout
.map(|l| {
l.clip
.iter()
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
.collect()
})
.unwrap_or_default();
let layout = layout.filter(|_| {
ps.iter()
.any(|p| p.ranges().any(|(s, e)| overlaps(&clip, s, e)))
});
let segments: Vec<(u32, u32)> = layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default();
for p in &mut ps {
probe(sampler, p, &segments, ev.container, ev.detector, halt)?;
}
Ok(Scoped { ps, layout, clip })
}
// The one encryption decision, every AACS format and source (CSS makes it on its scramble
// flag): content read in the clear asks no source, whatever key files the disc declares;
// encrypted content is keyed by the format's rules, refused when no key is found.
fn decide(
ev: &KeyEvidence,
sampler: &mut Sampler,
disc_hash: &str,
n_decl: Option<usize>,
seed: Option<&KeyRing>,
run: &mut Run,
) -> Result<Inner> {
let scoped = probe_scope(ev, sampler, run.halt)?;
if in_clear(&scoped, sampler, ev.container, run.halt)? {
tracing::info!(target: "freemkv::keys", pieces = scoped.ps.len(), "content in the clear: no key source asked");
let ps: Vec<Probed> = scoped
.ps
.into_iter()
.map(|p| Probed {
verdict: Verdict::Clear,
..p
})
.collect();
let mut inner = aacs_inner(run);
inner.no_stream_files = ev.no_stream_files;
build(&mut inner, &ps, None, None, run, Vec::new());
return Ok(inner);
}
match ev.detector {
Detector::Unverified => resolve_hddvd(ev, sampler, disc_hash, n_decl, run),
Detector::Verified => resolve_bd(ev, scoped, sampler, disc_hash, n_decl, seed, run),
}
}
// Whether every piece's probes read clear (a piece with no whole unit holds no AACS unit)
// and so do the forensic segments in scope.
fn in_clear(
scoped: &Scoped,
sampler: &mut Sampler,
format: ContentFormat,
halt: &Halt,
) -> Result<bool> {
let pieces_clear = scoped
.ps
.iter()
.all(|p| p.units == 0 || (p.enc.is_empty() && p.faults == 0));
if !pieces_clear {
return Ok(false);
}
match scoped.layout {
None => Ok(true),
Some(l) => super::fmts::segments_clear(sampler.source(), l, format, halt),
}
}
// KU §2.6: HD DVD with encrypted content is best effort (its encrypted flag is unverified,
// KS-27): a single declared key is applied to every piece; several, or none parseable, refuse.
fn resolve_hddvd(
ev: &KeyEvidence,
sampler: &mut Sampler,
@@ -483,32 +568,6 @@ fn resolve_hddvd(
n_decl: Option<usize>,
run: &mut Run,
) -> Result<Inner> {
// A copy already in the clear (it kept its AACS directory) needs no key, whatever the
// title-key file declares: every piece is clear.
if !ev.pieces.is_empty() {
let mut clear = true;
for p in &ev.pieces {
if !sampler.ps_in_clear(p, run.halt)? {
clear = false;
break;
}
}
if clear {
let ps: Vec<Probed> = ev
.pieces
.iter()
.cloned()
.map(|p| Probed {
verdict: Verdict::Clear,
..Probed::new(p)
})
.collect();
let mut inner = aacs_inner(run);
inner.no_stream_files = ev.no_stream_files;
build(&mut inner, &ps, None, None, run, Vec::new());
return Ok(inner);
}
}
if n_decl != Some(1) {
tracing::error!(target: "freemkv::keys", declared = ?n_decl, "multi-key HD DVD cannot be matched reliably");
return Err(Error::NoDiscKey {
@@ -546,9 +605,10 @@ fn resolve_hddvd(
Ok(inner)
}
// KU §2.3 steps 5–14 for BD/UHD content.
// KU §2.3 steps 8–14 for BD/UHD content not in the clear, over `probe_scope`'s probes.
fn resolve_bd(
ev: &KeyEvidence,
scoped: Scoped,
sampler: &mut Sampler,
disc_hash: &str,
n_decl: Option<usize>,
@@ -557,29 +617,11 @@ fn resolve_bd(
) -> Result<Inner> {
let format = ev.container;
let scope = &ev.scope;
// Step 5: the evidence's pieces.
let mut ps: Vec<Probed> = ev.pieces.iter().cloned().map(Probed::new).collect();
// Step 6: FMTS, when the forensic clip is in scope.
let layout = ev.fmts.as_ref();
let clip: Vec<(u32, u32)> = layout
.map(|l| {
l.clip
.iter()
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
.collect()
})
.unwrap_or_default();
let layout = layout.filter(|_| {
ps.iter()
.any(|p| p.ranges().any(|(s, e)| overlaps(&clip, s, e)))
});
let segments: Vec<(u32, u32)> = layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default();
// Step 7: probes.
for p in &mut ps {
probe(sampler, p, &segments, format, run.halt)?;
}
let Scoped {
mut ps,
layout,
clip,
} = scoped;
// Step 8: sample-independent sources, once, with the main title's samples.
let needs_keys = ps
.iter()
+206
View File
@@ -3160,3 +3160,209 @@ fn evidence_without_a_hash_is_looked_up_by_its_title_key_file() {
);
assert!(ring.is_for(&fx.disc.media_id()));
}
// ── One encryption decision, every AACS format ──────────────────────────────
#[derive(Clone, Copy, Debug)]
enum Media {
Bd,
Uhd,
HdDvd,
}
const MEDIA: [Media; 3] = [Media::Bd, Media::Uhd, Media::HdDvd];
// Units in the one stream file of a `media_fx` disc.
const MEDIA_UNITS: u32 = 10;
// Whole MPEG-PS packs (pack start in every sector, no scrambling flag): an HD DVD feature in
// the clear.
fn ps_clip(sectors: usize) -> Vec<u8> {
let mut clip = vec![0u8; sectors * 2048];
for (i, sector) in clip.chunks_mut(2048).enumerate() {
for (j, b) in sector.iter_mut().enumerate() {
*b = ((i * 31 + j) % 251) as u8 | 1;
}
sector[..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
sector[20] &= !0x30;
}
clip
}
// Encrypt every unit of file `f` (MPEG-PS) under `key`, its plain copy kept. A plaintext
// header byte is varied until the ciphertext carries the PES scrambling flag the reader
// gates on (that flag lies past the unit's clear seed).
fn encrypt_ps(img: &mut EncryptedBdImage, f: usize, key: &[u8; 16]) {
let (start, sectors) = img.files[f];
for u in 0..sectors / 3 {
let at = (start + u * 3) as usize * 2048;
let mut plain = img.plain[at..at + ALIGNED_UNIT_LEN].to_vec();
let ct = (0..=255u8)
.find_map(|t| {
plain[24] = t;
let mut ct = plain.clone();
assert!(encrypt_unit(&mut ct, key));
(ct[20] & 0x30 != 0).then_some(ct)
})
.expect("a flagged ciphertext");
img.plain[at..at + ALIGNED_UNIT_LEN].copy_from_slice(&plain);
img.image[at..at + ALIGNED_UNIT_LEN].copy_from_slice(&ct);
}
}
// A one-title `media` disc whose one stream file holds `MEDIA_UNITS` units, in the clear or
// encrypted under `key`, with a title-key file declaring `declared` keys.
fn media_fx(media: Media, key: Option<[u8; 16]>, declared: usize) -> Fx {
let version = match media {
Media::Uhd => AacsVersion::V20,
_ => AacsVersion::V10,
};
let uk_ro = unit_key_ro(version, &vec![[0xEE; 16]; declared], &[1]);
let (img, format) = match media {
Media::Bd | Media::Uhd => {
let img = encrypted_bd_image(&[stream(0, MEDIA_UNITS, key)], &uk_ro);
let format = match media {
Media::Uhd => DiscFormat::Uhd,
_ => DiscFormat::BluRay,
};
(img, format)
}
Media::HdDvd => {
let files = [
BdFile::new("HVDVD_TS/FEATURE.EVO", MEDIA_UNITS * 3, None)
.with_clip(ps_clip(MEDIA_UNITS as usize * 3)),
BdFile::new("BDMV/index.bdmv", 1, None),
];
let mut img = encrypted_bd_image(&files, &uk_ro);
if let Some(k) = &key {
encrypt_ps(&mut img, 0, k);
}
(img, DiscFormat::HdDvd)
}
};
let mut disc = disc_over(&img, &uk_ro, &[&[0]], format);
if let (Media::Uhd, Some(a)) = (media, disc.aacs.as_mut()) {
a.version = 2;
}
Fx { img, disc }
}
/// Every AACS format (BD, UHD, HD DVD) in the clear proceeds with no key source asked,
/// whatever its title-key file declares, for a title rip and a whole-disc copy alike, and
/// reads back as it is.
#[test]
fn every_format_in_the_clear_asks_no_source_whatever_it_declares() {
for media in MEDIA {
for declared in [1, 3] {
for scope in [KeyScope::Titles(vec![0]), KeyScope::WholeDisc] {
let fx = media_fx(media, None, declared);
let calls = Calls::default();
let specs = [Spec::keydb(&[K1], &calls), Spec::online(&[K1], &calls)];
let set = resolve(&fx, scope.clone(), &specs).unwrap_or_else(|e| {
panic!("{media:?} {declared} declared {scope:?}: clear content refused: {e}")
});
assert_eq!(calls.len(), 0, "{media:?} {scope:?}: a source was asked");
let s = set.status();
assert_eq!(
(s.keyed, s.lazy, s.best_effort),
(0, 0, false),
"{media:?} {scope:?}"
);
assert!(s.clear >= 1, "{media:?} {scope:?}");
let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap();
assert_eq!(
read(&mut r, &fx, 0, 0, MEDIA_UNITS).unwrap(),
fx.plain(fx.file(0).0, MEDIA_UNITS),
"{media:?} {scope:?}"
);
}
}
}
}
/// Every AACS format with encrypted content asks the configured sources and, when none has
/// the key, refuses before any output: E7022 for a title rip, E7032 for a whole-disc copy.
#[test]
fn every_format_encrypted_with_no_key_refuses_typed() {
for media in MEDIA {
for (scope, want) in [
(KeyScope::Titles(vec![0]), E7022),
(KeyScope::WholeDisc, E7032),
] {
let fx = media_fx(media, Some(K1), 1);
let calls = Calls::default();
let r = resolve(&fx, scope.clone(), &[Spec::keydb(&[], &calls)]);
assert_eq!(code(r), want, "{media:?} {scope:?}");
assert_eq!(calls.len(), 1, "{media:?} {scope:?}: the source is asked");
}
}
}
/// Every AACS format with encrypted content and a source holding its key decrypts it.
#[test]
fn every_format_encrypted_with_a_key_decrypts() {
for media in MEDIA {
let fx = media_fx(media, Some(K1), 1);
assert_ne!(
fx.raw(fx.file(0).0, 1),
fx.img.plain[fx.file(0).0 as usize * 2048..][..ALIGNED_UNIT_LEN],
"{media:?}: the fixture is encrypted"
);
let calls = Calls::default();
let set = resolve(
&fx,
KeyScope::Titles(vec![0]),
&[Spec::keydb(&[K1], &calls)],
)
.unwrap_or_else(|e| panic!("{media:?}: refused with its key held: {e}"));
assert_eq!(calls.len(), 1, "{media:?}");
assert_eq!(set.status().keyed, 1, "{media:?}");
let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap();
assert_eq!(
read(&mut r, &fx, 0, 0, MEDIA_UNITS).unwrap(),
fx.plain(fx.file(0).0, MEDIA_UNITS),
"{media:?}: decrypted"
);
}
}
/// An FMTS disc takes the same decision: base content and forensic segments in the clear ask
/// no source, forensic keys included; encrypted segments under clear base content are keyed.
#[test]
fn fmts_in_the_clear_asks_no_source_encrypted_segments_are_keyed() {
let mut fx = fmts_fixture();
for f in [0, 1] {
let (start, n) = fx.file(f);
let span = start as usize * 2048..(start + n) as usize * 2048;
let clear = masked(&fx.img.plain[span.clone()]);
fx.img.image[span.clone()].copy_from_slice(&clear);
fx.img.plain[span].copy_from_slice(&clear);
}
let calls = Calls::default();
let set = resolve(
&fx,
KeyScope::Titles(vec![0, 1, 2]),
&[Spec::keydb(&[K1, K2], &calls), fmts_online(&calls)],
)
.unwrap();
assert_eq!(calls.len(), 0, "no source, forensic included");
assert_eq!(set.status().forensic, ForensicState::None);
for (a, b, key) in [(0, 16, F1), (20, 36, F2)] {
for u in a..b {
fx.reencrypt(1, u, if (u - a) % 2 == 0 { &key } else { &ALT });
}
}
let calls = Calls::default();
let set = resolve(
&fx,
KeyScope::Titles(vec![0, 1, 2]),
&[Spec::keydb(&[], &calls), fmts_online(&calls)],
)
.unwrap();
assert!(
calls.of("online").iter().any(|c| c.forensic),
"encrypted segments ask for the forensic set"
);
assert_eq!(set.status().forensic, ForensicState::Resolved);
}