diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd1ab6..dcfc8e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,8 +47,8 @@ env: # Official libtropic, pinned to a release tag and its commit. Shared by the # coverage job (the TROPIC01 model config and venv layout) and the embedded # job (the se-fw-update vendor blobs). - LIBTROPIC_REF: v4.0.0 - LIBTROPIC_SHA: 756c8ee898ed61b12272ecb22b213edf97aab751 + LIBTROPIC_REF: v4.1.0 + LIBTROPIC_SHA: 0aa7e8873f08b0727138b7887b942268afca468a jobs: # Job 1: Host check and lint (whole workspace on the host) @@ -160,15 +160,15 @@ jobs: echo "libtropic $LIBTROPIC_REF moved: expected $LIBTROPIC_SHA, got $got" >&2 exit 1 fi - cpu="$(find "$src" -name 'fw_v2.0.0.hex32_signed_chunks.bin' | head -1)" - spect="$(find "$src" -name 'spect_app-v1.0.0_signed_chunks.bin' | head -1)" + cpu="$(find "$src" -name 'fw_v2.1.0.hex32_signed_chunks.bin' | head -1)" + spect="$(find "$src" -name 'spect_app-v1.3.0_signed_chunks.bin' | head -1)" if [ -z "$cpu" ] || [ -z "$spect" ]; then echo "vendor firmware blobs not found in libtropic $LIBTROPIC_REF" >&2 exit 1 fi mkdir -p crates/secure/fw_blobs - cp "$cpu" crates/secure/fw_blobs/cpu_fw_2_0_0.bin - cp "$spect" crates/secure/fw_blobs/spect_fw_1_0_0.bin + cp "$cpu" crates/secure/fw_blobs/cpu_fw_2_1_0.bin + cp "$spect" crates/secure/fw_blobs/spect_fw_1_3_0.bin - name: Two-stage TrustZone build run: | diff --git a/Cargo.lock b/Cargo.lock index b7d367a..1a78846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -727,7 +727,7 @@ dependencies = [ [[package]] name = "tropic01-driver" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes-gcm", "ecdsa", diff --git a/crates/nonsecure/src/main.rs b/crates/nonsecure/src/main.rs index 3b32412..f906861 100644 --- a/crates/nonsecure/src/main.rs +++ b/crates/nonsecure/src/main.rs @@ -100,7 +100,7 @@ mod firmware #[cfg(feature = "se-fw-update")] const FWU_ERR: u32 = 1 << 31; /// Fw-update word bit set when the update succeeded. The low byte carries the - /// updated-to-2.0.0 marker. + /// updated-to-2.1.0 marker. #[cfg(feature = "se-fw-update")] const FWU_OK: u32 = 1 << 8; @@ -343,7 +343,7 @@ mod firmware }; defmt::info! ( - "SE fw-update OK (updated to 2.0.0), marker {=u8:#04x}, \ + "SE fw-update OK (updated to 2.1.0), marker {=u8:#04x}, \ RISC-V now {=u32:#010x}, SPECT now {=u32:#010x}", fwu as u8, new_riscv, diff --git a/crates/secure/src/se_fw_update.rs b/crates/secure/src/se_fw_update.rs index 44943b4..ae37276 100644 --- a/crates/secure/src/se_fw_update.rs +++ b/crates/secure/src/se_fw_update.rs @@ -1,7 +1,7 @@ //! Secure-world TROPIC01 firmware-update routine, exported to the NSC veneer. //! -//! One-shot update of the secure element from factory FW to CPU 2.0.0 / SPECT -//! 1.0.0, driven from the secure world over SPI1. It is the secure side of the +//! One-shot update of the secure element to CPU 2.1.0 / SPECT 1.3.0, driven from +//! the secure world over SPI1. It is the secure side of the //! `patinakey_nsc_se_fw_update` non-secure-callable veneer: the non-secure world //! calls the veneer, the veneer forwards here, this code drives the update, packs //! the outcome into a `u32`, and returns. @@ -30,19 +30,19 @@ use tropic01_driver::SeError; use crate::se_smoke::build_device; use crate::se_smoke::se_error_code; -/// The signed CPU (RISC-V) firmware image, version 2.0.0. +/// The signed CPU (RISC-V) firmware image, version 2.1.0. /// /// A gitignored vendor blob (crates/secure/fw_blobs/). `include_bytes!` fails the /// build if it is absent, which is acceptable: a feature-on build requires the /// blob present. The bytes are the exact `cpu_image` stream `update_firmware` /// expects, relayed verbatim. -const CPU_FW_2_0_0: &[u8] = include_bytes!("../fw_blobs/cpu_fw_2_0_0.bin"); +const CPU_FW_2_1_0: &[u8] = include_bytes!("../fw_blobs/cpu_fw_2_1_0.bin"); -/// The signed SPECT firmware image, version 1.0.0. +/// The signed SPECT firmware image, version 1.3.0. /// /// A gitignored vendor blob (crates/secure/fw_blobs/). `include_bytes!` fails the /// build if absent. The bytes are the exact `spect_image` stream verbatim. -const SPECT_FW_1_0_0: &[u8] = include_bytes!("../fw_blobs/spect_fw_1_0_0.bin"); +const SPECT_FW_1_3_0: &[u8] = include_bytes!("../fw_blobs/spect_fw_1_3_0.bin"); // Status-word encoding (value-out, no pointer crosses the boundary). // @@ -57,7 +57,7 @@ const SPECT_FW_1_0_0: &[u8] = include_bytes!("../fw_blobs/spect_fw_1_0_0.bin"); // bits 15..8 (on ERR) STEP code: which step failed. // bits 7..0 (on ERR) the SeError code (se_error_code, shared with se_smoke). // bits 7..0 (on OK) FWU_UPDATED_MARKER: a fixed pattern the NS logs as -// "updated to 2.0.0". +// "updated to 2.1.0". // An error word can also set bit 8 incidentally (an odd STEP shifts a 1 into // bit 8 via STEP << 8). FWU_ERR (bit 31) is the discriminator: the NS tests // FWU_ERR FIRST, so an error word with bit 8 set is read as an error. @@ -69,7 +69,7 @@ const FWU_OK: u32 = 1 << 8; const FWU_ERR: u32 = 1 << 31; /// Low-byte marker returned on success. The non-secure side logs it as "updated -/// to 2.0.0". The running versions are read back via the existing version +/// to 2.1.0". The running versions are read back via the existing version /// veneers. const FWU_UPDATED_MARKER: u32 = 0x20; @@ -137,7 +137,7 @@ pub extern "C" fn patinakey_se_fw_update() -> u32 // Step 2: write both bank pairs from the two blobs verbatim. On success the // driver returns the two decoded image versions, reused by the verify below. - let (cpu_version, spect_version) = match bl.update_firmware(CPU_FW_2_0_0, SPECT_FW_1_0_0) + let (cpu_version, spect_version) = match bl.update_firmware(CPU_FW_2_1_0, SPECT_FW_1_3_0) { Ok(versions) => versions, Err(e) => return err_word(STEP_BANK_WRITE, e), diff --git a/crates/tropic01-driver/Cargo.toml b/crates/tropic01-driver/Cargo.toml index 5d3f757..0f5d53e 100644 --- a/crates/tropic01-driver/Cargo.toml +++ b/crates/tropic01-driver/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tropic01-driver" edition.workspace = true -version = "0.1.0" +version = "0.1.1" rust-version.workspace = true authors.workspace = true repository.workspace = true diff --git a/crates/tropic01-driver/README.md b/crates/tropic01-driver/README.md index a6094da..1e6b19a 100644 --- a/crates/tropic01-driver/README.md +++ b/crates/tropic01-driver/README.md @@ -73,6 +73,12 @@ Written as a clean-room rewrite with the official C SDK [`libtropic`](https://github.com/tropicsquare/libtropic) used as a differential **test oracle** (never linked : no C, no mbedTLS in the trusted computing base). +Protocol behaviour tracks **libtropic v4.1.0** (TROPIC01 Application FW 1.0.0 to +2.1.0, SPECT FW 1.0.0 to 1.3.0, bootloader 1.0.1 to 2.0.1). One deviation is +deliberate and stricter than upstream: a single CRC-retry budget covers a whole +chunked L3 packet, where libtropic refills its budget on every successful chunk +and so lets the worst case grow with message length. + > **Status: under active development.** The secure channel and the cryptographic > hot-path commands are tested host-side three ways: an in-repo chip mock (incl. > fault injection), a libtropic-derived handshake KAT, and a **live end-to-end @@ -110,7 +116,7 @@ pairing-slot index) is **caller-provided** via `SessionConfig`. The driver hardc | Area | What works | |------|------------| -| Transport | L1 SPI, L2 framing + multi-chunk reassembly | +| Transport | L1 SPI, L2 framing + multi-chunk reassembly, and CRC-fault recovery: a chip-reported CRC error replays the identical request (the chip ignored the frame, so it never ran), while a locally detected bad CRC on a response asks for a `Resend_Req` and never replays (the request may already have run) | | Secure channel | Noise KK1 handshake, `open_session` / `close_session`, `abort_session` (Encrypted_Session_Abt_Req 0x08: notifies the chip to drop the session, wipes host secrets first), session teardown gate | | Mode control | `reboot` (Startup_Req 0xB3: Start-up / Maintenance / Application FW), `sleep` (Sleep_Req 0x20), `chip_mode` (decodes CHIP_STATUS to Application / Startup / Alarm) | | Chip info (L2) | `Get_Info`: `x509_certificate_into` (raw cert store), `chip_id_into`, `riscv_fw_version`, `spect_fw_version`, `fw_bank_into` - read before a session, no secure channel | diff --git a/crates/tropic01-driver/src/device/bootloader.rs b/crates/tropic01-driver/src/device/bootloader.rs index 750893f..620c10d 100644 --- a/crates/tropic01-driver/src/device/bootloader.rs +++ b/crates/tropic01-driver/src/device/bootloader.rs @@ -31,8 +31,7 @@ use crate::error::SeError; use crate::ids::L2ReqId; use crate::ids::L2Status; use crate::ids::ObjectId; -use crate::l1; -use crate::l2::frame; +use crate::l2::retry; use crate::parse::take; use crate::parse::take_le_u32; use crate::parse::take_u8; @@ -410,10 +409,6 @@ where /// auto-selects and erases the target bank. Available only in Start-up /// (Maintenance) Mode. /// - /// FAITHFUL TRANSPORT: the driver validates only `header.len() == 104`. It - /// does not parse any field. The chip's signature check validates the - /// payload. - /// /// # Errors /// /// `SeError::InvalidArgument` when `header` is not exactly 104 bytes @@ -428,13 +423,16 @@ where { return Err(SeError::InvalidArgument); } - let n = frame::build_request(L2ReqId::MutableFwUpdate as u8, header, &mut self.l2)?; - l1::send_request(&mut self.spi, &self.l2[..n]).map_err(L2Error::from)?; - let frame_len = - l1::read_response(&mut self.spi, &mut self.wait, &mut self.l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&self.l2[..frame_len])?; + let resp = retry::exchange + ( + &mut self.spi, + &mut self.wait, + &mut self.l2, + L2ReqId::MutableFwUpdate as u8, + header, + )?; // A successful 0xB0 is acknowledged with an empty RequestOk frame. - if !matches!(resp.status, L2Status::RequestOk) || !resp.data.is_empty() + if !matches!(resp.status, L2Status::RequestOk) || resp.data_len != 0 { return Err(SeError::L2(L2Error::BadFrame)); } @@ -447,12 +445,6 @@ where /// on the last chunk, || `offset[2]` || data), relayed verbatim. Available /// only in Start-up (Maintenance) Mode. /// - /// FAITHFUL TRANSPORT: the driver validates only the length bounds. It does - /// NOT enforce the data field's documented 4-byte alignment: that is a - /// payload-semantics rule the chip's own signature already covers, and - /// enforcing it here would parse the image, breaking the pure-transport - /// contract. - /// /// # Errors /// /// `SeError::InvalidArgument` when `chunk.len()` is below @@ -467,13 +459,16 @@ where { return Err(SeError::InvalidArgument); } - let n = frame::build_request(L2ReqId::MutableFwUpdateData as u8, chunk, &mut self.l2)?; - l1::send_request(&mut self.spi, &self.l2[..n]).map_err(L2Error::from)?; - let frame_len = - l1::read_response(&mut self.spi, &mut self.wait, &mut self.l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&self.l2[..frame_len])?; + let resp = retry::exchange + ( + &mut self.spi, + &mut self.wait, + &mut self.l2, + L2ReqId::MutableFwUpdateData as u8, + chunk, + )?; // A successful 0xB1 is acknowledged with an empty RequestOk frame. - if !matches!(resp.status, L2Status::RequestOk) || !resp.data.is_empty() + if !matches!(resp.status, L2Status::RequestOk) || resp.data_len != 0 { return Err(SeError::L2(L2Error::BadFrame)); } diff --git a/crates/tropic01-driver/src/device/bootloader_tests.rs b/crates/tropic01-driver/src/device/bootloader_tests.rs index b89d914..49f401e 100644 --- a/crates/tropic01-driver/src/device/bootloader_tests.rs +++ b/crates/tropic01-driver/src/device/bootloader_tests.rs @@ -225,6 +225,57 @@ fn mutable_fw_update_data_surfaces_gen_err_recoverably() ); } +// CRC recovery across the firmware-update path (0xB0 / 0xB1) +#[test] +fn mutable_fw_update_recovers_from_a_crc_fault_with_a_resend() +{ + let mut spi = FwUpdateSpi::new(); + spi.set_crc_fault_on(L2ReqId::MutableFwUpdate as u8); + let dev = Tropic01::new(spi, MockWait::new()); + let mut bl = dev.enter_bootloader().map_err(|(_, e)| e).unwrap(); + bl.mutable_fw_update(&golden_b0_reqdata()).unwrap(); + assert_eq!(bl.spi_ref().resend_request_count(), 1); + assert_eq! + ( + bl.spi_ref().req_ids(), + std::vec![ + L2ReqId::Startup as u8, + L2ReqId::MutableFwUpdate as u8, + L2ReqId::Resend as u8 + ] + ); +} + +#[test] +fn mutable_fw_update_data_recovers_from_a_crc_fault_with_a_resend() +{ + let mut spi = FwUpdateSpi::new(); + spi.set_crc_fault_on(L2ReqId::MutableFwUpdateData as u8); + let dev = Tropic01::new(spi, MockWait::new()); + let mut bl = dev.enter_bootloader().map_err(|(_, e)| e).unwrap(); + bl.mutable_fw_update_data(&golden_b1_reqdata()).unwrap(); + assert_eq!(bl.spi_ref().resend_request_count(), 1); + assert_eq! + ( + bl.spi_ref().req_ids(), + std::vec![ + L2ReqId::Startup as u8, + L2ReqId::MutableFwUpdateData as u8, + L2ReqId::Resend as u8 + ] + ); +} + +#[test] +fn a_clean_update_path_asks_for_no_resend() +{ + let dev = Tropic01::new(FwUpdateSpi::new(), MockWait::new()); + let mut bl = dev.enter_bootloader().map_err(|(_, e)| e).unwrap(); + bl.mutable_fw_update(&golden_b0_reqdata()).unwrap(); + bl.mutable_fw_update_data(&golden_b1_reqdata()).unwrap(); + assert_eq!(bl.spi_ref().resend_request_count(), 0); +} + // fw_bank_into (Get_Info FW_BANK, Start-up only) #[test] @@ -559,7 +610,7 @@ fn nosession_update_firmware_demotes_when_recovery_exit_also_fails() // too. The handle is still relabeled NoSession, carrying the original error. let mut spi = FwUpdateSpi::new(); spi.fail_nth_b0(1); - spi.fail_nth_b3(2); // enter is B3 #1 (ok); the recovery exit is B3 #2. + spi.fail_nth_b3(2); // enter is B3 #1 (ok), the recovery exit is B3 #2. let dev = Tropic01::new(spi, MockWait::new()); let cpu = small_image(); let spect = small_image(); diff --git a/crates/tropic01-driver/src/device/commands.rs b/crates/tropic01-driver/src/device/commands.rs index 2232858..2a30e65 100644 --- a/crates/tropic01-driver/src/device/commands.rs +++ b/crates/tropic01-driver/src/device/commands.rs @@ -21,8 +21,7 @@ use crate::ids::CmdId; use crate::ids::L2ReqId; use crate::ids::L2Status; use crate::ids::L3Status; -use crate::l1; -use crate::l2::frame; +use crate::l2::retry; use crate::l3; use crate::parse::take; use crate::parse::take_array; @@ -1370,12 +1369,9 @@ where W: SeWait, { // Encrypted_Session_Abt_Req body is empty. REQ_LEN = 0, RSP carries no data. - let n = frame::build_request(L2ReqId::EncryptedSessionAbt as u8, &[], l2)?; - l1::send_request(spi, &l2[..n]).map_err(L2Error::from)?; - let frame_len = l1::read_response(spi, wait, l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&l2[..frame_len])?; + let resp = retry::exchange(spi, wait, l2, L2ReqId::EncryptedSessionAbt as u8, &[])?; // A successful abort is acknowledged with an empty RequestOk frame. - if !matches!(resp.status, L2Status::RequestOk) || !resp.data.is_empty() + if !matches!(resp.status, L2Status::RequestOk) || resp.data_len != 0 { return Err(SeError::L2(L2Error::BadFrame)); } diff --git a/crates/tropic01-driver/src/device/nosession.rs b/crates/tropic01-driver/src/device/nosession.rs index bc68489..bd4571e 100644 --- a/crates/tropic01-driver/src/device/nosession.rs +++ b/crates/tropic01-driver/src/device/nosession.rs @@ -22,6 +22,7 @@ use crate::ids::L2Status; use crate::ids::ObjectId; use crate::l1; use crate::l2::frame; +use crate::l2::retry; use crate::session::SessionKeys; use crate::wait::SeWait; @@ -95,7 +96,7 @@ where /// exactly as reading the device certificate to obtain STPUB does. /// /// A non-OK chip status surfaces as `SeError::L2(L2Error::Status(_))` via - /// `parse_response` and is recoverable by nature (no session state). A + /// the response parser and is recoverable by nature (no session state). A /// continuation status (`*Cont`) is anomalous for a single-frame `Get_Info` /// reply and is rejected as `L2Error::BadFrame`. `out` too small for the /// RSP_DATA returns `SeError::BufferTooSmall`. @@ -336,13 +337,16 @@ where { // Sleep_Req body = SLEEP_KIND(1). REQ_LEN = 1, RSP carries no data. let body = [SLEEP_KIND_SLEEP_MODE]; - let n = frame::build_request(L2ReqId::Sleep as u8, &body, &mut self.l2)?; - l1::send_request(&mut self.spi, &self.l2[..n]).map_err(L2Error::from)?; - let frame_len = - l1::read_response(&mut self.spi, &mut self.wait, &mut self.l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&self.l2[..frame_len])?; + let ack = retry::exchange + ( + &mut self.spi, + &mut self.wait, + &mut self.l2, + L2ReqId::Sleep as u8, + &body, + )?; // A successful Sleep_Req is acknowledged with an empty RequestOk frame. - if !matches!(resp.status, L2Status::RequestOk) || !resp.data.is_empty() + if !matches!(ack.status, L2Status::RequestOk) || ack.data_len != 0 { return Err(SeError::L2(L2Error::BadFrame)); } @@ -406,25 +410,30 @@ where pub fn get_log_into(&mut self, out: &mut [u8]) -> Result { // Get_Log_Req body is empty. REQ_LEN = 0. - let n = frame::build_request(L2ReqId::GetLog as u8, &[], &mut self.l2)?; - l1::send_request(&mut self.spi, &self.l2[..n]).map_err(L2Error::from)?; - let frame_len = - l1::read_response(&mut self.spi, &mut self.wait, &mut self.l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&self.l2[..frame_len])?; - // parse_response maps a non-OK chip status to L2Error::Status, but it - // still returns the data-bearing continuation statuses. A Get_Log reply - // is a single RequestOk frame, so a *Cont (or any other accepted status) - // is anomalous here and is rejected as BadFrame, like get_info_block. - if !matches!(resp.status, L2Status::RequestOk) + let info = retry::exchange + ( + &mut self.spi, + &mut self.wait, + &mut self.l2, + L2ReqId::GetLog as u8, + &[], + )?; + // The parser maps a non-OK chip status to L2Error::Status, but it still + // returns the data-bearing continuation statuses. A Get_Log reply is a + // single RequestOk frame, so a *Cont (or any other accepted status) is + // anomalous here and is rejected as BadFrame, like get_info_block. + if !matches!(info.status, L2Status::RequestOk) { return Err(SeError::L2(L2Error::BadFrame)); } - if out.len() < resp.data.len() + let data = frame::rsp_data(&self.l2, &info)?; + if out.len() < data.len() { return Err(SeError::BufferTooSmall); } - out[..resp.data.len()].copy_from_slice(resp.data); - Ok(resp.data.len()) + let dest = out.get_mut(..data.len()).ok_or(SeError::BufferTooSmall)?; + dest.copy_from_slice(data); + Ok(data.len()) } } @@ -557,17 +566,14 @@ where let mut body = [0u8; 33]; body[..32].copy_from_slice(ehpub); body[32] = cfg.pkey_index; - let n = frame::build_request(L2ReqId::Handshake as u8, &body, l2)?; - l1::send_request(spi, &l2[..n]).map_err(L2Error::from)?; - let frame_len = l1::read_response(spi, wait, l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&l2[..frame_len])?; + let info = retry::exchange(spi, wait, l2, L2ReqId::Handshake as u8, &body)?; // The handshake response is a single, complete frame. A continuation status // (`*Cont`) is anomalous here and must not be accepted. - if matches!(resp.status, L2Status::RequestCont | L2Status::ResultCont) + if matches!(info.status, L2Status::RequestCont | L2Status::ResultCont) { return Err(SeError::L2(L2Error::BadFrame)); } - let (etpub, t_tauth) = parse_handshake_resp(resp.data)?; + let (etpub, t_tauth) = parse_handshake_resp(frame::rsp_data(l2, &info)?)?; let keys = handshake::run ( cfg.ehpriv, @@ -613,15 +619,13 @@ where { // Startup_Req body = STARTUP_ID(1). REQ_LEN = 1, RSP carries no data. let body = [startup_id.wire_byte()]; - let n = frame::build_request(L2ReqId::Startup as u8, &body, l2)?; - l1::send_request(spi, &l2[..n]).map_err(L2Error::from)?; - let frame_len = l1::read_response(spi, wait, l2).map_err(L2Error::from)?; - // The Startup_Req response uses the errata-tolerant parser (see - // parse_startup_response): the reset can corrupt the second RSP_CRC byte, so - // only the first is validated here. Every other L2 response stays full-CRC. - let resp = frame::parse_startup_response(&l2[..frame_len])?; + // The Startup_Req response uses the errata-tolerant CRC rule (see + // frame::parse_startup_response): the reset can corrupt the second RSP_CRC + // byte, so only the first is validated. Every other L2 response stays + // full-CRC. + let ack = retry::exchange_startup(spi, wait, l2, L2ReqId::Startup as u8, &body)?; // A successful Startup_Req is acknowledged with an empty RequestOk frame. - if !matches!(resp.status, L2Status::RequestOk) || !resp.data.is_empty() + if !matches!(ack.status, L2Status::RequestOk) || ack.data_len != 0 { return Err(SeError::L2(L2Error::BadFrame)); } @@ -652,7 +656,7 @@ where /// # Errors /// /// A non-OK chip status surfaces as `SeError::L2(L2Error::Status(_))` via -/// `parse_response` and is recoverable by nature (no session state). A +/// the response parser and is recoverable by nature (no session state). A /// continuation status (`*Cont`) is anomalous for a single-frame `Get_Info` /// reply and is rejected as `L2Error::BadFrame`. `out` too small for the /// RSP_DATA returns `SeError::BufferTooSmall`. Otherwise `SeError` on a bus @@ -673,21 +677,20 @@ where { // Get_Info_Req body = OBJECT_ID(1) || BLOCK_INDEX(1). REQ_LEN = 2. let body = [object_id as u8, block_index]; - let n = frame::build_request(L2ReqId::GetInfo as u8, &body, l2)?; - l1::send_request(spi, &l2[..n]).map_err(L2Error::from)?; - let frame_len = l1::read_response(spi, wait, l2).map_err(L2Error::from)?; - let resp = frame::parse_response(&l2[..frame_len])?; + let info = retry::exchange(spi, wait, l2, L2ReqId::GetInfo as u8, &body)?; // A Get_Info reply fits one L2 chunk (RSP_DATA <= 128), so only a single // RequestOk frame is expected. *Cont (or any other accepted status) is a // malformed reply for this command. - if !matches!(resp.status, L2Status::RequestOk) + if !matches!(info.status, L2Status::RequestOk) { return Err(SeError::L2(L2Error::BadFrame)); } - if out.len() < resp.data.len() + let data = frame::rsp_data(l2, &info)?; + if out.len() < data.len() { return Err(SeError::BufferTooSmall); } - out[..resp.data.len()].copy_from_slice(resp.data); - Ok(resp.data.len()) + let dest = out.get_mut(..data.len()).ok_or(SeError::BufferTooSmall)?; + dest.copy_from_slice(data); + Ok(data.len()) } diff --git a/crates/tropic01-driver/src/device/tests.rs b/crates/tropic01-driver/src/device/tests.rs index dcc804a..3f0d62b 100644 --- a/crates/tropic01-driver/src/device/tests.rs +++ b/crates/tropic01-driver/src/device/tests.rs @@ -112,11 +112,31 @@ fn reboot_still_rejects_corrupt_first_crc_byte() let mut ack = l2_frame(L2Status::RequestOk as u8, &[]); let first = ack.len() - 2; ack[first] ^= 0xFF; // corrupt the first (high) CRC byte - let acks = std::vec![ack]; + let acks = std::vec![ack; 8]; let mut dev = Tropic01::new(RecordingSpi::new(acks), MockWait::new()); assert_eq!(dev.reboot(StartupId::Reboot), Err(SeError::L2(L2Error::Crc))); } +#[test] +fn reboot_on_a_silent_chip_reports_chip_busy() +{ + // One corrupt ack, then nothing, because the chip is + // in reset and no longer answers. + let mut ack = l2_frame(L2Status::RequestOk as u8, &[]); + let first = ack.len() - 2; + ack[first] ^= 0xFF; // corrupt the first (high) CRC byte + let acks = std::vec![ack]; + let mut dev = Tropic01::new(RecordingSpi::new(acks), MockWait::new()); + assert_eq!( + dev.reboot(StartupId::Reboot), + Err(SeError::L2(L2Error::L1(L1Error::ChipBusy))) + ); + // Exactly one Resend_Req followed the Startup_Req. + let writes = dev.spi_ref().writes(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[1].first().copied(), Some(L2ReqId::Resend as u8)); +} + #[test] fn maintenance_reboot_succeeds_when_chip_settles_into_maintenance() { @@ -185,7 +205,7 @@ fn sleep_succeeds_on_empty_request_ok_ack() fn sleep_disabled_is_recoverable() { // CFG_SLEEP_MODE off: the chip replies RespDisabled. No session exists, so - // this surfaces via parse_response as a recoverable L2 status error. + // this surfaces via the response parser as a recoverable L2 status error. let acks = std::vec![l2_frame(L2Status::RespDisabled as u8, &[])]; let mut dev = Tropic01::new(RecordingSpi::new(acks), MockWait::new()); assert_eq!(dev.sleep(), Err(SeError::L2(L2Error::Status(L2Status::RespDisabled)))); @@ -464,6 +484,95 @@ fn l2_crc_err_poisons_session() assert_session_lost_and_quiet(&mut dev); } +#[test] +fn transient_result_crc_error_is_recovered_by_a_resend() +{ + let mut dev = open(ChipFault::L2CrcErrOnce); + let mut out = [0u8; 16]; + assert_eq!(dev.ping_into(b"hi", &mut out), Ok(2)); + assert_eq!(&out[..2], b"hi"); + assert_eq!(dev.spi_ref().resend_request_count(), 1); + assert_eq!(dev.spi_ref().nonces(), (1, 1)); + assert_eq!(dev.ping_into(b"ok", &mut out), Ok(2)); + assert_eq!(dev.spi_ref().nonces(), (2, 2)); +} + +#[test] +fn transient_chip_crc_error_replays_the_identical_chunk() +{ + let mut dev = open(ChipFault::L2ChipCrcErrOnce); + let mut out = [0u8; 16]; + assert_eq!(dev.ping_into(b"hi", &mut out), Ok(2)); + assert_eq!(&out[..2], b"hi"); + assert_eq!(dev.spi_ref().resend_request_count(), 0); + assert_eq!(dev.spi_ref().nonces(), (1, 1)); +} + +#[test] +fn transient_chip_crc_error_replays_a_plain_l2_request() +{ + let mut dev = Tropic01::new + ( + ChipMockSpi::new + ( + vectors::KCMD, + vectors::KRES, + vectors::ETPUB, + vectors::T_TAUTH, + ChipFault::None, + ), + MockWait::new(), + ); + let payload = [0x5Au8; 128]; + dev.spi_mut() + .set_get_info(ObjectId::ChipId as u8, 0, &payload); + dev.spi_mut().set_get_info_fault(GetInfoFault::CrcErrStatusOnce); + let mut out = [0u8; 128]; + assert_eq!(dev.chip_id_into(&mut out), Ok(128)); + assert_eq!(out, payload); + assert_eq!(dev.spi_ref().resend_request_count(), 0); +} + +#[test] +fn transient_local_crc_error_resends_a_plain_l2_response() +{ + let mut dev = Tropic01::new + ( + ChipMockSpi::new + ( + vectors::KCMD, + vectors::KRES, + vectors::ETPUB, + vectors::T_TAUTH, + ChipFault::None, + ), + MockWait::new(), + ); + let payload = [0xA5u8; 128]; + dev.spi_mut() + .set_get_info(ObjectId::ChipId as u8, 0, &payload); + dev.spi_mut().set_get_info_fault(GetInfoFault::BadCrcOnce); + let mut out = [0u8; 128]; + assert_eq!(dev.chip_id_into(&mut out), Ok(128)); + assert_eq!(out, payload); + assert_eq!(dev.spi_ref().resend_request_count(), 1); +} + +#[test] +fn resend_returning_the_wrong_chunk_fails_closed_on_the_length_check() +{ + let mut dev = open(ChipFault::ResendWrongChunk); + let msg = [0x5Au8; 300]; + let mut out = [0u8; 300]; + assert_eq! + ( + dev.ping_into(&msg, &mut out), + Err(SeError::L3(L3Error::Oversize)) + ); + assert_eq!(dev.spi_ref().resend_request_count(), 1); + assert_session_lost_and_quiet(&mut dev); +} + #[test] fn alarm_poisons_session() { @@ -2207,7 +2316,7 @@ fn abort_session_bad_ack_still_wipes_and_returns_no_session() #[test] fn abort_session_chip_status_error_still_wipes_and_returns_no_session() { - // The chip replies a non-OK status: parse_response returns Err via `?`, a + // The chip replies a non-OK status: the parser returns Err via `?`, a // different failure path than the explicit ack check. The teardown still runs // (it precedes the notify), so the buffers all read zero. let acks = std::vec![l2_frame(L2Status::GenErr as u8, &[])]; @@ -3287,7 +3396,7 @@ fn get_info_error_status_is_recoverable() let mut dev = no_session(GetInfoFault::ErrorStatus); dev.spi_mut().set_get_info(ObjectId::ChipId as u8, 0, &[0u8; 128]); let mut out = [0u8; 128]; - // An L2 error status surfaces via parse_response, no session state. + // An L2 error status surfaces via the response parser, no session state. assert_eq!( dev.chip_id_into(&mut out), Err(SeError::L2(L2Error::Status(L2Status::UnknownErr))) diff --git a/crates/tropic01-driver/src/l2/frame.rs b/crates/tropic01-driver/src/l2/frame.rs index e3560b2..7788a32 100644 --- a/crates/tropic01-driver/src/l2/frame.rs +++ b/crates/tropic01-driver/src/l2/frame.rs @@ -19,19 +19,40 @@ use crate::parse::take; use crate::parse::take_array; use crate::parse::take_u8; -/// A parsed L2 response frame view. +/// Byte offset of RSP_DATA inside a response frame: STATUS(1) || RSP_LEN(1). +pub(crate) const RSP_DATA_OFFSET: usize = 2; + +/// A parsed L2 response frame. /// -/// Borrows the data slice out of the caller's frame buffer. The status is one -/// of the accepted framed-response variants (`RequestOk` / `ResultOk` / -/// `RequestCont` / `ResultCont`). The `*Cont` variants signal that more chunks -/// follow. The parser maps any error status to `L2Error::Status`. +/// The status is one of the accepted framed-response variants (`RequestOk` / +/// `ResultOk` / `RequestCont` / `ResultCont`). The `*Cont` variants signal that +/// more chunks follow. The parser maps any error status to `L2Error::Status`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct L2Response<'a> +pub(crate) struct L2ResponseInfo { /// The accepted status byte. pub(crate) status: L2Status, - /// The RSP_DATA payload (0..=252 bytes). - pub(crate) data: &'a [u8], + /// The RSP_DATA length in bytes (0..=252). + pub(crate) data_len: usize, +} + +/// Re-slices RSP_DATA out of `frame` for a previously parsed `info`. +/// +/// CALLER OBLIGATION: `info` must come from the last read performed into this +/// buffer. Nothing may write to `frame` between the parse and this call. +/// +/// Errors with `L2Error::ShortFrame` when the frame no longer holds the data. +pub(crate) fn rsp_data<'a> +( + frame: &'a [u8], + info: &L2ResponseInfo, +) +-> Result<&'a [u8], L2Error> +{ + // data_len <= 252 and the offset is 2, so the sum cannot overflow a usize. + frame + .get(RSP_DATA_OFFSET..RSP_DATA_OFFSET + info.data_len) + .ok_or(L2Error::ShortFrame) } /// Builds an L2 request frame into `out`. @@ -74,59 +95,57 @@ pub(crate) fn build_request } /// Selects how strictly the trailing RSP_CRC is validated. -/// -/// `Full` compares both CRC bytes and is the default for every L2 response. -/// `FirstByteOnly` compares only the first transmitted CRC byte (the high -/// byte) and is used solely for the `Startup_Req` response. See -/// `parse_startup_response` for the errata that motivates it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CrcCheck +#[derive(Debug, Clone, Copy)] +enum CrcCheck { /// Compare both RSP_CRC bytes (the strict default). Full, - /// Compare only the first RSP_CRC byte (Startup_Req errata workaround). - FirstByteOnly, + /// Allow the `Startup_Req` erratum frame, under libtropic's exact gate. + StartupErratum, } -/// Parses an L2 response frame out of `frame` with full CRC validation. +/// Parses an L2 response frame with full RSP_CRC validation. /// /// `frame` is the bytes AFTER the CHIP_STATUS byte, i.e. starting at STATUS. -/// Validates the length field, checks both CRC bytes, and maps non-OK statuses -/// to `L2Error::Status`. This is the strict path used by every response except -/// `Startup_Req`. +/// The strict rule every L2 response uses except the `Startup_Req` reply. /// /// Errors: /// - `L2Error::ShortFrame` when the slice is too short for the declared frame. /// - `L2Error::BadFrame` when RSP_LEN exceeds 252 or the status byte is unknown. /// - `L2Error::Crc` when the trailing CRC does not match. /// - `L2Error::Status(s)` for any non-OK chip status. -pub(crate) fn parse_response(frame: &[u8]) -> Result, L2Error> +pub(crate) fn parse_response(frame: &[u8]) -> Result { parse_response_with(frame, CrcCheck::Full) } -/// Parses a `Startup_Req` response, tolerating the RSP_CRC errata. +/// Parses a `Startup_Req` response, tolerating the RSP_CRC erratum. +/// +/// TROPIC01 mutable firmware up to 1.0.1 reboots after the host has read the +/// first RSP_CRC byte of a `Startup_Req` response, so the second byte can arrive +/// mangled. libtropic v2.0.0 tolerates that in `lt_l2_receive`, and only for the +/// exact frame the erratum produces. /// -/// TROPIC01 mutable firmware up to 1.0.1 has an erratum where the chip resets -/// after the host reads the FIRST RSP_CRC byte of a Startup_Req response, which -/// can corrupt the SECOND RSP_CRC byte. libtropic v2.0.0 mitigates this by -/// checking only the first CRC byte of the Startup_Req response. This helper -/// does the same: it validates only the first transmitted CRC byte (the high -/// byte) and ignores the second. Every other check (status byte, length, -/// bounds) is identical to the strict path. +/// This wrapper reproduces that gate condition for condition: the tolerance +/// applies only when the status is `RequestOk`, RSP_LEN is 0, and the first +/// RSP_CRC byte matches the value recomputed over the frame. Anything else falls +/// back to the strict rule, so a longer reply, an error status, or a wrong first +/// CRC byte is rejected exactly as on any other response. /// -/// Errors: same as `parse_response`, except `L2Error::Crc` fires only on a -/// first-CRC-byte mismatch. -pub(crate) fn parse_startup_response(frame: &[u8]) -> Result, L2Error> +/// Errors: same as `parse_response`, except that `L2Error::Crc` does not fire on +/// a second-CRC-byte mismatch of that one tolerated frame. +pub(crate) fn parse_startup_response(frame: &[u8]) -> Result { - parse_response_with(frame, CrcCheck::FirstByteOnly) + parse_response_with(frame, CrcCheck::StartupErratum) } -/// Shared response parser. `crc_check` selects full-vs-first-byte CRC. -/// -/// All callers reach this through `parse_response` (Full) or -/// `parse_startup_response` (FirstByteOnly). The relaxation is confined here. -fn parse_response_with(frame: &[u8], crc_check: CrcCheck) -> Result, L2Error> +/// Shared response parser. `crc_check` selects strict-vs-erratum RSP_CRC. +fn parse_response_with +( + frame: &[u8], + crc_check: CrcCheck, +) +-> Result { // STATUS then RSP_LEN. let (rest, status_byte) = take_u8(frame).map_err(|_| L2Error::ShortFrame)?; @@ -157,9 +176,8 @@ fn parse_response_with(frame: &[u8], crc_check: CrcCheck) -> Result { - // CRC covers STATUS + RSP_LEN + RSP_DATA = the first `2 + len` bytes. - // `get` keeps the bounds check on attacker-influenced input. - let covered = 2 + len; + // CRC covers STATUS + RSP_LEN + RSP_DATA. + let covered = RSP_DATA_OFFSET + len; let covered_bytes = frame.get(..covered).ok_or(L2Error::ShortFrame)?; // `crc16` returns the already-swapped value, so `to_be_bytes` yields // the on-wire pair [hi, lo] in the same order as `crc_bytes`. @@ -169,19 +187,31 @@ fn parse_response_with(frame: &[u8], crc_check: CrcCheck) -> Result crc_bytes == computed, - // Startup_Req errata: the premature reset can corrupt the second - // CRC byte, so only the first byte is trusted (as libtropic - // v2.0.0 does). The second byte is deliberately ignored here. - CrcCheck::FirstByteOnly => crc_bytes[0] == computed[0], + // libtropic `lt_l2_receive` gates the erratum tolerance on the + // whole frame shape, not on the CRC alone: REQUEST_OK status, + // RSP_LEN 0, and the expected first CRC byte. Reproduced here. + // Any other frame falls back to the strict comparison, so the + // relaxation cannot widen to a data-bearing reply. + CrcCheck::StartupErratum => + { + if status == L2Status::RequestOk && len == 0 + { + crc_bytes[0] == computed[0] + } + else + { + crc_bytes == computed + } + } }; if !crc_ok { return Err(L2Error::Crc); } - Ok(L2Response + Ok(L2ResponseInfo { status, - data, + data_len: data.len(), }) } other => Err(L2Error::Status(other)), @@ -213,7 +243,7 @@ mod tests frame[2 + data.len() + 1] = crc[1]; let resp = parse_response(&frame[..2 + data.len() + 2]).unwrap(); assert_eq!(resp.status, L2Status::ResultOk); - assert_eq!(resp.data, &data); + assert_eq!(rsp_data(&frame[..2 + data.len() + 2], &resp), Ok(&data[..])); } #[test] @@ -302,51 +332,93 @@ mod tests } } - /// Builds a valid framed response, then corrupts one CRC byte. + /// Builds a framed response with `status` and `data`, CRC included. /// - /// `corrupt_index` picks which CRC byte to flip (0 = first/high byte, + /// `corrupt_index` optionally flips one CRC byte (0 = first/high byte, /// 1 = second/low byte). Returns the frame buffer and its used length. - fn framed_response_with_corrupt_crc(corrupt_index: usize) -> ([u8; 16], usize) + fn framed_response + ( + status: L2Status, + data: &[u8], + corrupt_index: Option, + ) + -> ([u8; 16], usize) { - let data = [0x11u8, 0x22]; let mut frame = [0u8; 16]; - frame[0] = L2Status::RequestOk as u8; + frame[0] = status as u8; frame[1] = data.len() as u8; - frame[2] = data[0]; - frame[3] = data[1]; - let crc = crc_of(&frame[..4]); - frame[4] = crc[0]; - frame[5] = crc[1]; - frame[4 + corrupt_index] ^= 0xFF; - (frame, 6) + frame[2..2 + data.len()].copy_from_slice(data); + let crc = crc_of(&frame[..2 + data.len()]); + frame[2 + data.len()] = crc[0]; + frame[2 + data.len() + 1] = crc[1]; + if let Some(i) = corrupt_index + { + frame[2 + data.len() + i] ^= 0xFF; + } + (frame, 2 + data.len() + 2) + } + + /// The exact frame the Startup_Req erratum produces: RequestOk, RSP_LEN 0. + fn startup_ack(corrupt_index: Option) -> ([u8; 16], usize) + { + framed_response(L2Status::RequestOk, &[], corrupt_index) } #[test] fn startup_response_accepts_corrupt_second_crc_byte() { - // Startup_Req errata: the premature reset corrupts the SECOND CRC byte. - // FirstByteOnly must accept this, where the old Full check rejected it. - let (frame, n) = framed_response_with_corrupt_crc(1); + // Startup_Req erratum: the premature reset corrupts the SECOND CRC byte + // of an empty RequestOk ack. That one frame shape is tolerated. + let (frame, n) = startup_ack(Some(1)); // The strict path still rejects it, proving the test is non-vacuous. assert_eq!(parse_response(&frame[..n]), Err(L2Error::Crc)); let resp = parse_startup_response(&frame[..n]).unwrap(); assert_eq!(resp.status, L2Status::RequestOk); + assert_eq!(resp.data_len, 0); } #[test] fn startup_response_rejects_corrupt_first_crc_byte() { // Integrity is not fully abandoned: a corrupt FIRST CRC byte is still - // rejected under FirstByteOnly. - let (frame, n) = framed_response_with_corrupt_crc(0); + // rejected on the tolerated frame shape. + let (frame, n) = startup_ack(Some(0)); + assert_eq!(parse_startup_response(&frame[..n]), Err(L2Error::Crc)); + } + + #[test] + fn startup_tolerance_does_not_extend_to_a_data_bearing_reply() + { + // libtropic gates its tolerance on RSP_LEN == 0. A reply carrying + // RSP_DATA is not the erratum frame, so its second CRC byte still counts. + let (frame, n) = framed_response(L2Status::RequestOk, &[0x11, 0x22], Some(1)); assert_eq!(parse_startup_response(&frame[..n]), Err(L2Error::Crc)); } + #[test] + fn startup_tolerance_does_not_extend_to_a_continuation_status() + { + // libtropic gates on STATUS == REQUEST_OK. Any other accepted status + // stays under the strict comparison. + let (frame, n) = framed_response(L2Status::RequestCont, &[], Some(1)); + assert_eq!(parse_startup_response(&frame[..n]), Err(L2Error::Crc)); + } + + #[test] + fn startup_path_still_accepts_an_intact_data_bearing_reply() + { + // The tightened gate must not reject a clean frame: only the erratum + // tolerance narrows, the strict comparison is unchanged. + let (frame, n) = framed_response(L2Status::RequestOk, &[0x11, 0x22], None); + let resp = parse_startup_response(&frame[..n]).unwrap(); + assert_eq!(resp.data_len, 2); + } + #[test] fn full_path_still_rejects_corrupt_second_crc_byte() { // No regression: the relaxation must not leak into the normal path. - let (frame, n) = framed_response_with_corrupt_crc(1); + let (frame, n) = startup_ack(Some(1)); assert_eq!(parse_response(&frame[..n]), Err(L2Error::Crc)); } @@ -362,7 +434,7 @@ mod tests frame[2 + L2_CHUNK_MAX_DATA] = crc[0]; frame[2 + L2_CHUNK_MAX_DATA + 1] = crc[1]; let resp = parse_response(&frame[..2 + L2_CHUNK_MAX_DATA + 2]).unwrap(); - assert_eq!(resp.data.len(), L2_CHUNK_MAX_DATA); + assert_eq!(resp.data_len, L2_CHUNK_MAX_DATA); assert_eq!(resp.status, L2Status::RequestOk); } } diff --git a/crates/tropic01-driver/src/l2/mod.rs b/crates/tropic01-driver/src/l2/mod.rs index a9a5a84..70326c0 100644 --- a/crates/tropic01-driver/src/l2/mod.rs +++ b/crates/tropic01-driver/src/l2/mod.rs @@ -1,7 +1,10 @@ //! Layer 2: request/response framing over the L1 SPI transport. //! -//! `frame` builds/parses single frames and checks the CRC. `transport` drives -//! L1 to send chunked L3 packets and reassemble multi-frame results. +//! `frame` builds/parses single frames and checks the CRC. `retry` is the one +//! seam every exchange goes through, so a CRC fault is cured the same way on +//! the plain-L2 and the chunked-L3 path. `transport` drives L1 to send chunked +//! L3 packets and reassemble multi-frame results. pub(crate) mod frame; +pub(crate) mod retry; pub(crate) mod transport; diff --git a/crates/tropic01-driver/src/l2/retry.rs b/crates/tropic01-driver/src/l2/retry.rs new file mode 100644 index 0000000..6a4997c --- /dev/null +++ b/crates/tropic01-driver/src/l2/retry.rs @@ -0,0 +1,676 @@ +//! CRC retry seam shared by every L2 exchange. +//! +//! Two faults can hit one L2 frame, and they take opposite cures: +//! +//! - STATUS = CRC_ERR (0x7C), reported as `L2Error::Status(L2Status::CrcErr)`. +//! The chip rejected the CRC of the host request, so the request never ran. +//! The cure is to replay the whole exchange with the identical request bytes. +//! - A recomputed RSP_CRC mismatch, reported as `L2Error::Crc`. The host read a +//! corrupt response and the request may have run. The cure is a `Resend_Req` +//! (REQ_ID 0x10, REQ_LEN 0), never a replay, since replaying a request that +//! already executed would run it twice. +//! +//! A `Resend_Req` that itself draws a CRC_ERR status is sent again rather than +//! giving way to a replay. The corruption cannot be attributed to the original +//! frame or to the Resend frame, so the cure taken is the one that cannot +//! execute anything twice. +//! +//! Callers pass the request ingredients (REQ_ID plus body) rather than a built +//! frame, so a replay rebuilds the same bytes and the response read may +//! overwrite `l2`. +//! +//! The returned `L2ResponseInfo` borrows nothing. `exchange_within` and +//! `receive_within` run inside a caller loop that reads the next chunk into the +//! same frame buffer, where a returned borrow of `l2` would still be live. The +//! other entry points return the same type so the seam has one shape. RSP_DATA +//! is re-sliced out of `l2` with `frame::rsp_data`. +//! +//! One `RetryBudget` spans a whole chunked send or receive, so the retry cost of +//! one L3 packet is capped at `CRC_RETRY_ATTEMPTS` extra round-trips whatever +//! the packet length. That bound covers one packet and nothing wider: +//! `exchange_within` and `receive_within` are the entry points taking a +//! caller-owned budget, and the L3 chunked send and receive are their only +//! callers. `exchange` and `exchange_startup` mint a fresh budget per call, so a +//! sequence of L2 commands gets one allowance per command. A firmware image +//! relayed as one `Mutable_FW_Update` plus N `Mutable_FW_Update_Data` commands +//! owns N + 1 budgets, and its worst-case retry cost is linear in the image +//! size. Wall-time figures are in the crate-level error-path latency note. +//! +//! Source: libtropic `lt_l2_transfer`, `lt_l2_send_encrypted_cmd`, +//! `lt_l2_recv_encrypted_res`, and `lt_l2_resend_response`. Resend_Req syntax +//! from the TROPIC01 User API v1.4.0 (REQ_ID 0x10, REQ_LEN 0x00). + +use embedded_hal::spi::SpiDevice; + +use crate::error::L2Error; +use crate::ids::L2ReqId; +use crate::ids::L2Status; +use crate::l1; +use crate::l2::frame; +use crate::l2::frame::L2ResponseInfo; +use crate::wait::SeWait; + +/// How one response frame is parsed. +/// +/// Inhabited by the two named parsers in `frame`, the strict rule and the +/// `Startup_Req` erratum rule, and by nothing else. `exchange_startup` is the +/// one entry point that picks the erratum rule. +type ParseResponse = fn(&[u8]) -> Result; + +/// Retry attempts granted to one budget. +/// +/// Source: libtropic `LT_CRC_ERR_RETRY_ATTEMPTS`, whose default is 3. +const CRC_RETRY_ATTEMPTS: u32 = 3; + +/// A finite, never-refilled allowance of CRC retries. +/// +/// One budget covers both fault kinds, so a run of alternating faults draws on a +/// single allowance and cannot stretch the total. A single-frame command owns +/// one budget. A chunked send or receive owns one budget for all its chunks. +pub(crate) struct RetryBudget +{ + left: u32, +} + +impl RetryBudget +{ + /// Creates a full budget of `CRC_RETRY_ATTEMPTS` attempts. + pub(crate) const fn new() -> Self + { + RetryBudget + { + left: CRC_RETRY_ATTEMPTS, + } + } + + /// Spends one attempt, returning false when none is left. + /// + /// The one way to draw the count down, and nothing refills it, so every + /// loop guarded by this call terminates. + fn spend(&mut self) -> bool + { + if self.left == 0 + { + return false; + } + self.left -= 1; + true + } +} + +/// Sends one L2 request and returns the response, retrying on CRC faults. +/// +/// Owns a fresh `RetryBudget`, the entry point for a stand-alone single-frame +/// command. `l2` is the scratch frame buffer. The request is rebuilt from +/// `req_id` and `body` on every attempt, so the response read may overwrite +/// `l2`. The returned info borrows nothing: read RSP_DATA back out of `l2` with +/// `frame::rsp_data`. +/// +/// # Errors +/// +/// The first non-CRC error verbatim, or the CRC error that exhausted the retry +/// budget. The two CRC faults are the retried ones, nothing else. +pub(crate) fn exchange +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + req_id: u8, + body: &[u8], +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + exchange_with + ( + spi, + wait, + l2, + req_id, + body, + frame::parse_response, + &mut RetryBudget::new(), + ) +} + +/// Sends one L2 request against a caller-owned retry budget. +/// +/// Used by the chunked L3 send, where every chunk of one packet draws on the +/// same allowance. See `exchange` for the semantics of a single attempt. +/// +/// # Errors +/// +/// Same as `exchange`. +pub(crate) fn exchange_within +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + req_id: u8, + body: &[u8], + budget: &mut RetryBudget, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + exchange_with(spi, wait, l2, req_id, body, frame::parse_response, budget) +} + +/// Sends a `Startup_Req` exchange under the erratum-tolerant RSP_CRC rule. +/// +/// Identical to `exchange` except that the direct response to the `Startup_Req` +/// is parsed with `frame::parse_startup_response`, the errata workaround +/// documented there. The tolerance stays confined to this entry point, and +/// within it to the direct response: a `Resend_Req` issued while recovering is +/// a different request whose response the erratum does not cover, so that one +/// is parsed under the strict rule. +/// +/// # Errors +/// +/// Same as `exchange`, except that `L2Error::Crc` does not fire on a +/// second-CRC-byte mismatch of the tolerated frame. +pub(crate) fn exchange_startup +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + req_id: u8, + body: &[u8], +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + exchange_with + ( + spi, + wait, + l2, + req_id, + body, + frame::parse_startup_response, + &mut RetryBudget::new(), + ) +} + +/// Reads one response frame the host did not request, retrying on CRC faults. +/// +/// Used by the L3 result reassembly, where the chip streams chunk after chunk +/// with no request in between. Nothing can be replayed here, so both CRC faults +/// are cured with a `Resend_Req`. `budget` is caller-owned and shared by every +/// chunk of one result. +/// +/// # Errors +/// +/// The first non-CRC error verbatim, or the CRC error that exhausted the retry +/// budget. +pub(crate) fn receive_within +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + budget: &mut RetryBudget, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + match read_frame(spi, wait, l2, frame::parse_response) + { + Err(e @ (L2Error::Crc | L2Error::Status(L2Status::CrcErr))) => + { + resend_until_intact(spi, wait, l2, budget, e) + } + other => other, + } +} + +/// Shared exchange body. `parse` selects the strict or the erratum RSP_CRC rule. +/// +/// Termination: every loop turn either returns or spends one unit of `budget`, +/// which starts finite and never grows. At most `1 + CRC_RETRY_ATTEMPTS` +/// round-trips leave this function, fewer when the budget arrives part-spent. +fn exchange_with +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + req_id: u8, + body: &[u8], + parse: ParseResponse, + budget: &mut RetryBudget, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + loop + { + match send_and_read(spi, wait, l2, req_id, body, parse) + { + // The chip rejected the request CRC, so the request never ran. + // Rebuild the identical frame and replay, budget permitting. + Err(e @ L2Error::Status(L2Status::CrcErr)) => + { + if !budget.spend() + { + return Err(e); + } + } + // The response came back corrupt. The request may have run, so ask + // the chip to resend rather than replaying it. + Err(L2Error::Crc) => + { + return resend_until_intact(spi, wait, l2, budget, L2Error::Crc); + } + other => return other, + } + } +} + +/// Asks the chip to resend its last response until a frame arrives intact. +/// +/// `budget` is the caller's remaining allowance, so a run of alternating fault +/// kinds draws on one shared budget. `entry` is the CRC error that led here and +/// comes back unchanged when no attempt is left, so the caller sees the fault +/// it hit. +/// +/// The strict CRC rule applies here whatever the caller's own rule is. The +/// `Startup_Req` erratum covers the response to the `Startup_Req` itself, not +/// the response to a later `Resend_Req`, so the tolerance stops short of this +/// function. +/// +/// Termination: every loop turn spends one unit of `budget` and the loop ends +/// at zero. +fn resend_until_intact +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + budget: &mut RetryBudget, + entry: L2Error, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + let mut last = Err(entry); + while budget.spend() + { + // Resend_Req body is empty: REQ_ID 0x10, REQ_LEN 0x00 (TROPIC01 User API + // v1.4.0, libtropic `lt_l2_resend_response`). + last = send_and_read(spi, wait, l2, L2ReqId::Resend as u8, &[], frame::parse_response); + // A CRC_ERR status to a Resend_Req cannot be attributed to the original + // frame or to the Resend frame, so re-send the Resend_Req. Anything + // else, success or a real error, ends the loop. + if !matches!(last, Err(L2Error::Crc | L2Error::Status(L2Status::CrcErr))) + { + break; + } + } + last +} + +/// Builds `req_id || body` into `l2`, sends it, and reads the response back. +/// +/// One attempt, no retry logic. `l2` carries the request out and the response +/// back, so nothing survives the call except the returned info. +fn send_and_read +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + req_id: u8, + body: &[u8], + parse: ParseResponse, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + let n = frame::build_request(req_id, body, l2)?; + let request = l2.get(..n).ok_or(L2Error::ShortFrame)?; + l1::send_request(spi, request)?; + read_frame(spi, wait, l2, parse) +} + +/// Reads one response frame into `l2` and parses it with `parse`. +fn read_frame +( + spi: &mut SPI, + wait: &mut W, + l2: &mut [u8], + parse: ParseResponse, +) +-> Result +where + SPI: SpiDevice, + W: SeWait, +{ + let frame_len = l1::read_response(spi, wait, l2)?; + let response = l2.get(..frame_len).ok_or(L2Error::ShortFrame)?; + parse(response) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::buf::L2_FRAME_MAX; + use crate::test_support::l2_frame; + use crate::test_support::CrcFaultSpi; + use crate::test_support::CrcReply; + use crate::test_support::MockWait; + + /// A `Get_Info_Req` body: OBJECT_ID(1) || BLOCK_INDEX(1). + const GET_INFO_BODY: [u8; 2] = [0x01, 0x00]; + + /// The good reply every exchange test converges on. + fn ok_reply() -> std::vec::Vec + { + l2_frame(L2Status::RequestOk as u8, &[0xAA, 0xBB]) + } + + /// Repeats one scripted reply `n` times. + fn repeat(reply: CrcReply, frame: &[u8], n: usize) -> std::vec::Vec<(CrcReply, std::vec::Vec)> + { + (0..n).map(|_| (reply, frame.to_vec())).collect() + } + + /// Runs a `Get_Info` exchange against a scripted chip. + fn run_exchange + ( + script: std::vec::Vec<(CrcReply, std::vec::Vec)>, + ) + -> (Result, CrcFaultSpi) + { + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let r = exchange + ( + &mut spi, + &mut wait, + &mut l2, + L2ReqId::GetInfo as u8, + &GET_INFO_BODY, + ); + (r, spi) + } + + #[test] + fn exchange_replays_the_identical_request_on_a_chip_crc_error() + { + // Two CRC_ERR statuses, then a good reply. The retry must happen: three + // requests must reach the chip, all byte-identical Get_Info frames. + let mut script = repeat(CrcReply::ChipCrcErr, &[], 2); + script.push((CrcReply::Good, ok_reply())); + let (r, spi) = run_exchange(script); + assert_eq!(r.map(|i| i.data_len), Ok(2)); + assert_eq!(spi.reads(), 3); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::GetInfo as u8; 3]); + // A replay must re-send the same bytes, never a re-derived request. + assert_eq!(spi.requests()[0], spi.requests()[1]); + assert_eq!(spi.requests()[1], spi.requests()[2]); + } + + #[test] + fn exchange_stops_at_the_budget_on_a_chip_crc_error() + { + // The chip never accepts the request: exactly 1 + CRC_RETRY_ATTEMPTS + // attempts, then the CRC_ERR status surfaces. + let script = repeat(CrcReply::ChipCrcErr, &[], 16); + let (r, spi) = run_exchange(script); + assert_eq!(r, Err(L2Error::Status(L2Status::CrcErr))); + assert_eq!(spi.reads(), 1 + CRC_RETRY_ATTEMPTS as usize); + assert_eq!(spi.req_ids().len(), 1 + CRC_RETRY_ATTEMPTS as usize); + } + + #[test] + fn exchange_asks_for_a_resend_and_never_replays_on_a_local_crc_error() + { + // A corrupt response must not replay the request, which may have run. + // Resend_Req frames are the only ones allowed to follow it. + let good = ok_reply(); + let mut script = repeat(CrcReply::HostCrcErr, &good, 2); + script.push((CrcReply::Good, good.clone())); + let (r, spi) = run_exchange(script); + assert_eq!(r.map(|i| i.data_len), Ok(2)); + assert_eq!( + spi.req_ids(), + std::vec![L2ReqId::GetInfo as u8, L2ReqId::Resend as u8, L2ReqId::Resend as u8] + ); + } + + #[test] + fn a_resend_request_is_the_byte_exact_protocol_frame() + { + // Every other test here reads REQ_ID alone, so a wrong REQ_LEN or a + // stray body byte would pass unnoticed and bite on real silicon, where + // a chip that rejects a malformed Resend_Req kills the CRC recovery. + // Pin the complete frame instead. + let good = ok_reply(); + let script = std::vec![ + (CrcReply::HostCrcErr, good.clone()), + (CrcReply::Good, good.clone()), + ]; + let (r, spi) = run_exchange(script); + assert_eq!(r.map(|i| i.data_len), Ok(2)); + assert_eq!(spi.requests().len(), 2); + // REQ_ID 0x10, REQ_LEN 0x00, then the CRC over those two bytes, 0x03E0, + // high byte first (TROPIC01 User API v1.4.0, libtropic + // `lt_l2_resend_response`). + assert_eq!(spi.requests()[1], std::vec![0x10u8, 0x00, 0x03, 0xE0]); + } + + #[test] + fn exchange_stops_at_the_budget_on_a_local_crc_error() + { + let good = ok_reply(); + let script = repeat(CrcReply::HostCrcErr, &good, 16); + let (r, spi) = run_exchange(script); + assert_eq!(r, Err(L2Error::Crc)); + // One original request plus exactly CRC_RETRY_ATTEMPTS Resend_Req. + let mut expected = std::vec![L2ReqId::GetInfo as u8]; + expected.extend(std::iter::repeat_n( + L2ReqId::Resend as u8, + CRC_RETRY_ATTEMPTS as usize, + )); + assert_eq!(spi.req_ids(), expected); + } + + #[test] + fn exchange_resends_again_when_a_resend_draws_a_chip_crc_error() + { + // A CRC_ERR to a Resend_Req must produce another Resend_Req, never a + // replay of the original request. + let good = ok_reply(); + let script = std::vec![ + (CrcReply::HostCrcErr, good.clone()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, good.clone()), + ]; + let (r, spi) = run_exchange(script); + assert_eq!(r.map(|i| i.data_len), Ok(2)); + assert_eq!( + spi.req_ids(), + std::vec![L2ReqId::GetInfo as u8, L2ReqId::Resend as u8, L2ReqId::Resend as u8] + ); + } + + #[test] + fn exchange_budget_is_shared_across_alternating_fault_kinds() + { + // Alternating faults must not stretch the budget: the total stays at + // 1 + CRC_RETRY_ATTEMPTS round-trips, and the run terminates. + let good = ok_reply(); + let script = std::vec![ + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::HostCrcErr, good.clone()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::HostCrcErr, good.clone()), + (CrcReply::Good, good.clone()), + ]; + let (r, spi) = run_exchange(script); + assert_eq!(r, Err(L2Error::Crc)); + assert_eq!(spi.reads(), 1 + CRC_RETRY_ATTEMPTS as usize); + assert_eq!( + spi.req_ids(), + std::vec![ + L2ReqId::GetInfo as u8, + L2ReqId::GetInfo as u8, + L2ReqId::Resend as u8, + L2ReqId::Resend as u8 + ] + ); + } + + #[test] + fn exchange_never_retries_a_non_crc_error() + { + // TAG_ERR is a real answer, not a link fault. One attempt, no retry. + let script = std::vec![(CrcReply::Status(L2Status::TagErr as u8), std::vec::Vec::new())]; + let (r, spi) = run_exchange(script); + assert_eq!(r, Err(L2Error::Status(L2Status::TagErr))); + assert_eq!(spi.reads(), 1); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::GetInfo as u8]); + } + + #[test] + fn exchange_succeeds_without_any_retry_when_the_link_is_clean() + { + // Guards the tests above against a mock that always retries: a clean + // link must cost exactly one request and one read. + let script = std::vec![(CrcReply::Good, ok_reply())]; + let (r, spi) = run_exchange(script); + assert_eq!(r.map(|i| i.data_len), Ok(2)); + assert_eq!(spi.reads(), 1); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::GetInfo as u8]); + } + + /// The empty RequestOk ack a `Startup_Req` draws. + fn startup_ack() -> std::vec::Vec + { + l2_frame(L2Status::RequestOk as u8, &[]) + } + + /// Runs a `Startup_Req` exchange against a scripted chip. + fn run_startup + ( + script: std::vec::Vec<(CrcReply, std::vec::Vec)>, + ) + -> (Result, CrcFaultSpi) + { + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let r = exchange_startup(&mut spi, &mut wait, &mut l2, L2ReqId::Startup as u8, &[0x01]); + (r, spi) + } + + #[test] + fn startup_tolerates_a_corrupt_second_crc_byte_on_its_own_response() + { + // The erratum frame itself: one request, one read, accepted. + let (r, spi) = run_startup(std::vec![(CrcReply::HostCrcErr, startup_ack())]); + assert_eq!(r.map(|i| i.status), Ok(L2Status::RequestOk)); + assert_eq!(spi.reads(), 1); + assert!(spi.req_ids().iter().all(|&id| id == L2ReqId::Startup as u8)); + } + + #[test] + fn startup_does_not_extend_its_tolerance_to_a_resend_response() + { + // The erratum covers the response to the Startup_Req, not the response + // to a Resend_Req issued while recovering from it. A first response + // whose first CRC byte is corrupt (rejected by both rules) sends a + // Resend_Req, and the resend's own response comes back with a corrupt + // second CRC byte. That one must be refused, which forces a further + // Resend_Req. Propagating the tolerance would accept it after two reads. + let script = std::vec![ + (CrcReply::HostCrcErrFirstByte, startup_ack()), + (CrcReply::HostCrcErr, startup_ack()), + (CrcReply::Good, startup_ack()), + ]; + let (r, spi) = run_startup(script); + assert_eq!(r.map(|i| i.status), Ok(L2Status::RequestOk)); + assert_eq!(spi.reads(), 3); + assert_eq!( + spi.req_ids(), + std::vec![L2ReqId::Startup as u8, L2ReqId::Resend as u8, L2ReqId::Resend as u8] + ); + } + + /// Runs a bare `receive` against a scripted chip. + fn run_receive + ( + script: std::vec::Vec<(CrcReply, std::vec::Vec)>, + ) + -> (Result, CrcFaultSpi) + { + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let r = receive_within(&mut spi, &mut wait, &mut l2, &mut RetryBudget::new()); + (r, spi) + } + + #[test] + fn receive_asks_for_a_resend_on_a_local_crc_error() + { + let good = l2_frame(L2Status::ResultOk as u8, &[0x11, 0x22, 0x33]); + let script = std::vec![ + (CrcReply::HostCrcErr, good.clone()), + (CrcReply::Good, good.clone()), + ]; + let (r, spi) = run_receive(script); + assert_eq!(r.map(|i| i.data_len), Ok(3)); + assert_eq!(spi.reads(), 2); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::Resend as u8]); + } + + #[test] + fn receive_stops_at_the_budget() + { + let good = l2_frame(L2Status::ResultOk as u8, &[0x11]); + let script = repeat(CrcReply::HostCrcErr, &good, 16); + let (r, spi) = run_receive(script); + assert_eq!(r, Err(L2Error::Crc)); + assert_eq!(spi.reads(), 1 + CRC_RETRY_ATTEMPTS as usize); + assert_eq!(spi.req_ids().len(), CRC_RETRY_ATTEMPTS as usize); + } + + #[test] + fn receive_treats_a_chip_crc_status_as_a_resend_trigger() + { + // On a receive there is no request to replay, so a CRC_ERR status is + // cured with a Resend_Req like a local CRC fault. + let good = l2_frame(L2Status::ResultOk as u8, &[0x77]); + let script = std::vec![ + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, good.clone()), + ]; + let (r, spi) = run_receive(script); + assert_eq!(r.map(|i| i.data_len), Ok(1)); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::Resend as u8]); + } + + #[test] + fn receive_never_retries_a_non_crc_error() + { + let script = std::vec![(CrcReply::Status(L2Status::NoSession as u8), std::vec::Vec::new())]; + let (r, spi) = run_receive(script); + assert_eq!(r, Err(L2Error::Status(L2Status::NoSession))); + assert_eq!(spi.reads(), 1); + assert!(spi.req_ids().is_empty()); + } +} diff --git a/crates/tropic01-driver/src/l2/transport.rs b/crates/tropic01-driver/src/l2/transport.rs index f65eef3..d3733f6 100644 --- a/crates/tropic01-driver/src/l2/transport.rs +++ b/crates/tropic01-driver/src/l2/transport.rs @@ -12,8 +12,9 @@ use crate::buf::L2_CHUNK_MAX_DATA; use crate::error::L2Error; use crate::ids::L2ReqId; use crate::ids::L2Status; -use crate::l1; use crate::l2::frame; +use crate::l2::retry; +use crate::l2::retry::RetryBudget; use crate::wait::SeWait; /// Maximum number of result chunks to reassemble. @@ -29,6 +30,9 @@ const RECV_MAX_CHUNKS: usize = 42; /// `RequestCont` while more chunks are expected and `RequestOk` on the last, but /// this matches libtropic in not enforcing which one arrives on which chunk). /// Any other status aborts the send. +/// +/// CRC faults are cured by `retry`, under one budget shared by every chunk of +/// this packet. pub(crate) fn send_encrypted ( spi: &mut SPI, @@ -45,18 +49,26 @@ where { return Err(L2Error::BadFrame); } + let mut budget = RetryBudget::new(); let mut offset = 0usize; while offset < packet.len() { let remaining = packet.len() - offset; let chunk_len = remaining.min(L2_CHUNK_MAX_DATA); - let chunk = &packet[offset..offset + chunk_len]; - let n = frame::build_request(L2ReqId::EncryptedCmd as u8, chunk, l2)?; - l1::send_request(spi, &l2[..n])?; - let frame_len = l1::read_response(spi, wait, l2)?; - let resp = frame::parse_response(&l2[..frame_len])?; + let chunk = packet + .get(offset..offset + chunk_len) + .ok_or(L2Error::BadFrame)?; + let ack = retry::exchange_within + ( + spi, + wait, + l2, + L2ReqId::EncryptedCmd as u8, + chunk, + &mut budget, + )?; offset += chunk_len; - match resp.status + match ack.status { L2Status::RequestCont | L2Status::RequestOk => {} @@ -71,6 +83,8 @@ where /// Reads `RESULT_CONT` frames until a `RESULT_OK` frame ends the result. The /// running length is checked against `l3` on every chunk, and the chunk count /// is capped at `RECV_MAX_CHUNKS`. +/// +/// A corrupt chunk is recovered with a `Resend_Req`. pub(crate) fn recv_encrypted ( spi: &mut SPI, @@ -83,6 +97,7 @@ where SPI: SpiDevice, W: SeWait, { + let mut budget = RetryBudget::new(); let mut total = 0usize; let mut chunks = 0usize; loop @@ -91,18 +106,18 @@ where { return Err(L2Error::BadFrame); } - let frame_len = l1::read_response(spi, wait, l2)?; - let resp = frame::parse_response(&l2[..frame_len])?; - let data = resp.data; + let info = retry::receive_within(spi, wait, l2, &mut budget)?; + let data = frame::rsp_data(l2, &info)?; let end = total.checked_add(data.len()).ok_or(L2Error::BadFrame)?; if end > l3.len() { return Err(L2Error::BadFrame); } - l3[total..end].copy_from_slice(data); + let dest = l3.get_mut(total..end).ok_or(L2Error::BadFrame)?; + dest.copy_from_slice(data); total = end; chunks += 1; - match resp.status + match info.status { L2Status::ResultCont => continue, L2Status::ResultOk => return Ok(total), @@ -118,6 +133,8 @@ mod tests use crate::buf::L2_FRAME_MAX; use crate::buf::L3_FRAME_MAX; use crate::test_support::l2_frame; + use crate::test_support::CrcFaultSpi; + use crate::test_support::CrcReply; use crate::test_support::MockWait; use crate::test_support::RecordingSpi; use crate::test_support::ScriptedSpi; @@ -245,6 +262,201 @@ mod tests ); } + /// Builds a `packet.len()`-byte L3 packet spanning three chunks. + fn three_chunk_packet() -> std::vec::Vec + { + (0..2 * L2_CHUNK_MAX_DATA + 100) + .map(|i| (i % 251) as u8) + .collect() + } + + /// An empty `RequestCont` ack frame. + fn cont_ack() -> std::vec::Vec + { + l2_frame(L2Status::RequestCont as u8, &[]) + } + + /// An empty `RequestOk` ack frame. + fn ok_ack() -> std::vec::Vec + { + l2_frame(L2Status::RequestOk as u8, &[]) + } + + /// Runs `send_encrypted` for `packet` against a scripted chip. + fn run_send + ( + packet: &[u8], + script: std::vec::Vec<(CrcReply, std::vec::Vec)>, + ) + -> (Result<(), L2Error>, CrcFaultSpi) + { + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let r = send_encrypted(&mut spi, &mut wait, &mut l2, packet); + (r, spi) + } + + #[test] + fn send_replays_the_identical_chunk_on_a_chip_crc_error() + { + let packet = three_chunk_packet(); + let script = std::vec![ + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, cont_ack()), + (CrcReply::Good, cont_ack()), + (CrcReply::Good, ok_ack()), + ]; + let (r, spi) = run_send(&packet, script); + assert_eq!(r, Ok(())); + assert_eq!(spi.requests().len(), 4); + assert_eq!(spi.requests()[0], spi.requests()[1]); + assert!(spi.req_ids().iter().all(|&id| id == L2ReqId::EncryptedCmd as u8)); + } + + #[test] + fn send_shares_one_retry_budget_across_every_chunk() + { + // DELIBERATE DEVIATION from libtropic, which resets its retry counter on + // every successful chunk. Here one budget covers the whole packet. Three + // faults spread over three chunks land exactly on the budget and still + // succeed. + let packet = three_chunk_packet(); + let script = std::vec![ + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, cont_ack()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, cont_ack()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, ok_ack()), + ]; + let (r, spi) = run_send(&packet, script); + assert_eq!(r, Ok(())); + assert_eq!(spi.reads(), 6); + } + + #[test] + fn send_fails_when_the_shared_budget_runs_out_mid_packet() + { + // One fault more than the budget, spread across chunks. libtropic would + // ACCEPT this run because its counter restarts on each good chunk. The + // shared budget refuses it, which is the point of the deviation: the + // worst-case retry count cannot grow with the message length. + let packet = three_chunk_packet(); + let script = std::vec![ + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, cont_ack()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, cont_ack()), + (CrcReply::ChipCrcErr, std::vec::Vec::new()), + (CrcReply::Good, ok_ack()), + ]; + let (r, spi) = run_send(&packet, script); + assert_eq!(r, Err(L2Error::Status(L2Status::CrcErr))); + // It stopped on the fourth fault and never reached the trailing ack. + assert_eq!(spi.reads(), 6); + } + + #[test] + fn recv_recovers_a_corrupt_chunk_via_resend_and_reassembles_in_order() + { + let c1 = l2_frame(L2Status::ResultCont as u8, &[0xAAu8; 8]); + let c2 = l2_frame(L2Status::ResultOk as u8, &[0xBBu8; 4]); + let script = std::vec![ + (CrcReply::Good, c1.clone()), + (CrcReply::HostCrcErr, c2.clone()), + (CrcReply::Good, c2.clone()), + ]; + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let mut l3 = [0u8; L3_FRAME_MAX]; + let n = recv_encrypted(&mut spi, &mut wait, &mut l2, &mut l3); + assert_eq!(n, Ok(12)); + assert_eq!(&l3[..8], &[0xAAu8; 8]); + assert_eq!(&l3[8..12], &[0xBBu8; 4]); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::Resend as u8]); + } + + #[test] + fn send_cures_a_corrupt_ack_with_a_resend_against_the_shared_budget() + { + let packet = three_chunk_packet(); + let script = std::vec![ + (CrcReply::HostCrcErr, cont_ack()), + (CrcReply::Good, cont_ack()), + (CrcReply::Good, cont_ack()), + (CrcReply::Good, ok_ack()), + ]; + let (r, spi) = run_send(&packet, script); + assert_eq!(r, Ok(())); + assert_eq!( + spi.req_ids(), + std::vec![ + L2ReqId::EncryptedCmd as u8, + L2ReqId::Resend as u8, + L2ReqId::EncryptedCmd as u8, + L2ReqId::EncryptedCmd as u8 + ] + ); + assert_eq!(spi.reads(), 4); + } + + #[test] + fn recv_fails_when_the_shared_budget_runs_out_across_chunks() + { + let c1 = l2_frame(L2Status::ResultCont as u8, &[0x11u8; 4]); + let c2 = l2_frame(L2Status::ResultCont as u8, &[0x22u8; 4]); + let c3 = l2_frame(L2Status::ResultCont as u8, &[0x33u8; 4]); + let c4 = l2_frame(L2Status::ResultOk as u8, &[0x44u8; 4]); + let script = std::vec![ + (CrcReply::HostCrcErr, c1.clone()), + (CrcReply::Good, c1.clone()), + (CrcReply::HostCrcErr, c2.clone()), + (CrcReply::Good, c2.clone()), + (CrcReply::HostCrcErr, c3.clone()), + (CrcReply::Good, c3.clone()), + (CrcReply::HostCrcErr, c4.clone()), + (CrcReply::Good, c4.clone()), + ]; + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let mut l3 = [0u8; L3_FRAME_MAX]; + assert_eq!( + recv_encrypted(&mut spi, &mut wait, &mut l2, &mut l3), + Err(L2Error::Crc) + ); + assert_eq!(spi.reads(), 7); + assert_eq!(spi.req_ids(), std::vec![L2ReqId::Resend as u8; 3]); + } + + #[test] + fn recv_shared_budget_absorbs_exactly_three_faults_across_chunks() + { + let c1 = l2_frame(L2Status::ResultCont as u8, &[0x11u8; 4]); + let c2 = l2_frame(L2Status::ResultCont as u8, &[0x22u8; 4]); + let c3 = l2_frame(L2Status::ResultOk as u8, &[0x33u8; 4]); + let script = std::vec![ + (CrcReply::HostCrcErr, c1.clone()), + (CrcReply::Good, c1.clone()), + (CrcReply::HostCrcErr, c2.clone()), + (CrcReply::Good, c2.clone()), + (CrcReply::HostCrcErr, c3.clone()), + (CrcReply::Good, c3.clone()), + ]; + let mut spi = CrcFaultSpi::new(script); + let mut wait = MockWait::new(); + let mut l2 = [0u8; L2_FRAME_MAX]; + let mut l3 = [0u8; L3_FRAME_MAX]; + assert_eq!(recv_encrypted(&mut spi, &mut wait, &mut l2, &mut l3), Ok(12)); + assert_eq!(&l3[..4], &[0x11u8; 4]); + assert_eq!(&l3[4..8], &[0x22u8; 4]); + assert_eq!(&l3[8..12], &[0x33u8; 4]); + assert_eq!(spi.reads(), 6); + } + /// Collects a fixed set of frames into the owned vec `ScriptedSpi` expects. fn alloc_frames(frames: [std::vec::Vec; N]) -> std::vec::Vec> { diff --git a/crates/tropic01-driver/src/lib.rs b/crates/tropic01-driver/src/lib.rs index 3f8ccd1..96e45c8 100644 --- a/crates/tropic01-driver/src/lib.rs +++ b/crates/tropic01-driver/src/lib.rs @@ -22,6 +22,25 @@ //! the libFuzzer harnesses. `model-itest` compiles the live integration tests //! that run against the official TROPIC01 emulator. //! +//! # Error-path latency +//! +//! Every L2 exchange runs through a CRC-retry seam. A CRC fault buys up to 3 +//! extra round-trips, and each round-trip owns a full chip-response poll budget +//! of 50 polls spaced 25 ms apart. One exchange is therefore bounded by +//! `(1 + 3) * 50 * 25 = 5000 ms`, against 1250 ms for a single budget. That +//! 4-budget shape is the chip answering just before each budget expires. A chip +//! that goes silent after one corrupt frame is cheaper, 2 budgets and 2500 ms, +//! because the first unanswered budget ends the retry loop. Both shapes are +//! reachable, so 5000 ms is the number to size against. The success path is +//! untouched: one request, one read. +//! +//! That bound is PER EXCHANGE, not per call. A call built from N L2 exchanges +//! multiplies it. Reading the X.509 store is 30 `Get_Info` blocks. A firmware +//! image is relayed as one `Mutable_FW_Update` plus one `Mutable_FW_Update_Data` +//! per chunk, so its worst case grows linearly with the image size. An +//! integrator sizing a transport deadline, a USB stack for instance, sizes it on +//! the number of exchanges the call makes, not on the call. +//! //! # Example //! //! Open a secure channel and run one L3 command. The chip wiring (the SPI bus diff --git a/crates/tropic01-driver/src/session.rs b/crates/tropic01-driver/src/session.rs index 44bd798..7494c82 100644 --- a/crates/tropic01-driver/src/session.rs +++ b/crates/tropic01-driver/src/session.rs @@ -169,3 +169,58 @@ impl SessionKeys self.res_nonce = NonceCounter::from_value(res); } } + +#[cfg(test)] +mod tests +{ + use super::*; + + /// Plaintext length of the sealed frame the helpers below build. + const PLAIN_LEN: usize = 4; + + /// Seals a `PLAIN_LEN`-byte frame and returns the keys, buffer, wire length. + fn sealed_frame() -> (SessionKeys, [u8; 64], usize) + { + let mut keys = SessionKeys::new([0x11u8; 32], [0x11u8; 32]); + let mut l3 = [0u8; 64]; + l3[2..2 + PLAIN_LEN].copy_from_slice(&[0xA1, 0xA2, 0xA3, 0xA4]); + let wire = keys.seal_command(&mut l3, PLAIN_LEN).unwrap(); + (keys, l3, wire) + } + + #[test] + fn open_result_accepts_the_exact_wire_length() + { + let (mut keys, mut l3, wire) = sealed_frame(); + assert_eq!(wire, 2 + PLAIN_LEN + 16); + assert_eq!(keys.open_result(&mut l3, wire), Ok(PLAIN_LEN)); + } + + #[test] + fn open_result_rejects_a_wire_length_longer_than_the_declared_size() + { + let (mut keys, mut l3, wire) = sealed_frame(); + assert_eq!( + keys.open_result(&mut l3, wire + 1), + Err(SeError::L3(L3Error::Oversize)) + ); + } + + #[test] + fn open_result_rejects_a_wire_length_shorter_than_the_declared_size() + { + let (mut keys, mut l3, wire) = sealed_frame(); + assert_eq!( + keys.open_result(&mut l3, wire - 1), + Err(SeError::L3(L3Error::Oversize)) + ); + } + + #[test] + fn open_result_leaves_the_nonce_untouched_on_a_length_rejection() + { + let (mut keys, mut l3, wire) = sealed_frame(); + let _ = keys.open_result(&mut l3, wire + 1); + assert_eq!(keys.open_result(&mut l3, wire), Ok(PLAIN_LEN)); + } +} diff --git a/crates/tropic01-driver/src/test_support.rs b/crates/tropic01-driver/src/test_support.rs index 632d3a7..b97237f 100644 --- a/crates/tropic01-driver/src/test_support.rs +++ b/crates/tropic01-driver/src/test_support.rs @@ -245,8 +245,14 @@ pub(crate) enum ChipFault BadResultTag, /// Return an L2 TAG_ERR status instead of the result. L2TagErr, - /// Corrupt the result frame CRC. + /// Corrupt the result frame CRC on every delivery (a permanent fault). L2CrcErr, + /// Corrupt the result frame CRC on the first delivery only. + L2CrcErrOnce, + /// Answer the first encrypted-command chunk with STATUS = CRC_ERR. + L2ChipCrcErrOnce, + /// Answer a `Resend_Req` with the wrong result chunk. + ResendWrongChunk, /// Raise the CHIP_STATUS ALARM bit on the result read. Alarm, /// Seal a valid result whose RESULT status is FAIL (recoverable). @@ -328,6 +334,10 @@ pub(crate) enum GetInfoFault /// Queue no response (the read path then sees STATUS = 0xFF, the NO_RESP /// sentinel). NoResp, + /// Reply with STATUS = CRC_ERR once, then serve the object faithfully. + CrcErrStatusOnce, + /// Corrupt the reply frame CRC once, then serve the object faithfully. + BadCrcOnce, /// Reply with a valid-CRC frame carrying a RequestCont (more-chunks) status. /// /// A single-frame Get_Info reply must be RequestOk. A *Cont status is a @@ -340,6 +350,8 @@ pub(crate) enum GetInfoFault enum Pending { Frame(Vec), + /// A frame whose last RSP_CRC byte is flipped on this delivery only. + CorruptOnce(Vec), Alarm, } @@ -411,6 +423,10 @@ pub(crate) struct ChipMockSpi get_info_objects: BTreeMap<(u8, u8), Vec>, get_info_fault: GetInfoFault, last_cmd: Vec, + resend_frame: Option>, + wrong_resend_frame: Option>, + once_fault_used: bool, + resend_requests: usize, } impl ChipMockSpi @@ -450,9 +466,19 @@ impl ChipMockSpi last_cmd: Vec::new(), get_info_objects: BTreeMap::new(), get_info_fault: GetInfoFault::None, + resend_frame: None, + wrong_resend_frame: None, + once_fault_used: false, + resend_requests: 0, } } + /// How many `Resend_Req` frames the mock has received. + pub(crate) fn resend_request_count(&self) -> usize + { + self.resend_requests + } + /// Sets the RSP_DATA the mock returns for `Get_Info(object_id, block_index)`. /// /// An object/block left unset replies with the configured fault, or (with @@ -581,48 +607,93 @@ impl ChipMockSpi } else if id == L2ReqId::Handshake as u8 { - let mut body = Vec::with_capacity(48); - body.extend_from_slice(&self.etpub); - body.extend_from_slice(&self.t_tauth); - self.pending - .push_back(Pending::Frame(Self::frame(L2Status::ResultOk as u8, &body))); + self.handle_handshake(); + } + else if id == L2ReqId::Resend as u8 + { + self.handle_resend(); } else if id == L2ReqId::EncryptedCmd as u8 { - // The real chip caps each request chunk at L2_CHUNK_MAX_DATA. A - // driver that sent an over-large chunk would split the wire packet - // wrong, so reject it here and fail the round-trip. This makes the - // multi-chunk send path prove chunk-cap compliance, not just byte - // reassembly. - if len > crate::buf::L2_CHUNK_MAX_DATA - { - self.pending - .push_back(Pending::Frame(Self::frame(L2Status::GenErr as u8, &[]))); - self.accum.clear(); - return; - } - self.accum.extend_from_slice(data); - // Need the 2-byte CMD_SIZE before completeness can be judged. - if self.accum.len() < 2 - { - self.pending - .push_back(Pending::Frame(Self::frame(L2Status::RequestCont as u8, &[]))); - return; - } - let cmd_size = u16::from_le_bytes([self.accum[0], self.accum[1]]) as usize; - let total = 2 + cmd_size + 16; - if self.accum.len() < total - { - self.pending - .push_back(Pending::Frame(Self::frame(L2Status::RequestCont as u8, &[]))); - return; - } - // Final chunk: ack, then produce the result. + self.handle_encrypted_cmd(len, data); + } + } + + /// Queues the `Handshake` reply `ETPUB || T_TAUTH` in a single OK frame. + fn handle_handshake(&mut self) + { + let mut body = Vec::with_capacity(48); + body.extend_from_slice(&self.etpub); + body.extend_from_slice(&self.t_tauth); + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::ResultOk as u8, &body))); + } + + /// Replays the buffered frames for a `Resend_Req` and counts the request. + /// + /// The wrong-chunk frame is queued last so it reaches the host first, which + /// is what the `ResendWrongChunk` fault models. + fn handle_resend(&mut self) + { + self.resend_requests += 1; + if let Some(f) = self.resend_frame.clone() + { + self.pending.push_front(Pending::Frame(f)); + } + if let Some(w) = self.wrong_resend_frame.take() + { + self.pending.push_front(Pending::Frame(w)); + } + } + + /// Accumulates one `Encrypted_Cmd_Req` chunk and answers it. + /// + /// `len` is the frame LEN field, `data` its REQ_DATA. Replies RequestCont + /// while the command is incomplete, then RequestOk on the final chunk before + /// producing the result. Rejects an over-long chunk and the injected chip-side + /// CRC error. + fn handle_encrypted_cmd(&mut self, len: usize, data: &[u8]) + { + if self.fault == ChipFault::L2ChipCrcErrOnce && !self.once_fault_used + { + self.once_fault_used = true; + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::CrcErr as u8, &[]))); + return; + } + // The real chip caps each request chunk at L2_CHUNK_MAX_DATA. A + // driver that sent an over-large chunk would split the wire packet + // wrong, so reject it here and fail the round-trip. This makes the + // multi-chunk send path prove chunk-cap compliance, not just byte + // reassembly. + if len > crate::buf::L2_CHUNK_MAX_DATA + { self.pending - .push_back(Pending::Frame(Self::frame(L2Status::RequestOk as u8, &[]))); - self.produce_result(cmd_size); + .push_back(Pending::Frame(Self::frame(L2Status::GenErr as u8, &[]))); self.accum.clear(); + return; } + self.accum.extend_from_slice(data); + // Need the 2-byte CMD_SIZE before completeness can be judged. + if self.accum.len() < 2 + { + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::RequestCont as u8, &[]))); + return; + } + let cmd_size = u16::from_le_bytes([self.accum[0], self.accum[1]]) as usize; + let total = 2 + cmd_size + 16; + if self.accum.len() < total + { + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::RequestCont as u8, &[]))); + return; + } + // Final chunk: ack, then produce the result. + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::RequestOk as u8, &[]))); + self.produce_result(cmd_size); + self.accum.clear(); } /// Queues the `Get_Info` reply for `(object_id, block_index)`. @@ -642,6 +713,13 @@ impl ChipMockSpi .push_back(Pending::Frame(Self::frame(L2Status::UnknownErr as u8, &[]))); return; } + if self.get_info_fault == GetInfoFault::CrcErrStatusOnce && !self.once_fault_used + { + self.once_fault_used = true; + self.pending + .push_back(Pending::Frame(Self::frame(L2Status::CrcErr as u8, &[]))); + return; + } if self.get_info_fault == GetInfoFault::ContStatus { // A valid-CRC frame with a continuation status: the driver must @@ -670,6 +748,12 @@ impl ChipMockSpi let idx = f.len() - 1; f[idx] ^= 0xFF; } + if self.get_info_fault == GetInfoFault::BadCrcOnce && !self.once_fault_used + { + self.once_fault_used = true; + self.pending.push_back(Pending::CorruptOnce(f)); + return; + } self.pending.push_back(Pending::Frame(f)); } @@ -716,6 +800,9 @@ impl ChipMockSpi } ChipFault::None | ChipFault::L2CrcErr + | ChipFault::L2CrcErrOnce + | ChipFault::L2ChipCrcErrOnce + | ChipFault::ResendWrongChunk | ChipFault::ResultFail | ChipFault::ShortEcho | ChipFault::EmptyResult @@ -756,22 +843,11 @@ impl ChipMockSpi let chunk_len = remaining.min(chunk_max); let chunk = &wire[offset..offset + chunk_len]; offset += chunk_len; + // The first chunk ends exactly at its own length, the last one + // consumes the rest. A single-chunk result is both. + let first = offset == chunk_len; let last = offset >= wire.len(); - let status = if last - { - L2Status::ResultOk as u8 - } - else - { - L2Status::ResultCont as u8 - }; - let mut f = Self::frame(status, chunk); - if last && self.fault == ChipFault::L2CrcErr - { - let idx = f.len() - 1; - f[idx] ^= 0xFF; - } - self.pending.push_back(Pending::Frame(f)); + self.push_one_result_frame(chunk, first, last); if last { break; @@ -779,6 +855,42 @@ impl ChipMockSpi } } + /// Queues one result chunk as an L2 frame, applying the CRC faults. + /// + /// `first` and `last` mark the chunk's position in the result. A non-final + /// chunk carries `ResultCont`, the final one `ResultOk`. `ResendWrongChunk` + /// keeps the first frame aside as the wrong replay, `L2CrcErr` corrupts the + /// final CRC outright, and the once-faults queue it for a single corruption. + fn push_one_result_frame(&mut self, chunk: &[u8], first: bool, last: bool) + { + let status = if last + { + L2Status::ResultOk as u8 + } + else + { + L2Status::ResultCont as u8 + }; + let mut f = Self::frame(status, chunk); + if first && self.fault == ChipFault::ResendWrongChunk + { + self.wrong_resend_frame = Some(f.clone()); + } + if last && self.fault == ChipFault::L2CrcErr + { + let idx = f.len() - 1; + f[idx] ^= 0xFF; + } + if last && matches!(self.fault, ChipFault::L2CrcErrOnce | ChipFault::ResendWrongChunk) + { + self.pending.push_back(Pending::CorruptOnce(f)); + } + else + { + self.pending.push_back(Pending::Frame(f)); + } + } + /// Maps the active `ChipFault` to the L3 RESULT status byte. /// /// Returns the fault-forced status, or `Ok` when no status-overriding fault @@ -954,6 +1066,16 @@ impl ChipMockSpi { status[0] = 0x01; // READY out[..f.len()].copy_from_slice(&f); + self.resend_frame = Some(f); + } + Some(Pending::CorruptOnce(f)) => + { + status[0] = 0x01; // READY + let mut bad = f.clone(); + let idx = bad.len() - 1; + bad[idx] ^= 0xFF; + out[..bad.len()].copy_from_slice(&bad); + self.resend_frame = Some(f); } Some(Pending::Alarm) => { @@ -1091,6 +1213,131 @@ impl SpiDevice for ScriptedSpi } } +/// How the CRC-retry double serves one scripted reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CrcReply +{ + /// Serve the entry's frame verbatim. + Good, + /// Serve an empty STATUS = CRC_ERR frame: the chip rejected the request CRC. + ChipCrcErr, + /// Serve the entry's frame with its last RSP_CRC byte flipped. + HostCrcErr, + /// Serve the entry's frame with its first RSP_CRC byte flipped. + HostCrcErrFirstByte, + /// Serve an empty frame carrying the given STATUS byte. + Status(u8), +} + +/// A `SpiDevice` that scripts one chip reply per read and records requests. +pub(crate) struct CrcFaultSpi +{ + script: VecDeque<(CrcReply, Vec)>, + requests: Vec>, + reads: usize, +} + +impl CrcFaultSpi +{ + /// Builds a double serving `script`, one entry per read, in order. + pub(crate) fn new(script: Vec<(CrcReply, Vec)>) -> Self + { + CrcFaultSpi + { + script: script.into_iter().collect(), + requests: Vec::new(), + reads: 0, + } + } + + /// The full request frames the host wrote, in send order. + pub(crate) fn requests(&self) -> &[Vec] + { + &self.requests + } + + /// The REQ_ID of every request the host wrote, in send order. + pub(crate) fn req_ids(&self) -> Vec + { + self.requests + .iter() + .filter_map(|r| r.first().copied()) + .collect() + } + + /// How many response reads the host performed. + pub(crate) fn reads(&self) -> usize + { + self.reads + } + + /// Renders the next scripted entry into the bytes to clock back. + fn next_frame(&mut self) -> Vec + { + match self.script.pop_front() + { + Some((CrcReply::Good, frame)) => frame, + Some((CrcReply::ChipCrcErr, _)) => l2_frame(L2Status::CrcErr as u8, &[]), + Some((CrcReply::Status(s), _)) => l2_frame(s, &[]), + Some((CrcReply::HostCrcErr, mut frame)) => + { + if let Some(last) = frame.last_mut() + { + *last ^= 0xFF; + } + frame + } + Some((CrcReply::HostCrcErrFirstByte, mut frame)) => + { + // The first CRC byte sits just before the second, at len - 2. + let n = frame.len(); + if let Some(b) = n.checked_sub(2).and_then(|i| frame.get_mut(i)) + { + *b ^= 0xFF; + } + frame + } + // Script exhausted: the NO_RESP sentinel, so the host gives up + // rather than silently reusing a stale frame. + None => std::vec![0xFFu8], + } + } +} + +impl ErrorType for CrcFaultSpi +{ + type Error = MockSpiError; +} + +impl SpiDevice for CrcFaultSpi +{ + fn transaction + ( + &mut self, + operations: &mut [Operation<'_, u8>], + ) + -> Result<(), Self::Error> + { + match operations + { + [Operation::Write(frame)] => + { + self.requests.push(frame.to_vec()); + } + [Operation::TransferInPlace(status), Operation::Read(out)] => + { + self.reads += 1; + let f = self.next_frame(); + status[0] = 0x01; // READY + out[..f.len()].copy_from_slice(&f); + } + _ => + {} + } + Ok(()) + } +} + /// A `SpiDevice` that records every written frame and replays scripted reads. /// /// Like `ScriptedSpi`, but it captures each MOSI `Write` so a test can assert @@ -1193,7 +1440,10 @@ pub(crate) const FW_UPDATE_DEFAULT_VERSION: [u8; 4] = [0x00, 0x00, 0x00, 0x02]; /// orchestration sequence. An optional `gen_err_on_nth_b0` makes the nth /// (1-based) 0xB0 reply a `GenErr`, to drive the failure-stop path. The bank /// header `ver`, its size, and the running version are configurable so a test -/// can force a version mismatch or a wrong-size header. +/// can force a version mismatch or a wrong-size header. It also answers a +/// `Resend_Req` (0x10) by re-delivering the last frame in its clean form, and +/// `set_crc_fault_on` corrupts one reply's RSP_CRC, so the L2 CRC-retry seam can +/// be exercised across the firmware-update path. pub(crate) struct FwUpdateSpi { requests: Vec<(u8, Vec)>, @@ -1206,6 +1456,16 @@ pub(crate) struct FwUpdateSpi bank_version: [u8; 4], bank_header_len: usize, last_startup_id: u8, + /// REQ_ID whose NEXT reply is delivered with a flipped RSP_CRC byte. + crc_fault_on: Option, + /// Whether the next read must corrupt the frame it delivers. + corrupt_next_read: bool, + /// The last frame actually delivered on a read, in its clean form. + /// + /// A `Resend_Req` re-queues this, which is what the chip does. + resend_frame: Option>, + /// Number of `Resend_Req` frames the mock has received. + resend_requests: usize, } impl FwUpdateSpi @@ -1229,9 +1489,25 @@ impl FwUpdateSpi bank_header_len: 52, // Set on each Startup_Req: drives the post-reboot mode poll. last_startup_id: 0, + crc_fault_on: None, + corrupt_next_read: false, + resend_frame: None, + resend_requests: 0, } } + /// Corrupts the RSP_CRC of the next reply to a request with id `req_id`. + pub(crate) fn set_crc_fault_on(&mut self, req_id: u8) + { + self.crc_fault_on = Some(req_id); + } + + /// How many `Resend_Req` frames the mock has received. + pub(crate) fn resend_request_count(&self) -> usize + { + self.resend_requests + } + /// Makes the nth (1-based) `Mutable_FW_Update` (0xB0) reply a `GenErr`. pub(crate) fn fail_nth_b0(&mut self, n: u32) { @@ -1384,9 +1660,23 @@ impl FwUpdateSpi { self.handle_get_info(&data); } + Ok(L2ReqId::Resend) => + { + self.resend_requests += 1; + if let Some(f) = self.resend_frame.clone() + { + self.pending.push_front(f); + } + } _ => {} } + // Arm the one-shot RSP_CRC corruption for the reply just queued. + if self.crc_fault_on == Some(id) + { + self.crc_fault_on = None; + self.corrupt_next_read = true; + } } /// Handles a GET_RESPONSE read: pops the next queued frame. @@ -1397,7 +1687,17 @@ impl FwUpdateSpi Some(f) => { status[0] = 0x01; // READY - out[..f.len()].copy_from_slice(&f); + self.resend_frame = Some(f.clone()); + let mut delivered = f; + if self.corrupt_next_read + { + self.corrupt_next_read = false; + if let Some(last) = delivered.last_mut() + { + *last ^= 0xFF; + } + } + out[..delivered.len()].copy_from_slice(&delivered); } None => { diff --git a/docs/bench-runner.md b/docs/bench-runner.md index bcb3348..7c83337 100644 --- a/docs/bench-runner.md +++ b/docs/bench-runner.md @@ -66,7 +66,7 @@ logs the failing step number and an error code instead. |------------|-------------|------------------| | (none) | the product smoke | first-light SE identity: chip mode, RISC-V and SPECT firmware versions. No `0x5x` marker | | `se-session` | the SE proof suite | `0x51` L3 session + encrypted Ping, `0x53` reversible persistent state (counters, MAC-and-Destroy, imported-Ed25519 known-answer test), `0x54` safe reads + P-256 export. All three in one flash | -| `se-fw-update` | the SE firmware-update path | `0x20` SE firmware update 1.0.0 to 2.0.0 | +| `se-fw-update` | the SE firmware-update path | `0x20` SE firmware update to CPU 2.1.0 and SPECT 1.3.0 | Notes: