rc2: macOS cross-compile fix + security/recovery hardening

- build.rs: pass target -arch to cc so macos_shim cross-compiles (x86_64-apple-darwin)
- AACS/CSS: unit-aligned decrypting sweep; per-VTS CSS title keys (hard-fail on wrong VTS);
  reject truncated Unit_Key_RO; AACS 2.0 sig-verify skip; CSS bus-auth random nonce
- recovery: gap-filling mapfile load; sweep/copy resume reconciliation; stale-mapfile abort;
  patch wedge/damage-window range reset
- mux: TS continuity + PSI CC desync guards; HEVC numTemporalLayers clamp; MPEG-2 pending
  byte-cap; PS parse_pts marker-bit validation; HdrFormat strict parse; Unknown-variant metadata
- net/keydb: network:// SSRF parity (IPv4-mapped, CGNAT, 0.0.0.0/8, Class-E); bounded keydb
  header read + size cap + error context
- io: durable mapfile fsync; NFS writeback degrade; sync_file_range error capture;
  Windows SCSI u32 transfer guard
This commit is contained in:
Matthew Jackson
2026-06-22 08:58:10 -07:00
parent 5941c059c6
commit 337e77951c
25 changed files with 1722 additions and 153 deletions
+24 -15
View File
@@ -905,20 +905,23 @@ pub fn aacs_authenticate(
drive_nonce.copy_from_slice(&response[4..24]);
drive_cert.copy_from_slice(&response[24..116]);
// Verify drive certificate
// Verify drive certificate. `is_aacs20` tracks the 2.0 cert type so the
// step-6 key-signature verify below is skipped too (see there).
let is_aacs20 = drive_cert[0] == 0x11;
if drive_cert[0] == 0x01 {
// AACS 1.0 certificate
if !verify_cert(&drive_cert) {
return Err(Error::AacsCertVerify);
}
} else if drive_cert[0] == 0x11 {
} else if is_aacs20 {
// AACS 2.0 certificate — verification intentionally skipped here.
// Reason: backward compatibility. AACS 2.0 drives accept AACS 1.0 host
// certs, so we proceed with the AACS 1.0 flow regardless. The P-256
// LA public key needed to verify 2.0 certs is not always available, and
// failing here would break handshakes with drives that work fine otherwise.
// The drive's identity is still authenticated through the ECDH key
// exchange and signature verification in step 6 below.
// The 2.0 cert lays out its public key and signature at different byte
// offsets than the 1.0 cert, so the step-6 verify below (which reads
// 1.0 offsets) cannot validate a 2.0 cert and is skipped for it.
}
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
@@ -930,19 +933,25 @@ pub fn aacs_authenticate(
drive_key_point.copy_from_slice(&response[4..44]);
drive_key_sig.copy_from_slice(&response[44..84]);
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point)
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
let mut verify_data = [0u8; 60];
verify_data[..20].copy_from_slice(&host_nonce);
verify_data[20..60].copy_from_slice(&drive_key_point);
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point).
// Skipped for an AACS 2.0 (type 0x11) cert: `cert_pub_key` reads the public
// key at AACS-1.0 byte offsets, which don't apply to a 2.0 cert, so the
// verify would be meaningless (it would reject every 2.0 drive). Mirrors the
// cert-verify skip above; the ECDH key exchange still proceeds.
if !is_aacs20 {
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
let mut verify_data = [0u8; 60];
verify_data[..20].copy_from_slice(&host_nonce);
verify_data[20..60].copy_from_slice(&drive_key_point);
let mut sig_r = [0u8; 20];
let mut sig_s = [0u8; 20];
sig_r.copy_from_slice(&drive_key_sig[..20]);
sig_s.copy_from_slice(&drive_key_sig[20..40]);
let mut sig_r = [0u8; 20];
let mut sig_s = [0u8; 20];
sig_r.copy_from_slice(&drive_key_sig[..20]);
sig_s.copy_from_slice(&drive_key_sig[20..40]);
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
return Err(Error::AacsKeyVerify);
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
return Err(Error::AacsKeyVerify);
}
}
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
+22
View File
@@ -2,6 +2,16 @@
use std::collections::HashMap;
/// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`].
/// The real public UHD keydb is a few MiB; 64 MiB is generous headroom while
/// still bounding the worst-case allocation from a hostile/corrupt file.
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding
/// memory against a pathological input. Surplus lines are ignored.
const MAX_DISC_ENTRIES: usize = 500_000;
/// Parsed AACS key database.
#[derive(Debug)]
pub struct KeyDb {
@@ -185,6 +195,9 @@ impl KeyDb {
// Disc entry: starts with 0x
if line.starts_with("0x") && line.contains(" = ") {
if db.disc_entries.len() >= MAX_DISC_ENTRIES {
continue;
}
if let Some(entry) = Self::parse_disc_entry(line) {
db.disc_entries.insert(entry.disc_hash.clone(), entry);
}
@@ -204,6 +217,15 @@ impl KeyDb {
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
/// check the parsed contents.
pub fn load(path: &std::path::Path) -> crate::error::Result<Self> {
// Stat-and-cap before reading so a hostile/corrupt file can't force an
// unbounded allocation. A file at or over the cap is rejected outright.
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_KEYDB_BYTES {
return Err(crate::error::Error::KeydbLoad {
path: path.display().to_string(),
});
}
}
let data = std::fs::read_to_string(path).map_err(|_| crate::error::Error::KeydbLoad {
path: path.display().to_string(),
})?;
+52 -10
View File
@@ -163,6 +163,14 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
pos += stride;
}
// The loop above `break`s if the buffer runs out mid-key. A short list
// means the .inf is malformed/truncated — reject it rather than silently
// accepting fewer keys than the header declared, which would later map
// title CPS units to nonexistent keys.
if encrypted_keys.len() != num_uk {
return None;
}
// Title → CPS unit mapping
let mut title_cps_unit = Vec::new();
if data.len() >= 26 {
@@ -1370,6 +1378,41 @@ mod tests {
if path.exists() { Some(path) } else { None }
}
/// Finding #5 regression: parse_unit_key_ro must REJECT a Unit_Key_RO.inf
/// whose declared `num_unit_keys` exceeds the keys actually present in the
/// buffer, instead of silently returning a short list. A truncated list
/// would later map title CPS units to nonexistent keys.
#[test]
fn parse_unit_key_ro_rejects_truncated_key_list() {
// V10 layout: stride 48, keys start at uk_pos + 48.
// uk_pos = 32; num_uk = 2; keys at 80 and 128.
let uk_pos = 32usize;
let build = |total_len: usize| -> Vec<u8> {
let mut data = vec![0u8; total_len];
// uk_pos as BE32 at [0..4].
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
// num_unit_keys = 2 (BE16) at uk_pos.
data[uk_pos] = 0x00;
data[uk_pos + 1] = 0x02;
data
};
// Full buffer: room for both keys (keys_start 80, key1 at 128..144).
let full = build(144);
let ok =
parse_unit_key_ro(&full, AacsVersion::V10).expect("a full 2-key buffer must parse");
assert_eq!(ok.encrypted_keys.len(), 2);
// Truncated buffer: header still declares 2 keys, but only the first
// fits (len 128 — the second key's 16 bytes run off the end). Must be
// rejected, not silently accepted with one key.
let short = build(128);
assert!(
parse_unit_key_ro(&short, AacsVersion::V10).is_none(),
"a buffer declaring more keys than it contains must be rejected"
);
}
#[test]
fn derive_media_key_from_dk_survives_out_of_range_u_mask_shift() {
// Regression: a crafted/corrupt MKB with a Subset-Difference
@@ -2433,11 +2476,12 @@ mod tests {
}
#[test]
fn parse_unit_key_ro_stops_early_when_keys_run_off_end() {
// 3 keys declared but the buffer is sized to hold only 2 strides plus
// 8 trailing bytes (not a full 3rd 16-byte key) → the loop's
// `pos + 16 > len` guard breaks and returns the keys that fit, never
// reading OOB.
fn parse_unit_key_ro_rejects_when_keys_run_off_end() {
// Finding #5: 3 keys declared but the buffer holds only 2 strides plus
// 8 trailing bytes (not a full 3rd 16-byte key). The extraction loop
// breaks at the buffer end (never reading OOB), and the post-loop
// length check rejects the short list with None — a truncated/malformed
// .inf must NOT be silently accepted with fewer keys than declared.
let uk_pos = 0x60usize;
let stride = 48usize;
// Room for keys at uk_pos+48 and uk_pos+48+48, then only 8 spare bytes
@@ -2446,11 +2490,9 @@ mod tests {
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[uk_pos + 1] = 3; // declare 3 keys
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(
parsed.encrypted_keys.len(),
2,
"must stop at the buffer end, not read past it"
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a buffer declaring more keys than it contains must be rejected"
);
}