From 3da82280682bc09196901b2f1b16a90787a69e2e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:18:33 -0700 Subject: [PATCH] aacs: parse FMTS SegmentKey.tbl container (16-bit variant-indexed) New aacs::segment_key: parses the AACS 2.1 SegmentKeyNNNNN.tbl container, confirmed against a retail disc as an 8-byte header + 65536 records of 536 bytes, indexed by the 16-bit variant selector the Media Key Variant chain produces. This is the confirmed link between the two 2.1 variant layers (selector picks the device's per-segment variant). The per-record 528-byte payload layout is not yet reversed. --- src/aacs/mod.rs | 1 + src/aacs/segment_key.rs | 165 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/aacs/segment_key.rs diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 466116f..98dd922 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -34,6 +34,7 @@ pub mod mkb; pub mod provider; pub mod resolve; pub mod segment; +pub mod segment_key; pub mod trace; pub mod types; pub mod variant; diff --git a/src/aacs/segment_key.rs b/src/aacs/segment_key.rs new file mode 100644 index 0000000..3b9dbf3 --- /dev/null +++ b/src/aacs/segment_key.rs @@ -0,0 +1,165 @@ +//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`. +//! +//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key +//! store for the forensic variant segments mapped by [`super::segment`]. A +//! device does not read a segment key directly. It derives a **16-bit variant +//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses +//! that selector to index this table, which is how the device's position in the +//! key tree decides which variant it can decrypt (the traitor-tracing link). +//! +//! Container format (confirmed against a retail AACS 2.1 disc): +//! ```text +//! header (8 bytes): u32 tag | u16 index_space | u16 record_size +//! record[index_space] (record_size bytes each) +//! ``` +//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector +//! space, 65536 records), `record_size` = `0x0218` = 536. Total +//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each +//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key +//! material. +//! +//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and +//! how it maps onto the segments of [`super::segment`]. One numeric coincidence +//! worth noting for whoever cracks it: the reference disc has 792 segments and +//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until +//! the mapping and the key derivation are pinned, this module exposes only the +//! confirmed container: locate the record for a given 16-bit selector. + +/// Bytes of the fixed file header. +pub const HEADER_LEN: usize = 8; + +/// The on-disc segment-key table container. Borrows the file bytes; a record is +/// looked up by the 16-bit variant selector. +#[derive(Debug, Clone, Copy)] +pub struct SegmentKeyTable<'a> { + data: &'a [u8], + /// Number of records (the selector index space, e.g. 65536). + count: usize, + /// Bytes per record (e.g. 536). + record_size: usize, +} + +impl<'a> SegmentKeyTable<'a> { + /// Parse and validate the container header against the buffer length. + /// + /// Returns `None` when the buffer is too small, or the declared + /// `count * record_size` (plus header) does not match the buffer, so a + /// truncated or foreign table degrades to "no segment keys" rather than + /// handing back bogus records. `index_space` of `0xffff` is read as the full + /// 65536-entry space (a device selector is a full 16-bit value). + pub fn parse(data: &'a [u8]) -> Option { + if data.len() < HEADER_LEN { + return None; + } + let index_space = u16::from_be_bytes([data[4], data[5]]); + let record_size = u16::from_be_bytes([data[6], data[7]]) as usize; + // 0xffff means the full 16-bit selector space (65536 records). + let count = if index_space == 0xffff { + 0x1_0000 + } else { + index_space as usize + }; + if record_size == 0 { + return None; + } + let body = count.checked_mul(record_size)?; + if HEADER_LEN.checked_add(body)? != data.len() { + return None; + } + Some(Self { + data, + count, + record_size, + }) + } + + /// Number of records (the selector index space). + pub fn record_count(&self) -> usize { + self.count + } + + /// Bytes per record. + pub fn record_size(&self) -> usize { + self.record_size + } + + /// The raw record for a 16-bit variant `selector`, including its 8-byte + /// sub-header. `None` if the selector is past the table (only possible when + /// `index_space` was not the full 16-bit space). + pub fn record(&self, selector: u16) -> Option<&'a [u8]> { + let idx = selector as usize; + if idx >= self.count { + return None; + } + let start = HEADER_LEN + idx * self.record_size; + self.data.get(start..start + self.record_size) + } + + /// The encrypted key payload for a selector: the record with its 8-byte + /// sub-header stripped. The internal layout of these bytes is not yet + /// reversed (see module docs). + pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> { + self.record(selector).and_then(|r| r.get(HEADER_LEN..)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a container with `record_size` and the given `index_space`, filling + /// each record with a distinguishable byte so lookups can be checked. + fn build(index_space: u16, record_size: u16) -> Vec { + let count = if index_space == 0xffff { + 0x1_0000 + } else { + index_space as usize + }; + let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize); + v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag + v.extend_from_slice(&index_space.to_be_bytes()); + v.extend_from_slice(&record_size.to_be_bytes()); + for i in 0..count { + let mut rec = vec![(i & 0xff) as u8; record_size as usize]; + // sub-header, as seen on disc + rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]); + v.extend_from_slice(&rec); + } + v + } + + #[test] + fn parses_retail_container_geometry() { + // The real disc: 0xffff index space, 536-byte records, 35,127,304 total. + let data = build(0xffff, 536); + assert_eq!( + data.len(), + 35_127_304, + "matches the retail file size exactly" + ); + let t = SegmentKeyTable::parse(&data).expect("parse"); + assert_eq!(t.record_count(), 65_536); + assert_eq!(t.record_size(), 536); + let rec = t.record(0x1234).expect("record"); + assert_eq!(rec.len(), 536); + assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]); + assert_eq!(t.record_payload(0x1234).unwrap().len(), 528); + } + + #[test] + fn small_index_space_bounds_lookups() { + let data = build(4, 32); + let t = SegmentKeyTable::parse(&data).expect("parse"); + assert_eq!(t.record_count(), 4); + assert!(t.record(3).is_some()); + assert!(t.record(4).is_none(), "selector past the table is None"); + } + + #[test] + fn rejects_size_mismatch_and_truncation() { + assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none()); + let mut data = build(4, 32); + data.truncate(data.len() - 1); // body no longer matches header + assert!(SegmentKeyTable::parse(&data).is_none()); + } +}