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:
+24
-15
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user