From e47dd26dc5d4f6137ceb5f7f24466eb4c0d4f417 Mon Sep 17 00:00:00 2001 From: 0xEthamin Date: Thu, 20 Aug 2026 21:20:09 +0200 Subject: [PATCH] firmware: L2 CRC retry, libtropic v4.1.0, SE firmware 2.1.0 Three related changes, all triggered by the libtropic 4.1.0 release. L2 CRC RETRY. Close a parity gap dating from libtropic 4.0.0, where the driver raised a CRC fault straight to the caller with no reprise. One seam in l2/retry.rs now carries all eight L2 call sites, and the two faults get opposite cures. A chip-reported CRC error (status 0x7C) means TROPIC01 ignored the frame, so the identical request is replayed, which the datasheet confirms is safe even for a firmware-write chunk or a monotonic counter. A locally detected bad CRC on an otherwise valid response means the request may already have run, so the driver asks for a Resend_Req and never replays. DELIBERATE DEVIATION, STRICTER THAN UPSTREAM. One shared 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. A link needing more than three retries on one packet is failing rather than recovering, so a hard predictable bound is preferred. Error-path latency becomes a caller-visible concern and is documented as such, bounded per exchange by (1 + CRC_RETRY_ATTEMPTS) * READ_MAX_TRIES * READ_RETRY_DELAY_MS. The Startup_Req CRC relaxation stays confined by construction. The parameterised parser is private and only two named wrappers are exposed, so no caller can ask for the relaxed check. The gate reproduces libtropic condition for condition and no longer leaks into the resend path. LIBTROPIC v4.1.0. Move the pinned reference to v4.1.0. It carries no protocol change and no API change, and model_cfg.yml is byte-identical so the cert-store golden does not move. SE FIRMWARE TARGET 2.1.0 AND SPECT 1.3.0. App FW 2.1.0 fixes a Resend_Req arriving during a multi-chunk L3 result, which could double-encrypt the result or advance to the next chunk early. That removes at its source the hazard the resend path otherwise leaves to the host length and tag checks. SPECT 1.3.0 carries the side-channel hardening of the ECC engine: scalar additive splitting, branch-free and CSWAP-free long routines, a changed power and EM profile, and public-key validity checks on read. --- .github/workflows/ci.yml | 12 +- Cargo.lock | 2 +- crates/nonsecure/src/main.rs | 4 +- crates/secure/src/se_fw_update.rs | 18 +- crates/tropic01-driver/Cargo.toml | 2 +- crates/tropic01-driver/README.md | 8 +- .../tropic01-driver/src/device/bootloader.rs | 43 +- .../src/device/bootloader_tests.rs | 53 +- crates/tropic01-driver/src/device/commands.rs | 10 +- .../tropic01-driver/src/device/nosession.rs | 89 +-- crates/tropic01-driver/src/device/tests.rs | 117 ++- crates/tropic01-driver/src/l2/frame.rs | 208 ++++-- crates/tropic01-driver/src/l2/mod.rs | 7 +- crates/tropic01-driver/src/l2/retry.rs | 676 ++++++++++++++++++ crates/tropic01-driver/src/l2/transport.rs | 236 +++++- crates/tropic01-driver/src/lib.rs | 19 + crates/tropic01-driver/src/session.rs | 55 ++ crates/tropic01-driver/src/test_support.rs | 408 +++++++++-- docs/bench-runner.md | 2 +- 19 files changed, 1733 insertions(+), 236 deletions(-) create mode 100644 crates/tropic01-driver/src/l2/retry.rs 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: