libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)

Test-hardening release, no runtime changes. Adds spec-grounded unit tests
across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD
title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers,
MKV/EBML container output, the mux pipeline, sector prefetch + decrypt
decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each
test is grounded in the format spec or real on-disc behavior and verified to
fail under a targeted source mutation. No behavior changed.
This commit is contained in:
Matthew Jackson
2026-06-07 22:28:29 -07:00
parent 2a55bab3ed
commit 8000bae177
85 changed files with 22998 additions and 1 deletions
+381
View File
@@ -480,4 +480,385 @@ mod tests {
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
assert_eq!(count, ts_packet_total(&unit));
}
// ── Helpers for the hardening tests below ──────────────────────────────
/// Encrypt an aligned unit in place with the AACS unit-decrypt
/// algorithm run in reverse, so [`decrypt_unit`] with the same
/// `unit_key` recovers the plaintext. This is the exact inverse of
/// the production decrypt: derive `decrypt_key = AES-ECB-E(unit_key,
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
/// fixed AACS IV.
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = AACS_IV;
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut block);
unit[off..off + 16].copy_from_slice(&block);
prev.copy_from_slice(&unit[off..off + 16]);
}
}
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
fn clear_unit() -> Vec<u8> {
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
unit[off] = TS_SYNC;
off += TS_PACKET_LEN;
}
unit
}
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
#[test]
fn aes_ecb_matches_fips197_known_answer() {
// FIPS-197 Appendix C.1 AES-128 KAT:
// key = 000102030405060708090a0b0c0d0e0f
// plaintext = 00112233445566778899aabbccddeeff
// ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a
// This pins the AES primitive against a published vector — a wrong
// cipher (or a key/plaintext byte-order slip) fails it.
let key = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
let pt = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let expected = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
0xC5, 0x5A,
];
assert_eq!(aes_ecb_encrypt(&key, &pt), expected);
// And decrypt is the exact inverse.
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
}
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
#[test]
fn cbc_decrypt_first_block_xors_aacs_iv() {
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
// constant (not zero). Encrypt a single block forward with IV, then
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
// makes the recovered block wrong.
let key = [0x24u8; 16];
let plain = [0x5Au8; 16];
// Forward CBC for one block: C = AES-E(K, P XOR IV).
let mut x = plain;
for j in 0..16 {
x[j] ^= AACS_IV[j];
}
let ct = aes_ecb_encrypt(&key, &x);
let mut buf = ct;
aes_cbc_decrypt(&key, &mut buf);
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
}
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
#[test]
fn decrypt_unit_roundtrip_restores_all_syncs() {
// Encrypt a clear unit, confirm it reads as scrambled, then decrypt
// and confirm every TS sync byte at the 192-byte stride is restored.
let unit_key = [0x37u8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key);
assert!(
is_aacs_scrambled(&unit),
"encrypted unit must look scrambled"
);
assert!(decrypt_unit(&mut unit, &unit_key));
// All 32 stride positions carry sync after decrypt.
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
assert!(!is_aacs_scrambled(&unit));
}
#[test]
fn decrypt_unit_wrong_key_fails_and_does_not_falsely_clear() {
// A wrong unit key fails verify_ts (the body stays scrambled), so
// decrypt_unit returns false. Grounds the brute-force gate: a bad key
// must NOT report success.
let good = [0x11u8; 16];
let bad = [0x22u8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &good);
assert!(!decrypt_unit(&mut unit, &bad), "wrong key must not verify");
}
#[test]
fn decrypt_unit_rejects_short_unit() {
// unit.len() < ALIGNED_UNIT_LEN → false (no panic on the 16.. slice).
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
assert!(!decrypt_unit(&mut short, &[0u8; 16]));
}
#[test]
fn decrypt_unit_only_touches_bytes_16_onward() {
// The first 16 bytes are the plaintext TP_extra header and must be
// left untouched by decrypt (only unit[16..] is CBC-processed).
let unit_key = [0x9Au8; 16];
let mut clear = clear_unit();
// Put a distinctive header so we can confirm it survives.
clear[..16].copy_from_slice(&[
0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
]);
let header_before: [u8; 16] = clear[..16].try_into().unwrap();
let mut unit = clear;
aacs_encrypt_unit(&mut unit, &unit_key);
// Encryption also leaves the header untouched (only 16.. is encrypted).
assert_eq!(&unit[..16], &header_before);
decrypt_unit(&mut unit, &unit_key);
assert_eq!(
&unit[..16],
&header_before,
"header bytes must be preserved"
);
}
// ── decrypt_unit_try_keys: AlreadyClear vs DecryptedWith vs None ───────
#[test]
fn try_keys_reports_already_clear_without_consuming_a_key() {
// A clear unit returns AlreadyClear even with an empty key list — the
// old Option<usize> form conflated this with Some(0). Grounds the
// UnitKeyResult enum distinction.
let mut unit = clear_unit();
assert_eq!(
decrypt_unit_try_keys(&mut unit, &[]),
Some(UnitKeyResult::AlreadyClear)
);
}
#[test]
fn try_keys_reports_correct_index_among_several() {
// Three keys, only the 3rd (index 2) decrypts → DecryptedWith(2).
let real = [0x44u8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &real);
let keys = [[0x01u8; 16], [0x02u8; 16], real];
assert_eq!(
decrypt_unit_try_keys(&mut unit, &keys),
Some(UnitKeyResult::DecryptedWith(2))
);
assert!(
!is_aacs_scrambled(&unit),
"unit must be clear after the hit"
);
}
#[test]
fn try_keys_restores_original_bytes_on_total_failure() {
// When no key works, the unit must be byte-identical to the input
// (the function CBC-mangles it per attempt, then restores). A buggy
// restore would leave the unit corrupted — silent data damage.
let real = [0x55u8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &real);
let snapshot = unit.clone();
let wrong = [[0xAAu8; 16], [0xBBu8; 16]];
assert_eq!(decrypt_unit_try_keys(&mut unit, &wrong), None);
assert_eq!(unit, snapshot, "failed try must restore the original bytes");
}
// ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
#[test]
fn unit_key_validates_agrees_with_decrypt_unit() {
// The fast 1-byte gate's accept/reject set must be identical to the
// authoritative decrypt_unit. Confirm: correct key → true on both;
// wrong key → false on both.
let good = [0x6Au8; 16];
let bad = [0x6Bu8; 16];
let mut enc = clear_unit();
aacs_encrypt_unit(&mut enc, &good);
assert!(unit_key_validates(&enc, &good));
let mut probe = enc.clone();
assert!(decrypt_unit(&mut probe, &good));
assert!(!unit_key_validates(&enc, &bad));
let mut probe2 = enc.clone();
assert!(!decrypt_unit(&mut probe2, &bad));
}
#[test]
fn unit_key_validates_is_non_mutating() {
// The accelerator must never write its input (it operates on the
// ciphertext and confirms on a copy). A mutation that decrypted in
// place would corrupt the caller's buffer.
let good = [0x7Cu8; 16];
let mut enc = clear_unit();
aacs_encrypt_unit(&mut enc, &good);
let snapshot = enc.clone();
let _ = unit_key_validates(&enc, &good);
assert_eq!(enc, snapshot, "unit_key_validates must not mutate input");
}
#[test]
fn unit_key_validates_rejects_short_unit() {
let short = vec![0u8; ALIGNED_UNIT_LEN - 16];
assert!(!unit_key_validates(&short, &[0u8; 16]));
}
// ── bus decryption (AACS 2.0 / UHD) ────────────────────────────────────
#[test]
fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() {
// Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector
// (3 sectors per aligned unit), leaving the first 16 plaintext. Build
// the forward transform, then confirm decrypt_bus inverts it and
// leaves each sector's first 16 bytes untouched.
let rdk = [0x13u8; 16];
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
// Fill with a recognisable pattern.
for (i, b) in unit.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
let plain = unit.clone();
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
let mut prev = AACS_IV;
let body = s + 16;
let end = s + SECTOR_LEN;
let nblocks = (end - body) / 16;
for i in 0..nblocks {
let off = body + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut blk);
unit[off..off + 16].copy_from_slice(&blk);
prev.copy_from_slice(&unit[off..off + 16]);
}
}
assert_ne!(unit, plain, "forward bus-encrypt must change the body");
decrypt_bus(&mut unit, &rdk);
assert_eq!(
unit, plain,
"decrypt_bus must invert per-sector bus encrypt"
);
// Each sector's first 16 bytes equal the original (never touched).
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
}
}
#[test]
fn decrypt_bus_processes_all_three_sectors() {
// 6144 / 2048 = 3 sectors. Confirm the loop covers all three: corrupt
// the body of sector 2 (the last) and confirm decrypt_bus touches it
// (i.e. it isn't skipped). We do this by checking that round-tripping
// only works when all three are processed — encrypt all 3, decrypt,
// expect full recovery (covered above); here assert the step count.
let starts: Vec<usize> = (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN).collect();
assert_eq!(starts, vec![0, 2048, 4096]);
}
// ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ───
#[test]
fn decrypt_unit_full_passthrough_when_already_clear() {
// A clear unit returns true and is not modified, regardless of keys.
let mut unit = clear_unit();
let snapshot = unit.clone();
assert!(decrypt_unit_full(
&mut unit,
&[0u8; 16],
Some(&[0xFFu8; 16])
));
assert_eq!(unit, snapshot, "clear unit must pass through untouched");
}
#[test]
fn decrypt_unit_full_applies_bus_then_aacs() {
// AACS 2.0 pipeline: content is first AACS-unit-encrypted, then
// bus-encrypted on top. Decrypt must undo bus FIRST, then AACS.
// Build that exact two-layer ciphertext and confirm full recovery.
let unit_key = [0x21u8; 16];
let rdk = [0x84u8; 16];
let mut unit = clear_unit();
// Layer 1: AACS unit-encrypt.
aacs_encrypt_unit(&mut unit, &unit_key);
// Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
let mut prev = AACS_IV;
for i in 0..((SECTOR_LEN - 16) / 16) {
let off = s + 16 + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut blk);
unit[off..off + 16].copy_from_slice(&blk);
prev.copy_from_slice(&unit[off..off + 16]);
}
}
assert!(is_aacs_scrambled(&unit));
assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
}
// ── is_aacs_scrambled / ts_sync_count edge cases ───────────────────────
#[test]
fn is_aacs_scrambled_false_for_sub_unit_length() {
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
// shorter is reported NOT scrambled (so the decrypt gate skips it)
// rather than indexing past the end.
assert!(!is_aacs_scrambled(&[]));
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
// A scrambled-looking buffer that is one byte short is still "not
// scrambled" by the length guard.
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
almost[4] = 0x00; // no syncs
assert!(!is_aacs_scrambled(&almost));
}
#[test]
fn ts_sync_count_only_samples_the_192_byte_stride() {
// A 0x47 placed OFF the stride (e.g. offset 5) must not be counted —
// the detector samples exactly offset 4, 196, 388, ... A mutation that
// scanned every byte would over-count and misclassify scrambled units.
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
unit[5] = TS_SYNC; // off-stride
unit[197] = TS_SYNC; // off-stride
assert_eq!(ts_sync_count(&unit), 0, "off-stride 0x47 must not count");
unit[4] = TS_SYNC; // on-stride
assert_eq!(ts_sync_count(&unit), 1);
}
#[test]
fn ts_packet_total_for_various_lengths() {
// total = len / 192 (BD-TS packet size). Pin a few lengths.
assert_eq!(ts_packet_total(&[0u8; 192]), 1);
assert_eq!(ts_packet_total(&[0u8; 384]), 2);
assert_eq!(ts_packet_total(&[0u8; 191]), 0);
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
}
+290
View File
@@ -1526,4 +1526,294 @@ mod tests {
}
}
}
// ════════════════════════════════════════════════════════════════════
// Hardening additions
// ════════════════════════════════════════════════════════════════════
// ── EC curve invariants: a, b chosen so 4a³+27b² != 0 (nonsingular) ────
#[test]
fn aacs1_curve_is_nonsingular() {
// A valid Weierstrass curve requires discriminant 4a³ + 27b² ≠ 0
// (mod p). A typo in EC_A or EC_B that singularised the curve would be
// caught here.
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let b = BigUint::from_bytes_be(&EC_B);
let four = BigUint::from(4u32);
let twenty_seven = BigUint::from(27u32);
let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p;
assert!(!disc.is_zero(), "AACS 1.0 curve must be nonsingular");
}
#[test]
fn p256_curve_is_nonsingular() {
let p = BigUint::from_bytes_be(&P256_P);
let a = BigUint::from_bytes_be(&P256_A);
let b = BigUint::from_bytes_be(&P256_B);
let four = BigUint::from(4u32);
let twenty_seven = BigUint::from(27u32);
let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p;
assert!(!disc.is_zero(), "P-256 curve must be nonsingular");
}
// ── mod_inv ────────────────────────────────────────────────────────────
#[test]
fn mod_inv_round_trips() {
// a * a⁻¹ ≡ 1 (mod m). Pin against the AACS prime.
let m = BigUint::from_bytes_be(&EC_N);
let a = BigUint::from(123456789u64);
let inv = mod_inv(&a, &m).expect("inverse exists for a coprime to prime n");
assert_eq!((&a * &inv) % &m, BigUint::one());
}
#[test]
fn mod_inv_of_one_is_one() {
let m = BigUint::from(97u32);
assert_eq!(mod_inv(&BigUint::one(), &m), Some(BigUint::one()));
}
// ── to_bytes_be_padded ─────────────────────────────────────────────────
#[test]
fn to_bytes_be_padded_left_pads_short_values() {
// A small number must be left-zero-padded to the fixed width (keys are
// fixed-size big-endian; a short value left unpadded would shift bytes).
let n = BigUint::from(0x1234u32);
assert_eq!(to_bytes_be_padded(&n, 20), {
let mut v = vec![0u8; 18];
v.extend_from_slice(&[0x12, 0x34]);
v
});
}
#[test]
fn to_bytes_be_padded_truncates_to_low_bytes_when_longer() {
// When the encoding is longer than len, the low `len` bytes are kept
// (the function slices the tail) — this is how the 256-bit ECDH x is
// reduced to the low 128 bits for the bus key.
let n = BigUint::from(0x0102030405u64); // 5 bytes
assert_eq!(to_bytes_be_padded(&n, 2), vec![0x04, 0x05]);
}
// ── point_on_curve (via compute_bus_key acceptance) ────────────────────
// point_on_curve is private; exercise it through compute_bus_key, which
// calls it as the invalid-curve guard.
#[test]
fn off_curve_x_out_of_field_is_rejected() {
// A coordinate >= p is outside the field and must be rejected before
// the multiply (the `x >= p || y >= p` guard). Use x = p (== modulus).
let (host_priv, _, _) = generate_host_key_pair();
// EC_P itself as the x coordinate → x == p → out of field.
assert!(
compute_bus_key(&host_priv, &EC_P, &EC_GY).is_none(),
"x == p is out of field and must be rejected"
);
}
// ── CDB builders: REPORT KEY / SEND KEY / REPORT DISC STRUCTURE ────────
#[test]
fn cdb_report_key_layout() {
// 0xA4 opcode; AACS key class at byte 7; BE16 length at 8/9;
// (agid<<6)|format at byte 10. Pin the exact bit packing.
let cdb = cdb_report_key(0b10, 0x02, 0x0054);
assert_eq!(cdb[0], crate::scsi::SCSI_REPORT_KEY);
assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS);
assert_eq!(cdb[8], 0x00);
assert_eq!(cdb[9], 0x54);
// agid=2 → bits 7:6 = 10b = 0x80; format 0x02 in low 6 bits.
assert_eq!(cdb[10], 0x80 | 0x02);
}
#[test]
fn cdb_report_key_format_masked_to_6_bits() {
// The format field is `format & 0x3F`; a value with bits 6/7 set must
// not bleed into the AGID field. 0xFF & 0x3F == 0x3F.
let cdb = cdb_report_key(0, 0xFF, 2);
assert_eq!(cdb[10], 0x3F, "format must be masked to its low 6 bits");
}
#[test]
fn cdb_send_key_layout() {
let cdb = cdb_send_key(0b11, 0x01, 116);
assert_eq!(cdb[0], crate::scsi::SCSI_SEND_KEY);
assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS);
assert_eq!(cdb[8], (116u16 >> 8) as u8);
assert_eq!(cdb[9], (116u16 & 0xFF) as u8);
assert_eq!(cdb[10], (0b11 << 6) | 0x01);
}
#[test]
fn cdb_report_disc_structure_layout() {
// 0xAD opcode; byte 1 = 0x01 (Blu-ray); format at byte 7; BE16 length;
// agid<<6 at byte 10 (no format bits here).
let cdb = cdb_report_disc_structure(0b01, 0x80, 36);
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(cdb[1], 0x01);
assert_eq!(cdb[7], 0x80);
assert_eq!(cdb[8], 0x00);
assert_eq!(cdb[9], 36);
assert_eq!(cdb[10], 0b01 << 6);
}
// ── verify_cert (AACS 1.0): length guard ───────────────────────────────
#[test]
fn verify_cert_v1_rejects_short_cert_no_panic() {
// < 92 bytes → false (the sig slices cert[52..72]/[72..92] would
// otherwise panic). Sweep the boundary.
for len in [0usize, 51, 52, 71, 72, 91] {
assert!(!verify_cert(&vec![0u8; len]), "len {len} must not panic");
}
}
#[test]
fn cert_pub_key_v1_zeroes_when_too_short() {
// < 52 bytes → zeroed (x,y) rather than an OOB slice on cert[12..52].
let (x, y) = cert_pub_key(&[0u8; 40]);
assert_eq!(x, [0u8; 20]);
assert_eq!(y, [0u8; 20]);
}
#[test]
fn cert_pub_key_v1_extracts_offsets_12_32_52() {
// pub_x at [12..32], pub_y at [32..52]. Build a 92-byte cert with
// distinct x/y regions.
let mut cert = vec![0u8; 92];
for b in &mut cert[12..32] {
*b = 0xA1;
}
for b in &mut cert[32..52] {
*b = 0xB2;
}
let (x, y) = cert_pub_key(&cert);
assert_eq!(x, [0xA1u8; 20]);
assert_eq!(y, [0xB2u8; 20]);
}
#[test]
fn cert_pub_key_p256_extracts_offsets_10_42_74() {
// AACS 2.0: pub_x at [10..42], pub_y at [42..74].
let mut cert = vec![0u8; 138];
for b in &mut cert[10..42] {
*b = 0xC3;
}
for b in &mut cert[42..74] {
*b = 0xD4;
}
let (x, y) = cert_pub_key_p256(&cert);
assert_eq!(x, [0xC3u8; 32]);
assert_eq!(y, [0xD4u8; 32]);
}
#[test]
fn cert_pub_key_p256_zeroes_when_too_short() {
// < 74 bytes → zeroed, matching the verify_cert_p256 >= 138 guard's
// safety contract (no OOB on cert[10..74]).
let (x, y) = cert_pub_key_p256(&[0u8; 73]);
assert_eq!(x, [0u8; 32]);
assert_eq!(y, [0u8; 32]);
}
// ── ECDSA sign produces 20/32-byte fixed-width outputs ─────────────────
#[test]
fn ecdsa_sign_outputs_are_fixed_width_and_verify() {
// Sign/verify already covered; here assert the (r,s) are full-width
// (the to_bytes_be_padded path must not emit short arrays — a fixed
// [u8;20] return enforces width, but verify the values are non-trivial
// and round-trip).
let (priv_key, px, py) = generate_host_key_pair();
let (r, s) = ecdsa_sign(&priv_key, b"payload");
assert_ne!(r, [0u8; 20]);
assert_ne!(s, [0u8; 20]);
assert!(ecdsa_verify(&px, &py, &r, &s, b"payload"));
}
#[test]
fn ecdsa_verify_rejects_out_of_range_signature_components() {
// r or s == 0, or >= n, must be rejected up front (standard ECDSA
// range check). Use r = 0.
let (_priv, px, py) = generate_host_key_pair();
let zero = [0u8; 20];
let some = [0x01u8; 20];
assert!(
!ecdsa_verify(&px, &py, &zero, &some, b"d"),
"r == 0 must be rejected"
);
assert!(
!ecdsa_verify(&px, &py, &some, &zero, b"d"),
"s == 0 must be rejected"
);
// r == n must be rejected (>= n).
assert!(!ecdsa_verify(&px, &py, &EC_N, &some, b"d"));
}
// ── ec_add / ec_double identities ──────────────────────────────────────
#[test]
fn ec_add_with_infinity_is_identity() {
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let inf = EcPoint::infinity();
let r1 = ec_add(&g, &inf, &a, &p);
let r2 = ec_add(&inf, &g, &a, &p);
assert_eq!((r1.x, r1.y), (g.x.clone(), g.y.clone()));
assert_eq!((r2.x, r2.y), (g.x, g.y));
}
#[test]
fn ec_add_point_and_its_negation_is_infinity() {
// P + (-P) = O. -P has y' = p - y. Same x, different y → infinity.
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let neg_y = (&p - &g.y) % &p;
let neg_g = EcPoint::new(g.x.clone(), neg_y);
let sum = ec_add(&g, &neg_g, &a, &p);
assert!(sum.infinity, "P + (-P) must be the point at infinity");
}
#[test]
fn ec_mul_two_g_equals_g_plus_g() {
// 2·G via scalar mul equals ec_double(G) and ec_add(G,G).
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let two = BigUint::from(2u32);
let mul2 = ec_mul(&two, &g, &a, &p);
let dbl = ec_double(&g, &a, &p);
let add = ec_add(&g, &g, &a, &p);
assert_eq!((mul2.x.clone(), mul2.y.clone()), (dbl.x, dbl.y));
assert_eq!((mul2.x, mul2.y), (add.x, add.y));
}
// ── AES-CMAC subkey: K1 doubling with Rb=0x87 ──────────────────────────
#[test]
fn aes_cmac_full_block_changes_with_one_input_bit() {
// A single-bit flip in the message must change the MAC (the K1 XOR +
// encrypt is sensitive to all input bits). Pairs with the NIST KAT.
let key = [0x2bu8; 16];
let m1 = [0x00u8; 16];
let mut m2 = m1;
m2[7] ^= 0x01;
assert_ne!(aes_cmac_16(&m1, &key), aes_cmac_16(&m2, &key));
}
// ── verify_cert_p256 boundary at exactly 138 ───────────────────────────
#[test]
fn verify_cert_p256_accepts_138_byte_length_without_panic() {
// 138 bytes is the minimum that satisfies the guard; the slices
// cert[74..106]/[106..138] are all in-bounds. The signature won't
// verify (random bytes) but it must NOT panic and must return false.
let cert = vec![0x00u8; 138];
assert!(!verify_cert_p256(&cert));
}
}
+311
View File
@@ -675,4 +675,315 @@ mod tests {
db.processing_keys.len()
);
}
// ════════════════════════════════════════════════════════════════════
// Hardening additions
// ════════════════════════════════════════════════════════════════════
use super::super::provider::KeyProvider;
// ── parse_hex / parse_hex16 / parse_hex20 ──────────────────────────────
#[test]
fn parse_hex_strips_lower_and_upper_prefixes() {
// Both lower- and upper-case prefixes are stripped (trim_start_matches
// "0x" then "0X"). Without one of those strips a value would be off by
// a nibble or fail length checks.
assert_eq!(parse_hex("0xABCD"), Some(vec![0xAB, 0xCD]));
assert_eq!(parse_hex("0XABCD"), Some(vec![0xAB, 0xCD]));
assert_eq!(parse_hex("ABCD"), Some(vec![0xAB, 0xCD]));
}
#[test]
fn parse_hex_mixed_case_nibbles() {
// to_digit(16) accepts both cases.
assert_eq!(parse_hex("aB"), Some(vec![0xAB]));
assert_eq!(parse_hex("Ff00"), Some(vec![0xFF, 0x00]));
}
#[test]
fn parse_hex_rejects_non_hex_digit() {
// 'G' is not a hex digit → None (not silently 0).
assert!(parse_hex("0xGG").is_none());
assert!(parse_hex("12ZZ").is_none());
}
#[test]
fn parse_hex_empty_is_empty_vec() {
// Empty (or bare "0x") → Some(empty): even byte-length 0 passes, and
// there are no nibbles to reject. parse_hex16/20 then reject on length.
assert_eq!(parse_hex(""), Some(vec![]));
assert_eq!(parse_hex("0x"), Some(vec![]));
}
#[test]
fn parse_hex16_enforces_exactly_16_bytes() {
assert!(parse_hex16(&format!("0x{}", "00".repeat(15))).is_none());
assert!(parse_hex16(&format!("0x{}", "00".repeat(17))).is_none());
assert_eq!(
parse_hex16(&format!("0x{}", "00".repeat(16))),
Some([0u8; 16])
);
}
#[test]
fn parse_hex20_enforces_exactly_20_bytes() {
assert!(parse_hex20(&format!("0x{}", "00".repeat(19))).is_none());
assert_eq!(
parse_hex20(&format!("0x{}", "11".repeat(20))),
Some([0x11u8; 20])
);
}
// ── Disc entry field parsing ───────────────────────────────────────────
#[test]
fn disc_entry_hash_is_lowercased() {
// The disc_hash key is lowercased so HashMap lookups are
// case-insensitive (find_disc lowercases its query too).
let z32 = "00".repeat(16);
let line = format!("0xABCDEF = T | M | 0x{z32}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.disc_hash, "0xabcdef");
}
#[test]
fn disc_entry_title_uses_display_in_parens() {
// "RAW_NAME (Display Name)" → title is the parenthesised display name.
let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "Display Name");
}
#[test]
fn disc_entry_title_without_parens_uses_whole() {
let line = "0x00 = PlainTitle | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "PlainTitle");
}
#[test]
fn disc_entry_malformed_parens_falls_back_to_whole_title() {
// ')' before '(' would make start+1 > end; the guarded get() returns
// None and the parser falls back to the whole title (no panic).
let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "FILM) (X");
}
#[test]
fn disc_entry_parses_all_tagged_fields() {
// M, I, V, U each populate their field. U accepts "n-0xKEY".
let m = "11".repeat(16);
let i = "22".repeat(16);
let v = "33".repeat(16);
let u = "44".repeat(16);
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.media_key, Some([0x11u8; 16]));
assert_eq!(e.disc_id, Some([0x22u8; 16]));
assert_eq!(e.vuk, Some([0x33u8; 16]));
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
}
#[test]
fn disc_entry_multiple_unit_keys_space_separated() {
// The U field carries space-separated "n-0xKEY" pairs.
let k1 = "01".repeat(16);
let k2 = "02".repeat(16);
let line = format!("0xAA = T | U | 1-0x{k1} 2-0x{k2}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]);
}
#[test]
fn disc_entry_unit_key_strips_trailing_comment() {
// "U | 1-0xKEY ; comment" — the ';' comment must be stripped before
// splitting unit keys.
let k = "05".repeat(16);
let line = format!("0xAA = T | U | 1-0x{k} ; MKBv77 note");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(1, [0x05u8; 16])]);
}
#[test]
fn disc_entry_skips_unparseable_unit_key_pair() {
// A bad nibble in one unit key drops just that pair (parse_hex16 →
// None), keeping the valid ones — no panic, no half-garbage key.
let good = "07".repeat(16);
let line = format!("0xAA = T | U | 1-0xZZ 2-0x{good}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(2, [0x07u8; 16])]);
}
#[test]
fn disc_entry_field_with_short_hex_is_none_not_panic() {
// A 30-hex-char (15-byte) M value fails parse_hex16 → media_key None.
let short = "00".repeat(15);
let line = format!("0xAA = T | M | 0x{short}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert!(e.media_key.is_none());
}
// ── find_disc / find_vuk: prefix-agnostic lookup ───────────────────────
#[test]
fn find_disc_matches_with_and_without_0x_and_case() {
let v = "33".repeat(16);
let line = format!("0xABCDEF = T | V | 0x{v}");
let db = KeyDb::parse(&line);
// Stored key is "0xabcdef". Query in several shapes.
assert!(db.find_disc("0xABCDEF").is_some());
assert!(db.find_disc("ABCDEF").is_some()); // no prefix
assert!(db.find_disc("0xabcdef").is_some());
assert!(db.find_disc(" 0xAbCdEf ").is_some()); // padded + mixed case
assert_eq!(db.find_vuk("ABCDEF"), Some([0x33u8; 16]));
assert!(db.find_disc("0xDEADBE").is_none());
}
// ── KeyProvider impl over KeyDb ────────────────────────────────────────
#[test]
fn provider_lookup_by_hash_formats_lowercase_hex() {
// lookup_disc_by_hash writes the 20-byte hash as lowercase hex with a
// 0x prefix; it must hit an entry keyed that way.
let hash = [
0x00u8, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF, 0x01, 0x02, 0x03, 0x04,
];
let hex = format!(
"0x{}",
hash.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
let mut db = KeyDb::empty();
db.disc_entries.insert(
hex.clone(),
DiscEntry {
disc_hash: hex,
title: "t".to_string(),
media_key: None,
disc_id: None,
vuk: Some([0x9u8; 16]),
unit_keys: Vec::new(),
},
);
let found = db.lookup_disc_by_hash(&hash).expect("hash lookup hit");
assert_eq!(found.vuk, Some([0x9u8; 16]));
// A different hash misses.
assert!(db.lookup_disc_by_hash(&[0xFFu8; 20]).is_none());
}
#[test]
fn provider_lookup_by_vid_matches_disc_id() {
let vid = [0x42u8; 16];
let mut db = KeyDb::empty();
db.disc_entries.insert(
"0xa".to_string(),
DiscEntry {
disc_hash: "0xa".to_string(),
title: "t".to_string(),
media_key: Some([1u8; 16]),
disc_id: Some(vid),
vuk: None,
unit_keys: Vec::new(),
},
);
assert!(db.lookup_disc_by_vid(&vid).is_some());
assert!(db.lookup_disc_by_vid(&[0x00u8; 16]).is_none());
}
#[test]
fn provider_media_keys_collects_every_per_disc_mk() {
// media_keys() returns every entry's Some(media_key). MKs are
// MKB-scoped, so the resolver dedups later; the provider returns all.
let mut db = KeyDb::empty();
for (i, mk) in [[0x1u8; 16], [0x2u8; 16]].iter().enumerate() {
db.disc_entries.insert(
format!("0x{i}"),
DiscEntry {
disc_hash: format!("0x{i}"),
title: "t".to_string(),
media_key: Some(*mk),
disc_id: None,
vuk: None,
unit_keys: Vec::new(),
},
);
}
// An entry with no MK contributes nothing.
db.disc_entries.insert(
"0x9".to_string(),
DiscEntry {
disc_hash: "0x9".to_string(),
title: "t".to_string(),
media_key: None,
disc_id: None,
vuk: None,
unit_keys: Vec::new(),
},
);
let mut mks = db.media_keys();
mks.sort();
assert_eq!(mks, vec![[0x1u8; 16], [0x2u8; 16]]);
}
// ── Comments / blank lines / unknown lines ─────────────────────────────
#[test]
fn parse_ignores_comments_and_blank_lines() {
let cfg = "\n; a comment\n# another\n \n";
let db = KeyDb::parse(cfg);
assert!(db.device_keys.is_empty());
assert!(db.processing_keys.is_empty());
assert!(db.disc_entries.is_empty());
assert!(db.host_certs.is_empty());
}
#[test]
fn parse_empty_or_keyless_file_is_lenient_not_error() {
// parse() never errors; a keyless file is an empty KeyDb (documented
// contract — load() errors only on read failure, not empty content).
let db = KeyDb::parse("; nothing here\n");
assert_eq!(db.disc_entries.len(), 0);
}
#[test]
fn parse_device_key_requires_all_four_fields() {
// Missing KEY_U_MASK_SHIFT → parse_device_key returns None; with no
// position fields at all it would be an orphan DK instead. Here the
// line has DEVICE_NODE + KEY_UV but no shift → neither parser accepts
// it as a positioned DK, and parse_orphan_dk rejects it (has position
// fields), so nothing is loaded.
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400";
assert!(KeyDb::parse_device_key(line).is_none());
let db = KeyDb::parse(line);
assert!(db.device_keys.is_empty());
assert!(db.processing_keys.is_empty());
}
#[test]
fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() {
// v2 priv must be exactly 32 bytes; cert must be >= 132.
let bad_priv = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(31),
"00".repeat(132)
);
assert!(KeyDb::parse_host_cert_v2(&bad_priv).is_none());
let short_cert = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(32),
"00".repeat(131)
);
assert!(KeyDb::parse_host_cert_v2(&short_cert).is_none());
}
#[test]
fn parse_processing_key_pk_row() {
// "| PK | 0x..." → 16-byte processing key. A trailing comment is
// stripped at ';'.
let line = format!("| PK | 0x{} ; MKBv64", "AB".repeat(16));
let pk = KeyDb::parse_processing_key(&line).unwrap();
assert_eq!(pk, [0xABu8; 16]);
}
}
+551
View File
@@ -2190,4 +2190,555 @@ mod tests {
assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption);
}
// ════════════════════════════════════════════════════════════════════
// Hardening additions
// ════════════════════════════════════════════════════════════════════
// ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ─────────
#[test]
fn derive_vuk_matches_spec_relation_explicitly() {
// Independently compute AES-ECB-D(mk, vid) XOR vid and confirm
// derive_vuk produces the same 16 bytes. A mutation that dropped the
// XOR-VID step, or used encrypt instead of decrypt, fails this.
use super::super::decrypt::aes_ecb_decrypt as dec;
let mk = [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
0x1E, 0x1F,
];
let vid = [
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D,
0x2E, 0x2F,
];
let mut expected = dec(&mk, &vid);
for i in 0..16 {
expected[i] ^= vid[i];
}
assert_eq!(derive_vuk(&mk, &vid), expected);
}
#[test]
fn decrypt_unit_key_is_plain_aes_ecb_decrypt_under_vuk() {
// The encrypted unit key in Unit_Key_RO.inf is AES-ECB-E(VUK, uk);
// decrypt_unit_key must be the matching ECB-decrypt. Round-trip via
// encrypt to pin the relation.
use super::super::decrypt::aes_ecb_encrypt as enc;
let vuk = [0x9Eu8; 16];
let uk = [0x3Cu8; 16];
let enc_uk = enc(&vuk, &uk);
assert_eq!(decrypt_unit_key(&vuk, &enc_uk), uk);
}
// ── Unit_Key_RO stride: 48 (V10) vs 64 (V20/V21) ──────────────────────
/// Build a Unit_Key_RO.inf carrying `num_uk` keys at a given stride,
/// where key `i` is filled with byte `0x10 + i`. uk_pos = 0x60.
fn build_unit_key_ro(num_uk: usize, stride: usize) -> Vec<u8> {
let uk_pos = 0x60usize;
let size = uk_pos + 48 + stride * num_uk + 64;
let mut data = vec![0u8; size];
// uk_pos BE32 at [0..4].
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[16] = 1; // app_type
data[17] = 1; // num_bdmv_dir
// num_unit_keys BE16 at uk_pos.
data[uk_pos..uk_pos + 2].copy_from_slice(&(num_uk as u16).to_be_bytes());
// Keys start at uk_pos + 48, stride apart.
let mut pos = uk_pos + 48;
for i in 0..num_uk {
for b in &mut data[pos..pos + 16] {
*b = 0x10 + i as u8;
}
pos += stride;
}
data
}
#[test]
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (keys.rs:30-35).
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
// those keys; parsing the SAME bytes at V10 (48) stride would read the
// wrong (intermediate) bytes for key 2 onward — proving the stride
// selector matters.
let data = build_unit_key_ro(2, 64);
let v20 = parse_unit_key_ro(&data, AacsVersion::V20).unwrap();
assert_eq!(v20.encrypted_keys.len(), 2);
assert_eq!(v20.encrypted_keys[0].1, [0x10; 16]);
assert_eq!(v20.encrypted_keys[1].1, [0x11; 16]);
// Same buffer, V10 stride: key 1 still lands at uk_pos+48, but key 2
// is read at +48 (not +64) so it is NOT the planted 0x11 block.
let v10 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(v10.encrypted_keys[0].1, [0x10; 16]);
assert_ne!(
v10.encrypted_keys[1].1, [0x11; 16],
"48-byte stride must read different bytes than 64-byte stride"
);
}
#[test]
fn v21_uses_same_64_byte_stride_as_v20() {
// V21 shares V20's 64-byte stride (the enum match groups V20|V21).
let data = build_unit_key_ro(2, 64);
let v20 = parse_unit_key_ro(&data, AacsVersion::V20).unwrap();
let v21 = parse_unit_key_ro(&data, AacsVersion::V21).unwrap();
assert_eq!(v20.encrypted_keys, v21.encrypted_keys);
assert_eq!(v21.version, AacsVersion::V21);
}
// ── parse_unit_key_ro: early returns / boundaries ──────────────────────
#[test]
fn parse_unit_key_ro_rejects_too_short_header() {
// < 20 bytes → None (header fields at 16-18 would index OOB).
assert!(parse_unit_key_ro(&[0u8; 19], AacsVersion::V10).is_none());
}
#[test]
fn parse_unit_key_ro_rejects_uk_pos_past_end() {
// uk_pos points past the buffer → the `uk_pos + 2 > len` guard
// returns None rather than indexing OOB.
let mut data = vec![0u8; 64];
data[0..4].copy_from_slice(&1000u32.to_be_bytes()); // uk_pos = 1000
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
}
#[test]
fn parse_unit_key_ro_zero_keys_returns_empty_set() {
// num_unit_keys == 0 → a valid file with no encrypted keys (early
// Some(..) branch), NOT None.
let uk_pos = 0x60usize;
let mut data = vec![0u8; uk_pos + 48];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[16] = 1;
// num_uk left 0.
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert!(parsed.encrypted_keys.is_empty());
assert_eq!(parsed.app_type, 1);
}
#[test]
fn parse_unit_key_ro_truncated_key_region_returns_none() {
// keys_start + 16 > len → None (the first key can't fit).
let uk_pos = 0x60usize;
let mut data = vec![0u8; uk_pos + 48 + 8]; // only 8 of 16 key bytes
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[uk_pos + 1] = 1; // 1 key declared
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
}
#[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.
let uk_pos = 0x60usize;
let stride = 48usize;
// Room for keys at uk_pos+48 and uk_pos+48+48, then only 8 spare bytes
// (key 3 would start at uk_pos+48+96 and need 16, but only 8 remain).
let size = uk_pos + 48 + stride + 16 + 8;
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"
);
}
#[test]
fn parse_unit_key_ro_app_type_and_skb_flag() {
// app_type at [16], num_bdmv_dir at [17], use_skb_mkb = bit 7 of [18].
let mut data = build_unit_key_ro(1, 48);
data[16] = 0x02;
data[17] = 0x05;
data[18] = 0x80; // bit 7 set
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(p.app_type, 0x02);
assert_eq!(p.num_bdmv_dir, 0x05);
assert!(p.use_skb_mkb, "bit 7 of byte 18 → use_skb_mkb true");
// Clearing bit 7 (other bits set) → false.
data[18] = 0x7F;
let p2 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert!(!p2.use_skb_mkb);
}
#[test]
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
// The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162.
let data = build_unit_key_ro(3, 48);
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(
p.encrypted_keys.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
vec![1, 2, 3]
);
}
#[test]
fn parse_unit_key_ro_title_cps_mapping_first_play_top_menu_then_titles() {
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
// per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2.
let mut data = build_unit_key_ro(2, 64);
data[20..22].copy_from_slice(&7u16.to_be_bytes()); // first_play
data[22..24].copy_from_slice(&9u16.to_be_bytes()); // top_menu
data[24..26].copy_from_slice(&2u16.to_be_bytes()); // num_titles
data[28..30].copy_from_slice(&3u16.to_be_bytes()); // title 0 CPS
data[32..34].copy_from_slice(&4u16.to_be_bytes()); // title 1 CPS
let p = parse_unit_key_ro(&data, AacsVersion::V20).unwrap();
assert_eq!(p.title_cps_unit, vec![7, 9, 3, 4]);
}
// ── MKB record framing: rec_len is BE24 incl. 4-byte header ────────────
#[test]
fn mkb_version_uses_be24_length_and_reads_offset_8() {
// Type 0x10, BE24 length 0x0C (12), version u32 at body offset 8.
// Confirm a length encoded in the high BE24 byte is honored.
let mkb = [
0x10, 0x00, 0x00, 0x0C, 0x11, 0x22, 0x33, 0x44, 0x01, 0x02, 0x03, 0x04,
];
// version = 0x01020304.
assert_eq!(mkb_version(&mkb), Some(0x0102_0304));
}
#[test]
fn mkb_find_mk_dv_skips_short_verify_record() {
// A 0x81 record with rec_len < 20 carries no full mk_dv; the finder
// must skip it and keep walking (here to a valid 0x86 after it).
let mut mkb = vec![0x81, 0x00, 0x00, 0x10]; // rec_len 16 (< 20)
mkb.extend_from_slice(&[0x00; 12]);
let expected = [0xC1u8; 16];
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&expected);
mkb.extend_from_slice(&[0x00; 4]);
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
}
#[test]
fn mkb_find_mk_dv_stops_on_overrun_length() {
// A rec_len that runs past the buffer ends the walk (break), so no
// mk_dv is found and we get None rather than an OOB slice.
let mkb = [0x81, 0x00, 0xFF, 0xFF, 0x00, 0x00]; // claims 65535 bytes
assert_eq!(mkb_find_mk_dv(&mkb), None);
}
#[test]
fn mkb_find_mk_dv_stops_on_zero_length_record() {
// rec_len < 4 (here 0) breaks the walk — guards against an infinite
// loop on a malformed record (pos would never advance).
let mkb = [0x81, 0x00, 0x00, 0x00, 0x99];
assert_eq!(mkb_find_mk_dv(&mkb), None);
}
// ── mkb_content_len / trim_mkb ─────────────────────────────────────────
#[test]
fn mkb_content_len_stops_at_zero_type_padding_byte() {
// A type==0 byte marks the start of padding (records done). Two real
// records then a 0x00 type byte → content_len == sum of the two recs.
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1]; // 8-byte rec
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]); // 8-byte rec
let content = mkb.len();
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x08]); // padding starts (type 0)
assert_eq!(mkb_content_len(&mkb), content);
}
#[test]
fn mkb_content_len_returns_full_len_when_no_padding() {
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]);
assert_eq!(mkb_content_len(&mkb), mkb.len());
}
#[test]
fn trim_mkb_leaves_exactly_sized_buffer_untouched() {
// n == mkb.len() (no padding) → the `n < mkb.len()` guard is false,
// so the buffer is returned untouched (no spurious truncate).
let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
assert_eq!(trim_mkb(mkb.clone()), mkb);
}
// ── Content Certificate parsing ────────────────────────────────────────
#[test]
fn parse_content_cert_rejects_short_buffer() {
// < 8 bytes → None (cc_id slice [2..8] would index OOB).
assert!(parse_content_cert(&[0x00; 7]).is_none());
}
#[test]
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
// [0]=type, [1]=bus-enc bit0, [2..8]=cc_id. Any non-0x00 type → V20.
let mut data = vec![0u8; 8];
data[0] = 0x02; // not 0x00 and not 0x01 → still V20
data[1] = 0x00;
data[2..8].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20);
assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
assert!(!cc.bus_encryption);
}
#[test]
fn parse_content_cert_bus_encryption_only_reads_bit0() {
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with
// bit0 clear → false. Pins the mask, not a truthiness of the byte.
let mut data = vec![0u8; 8];
data[1] = 0x02; // bit 1 set, bit 0 clear
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
data[1] = 0x03; // bit 0 set
assert!(parse_content_cert(&data).unwrap().bus_encryption);
}
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
#[test]
fn resolve_keys_v2_upgrades_to_v21_on_variant_mkb() {
// resolve_keys_v2 parses with the V20 64-byte stride but upgrades the
// result's version to V21 if the MKB carries a 0x82/0x83 variant
// record. Path 4 (hash→VUK) supplies the actual keys.
let uk_ro = build_unit_key_ro(1, 64);
let hash = disc_hash(&uk_ro);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "fixture".to_string(),
media_key: None,
disc_id: None,
vuk: Some([0x5Au8; 16]),
unit_keys: Vec::new(),
},
);
// MKB with a 0x83 variant record makes is_variant_mkb true.
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0x55; 16]);
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16],
providers,
mkb: Some(&mkb),
};
let resolved = resolve_keys_v2(&ctx).expect("path 4 resolves");
assert_eq!(
resolved.version,
AacsVersion::V21,
"variant MKB must upgrade V20 result to V21"
);
}
#[test]
fn resolve_keys_v2_stays_v20_on_classical_mkb() {
// No variant records → version stays V20.
let uk_ro = build_unit_key_ro(1, 64);
let hash = disc_hash(&uk_ro);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "f".to_string(),
media_key: None,
disc_id: None,
vuk: Some([0x5Au8; 16]),
unit_keys: Vec::new(),
},
);
let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16],
providers,
mkb: Some(&mkb),
};
assert_eq!(resolve_keys_v2(&ctx).unwrap().version, AacsVersion::V20);
}
#[test]
fn resolve_keys_bus_encryption_flag_flows_from_content_cert() {
// The resolved.bus_encryption must reflect the content cert's bit0.
let uk_ro = build_unit_key_ro(1, 48);
let hash = disc_hash(&uk_ro);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "f".to_string(),
media_key: None,
disc_id: None,
vuk: Some([1u8; 16]),
unit_keys: Vec::new(),
},
);
// Content cert: AACS2 + bus encryption enabled.
let mut cc = vec![0u8; 8];
cc[0] = 0x01;
cc[1] = 0x01;
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: Some(&cc),
volume_id: &[0u8; 16],
providers,
mkb: None,
};
assert!(resolve_keys_v1(&ctx).unwrap().bus_encryption);
}
#[test]
fn resolve_keys_v21_path4_resolves_by_hash() {
// resolve_keys_v21 must hit path 4 (hash→VUK) and stamp version V21,
// deriving unit keys from the VUK.
use super::super::decrypt::aes_ecb_encrypt as enc;
let data = build_unit_key_ro(1, 64);
// The single encrypted key in build_unit_key_ro is [0x10;16].
let hash = disc_hash(&data);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let vuk = [0x77u8; 16];
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "f".to_string(),
media_key: None,
disc_id: None,
vuk: Some(vuk),
unit_keys: Vec::new(),
},
);
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &data,
content_cert: None,
volume_id: &[0u8; 16],
providers,
mkb: None,
};
let r = resolve_keys_v21(&ctx).expect("v21 path 4");
assert_eq!(r.version, AacsVersion::V21);
assert_eq!(r.key_source, 4);
assert_eq!(r.vuk, Some(vuk));
// Unit key derived: AES-D(vuk, enc_key). enc_key here is [0x10;16].
assert_eq!(r.unit_keys[0].1, decrypt_unit_key(&vuk, &[0x10u8; 16]));
// Self-consistency: encrypting it back under VUK gives the stored block.
assert_eq!(enc(&vuk, &r.unit_keys[0].1), [0x10u8; 16]);
}
#[test]
fn resolve_keys_path3_derives_vuk_from_vid_match() {
// Path 3: an entry whose disc_id == ctx.volume_id supplies an MK;
// resolver derives VUK = derive_vuk(mk, vid). No hash match needed.
let uk_ro = minimal_unit_key_ro();
let vid = [0x42u8; 16];
let mk = [0x24u8; 16];
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
"0xnotthishash".to_string(),
DiscEntry {
disc_hash: "0xnotthishash".to_string(),
title: "sibling".to_string(),
media_key: Some(mk),
disc_id: Some(vid),
vuk: None,
unit_keys: Vec::new(),
},
);
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
providers,
mkb: None, // no MKB → paths 1/2/2.5 skipped, path 3 fires
};
let r = resolve_keys_v1(&ctx).expect("path 3 by VID");
assert_eq!(r.key_source, 3);
assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid)));
}
#[test]
fn resolve_keys_returns_none_when_no_provider_has_anything() {
// Empty provider array + VID present + no MKB → all paths miss → None.
let uk_ro = minimal_unit_key_ro();
let providers: &[&dyn super::super::KeyProvider] = &[];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0x42u8; 16],
providers,
mkb: None,
};
assert!(resolve_keys_v1(&ctx).is_none());
}
#[test]
fn match_keydb_unit_keys_empty_keydb_returns_none() {
// match_keydb_unit_keys with empty keydb keys → None (so path 5 can't
// fire on an entry with no unit keys).
let uk_file = parse_unit_key_ro(&minimal_unit_key_ro(), AacsVersion::V10).unwrap();
assert!(match_keydb_unit_keys(&uk_file, &[]).is_none());
}
// ── derive_media_key_from_dk: revoked-marker stops the uv scan ─────────
#[test]
fn derive_media_key_from_dk_breaks_on_revoked_marker() {
// A subset-difference entry whose u_mask_shift has bit 0x40/0x80 set
// is a revoke marker; the scan must `break` (not derive a key from it
// and not panic). Pair it with a DK that would otherwise be tempting.
let mut mkb: Vec<u8> = Vec::new();
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
// 0x04 with one entry, u_mask_shift = 0xC0 (both top bits → revoked).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0xC0, 0x00, 0x00, 0x00, 0x01]);
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let dk = DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
u_mask_shift: 0,
};
// The 0xC0 entry is filtered by the num_uvs take_while, so the scan
// sees zero usable slots and returns None — never a wrong key.
assert!(derive_media_key_from_dk(&mkb, &[dk]).is_none());
}
#[test]
fn derive_media_key_from_dk_returns_none_when_records_missing() {
// No 0x04 / 0x05 records → the `?` short-circuits return None.
let mkb = vec![
0x81, 0x00, 0x00, 0x14, /* mk_dv */ 0u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0,
];
assert!(derive_media_key_from_dk(&mkb, &[]).is_none());
}
#[test]
fn find_record_body_returns_none_for_empty_body_record() {
// find_record_body requires rec_len > 4 (non-empty body). A 4-byte
// record (header only, empty body) is treated as absent.
let mkb = [0x05, 0x00, 0x00, 0x04]; // type 0x05, no body
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
}
}
+48
View File
@@ -41,3 +41,51 @@ pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
};
#[cfg(test)]
mod tests {
//! Re-export surface guards. The module's public API is the set of
//! `pub use` items above. A regression that drops or renames an export
//! (the class of bug that shipped in 0.31.0 by silently changing a
//! surface) breaks compilation of these references, so they act as a
//! compile-time contract for the crate's AACS surface.
use super::*;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
// ALIGNED_UNIT_LEN is the AACS aligned-unit size: 3 × 2048 = 6144.
// Re-exported from decrypt; pin the value here so the public constant
// and the spec stay in lockstep.
assert_eq!(ALIGNED_UNIT_LEN, 6144);
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
}
#[test]
fn version_strides_are_reexported_and_distinct() {
// The three AACS generations are part of the public surface, and the
// V10 (48) vs V20/V21 (64) stride distinction is the load-bearing
// difference. Confirm the enum re-export is usable and the variants
// are distinct values.
assert_ne!(AacsVersion::V10, AacsVersion::V20);
assert_ne!(AacsVersion::V20, AacsVersion::V21);
}
#[test]
fn key_correction_data_placeholder_is_all_zero() {
// The variant chain refuses to run against this all-zero placeholder
// KCD; the public constant must therefore be exactly 16 zero bytes.
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
}
#[test]
fn public_helpers_are_callable_through_the_facade() {
// Touch a representative function from each re-export group so a
// dropped/renamed export fails to compile. These are smoke calls, not
// behavioural assertions (behaviour is covered in each module).
let _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));
}
}
+226
View File
@@ -181,3 +181,229 @@ impl KeyProvider for SuppliedKey {
self.disc_entry.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(hash: &str, vuk: u8) -> DiscEntry {
DiscEntry {
disc_hash: hash.to_string(),
title: "t".to_string(),
media_key: None,
disc_id: None,
vuk: Some([vuk; 16]),
unit_keys: Vec::new(),
}
}
fn dk(byte: u8, node: u16) -> DeviceKey {
DeviceKey {
key: [byte; 16],
node,
uv: 1,
u_mask_shift: 0,
}
}
/// A provider that returns fixed bulk material and an optional disc entry
/// keyed unconditionally (used to test array-order short-circuiting).
#[derive(Default)]
struct Fixed {
dks: Vec<DeviceKey>,
pks: Vec<[u8; 16]>,
mks: Vec<[u8; 16]>,
hash_hit: Option<DiscEntry>,
vid_hit: Option<DiscEntry>,
}
impl KeyProvider for Fixed {
fn device_keys(&self) -> Vec<DeviceKey> {
self.dks.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.pks.clone()
}
fn media_keys(&self) -> Vec<[u8; 16]> {
self.mks.clone()
}
fn lookup_disc_by_hash(&self, _h: &[u8; 20]) -> Option<DiscEntry> {
self.hash_hit.clone()
}
fn lookup_disc_by_vid(&self, _v: &[u8; 16]) -> Option<DiscEntry> {
self.vid_hit.clone()
}
}
// ── KeyProvider default methods all return empty ───────────────────────
#[test]
fn default_provider_methods_return_empty() {
// A bare provider that overrides nothing must yield empty material so
// the resolver simply finds nothing through it (no surprise hits).
struct Empty;
impl KeyProvider for Empty {}
let e = Empty;
assert!(e.device_keys().is_empty());
assert!(e.processing_keys().is_empty());
assert!(e.media_keys().is_empty());
assert!(e.host_certs().is_empty());
assert!(e.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(e.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
// ── Providers::processing_keys: union + dedup ──────────────────────────
#[test]
fn providers_processing_keys_union_and_dedup() {
// Two providers each carrying overlapping PKs → the aggregate is the
// deduped union (the resolver must not re-validate identical material).
let a = Fixed {
pks: vec![[0x01u8; 16], [0x02u8; 16]],
..Default::default()
};
let b = Fixed {
pks: vec![[0x02u8; 16], [0x03u8; 16]],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let mut got = Providers(arr).processing_keys();
got.sort();
assert_eq!(got, vec![[0x01u8; 16], [0x02u8; 16], [0x03u8; 16]]);
}
#[test]
fn providers_media_keys_union_and_dedup() {
let a = Fixed {
mks: vec![[0xAAu8; 16]],
..Default::default()
};
let b = Fixed {
mks: vec![[0xAAu8; 16], [0xBBu8; 16]],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let mut got = Providers(arr).media_keys();
got.sort();
assert_eq!(got, vec![[0xAAu8; 16], [0xBBu8; 16]]);
}
#[test]
fn providers_device_keys_dedup_on_value_tuple() {
// DeviceKey has no Hash/Ord; dedup keys on (key,node,uv,u_mask_shift).
// Two identical DKs across providers collapse to one; a DK differing
// only in node is kept.
let a = Fixed {
dks: vec![dk(0x11, 5), dk(0x11, 5)],
..Default::default()
};
let b = Fixed {
dks: vec![dk(0x11, 5), dk(0x11, 6)],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).device_keys();
assert_eq!(got.len(), 2, "identical DKs dedup; differing node kept");
let nodes: Vec<u16> = got.iter().map(|d| d.node).collect();
assert!(nodes.contains(&5) && nodes.contains(&6));
}
// ── Disc-keyed lookups: array-order short-circuit ──────────────────────
#[test]
fn providers_lookup_by_hash_first_hit_wins() {
// Querying providers in array order, the FIRST hit wins (closest /
// fastest first). Provider 0 hits → its entry is returned even though
// provider 1 also has one.
let a = Fixed {
hash_hit: Some(entry("first", 0x01)),
..Default::default()
};
let b = Fixed {
hash_hit: Some(entry("second", 0x02)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
assert_eq!(got.disc_hash, "first");
assert_eq!(got.vuk, Some([0x01u8; 16]));
}
#[test]
fn providers_lookup_by_hash_falls_through_to_later_provider() {
// Provider 0 misses, provider 1 hits → the later provider's entry is
// used (find_map continues past None).
let a = Fixed::default(); // hash_hit None
let b = Fixed {
hash_hit: Some(entry("second", 0x02)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
assert_eq!(got.disc_hash, "second");
}
#[test]
fn providers_lookup_by_vid_first_hit_wins() {
let a = Fixed {
vid_hit: Some(entry("vid-a", 0x07)),
..Default::default()
};
let b = Fixed {
vid_hit: Some(entry("vid-b", 0x08)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_vid(&[0u8; 16]).unwrap();
assert_eq!(got.disc_hash, "vid-a");
}
#[test]
fn providers_empty_array_yields_nothing() {
let arr: &[&dyn KeyProvider] = &[];
let p = Providers(arr);
assert!(p.device_keys().is_empty());
assert!(p.processing_keys().is_empty());
assert!(p.media_keys().is_empty());
assert!(p.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(p.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
// ── SuppliedKey: each level exposes only its own material ──────────────
#[test]
fn supplied_key_exposes_only_populated_fields() {
// A SuppliedKey filled at the DK level exposes DKs and nothing else,
// so the resolver runs the matching (DK→…) path and no other.
let sk = SuppliedKey {
device_keys: vec![dk(0x33, 9)],
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
assert_eq!(sk.device_keys().len(), 1);
assert!(sk.processing_keys().is_empty());
assert!(sk.media_keys().is_empty());
assert!(sk.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(sk.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
#[test]
fn supplied_key_disc_entry_returned_for_any_hash_or_vid() {
// decrypt_with already knows the disc, so a present disc_entry is
// returned regardless of the hash/VID argument (the lookup args are
// irrelevant in this bridge).
let sk = SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: Some(entry("supplied", 0x44)),
};
// Two unrelated hashes both return the same entry.
let h1 = sk.lookup_disc_by_hash(&[0x01u8; 20]).unwrap();
let h2 = sk.lookup_disc_by_hash(&[0xFFu8; 20]).unwrap();
assert_eq!(h1.disc_hash, "supplied");
assert_eq!(h2.disc_hash, "supplied");
// And by VID likewise.
assert!(sk.lookup_disc_by_vid(&[0x00u8; 16]).is_some());
}
}
+264
View File
@@ -779,4 +779,268 @@ mod tests {
};
(recs, dk, kp, kmp)
}
// ════════════════════════════════════════════════════════════════════
// Hardening additions
// ════════════════════════════════════════════════════════════════════
// ── walk_mkb framing: BE24 length incl. header, end markers ────────────
#[test]
fn walk_mkb_reports_offsets_and_be24_lengths() {
// Two records; the walker must report each record's byte offset and
// its full length (header + body). rec_len is the 3-byte BE field at
// bytes 1..4, and INCLUDES the 4-byte header.
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB]; // len 6 (2-byte body)
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]); // len 8
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].offset, 0);
assert_eq!(recs[0].rec_len, 6);
assert_eq!(recs[0].body, vec![0xAA, 0xBB]);
assert_eq!(recs[1].offset, 6);
assert_eq!(recs[1].rec_len, 8);
assert_eq!(recs[1].body, vec![1, 2, 3, 4]);
}
#[test]
fn walk_mkb_be24_high_byte_is_honored() {
// A record longer than 255 bytes needs the high BE24 byte. Build a
// 0x10 record of total length 0x000110 (272) and confirm the body is
// 268 bytes (a parser that read only the low byte would see len 0x10).
let total = 0x0110usize; // 272
let mut mkb = vec![0x10, 0x00, 0x01, 0x10];
mkb.resize(total, 0xAB);
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].rec_len, total);
assert_eq!(recs[0].body.len(), total - 4);
}
#[test]
fn walk_mkb_stops_at_type0_len0_end_marker() {
// A (type=0, len=0) record ends the walk; trailing bytes after it are
// not parsed.
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]); // ignored
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].rec_type, 0x10);
}
#[test]
fn walk_mkb_stops_on_overrun_record() {
// rec_len running past the buffer ends the walk after the records that
// fit (no OOB, no partial body past the end).
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
mkb.extend_from_slice(&[0x05, 0x00, 0xFF, 0xFF]); // claims 65535 bytes
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 1, "overrun record must be dropped");
}
#[test]
fn walk_mkb_stops_on_sub_4_length() {
// A non-zero type with rec_len < 4 (and not the 0/0 marker) breaks the
// walk — otherwise pos would not advance (infinite loop guard).
let mkb = vec![0x10, 0x00, 0x00, 0x02, 0xAA];
assert!(walk_mkb(&mkb).is_empty());
}
#[test]
fn walk_mkb_handles_trailing_partial_header() {
// Fewer than 4 bytes left → loop condition `pos + 4 <= len` stops.
let mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB, 0x05, 0x00]; // 2 trailing
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 1);
}
// ── Record selectors ───────────────────────────────────────────────────
#[test]
fn is_variant_mkb_true_for_0x82_alone_and_0x83_alone() {
// Either record type alone flags the MKB as variant.
let only82 = walk_mkb(&{
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
m.extend_from_slice(&[0xEE; 16]);
m
});
assert!(is_variant_mkb(&only82));
let only83 = walk_mkb(&{
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
m.extend_from_slice(&[0x55; 16]);
m
});
assert!(is_variant_mkb(&only83));
}
#[test]
fn variant_nonce_requires_16_byte_body() {
// A 0x83 record with < 16-byte body → None (no panic on the copy).
let recs = walk_mkb(&{
let mut m = vec![0x83, 0x00, 0x00, 0x0C]; // 8-byte body
m.extend_from_slice(&[0x11; 8]);
m
});
assert_eq!(variant_nonce(&recs), None);
}
#[test]
fn variant_key_data_requires_nonempty_multiple_of_16() {
// A 0x82 body that is NOT a multiple of 16 is rejected by
// variant_key_data (it needs whole 16-byte VKD slots).
let recs = walk_mkb(&{
let mut m = vec![0x82, 0x00, 0x00, 0x0E]; // 10-byte body (not %16)
m.extend_from_slice(&[0x22; 10]);
m
});
assert_eq!(variant_key_data(&recs), None);
// variant_data_record returns the body regardless of length.
assert_eq!(variant_data_record(&recs), Some(&[0x22u8; 10][..]));
}
// ── derive_media_key_variant: missing-record classification ────────────
#[test]
fn chain_reports_processing_key_unavailable_with_no_dks() {
// A complete variant MKB but an empty device-key pool → no uv covered
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
.expect_err("no DK → ProcessingKeyUnavailable");
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
}
#[test]
fn chain_reports_mkb_incomplete_when_nonce_missing() {
// Build a variant MKB (has 0x82 so is_variant true, and a DK can walk
// it) but WITHOUT a 0x83 nonce record → MkbIncomplete at the
// variant_nonce `?`.
// Start from the full setup, then rebuild the byte stream dropping
// the 0x83 record.
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
// Reconstruct bytes without the 0x83 record.
let mut mkb = Vec::new();
for r in &recs {
if r.rec_type == 0x83 {
continue;
}
mkb.push(r.rec_type);
mkb.push(((r.rec_len >> 16) & 0xFF) as u8);
mkb.push(((r.rec_len >> 8) & 0xFF) as u8);
mkb.push((r.rec_len & 0xFF) as u8);
mkb.extend_from_slice(&r.body);
}
let recs2 = walk_mkb(&mkb);
assert!(is_variant_mkb(&recs2), "still variant via 0x82");
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("missing nonce → MkbIncomplete");
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
}
// ── walk_processing_key: skips out-of-range u_mask_shift ───────────────
#[test]
fn walk_processing_key_skips_shift_32_to_63_without_panic() {
// A subset-difference u_mask_shift in 0x20..=0x3F passes the 0xC0
// revoke check but is out of range for a u32 shift. The walk must skip
// the slot (continue) and not panic / not match a wrong uv. With only
// that one bad slot, no match → None.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// 0x04: u_mask_shift=0x20 (32), uv=2.
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x20, 0x00, 0x00, 0x00, 0x02]);
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let recs = walk_mkb(&mkb);
let dk = DeviceKey {
key: [0x11; 16],
node: 4,
uv: 2,
u_mask_shift: 3,
};
assert!(
walk_processing_key(&recs, &[dk]).is_none(),
"out-of-range shift must be skipped, yielding no match"
);
}
#[test]
fn walk_processing_key_skips_uv_zero() {
// A uv == 0 slot is skipped (`if uv == 0 { continue }`). With only a
// zero-uv slot present, no DK can match → None.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x00]); // uv = 0
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let recs = walk_mkb(&mkb);
let dk = DeviceKey {
key: [0x11; 16],
node: 4,
uv: 2,
u_mask_shift: 3,
};
assert!(walk_processing_key(&recs, &[dk]).is_none());
}
#[test]
fn walk_processing_key_returns_match_on_variant_mkb_without_magic() {
// On a variant MKB the per-match VERIFY_MAGIC check does not hold, but
// the walk still returns the (Kp, uv) match because variant_present is
// true. The synthetic_variant_setup fixture is exactly this case.
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
assert_eq!(m.uv, 2, "matched the planted uv");
assert_eq!(
m.kp, planted_kp,
"Kp equals aesg3_step(dk,1) for the no-op walk"
);
assert_eq!(m.cvalue_index, 0);
}
#[test]
fn aes_g_matches_decrypt_xor_relation() {
// AES-G(x1,x2) = AES-128D(x1,x2) XOR x2 — the same form as derive_vuk.
// Pin it explicitly so a dropped XOR or an encrypt-instead-of-decrypt
// is caught.
let x1 = [0x31u8; 16];
let x2 = [0x9Fu8; 16];
let mut expected = aes_ecb_decrypt(&x1, &x2);
for i in 0..16 {
expected[i] ^= x2[i];
}
assert_eq!(aes_g(&x1, &x2), expected);
}
#[test]
fn error_codes_are_unique_and_in_7100_range() {
// Each MediaKeyVariantError maps to a distinct E71xx code. A
// copy-paste collision (two variants sharing a code) would break
// operator triage; assert all nine are distinct.
use std::collections::HashSet;
let cases = [
MediaKeyVariantError::NotVariantMkb,
MediaKeyVariantError::MkbIncomplete,
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
];
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
}
}