diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 2240534..516c03b 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -61,9 +61,25 @@ impl Unlocker for AacsUnlocker { return Ok(None); } crate::fallthrough( - handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| Unlocked { - vid: Some(h.volume_id), - bus_key: h.read_data_key, + handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| { + // A UHD disc whose bus-key fetch the drive refused (non-transport) + // returns Ok with read_data_key: None + read_data_key_err: Some — + // otherwise indistinguishable from an AACS-1.0 disc. Surface it. + if h.read_data_key.is_none() + && let Some(code) = h.read_data_key_err + { + tracing::warn!( + target: "freemkv::disc", + phase = "read_data_key_dropped", + error_code = code, + "AACS auth + VID succeeded but the drive served no read_data_key (bus key); \ + unlock reports bus_key: None" + ); + } + Unlocked { + vid: Some(h.volume_id), + bus_key: h.read_data_key, + } }), ) } diff --git a/src/ld/platform/mt1959/mod.rs b/src/ld/platform/mt1959/mod.rs index a867b88..05d5588 100644 --- a/src/ld/platform/mt1959/mod.rs +++ b/src/ld/platform/mt1959/mod.rs @@ -586,6 +586,40 @@ mod tests { assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496"); } + // Byte-for-byte pin of variant_b's Step-2 metadata-read (READ_BUFFER offset + // 0x3000 at cdb[4]=0x30, length 0x10 at cdb[8]) and Step-3 write-extra CDBs. + #[test] + fn variant_b_meta_read_and_write_extra_cdbs_are_exact_bytes() { + let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]); + profile.firmware = vec![0u8; 2208]; + let mut mt = Mt1959::new(profile, true); + let mut t = RecordingTransport { cdbs: Vec::new() }; + let _ = variant_b::load_firmware(&mut mt, &mut t); + + // Step 2: READ_BUFFER mode 6, offset 0x3000, length 0x10. + let meta = t + .cdbs + .iter() + .find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(4) == Some(&0x30)) + .expect("Step-2 metadata READ_BUFFER issued"); + assert_eq!( + meta.as_slice(), + &[0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00] + ); + + // Step 3: WRITE_BUFFER of the 0x10-byte extra block. Distinguish from + // the Step-1 firmware upload (WRITE_BUFFER too) by its length byte. + let extra = t + .cdbs + .iter() + .find(|c| c.first() == Some(&SCSI_WRITE_BUFFER) && c.get(8) == Some(&0x10)) + .expect("Step-3 write-extra WRITE_BUFFER issued"); + assert_eq!( + extra.as_slice(), + &[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00] + ); + } + // Pins variant_a's upload sequence: WRITE_BUFFER at the real per-drive // length in the CDB's 24-bit length field, then the 0x45 verify // READ_BUFFER whose result used to be discarded outright. @@ -614,6 +648,40 @@ mod tests { ); } + // Byte-for-byte pin of variant_a's two firmware-upload CDBs (not just the + // length field): the WRITE_BUFFER (mode cdb[1]=0x06, reserved, 24-bit len, + // control byte 9) and the whole 0x45 verify READ_BUFFER CDB. + #[test] + fn variant_a_write_buffer_and_verify_cdbs_are_exact_bytes() { + let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]); + profile.firmware = vec![0u8; 2208]; // 0x0008A0 + let mut mt = Mt1959::new(profile, false); + let mut t = RecordingTransport { cdbs: Vec::new() }; + let _ = variant_a::load_firmware(&mut mt, &mut t); + + let wb = t + .cdbs + .iter() + .find(|c| c.first() == Some(&SCSI_WRITE_BUFFER)) + .expect("WRITE_BUFFER issued"); + // 3B [mode 06] [rsvd 00 00 00 00] [len 24-bit BE = 0008A0] [control 00] + assert_eq!( + wb.as_slice(), + &[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xA0, 0x00] + ); + + let verify = t + .cdbs + .iter() + .find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(2) == Some(&0x45)) + .expect("0x45 verify READ_BUFFER issued"); + // 3C [MODE_A 01] [buffer_id 45] [rsvd ×5] [alloc len 04] [control 00] + assert_eq!( + verify.as_slice(), + &[0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00] + ); + } + /// An EMPTY firmware blob cannot be uploaded — catches a profile whose /// firmware failed to decode being pushed at the drive as a zero-length /// WRITE_BUFFER. diff --git a/src/lib.rs b/src/lib.rs index dbfc31e..16b84c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,7 +61,7 @@ pub enum DiscKind { /// A host certificate for the AACS cert handshake (raw; the consumer collects /// these from its key sources and passes them in). -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct HostCert { /// AACS 1.0 host private key (20 bytes). pub private_key: [u8; 20], @@ -73,6 +73,20 @@ pub struct HostCert { pub certificate_v2: Option>, } +// Manual Debug: `private_key`/`private_key_v2` are the raw host private keys — +// they must never reach a log in plaintext; presence (Some/None) of the v2 key +// stays observable. Mirrors the redacting Debug on AacsAuth/Unlocked. +impl std::fmt::Debug for HostCert { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostCert") + .field("private_key", &"[redacted]") + .field("certificate", &self.certificate) + .field("private_key_v2", &self.private_key_v2.map(|_| "[redacted]")) + .field("certificate_v2", &self.certificate_v2) + .finish() + } +} + /// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the /// drive identity and the mounted disc's kind. These are the shared facts the /// dispatch loop knows; anything an individual unlocker needs beyond them (the @@ -250,4 +264,29 @@ mod tests { // Presence (Some/None) stays observable. assert!(s.contains("Some"), "must still show a key WAS present: {s}"); } + + // `private_key`/`private_key_v2` are the raw host private keys; `Debug` must + // NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte + // arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red. + #[test] + fn host_cert_debug_redacts_private_keys() { + let c = HostCert { + private_key: [0xAB; 20], + certificate: vec![0x01, 0x02], + private_key_v2: Some([0xCD; 32]), + certificate_v2: None, + }; + let s = format!("{c:?}"); + assert!(!s.contains("171"), "private_key bytes must not appear: {s}"); + assert!( + !s.contains("205"), + "private_key_v2 bytes must not appear: {s}" + ); + assert!(s.contains("[redacted]"), "must mark redaction: {s}"); + // Presence of the v2 key stays observable. + assert!( + s.contains("Some"), + "must still show the v2 key WAS present: {s}" + ); + } } diff --git a/src/renesas/mod.rs b/src/renesas/mod.rs index fa0378c..463e8f7 100644 --- a/src/renesas/mod.rs +++ b/src/renesas/mod.rs @@ -306,6 +306,94 @@ mod tests { ); } + // A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but + // whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the + // knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md. + #[test] + fn knock_runs_between_a_and_b_and_b_opens() { + // The exact knock CDB the code issues (pinned wire format). + const KNOCK_A5AAAA_CDB: [u8; 10] = + [0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00]; + + struct AFailsBSucceeds { + cdbs: Vec>, + } + impl ScsiTransport for AFailsBSucceeds { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.cdbs.push(cdb.to_vec()); + // Gate 0xF1 → SAT identity. + if cdb.get(2) == Some(&0xF1) { + let p = renesas_payload(); + let n = p.len().min(data.len()); + data[..n].copy_from_slice(&p[..n]); + return Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }); + } + // A read (RB 0xB0@0x04) → refuse with a drive sense. + if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) { + let mut sense = [0u8; 32]; + sense[2] = 0x05; // ILLEGAL REQUEST + sense[12] = 0x20; + return Err(ScsiError { + status: crate::scsi::SCSI_STATUS_CHECK_CONDITION, + sense: Some(sense), + }); + } + // B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID + // read all return GOOD so the open + VID read succeed. + let n = data.len().min(36); + if !data.is_empty() { + // Non-zero VID so read_aacs_vid yields Some. + for b in data.iter_mut().take(n) { + *b = 0x5A; + } + } + Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + + let mut t = AFailsBSucceeds { cdbs: Vec::new() }; + let id = crate::DriveId::default(); + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); + let out = Renesas::new() + .unlock(&mut t, &ctx) + .expect("no fault") + .expect("B read opens the drive → unlocked"); + assert_eq!(out.bus_key, None); + + // The knock must appear, between the A read (0xB0@0x04) and the B read + // (0xB0@0x50). + let a = t + .cdbs + .iter() + .position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00)) + .expect("A read issued"); + let knock = t + .cdbs + .iter() + .position(|c| c.as_slice() == KNOCK_A5AAAA_CDB) + .expect("knock CDB issued"); + let b = t + .cdbs + .iter() + .position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50)) + .expect("B read issued"); + assert!(a < knock && knock < b, "knock must run between A and B"); + } + // A recognized Renesas drive that REFUSES the vendor open read must // defer to the next unlocker, not claim the drive. See docs/renesas-mod.md. #[test] diff --git a/src/vid.rs b/src/vid.rs index 7855781..cceef55 100644 --- a/src/vid.rs +++ b/src/vid.rs @@ -172,6 +172,18 @@ mod tests { assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None); } + /// A rejection surfaced as `Err` WITH a sense (a non-conforming transport) + /// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never + /// `Err(Transport)`. + #[test] + fn err_with_sense_is_a_vid_miss_not_transport() { + let mut t = MockTransport::always(Reply::illegal_request_as_err()); + assert_eq!( + read_aacs_vid(&mut t).expect("a sense is not a dead bus"), + None + ); + } + /// Only a dead bus is an error. #[test] fn transport_fault_propagates() {