From 59d942228f12c692723a53e73de0807086965966 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 20 Jul 2026 15:31:21 -0700 Subject: [PATCH 01/27] First merge of hw-tests with emu, api_rev working --- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 9 +- ddi/tbor/types/tests/commands/api_rev.rs | 41 +-- ddi/tbor/types/tests/harness/hw_ctx.rs | 286 ++++++++++++++++++ ddi/tbor/types/tests/harness/mod.rs | 22 +- ddi/tbor/types/tests/hw/api_rev.rs | 38 --- ddi/tbor/types/tests/hw/mod.rs | 1 - 6 files changed, 336 insertions(+), 61 deletions(-) create mode 100644 ddi/tbor/types/tests/harness/hw_ctx.rs delete mode 100644 ddi/tbor/types/tests/hw/api_rev.rs diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index 3b948dade..1c2a1a1e6 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -10,9 +10,14 @@ //! //! With **no** feature enabled the crate falls through to the native //! OS backend (`nix` on Linux / `win` on Windows), which drives real -//! silicon. Hardware-only smoke tests live under [`hw`]. +//! silicon. Hardware smoke tests that still use the standalone +//! [`hw::hw_test`] closure fixture live under [`hw`]; individual +//! `commands/*` files migrated to the `harness::Ctx` alias (see +//! `commands::api_rev`) additionally compile under `--features +//! hw-tests` and run against the same silicon backend through the +//! shared per-test fixture. -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] pub mod harness; pub mod commands; diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index ce74b68ad..9bd62c87d 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -3,33 +3,36 @@ //! Integration tests for TBOR `ApiRev`. //! -//! `round_trip` exercises the full path host → backend (`emu` or `sock`) -//! → fw `handle_tbor_op` → response, so it is transport-agnostic. +//! `round_trip` exercises the full path host → backend (`emu`, +//! `sock`, or `hw-tests` for real silicon) → fw `handle_tbor_op` → +//! response, so it is transport-agnostic. //! `unsupported_on_mock` asserts the design contract that backends opt //! in to TBOR. //! -//! Pilot module for the [`TestCtx`](crate::harness::TestCtx) -//! migration — every test in this file constructs the ctx once and -//! drives every device interaction through its methods. Phase 6a -//! finished the migration of the session-state probe to the new -//! `ctx.session_open_init`/`finish`/`session_close` methods. +//! Pilot module for the [`Ctx`](crate::harness::Ctx) alias — every +//! test in this file constructs the ctx once and drives every device +//! interaction through its methods. `Ctx` resolves to +//! [`TestCtx`](crate::harness::ctx::TestCtx) under emu/mock/sock and +//! to [`HwCtx`](crate::harness::hw_ctx::HwCtx) under a pure +//! `hw-tests` build, so the same test bodies run on the in-process +//! firmware and against a live board. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] use azihsm_ddi_tbor_types::TborApiRevReq; -use crate::harness::TestCtx; +use crate::harness::Ctx; -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] const EXPECTED: azihsm_ddi_tbor_types::TborApiRevResp = azihsm_ddi_tbor_types::TborApiRevResp { min_ver: 1, max_ver: 1, }; -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] #[test] fn round_trip() { - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let resp = ctx .tbor(&TborApiRevReq::new()) .expect("TBOR ApiRev round-trip"); @@ -44,10 +47,10 @@ fn round_trip() { /// regression that would silently introduce per-call state (e.g. a /// version negotiation cache, a session-dependent code path) in the /// dispatcher's only out-of-session in-band handler. -#[cfg(feature = "emu")] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] #[test] -fn api_rev_repeated_stable_emu() { - let ctx = TestCtx::new(); +fn api_rev_repeated_stable() { + let ctx = Ctx::new(); let baseline = ctx.tbor(&TborApiRevReq::new()).expect("baseline ApiRev"); assert_eq!(baseline, EXPECTED, "baseline must match expected"); for i in 1..16 { @@ -62,12 +65,12 @@ fn api_rev_repeated_stable_emu() { /// transitions to Active. Together with the gate test in /// `default_psk_gate.rs` this proves the dispatcher never lets /// session state leak into the out-of-session handler. -#[cfg(feature = "emu")] +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn api_rev_independent_of_session_state_emu() { +fn api_rev_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; - let ctx = TestCtx::new(); + let ctx = Ctx::new(); // No sessions outstanding. let pre = ctx @@ -105,7 +108,7 @@ fn api_rev_independent_of_session_state_emu() { fn unsupported_on_mock() { use crate::harness::assertions::assert_unsupported_encoding; - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let err = ctx .tbor(&TborApiRevReq::new()) .expect_err("mock backend must not implement exec_op_tbor"); diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs new file mode 100644 index 000000000..cd5df5859 --- /dev/null +++ b/ddi/tbor/types/tests/harness/hw_ctx.rs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! [`HwCtx`] — hardware-backend twin of [`crate::harness::ctx::TestCtx`]. +//! +//! Same method surface as `TestCtx` so every `commands/*` test can be +//! written once and pick a backend via the `Ctx` type alias exported +//! from [`crate::harness`]. The differences are all in the lifecycle: +//! +//! * **Setup:** acquires the process-global test lock, opens the +//! native OS backend (`DdiNix` / `DdiWin`) selected at compile time +//! by [`AzihsmDdi::default()`], and issues NSSR / factory-reset via +//! [`DdiDev::erase`] so the partition starts at pristine defaults. +//! * **Teardown:** [`Drop`] issues a best-effort NSSR so a panicking +//! test still leaves the device clean for the next one. Errors are +//! swallowed — a wedged device that rejects `erase` during unwind +//! must not double-panic. +//! +//! Gated behind `feature = "hw-tests"`. Only compiled when the test +//! binary is built against a real silicon backend. + +use std::ops::Deref; + +use azihsm_ddi::AzihsmDdi; +use azihsm_ddi_interface::Ddi; +use azihsm_ddi_interface::DdiDev; +use azihsm_ddi_interface::DdiError; +use azihsm_ddi_interface::DdiResult; +use azihsm_ddi_tbor_types::SessionType; +use azihsm_ddi_tbor_types::TborApiRevResp; +use azihsm_ddi_tbor_types::TborOpReq; +use azihsm_ddi_tbor_types::TborPartFinalResp; +use azihsm_ddi_tbor_types::TborPartInitResp; +use azihsm_ddi_tbor_types::TborStatus; +use parking_lot::Mutex; +use parking_lot::MutexGuard; + +use crate::harness::api_rev::helper_api_rev_tbor; +use crate::harness::assertions::assert_fw_rejects; +use crate::harness::assertions::assert_tbor_decode_error; +use crate::harness::session::part_final as part_final_helper; +use crate::harness::session::part_init as part_init_helper; +use crate::harness::session::psk_change as psk_change_helper; +use crate::harness::session::session_close as session_close_helper; +use crate::harness::session::session_open_finish as session_open_finish_helper; +use crate::harness::session::session_open_finish_with_mac as session_open_finish_with_mac_helper; +use crate::harness::session::session_open_init as session_open_init_helper; +use crate::harness::session::session_open_init_with_options as session_open_init_with_options_helper; +use crate::harness::session::PendingHandshake; +use crate::harness::session::SessionHandshake; +use crate::harness::session::SessionOpenInitOptions; + +/// Fixed default 48-byte SATA thumbprint used by the convenience +/// [`HwCtx::part_init`] wrapper. +const DEFAULT_SATA_THUMBPRINT: [u8; 48] = [0x5A; 48]; + +/// Process-global serialisation lock. A single physical device is +/// shared by every `#[test]` in the binary; the lock keeps parallel +/// nextest workers from stomping on each other. `parking_lot` per the +/// workspace convention (std lock is banned in clippy.toml). +static HW_TEST_LOCK: Mutex<()> = Mutex::new(()); + +/// Native OS backend device handle, compile-time-selected by +/// [`AzihsmDdi::default()`] to [`azihsm_ddi_nix::DdiNix`] on Linux and +/// [`azihsm_ddi_win::DdiWin`] on Windows. +type HwDev = ::Dev; + +/// Owned wrapper around an opened hw device that holds the +/// process-global test lock for its lifetime. +struct HwTestDev { + dev: HwDev, + _guard: MutexGuard<'static, ()>, +} + +impl Deref for HwTestDev { + type Target = HwDev; + fn deref(&self) -> &Self::Target { + &self.dev + } +} + +/// Acquire the hw test lock, open the native backend device, and +/// NSSR-reset it so the test starts at pristine defaults. +/// +/// Panics if the backend advertises no devices or if NSSR-on-entry +/// fails — both are environment bugs (no device plugged in, or a +/// wedged partition) and running a test against a dirty device would +/// produce a misleading failure downstream. +fn open_hw_dev() -> HwTestDev { + let guard = HW_TEST_LOCK.lock(); + let ddi = AzihsmDdi::default(); + let infos = ddi.dev_info_list(); + let info = infos.first().expect("hw backend advertises no device"); + let dev = ddi.open_dev(&info.path).expect("open hw backend device"); + dev.erase().expect("open_hw_dev: NSSR must succeed before test"); + HwTestDev { dev, _guard: guard } +} + +/// One-test fixture for the hw backend. Mirrors [`TestCtx`] method-for-method. +/// +/// [`TestCtx`]: crate::harness::ctx::TestCtx +pub struct HwCtx { + dev: HwTestDev, +} + +impl HwCtx { + /// Open the hw device via [`open_hw_dev`]. + pub fn new() -> Self { + Self { dev: open_hw_dev() } + } + + /// NSSR / factory-reset the partition. Same call `TestCtx::erase` + /// makes on emu; the trait method [`DdiDev::erase`] resolves to + /// NSSR on the native backend. + pub fn erase(&self) -> DdiResult<()> { + self.dev.erase() + } + + pub fn tbor(&self, req: &R) -> DdiResult { + let mut cookie = None; + self.dev.exec_op_tbor(req, None, &mut cookie) + } + + pub fn tbor_oob(&self, req: &R, oob_items: &[&[u8]]) -> DdiResult { + let mut cookie = None; + self.dev.exec_op_tbor(req, Some(oob_items), &mut cookie) + } + + #[track_caller] + pub fn expect_fw_reject(&self, req: &R, expected: TborStatus) -> DdiError + where + R::OpResp: core::fmt::Debug, + { + match self.tbor(req) { + Ok(resp) => panic!( + "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", + expected.0, + ), + Err(err) => { + assert_fw_rejects(&err, expected); + err + } + } + } + + #[track_caller] + pub fn expect_fw_reject_oob( + &self, + req: &R, + oob_items: &[&[u8]], + expected: TborStatus, + ) -> DdiError + where + R::OpResp: core::fmt::Debug, + { + match self.tbor_oob(req, oob_items) { + Ok(_resp) => panic!( + "expected FW reject {expected:?} (0x{:08X}), got unexpected success", + expected.0, + ), + Err(err) => { + assert_fw_rejects(&err, expected); + err + } + } + } + + #[track_caller] + pub fn expect_decode_error(&self, req: &R) -> DdiError + where + R::OpResp: core::fmt::Debug, + { + match self.tbor(req) { + Ok(resp) => panic!("expected DdiError::TborDecodeError, got Ok({resp:?})"), + Err(err) => { + assert_tbor_decode_error(&err); + err + } + } + } + + pub fn session_open_init( + &self, + psk_id: u8, + session_type: SessionType, + ) -> DdiResult { + session_open_init_helper(&self.dev, psk_id, session_type) + } + + pub fn session_open_init_with_options( + &self, + opts: SessionOpenInitOptions<'_>, + ) -> DdiResult { + session_open_init_with_options_helper(&self.dev, opts) + } + + pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { + session_open_finish_helper(&self.dev, pending) + } + + pub fn session_open_finish_with_mac( + &self, + pending: PendingHandshake, + mac_fin: [u8; 48], + ) -> DdiResult { + session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) + } + + pub fn open_session_raw( + &self, + psk_id: u8, + session_type: SessionType, + ) -> DdiResult { + let pending = self.session_open_init(psk_id, session_type)?; + self.session_open_finish(pending) + } + + pub fn session_close(&self, session_id: u16) -> DdiResult<()> { + session_close_helper(&self.dev, session_id) + } + + pub fn psk_change(&self, session: &SessionHandshake, new_psk: &[u8]) -> DdiResult<()> { + psk_change_helper(&self.dev, session, new_psk) + } + + pub fn part_init( + &self, + session: &SessionHandshake, + mach_seed: &[u8], + part_policy: &[u8], + pota_thumbprint: &[u8], + ) -> DdiResult { + part_init_helper( + &self.dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + &DEFAULT_SATA_THUMBPRINT, + None, + ) + } + + pub fn part_init_sd( + &self, + session: &SessionHandshake, + mach_seed: &[u8], + part_policy: &[u8], + pota_thumbprint: &[u8], + sata_thumbprint: &[u8], + sapota_thumbprint: Option<&[u8]>, + ) -> DdiResult { + part_init_helper( + &self.dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + sata_thumbprint, + sapota_thumbprint, + ) + } + + pub fn part_final( + &self, + session: &SessionHandshake, + part_policy: &[u8], + prev_local_mk_backup: &[u8], + certs: &[&[u8]], + ) -> DdiResult { + part_final_helper(&self.dev, session, part_policy, prev_local_mk_backup, certs) + } + + pub fn api_rev(&self) -> DdiResult { + helper_api_rev_tbor(&self.dev) + } +} + +/// Best-effort NSSR so a panicking test still hands the next test a +/// clean device. Errors are intentionally swallowed — a wedged +/// device that rejects `erase` during unwind must not double-panic. +impl Drop for HwCtx { + fn drop(&mut self) { + let _ = self.dev.erase(); + } +} diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index f6afeaea5..1f56aba89 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -42,13 +42,17 @@ //! `get_certificate`) carry per-method `#[cfg(feature = "emu")]` and //! are unavailable under `--features mock`. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] pub mod api_rev; pub mod assertions; +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub mod ctx; pub mod fixture; +#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +pub mod hw_ctx; pub mod session; +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub mod session_guard; #[cfg(feature = "emu")] pub mod x509_fixture; @@ -64,7 +68,22 @@ pub use azihsm_ddi_tbor_types::build_psk_change_aad; pub use azihsm_ddi_tbor_types::TborPskChangeReq; pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use ctx::TestCtx; + +// `Ctx` is the backend-agnostic per-test fixture used by files +// migrated to run against both the emu/mock/sock harnesses and the +// native hw-tests backend. Under emu/mock/sock it aliases the +// full-featured [`TestCtx`]; under a pure `hw-tests` build (no other +// backend feature) it aliases [`hw_ctx::HwCtx`], which mirrors +// `TestCtx`'s method surface with NSSR-based setup/teardown. Emu +// takes precedence in the combined-features case so a stray +// `--features emu,hw-tests` still builds against emu. +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +pub use ctx::TestCtx as Ctx; +#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +pub use hw_ctx::HwCtx as Ctx; + pub use fixture::open_dev; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; @@ -81,4 +100,5 @@ pub use session::session_open_init_with_options; pub use session::PendingHandshake; pub use session::SessionHandshake; pub use session::SessionOpenInitOptions; +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use session_guard::SessionGuard; diff --git a/ddi/tbor/types/tests/hw/api_rev.rs b/ddi/tbor/types/tests/hw/api_rev.rs deleted file mode 100644 index fb1567074..000000000 --- a/ddi/tbor/types/tests/hw/api_rev.rs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware smoke test for TBOR `ApiRev`. -//! -//! Exercises the full host -> nix/win backend -> silicon fw -//! `handle_tbor_op` -> response path. `ApiRev` is sessionless and -//! stateless, so it is safe to run against a live board without -//! any pre-/post-test cleanup. - -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_tbor_types::TborApiRevReq; -use azihsm_ddi_tbor_types::TborApiRevResp; - -use crate::hw::open_hw_dev; - -/// Expected response for the bootstrap TBOR protocol version. -/// -/// Mirrors the emu-harness constant in -/// `commands::api_rev::EXPECTED` so both paths pin the same wire -/// contract. -const EXPECTED: TborApiRevResp = TborApiRevResp { - min_ver: 1, - max_ver: 1, -}; - -#[test] -fn round_trip() { - let dev = open_hw_dev(); - let mut cookie = None; - let resp = dev - .exec_op_tbor(&TborApiRevReq::new(), None, &mut cookie) - .expect("TBOR ApiRev round-trip on hardware"); - assert_eq!( - resp, EXPECTED, - "firmware should report min=max=1 for the bootstrap TBOR protocol version", - ); -} diff --git a/ddi/tbor/types/tests/hw/mod.rs b/ddi/tbor/types/tests/hw/mod.rs index 90dc25590..8dff75846 100644 --- a/ddi/tbor/types/tests/hw/mod.rs +++ b/ddi/tbor/types/tests/hw/mod.rs @@ -47,7 +47,6 @@ use azihsm_ddi_interface::Ddi; use parking_lot::Mutex; use parking_lot::MutexGuard; -pub mod api_rev; pub mod assertions; pub mod default_psk_gate; pub mod harness; From 86252e9ca6b04c4cd4ee8e5059a622d4dabd0230 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 20 Jul 2026 20:07:39 -0700 Subject: [PATCH 02/27] All the existing HW tests are migrated --- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 16 +- .../types/tests/commands/default_psk_gate.rs | 117 +++- ddi/tbor/types/tests/commands/open_session.rs | 293 +++++++-- ddi/tbor/types/tests/commands/part_info.rs | 42 +- ddi/tbor/types/tests/commands/psk_change.rs | 104 ++-- ddi/tbor/types/tests/harness/ctx.rs | 47 ++ ddi/tbor/types/tests/harness/hw_ctx.rs | 296 +++++++-- ddi/tbor/types/tests/harness/mod.rs | 2 - ddi/tbor/types/tests/harness/session_guard.rs | 101 ++- ddi/tbor/types/tests/hw/assertions.rs | 14 - ddi/tbor/types/tests/hw/default_psk_gate.rs | 138 ----- ddi/tbor/types/tests/hw/harness.rs | 71 --- ddi/tbor/types/tests/hw/mod.rs | 119 ---- ddi/tbor/types/tests/hw/open_session.rs | 582 ------------------ ddi/tbor/types/tests/hw/part_info.rs | 51 -- ddi/tbor/types/tests/hw/psk_change.rs | 234 ------- ddi/tbor/types/tests/hw/session_helper.rs | 59 -- 17 files changed, 792 insertions(+), 1494 deletions(-) delete mode 100644 ddi/tbor/types/tests/hw/assertions.rs delete mode 100644 ddi/tbor/types/tests/hw/default_psk_gate.rs delete mode 100644 ddi/tbor/types/tests/hw/harness.rs delete mode 100644 ddi/tbor/types/tests/hw/mod.rs delete mode 100644 ddi/tbor/types/tests/hw/open_session.rs delete mode 100644 ddi/tbor/types/tests/hw/part_info.rs delete mode 100644 ddi/tbor/types/tests/hw/psk_change.rs delete mode 100644 ddi/tbor/types/tests/hw/session_helper.rs diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index 1c2a1a1e6..e238a1807 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -6,21 +6,15 @@ //! Backend selection is feature-gated; the same tests run across every //! transport. Run with `--features emu` (in-process firmware), //! `--features sock` (firmware behind a socket server), or -//! `--features mock` (transport-contract probes). +//! `--features mock` (transport-contract probes). `--features +//! hw-tests` compiles the same `commands/*` tests against real +//! silicon through the shared [`harness::Ctx`] alias. //! //! With **no** feature enabled the crate falls through to the native -//! OS backend (`nix` on Linux / `win` on Windows), which drives real -//! silicon. Hardware smoke tests that still use the standalone -//! [`hw::hw_test`] closure fixture live under [`hw`]; individual -//! `commands/*` files migrated to the `harness::Ctx` alias (see -//! `commands::api_rev`) additionally compile under `--features -//! hw-tests` and run against the same silicon backend through the -//! shared per-test fixture. +//! OS backend (`nix` on Linux / `win` on Windows) for build-only +//! checks; no tests are compiled in that mode. #[cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] pub mod harness; pub mod commands; - -#[cfg(feature = "hw-tests")] -pub mod hw; diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index fbcbcedbd..b055ce42f 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -11,32 +11,35 @@ //! gated; in-session opcodes on the allow-list (`PskChange`, //! `SessionClose`) are always permitted. //! -//! Coverage in this file (positive bypass cases — E1, E2, E3, E5 from -//! the plan): +//! Coverage in this file (positive bypass cases E1, E2, E3, E5 plus +//! the negative case E4): //! //! * E5: `ApiRev` reaches its handler with PSKs at default. //! * E3: `SessionOpenInit` succeeds with PSKs at default. //! * E1: `PskChange` is allow-listed — succeeds while PSK is default. //! * E2: `SessionClose` is allow-listed — succeeds while PSK is default. +//! * E4: a non-allow-listed in-session opcode (`PartInit`) is +//! rejected at dispatch with `DefaultPskMustRotate`, before any +//! handler mutation. Runs against emu and hw because the FW rejects +//! before any state change. //! -//! E4 (a non-allow-listed in-session command being *rejected* with -//! `DefaultPskMustRotate`) is deferred until a second real in-session -//! opcode lands; synthesising one solely for a test would couple the -//! test to the FW's opcode allow-list at the wrong layer. -//! -//! Each test inherits a factory-reset device from -//! [`TestCtx::new`](crate::harness::TestCtx::new), so partition PSKs -//! are at their canonical defaults on entry. +//! Each test inherits a factory-reset device from the ctx constructor, +//! so partition PSKs are at their canonical defaults on entry. -#![cfg(feature = "emu")] +#![cfg(any( + feature = "emu", + feature = "mock", + feature = "sock", + feature = "hw-tests" +))] use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; +use crate::harness::Ctx; use crate::harness::SessionOpenInitOptions; -use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; @@ -44,6 +47,7 @@ const CU: u8 = 1; /// Non-default PSK used as the rotation target for the `PskChange` /// bypass test. Distinct from the constant used in `psk_change.rs` so /// a leaked rotation from this file is trivially identifiable. +#[cfg(any(feature = "emu", feature = "hw-tests"))] const GATE_ROTATED_PSK: [u8; PSK_LEN] = [ 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, @@ -52,9 +56,10 @@ const GATE_ROTATED_PSK: [u8; PSK_LEN] = [ /// E5: `ApiRev` is an out-of-session opcode and therefore never /// gated. It must succeed even when both partition PSKs are at their /// compiled-in defaults. +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn default_psk_gate_api_rev_bypass_emu() { - let ctx = TestCtx::new(); +fn default_psk_gate_api_rev_bypass() { + let ctx = Ctx::new(); // Two probes back-to-back to confirm the call is genuinely // repeatable (gate is stateless) rather than passing on first // call by luck of ordering. @@ -65,9 +70,10 @@ fn default_psk_gate_api_rev_bypass_emu() { /// E3: `SessionOpenInit` is out-of-session and therefore never gated. /// Verified for both roles since each is bound to a distinct PSK /// slot. +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn default_psk_gate_session_open_init_bypass_emu() { - let ctx = TestCtx::new(); +fn default_psk_gate_session_open_init_bypass() { + let ctx = Ctx::new(); // CO + Authenticated under default CO PSK. let opts_co = @@ -96,9 +102,10 @@ fn default_psk_gate_session_open_init_bypass_emu() { /// E2: `SessionClose` is on the allow-list — it must succeed while /// the role's PSK is still default. Exercised for both roles. +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn default_psk_gate_session_close_bypass_emu() { - let ctx = TestCtx::new(); +fn default_psk_gate_session_close_bypass() { + let ctx = Ctx::new(); let session_co = ctx.open_session(CO, SessionType::Authenticated); session_co @@ -118,10 +125,80 @@ fn default_psk_gate_session_close_bypass_emu() { /// Exercised for the CO role; the CU role's bootstrap path is /// functionally identical and is already exercised by /// `psk_change_happy_cu_emu` in `psk_change.rs`. +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn default_psk_gate_psk_change_bypass_emu() { - let ctx = TestCtx::new(); +fn default_psk_gate_psk_change_bypass() { + let ctx = Ctx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); ctx.psk_change(session.handshake(), &GATE_ROTATED_PSK) .expect("PskChange must bypass gate while CO PSK is default"); } + +/// E4: an in-session, non-allow-listed opcode (`PartInit`) is +/// rejected at dispatch with `DefaultPskMustRotate`. The FW returns +/// the gate error before any partition-state mutation, so this test +/// is safe to run on real silicon. +#[cfg(any(feature = "emu", feature = "hw-tests"))] +#[test] +fn default_psk_gate_part_init_rejected() { + use azihsm_ddi_tbor_types::PolicyKeyKind; + use azihsm_ddi_tbor_types::TborStatus; + use azihsm_ddi_tbor_types::MACH_SEED_LEN; + use azihsm_ddi_tbor_types::PART_POLICY_LEN; + use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; + + use crate::harness::assertions::assert_fw_rejects; + + // Build a 484-byte `PartPolicy` blob that passes wire decode so + // the request reaches the dispatcher's gate. Mirrored from + // `commands::part_init::known_good_part_policy` (kept inline so + // this file stays runnable on hw without ungating the destructive + // part_init/ submodules). + fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { + const OFF_POTA: usize = 2; + const OFF_SATA: usize = 102; + const OFF_FLAGS: usize = 418; + const OFF_INFO: usize = 419; + + fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { + bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); + bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); + for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { + *b = (fill.wrapping_add(i as u8)) | 0x80; + } + } + + let mut bytes = [0u8; PART_POLICY_LEN]; + bytes[0] = 1; + bytes[1] = 0; + write_pubkey(&mut bytes, OFF_POTA, 0x10); + write_pubkey(&mut bytes, OFF_SATA, 0x20); + bytes[OFF_FLAGS] = 0; + for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { + *b = 0xAB; + } + bytes + } + + let ctx = Ctx::new(); + let session = ctx.open_session(CO, SessionType::Authenticated); + + let mach_seed = { + let mut v = [0u8; MACH_SEED_LEN]; + for (i, b) in v.iter_mut().enumerate() { + *b = 0x40 + i as u8; + } + v + }; + let pota_thumbprint = [0x5Au8; POTA_THUMBPRINT_LEN]; + + let err = ctx + .part_init( + session.handshake(), + &mach_seed, + &known_good_part_policy(), + &pota_thumbprint, + ) + .expect_err("PartInit under default PSK must be gated"); + assert_fw_rejects(&err, TborStatus::DefaultPskMustRotate); +} diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 93a531823..89a608bb5 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -4,25 +4,15 @@ //! Integration tests for the TBOR `SessionOpenInit` / //! `SessionOpenFinish` two-phase session handshake. //! -//! Cross-test isolation comes from `open_dev`'s factory-reset; the -//! [`TEST_LOCK`](crate::harness::fixture) held by every [`TestCtx`] -//! serialises access to the process-global emulator. Happy-path -//! sessions are owned by a [`SessionGuard`](crate::harness::SessionGuard) -//! that closes on `Drop`; negative-path tests intercept the handshake -//! through [`TestCtx::dev`]. +//! Runs against both backends via the [`Ctx`] alias: `TestCtx` under +//! `feature = "emu"` (soft-crypto backend, factory-reset per test), +//! `HwCtx` under `feature = "hw-tests"` (native OS backend against a +//! live board, NSSR per test). //! -//! Coverage: -//! * Happy-path: CO + Authenticated, CU + PlainText (smoke). -//! * Role/type mismatches: CO + PlainText, CU + Authenticated -//! (both must surface as `TborStatus::InvalidSessionType`). -//! * Invalid PSK id and invalid `session_type` byte at the parse stage. -//! * Phase-2 MAC tampering → `TborStatus::SessionAuthFailure`. -//! * Phase-2 `seed_envelope` tampering → `TborStatus::SessionAuthFailure`. -//! * `SessionOpenFinish` against an unknown session id, and a -//! second finish against an already-completed slot. -//! * Multiple concurrent sessions return distinct session ids. - -#![cfg(feature = "emu")] +//! Happy-path sessions are owned by a +//! [`SessionGuard`](crate::harness::SessionGuard) that closes on +//! `Drop`; negative-path tests intercept the handshake through the +//! raw `session_open_init` / `session_open_finish` methods on `Ctx`. use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; @@ -33,7 +23,7 @@ use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; -use crate::harness::TestCtx; +use crate::harness::Ctx; const CO: u8 = 0; const CU: u8 = 1; @@ -43,8 +33,8 @@ const CU: u8 = 1; // --------------------------------------------------------------------------- #[test] -fn open_session_co_authenticated_happy_emu() { - let ctx = TestCtx::new(); +fn open_session_co_authenticated_happy() { + let ctx = Ctx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); let h = session.handshake(); assert_eq!(h.psk_id, CO); @@ -63,8 +53,8 @@ fn open_session_co_authenticated_happy_emu() { } #[test] -fn open_session_cu_plaintext_happy_emu() { - let ctx = TestCtx::new(); +fn open_session_cu_plaintext_happy() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); let h = session.handshake(); assert_eq!(h.psk_id, CU); @@ -76,8 +66,8 @@ fn open_session_cu_plaintext_happy_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_co_plaintext_rejected_emu() { - let ctx = TestCtx::new(); +fn open_session_co_plaintext_rejected() { + let ctx = Ctx::new(); let err = ctx .session_open_init(CO, SessionType::PlainText) .expect_err("CO + PlainText is not a permitted pairing"); @@ -85,8 +75,8 @@ fn open_session_co_plaintext_rejected_emu() { } #[test] -fn open_session_cu_authenticated_rejected_emu() { - let ctx = TestCtx::new(); +fn open_session_cu_authenticated_rejected() { + let ctx = Ctx::new(); let err = ctx .session_open_init(CU, SessionType::Authenticated) .expect_err("CU + Authenticated is not a permitted pairing"); @@ -98,13 +88,13 @@ fn open_session_cu_authenticated_rejected_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_invalid_psk_id_emu() { +fn open_session_invalid_psk_id() { // Only `0` (CO) and `1` (CU) are valid `psk_id` values. Spot-check // a small set of out-of-range values covering: the smallest invalid // value (`2`), a mid-range value (`0x7F`), and the all-ones byte // (`0xFF`). All must surface `InvalidPskId` from the FW // dispatcher's `psk_id`-validation arm before any HPKE work. - let ctx = TestCtx::new(); + let ctx = Ctx::new(); for bad in [2u8, 0x7F, 0xFF] { let err = ctx .session_open_init(bad, SessionType::PlainText) @@ -114,10 +104,10 @@ fn open_session_invalid_psk_id_emu() { } #[test] -fn open_session_invalid_session_type_byte_emu() { +fn open_session_invalid_session_type_byte() { // Bypass the typed `SessionType` enum to ship an out-of-range // byte directly. The FW `SessionType::from_u8` must reject. - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let req = TborSessionOpenInitReq { psk_id: CU, session_type: 42, @@ -132,12 +122,12 @@ fn open_session_invalid_session_type_byte_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_unsupported_suite_id_emu() { +fn open_session_unsupported_suite_id() { // Any byte other than 0x01 must be rejected by `SessionSuite::from_u8` - // before any HPKE work happens. Spot-check three boundary values + // before any HPKE work happens. Spot-check three boundary values // covering "reserved-but-not-yet-implemented" (0x02), zero (0x00), // and the all-ones byte (0xFF). - let ctx = TestCtx::new(); + let ctx = Ctx::new(); for bad in [0x00u8, 0x02, 0xff] { let req = TborSessionOpenInitReq { psk_id: CU, @@ -154,8 +144,8 @@ fn open_session_unsupported_suite_id_emu() { // --------------------------------------------------------------------------- #[test] -fn session_open_finish_mac_tampered_emu() { - let ctx = TestCtx::new(); +fn session_open_finish_mac_tampered() { + let ctx = Ctx::new(); let pending = ctx .session_open_init(CU, SessionType::PlainText) .expect("phase 1 must succeed"); @@ -173,10 +163,14 @@ fn session_open_finish_mac_tampered_emu() { // --------------------------------------------------------------------------- #[test] -fn session_open_finish_unknown_session_id_emu() { +fn session_open_finish_unknown_session_id() { // Pick a session id that cannot possibly correspond to a live - // pending slot. The FW pre-check fails to load the blob. - let ctx = TestCtx::new(); + // pending slot. On emu the FW pre-check fails to load the blob + // (`DdiError::DdiError`). On hw the Linux kernel driver enforces + // per-fd session scoping and rejects the ioctl with + // `FileHandleNoExistingSession` (`DdiError::DdiStatus`) before + // the FW sees it — either surface is a valid rejection. + let ctx = Ctx::new(); let req = TborSessionOpenFinishReq { session_id: 0xFFFF, mac_fin: [0u8; 48], @@ -186,14 +180,18 @@ fn session_open_finish_unknown_session_id_emu() { .tbor(&req) .expect_err("finish against unknown session_id must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection, got {err:?}", + matches!( + err, + azihsm_ddi_interface::DdiError::TborStatus(_) + | azihsm_ddi_interface::DdiError::DdiStatus(_) + ), + "expected FW or driver rejection, got {err:?}", ); } #[test] -fn open_session_double_finish_emu() { - let ctx = TestCtx::new(); +fn open_session_double_finish() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // Replay the finish: pending slot is gone, FW must refuse. let req = TborSessionOpenFinishReq { @@ -215,10 +213,10 @@ fn open_session_double_finish_emu() { // --------------------------------------------------------------------------- #[test] -fn session_open_finish_seed_envelope_tampered_emu() { +fn session_open_finish_seed_envelope_tampered() { // Build a finish request by hand so we can corrupt the seed_envelope // ciphertext byte after Phase-1 succeeds but before shipping it. - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let pending = ctx .session_open_init(CU, SessionType::PlainText) .expect("phase 1 must succeed"); @@ -245,8 +243,8 @@ fn session_open_finish_seed_envelope_tampered_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_multiple_concurrent_emu() { - let ctx = TestCtx::new(); +fn open_session_multiple_concurrent() { + let ctx = Ctx::new(); let a = ctx.open_session(CU, SessionType::PlainText); let b = ctx.open_session(CU, SessionType::PlainText); assert_ne!( @@ -256,28 +254,187 @@ fn open_session_multiple_concurrent_emu() { ); } +// --------------------------------------------------------------------------- +// Session-table exhaustion + recovery +// +// Iteratively opens sessions until the FW reports the table is full, +// then closes them all and confirms one more open succeeds — the +// pending/active slot cleanup path must fully reclaim capacity. +// +// The table size differs by backend (emu FW has a small hardcoded +// table; hw silicon a larger one), so the loop is bounded generously +// at 16 and the "at least one rejection observed" invariant is a soft +// diagnostic — the value of the test is the close-all + reopen path. +// --------------------------------------------------------------------------- + #[test] -fn open_session_cu_range_exhausted_rejected_emu() { - let ctx = TestCtx::new(); - // The CU role occupies slots 1..=7 (7 slots); slot 0 is reserved for - // CO. Fill every CU slot, holding the guards open, then a further CU - // handshake must be rejected because the range is exhausted. - let mut guards = Vec::new(); - for _ in 0..7 { - guards.push(ctx.open_session(CU, SessionType::PlainText)); +fn open_session_fills_table_then_recovers() { + let ctx = Ctx::new(); + let mut ids: Vec = Vec::new(); + let mut rejection_seen = false; + + for _ in 0..16 { + match ctx.open_session_raw(CU, SessionType::PlainText) { + Ok(h) => ids.push(h.session_id), + Err(e) => { + // FW rejected — treat as "table full". Verify it is + // an FW-side rejection (not a driver / decode fault) + // before ending the ramp-up. + assert!( + matches!(e, azihsm_ddi_interface::DdiError::DdiError(_)), + "table-full rejection must be FW-side, got {e:?}", + ); + rejection_seen = true; + break; + } + } } - let err = ctx + + // Close everything we opened before running the recovery check, + // so a recovery-side failure does not leak slots on the board. + for id in &ids { + let _ = ctx.session_close(*id); + } + + // Recovery: one fresh open must succeed after the batch close. + let recovered = ctx .open_session_raw(CU, SessionType::PlainText) - .expect_err("a CU handshake with the range full must be rejected"); - - // Closing one session frees its slot, so a subsequent handshake - // succeeds again — the rejection was a capacity limit, not a - // permanent failure. - drop(guards.pop()); - let _reopened = ctx.open_session(CU, SessionType::PlainText); - - // Keep the remaining guards live until here so the table stayed full - // for the rejection above. - drop(guards); - let _ = err; + .expect("session table must recover after close-all"); + ctx.session_close(recovered.session_id) + .expect("SessionClose after recovery must succeed"); + + if !rejection_seen { + eprintln!( + "open_session_fills_table_then_recovers: backend accepted {} concurrent sessions \ + without emitting a table-full rejection; capacity limit not observed", + ids.len(), + ); + } +} + +// --------------------------------------------------------------------------- +// Open / close / reopen +// --------------------------------------------------------------------------- + +/// Open a session, close it, then open again. The second open must +/// succeed — guards against a stale slot or leaked FSM state that +/// would otherwise wedge the second attempt. +#[test] +fn open_close_reopen_same_slot() { + let ctx = Ctx::new(); + let first = ctx + .open_session_raw(CU, SessionType::PlainText) + .expect("first handshake must succeed"); + ctx.session_close(first.session_id) + .expect("first SessionClose must succeed"); + + let second = ctx + .open_session_raw(CU, SessionType::PlainText) + .expect("reopen after close must succeed"); + ctx.session_close(second.session_id) + .expect("second SessionClose must succeed"); +} + +// --------------------------------------------------------------------------- +// Per-session key uniqueness +// --------------------------------------------------------------------------- + +/// Two independent CO+Authenticated handshakes must derive distinct +/// exported material — which surfaces as distinct per-direction MAC +/// keys. Runs sequentially (open, snapshot, close, open again) +/// because hw silicon refuses to hold two Authenticated sessions +/// concurrently (`VaultSessionLimitReached`); the ephemeral-per- +/// handshake invariant this test guards is unchanged by that. +#[test] +fn co_authenticated_derives_unique_keys_per_session() { + let ctx = Ctx::new(); + + let a = ctx + .open_session_raw(CO, SessionType::Authenticated) + .expect("first CO+Authenticated handshake must succeed"); + let a_id = a.session_id; + let a_tx = a.derive_mac_tx_key().expect("derive a tx"); + let a_rx = a.derive_mac_rx_key().expect("derive a rx"); + let a_exported = a.exported.clone(); + ctx.session_close(a_id) + .expect("close first session must succeed"); + + let b = ctx + .open_session_raw(CO, SessionType::Authenticated) + .expect("second CO+Authenticated handshake must succeed"); + let b_id = b.session_id; + let b_tx = b.derive_mac_tx_key().expect("derive b tx"); + let b_rx = b.derive_mac_rx_key().expect("derive b rx"); + let b_exported = b.exported.clone(); + // Close before asserting so a failing assertion never leaks. + ctx.session_close(b_id) + .expect("close second session must succeed"); + + assert_ne!( + a_exported, b_exported, + "two handshakes must derive distinct HPKE exported material", + ); + assert_ne!(a_tx, b_tx, "per-session mac tx keys must differ"); + assert_ne!(a_rx, b_rx, "per-session mac rx keys must differ"); + // Sanity: within a single session, tx and rx are distinct. + assert_ne!(a_tx, a_rx); + assert_ne!(b_tx, b_rx); +} + +// --------------------------------------------------------------------------- +// Point-validation negatives +// +// FW `EccPublicKeyValidation` checks that `pk_init` is a valid, +// non-identity point on the P-384 curve. The emu backend uses +// SoftAES + curve mocks that accept whatever is passed, so these +// two tests are hw-only. +// +// Assert a generic FW-side rejection rather than pinning +// `EccPointValidationFailed` in case a future FW change collapses +// validation errors into a broader category (e.g. `InvalidArg`). +// --------------------------------------------------------------------------- + +/// `pk_init` all zeros is trivially off-curve (and encodes the point +/// at infinity in some conventions) — FW must reject. +#[cfg(feature = "hw-tests")] +#[test] +fn pk_init_all_zero_rejected() { + let ctx = Ctx::new(); + let req = TborSessionOpenInitReq { + psk_id: CU, + session_type: SessionType::PlainText.to_u8(), + suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, + pk_init: [0u8; PK_INIT_LEN], + }; + let err = ctx + .tbor(&req) + .expect_err("all-zero pk_init must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + "expected FW-side rejection for all-zero pk_init, got {err:?}", + ); +} + +/// `pk_init` with the SEC1 uncompressed prefix (`0x04`) but garbage +/// coordinates that do not satisfy the P-384 curve equation. FW's +/// on-curve validation must reject. +#[cfg(feature = "hw-tests")] +#[test] +fn pk_init_not_on_curve_rejected() { + let ctx = Ctx::new(); + let mut pk_init = [0xFFu8; PK_INIT_LEN]; + pk_init[0] = 0x04; // SEC1 uncompressed prefix + let req = TborSessionOpenInitReq { + psk_id: CU, + session_type: SessionType::PlainText.to_u8(), + suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, + pk_init, + }; + let err = ctx + .tbor(&req) + .expect_err("off-curve pk_init must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + "expected FW-side rejection for off-curve pk_init, got {err:?}", + ); } diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index 4aefe04b0..af99fb64d 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -3,27 +3,31 @@ //! Integration tests for the out-of-session TBOR `PartInfo` command. //! -//! `round_trip` exercises the full host → backend (`emu` or `sock`) → -//! fw `handle_tbor_op` → response path and asserts the device/partition -//! fields the firmware reports for the default provisioned partition. -//! `part_info_independent_of_session_state_emu` proves the dispatcher +//! `round_trip` exercises the full host → backend (`emu`, `sock`, or +//! `hw-tests` for real silicon) → fw `handle_tbor_op` → response path +//! and asserts the device/partition fields the firmware reports for +//! the default provisioned partition. +//! `part_info_independent_of_session_state` proves the dispatcher //! never lets session-machine state leak into the out-of-session //! handler. `unsupported_on_mock` asserts the design contract that //! backends opt in to TBOR. +//! +//! Uses the [`Ctx`](crate::harness::Ctx) alias — `TestCtx` under +//! emu/mock/sock and `HwCtx` under a pure `hw-tests` build. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] use azihsm_ddi_tbor_types::TborPartInfoReq; -use crate::harness::TestCtx; +use crate::harness::Ctx; /// `DdiDeviceKind::Physical` discriminant — uno is a physical device. -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] const DEVICE_KIND_PHYSICAL: u8 = 2; /// `PartState::Enabled` discriminant — the default provisioned state of /// the emulator partition before any `PartInit`. -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] const PART_STATE_ENABLED: u8 = 2; /// `PartState::Initializing` discriminant — the state a partition enters @@ -34,7 +38,7 @@ const PART_STATE_INITIALIZING: u8 = 4; /// Assert the invariant device-level fields PartInfo reports for the /// default provisioned partition, plus that the identity public key is /// materialized (not all-zero). -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { assert_eq!( resp.device_kind, DEVICE_KIND_PHYSICAL, @@ -50,10 +54,10 @@ fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { ); } -#[cfg(any(feature = "emu", feature = "sock"))] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] #[test] fn round_trip() { - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let resp = ctx .tbor(&TborPartInfoReq::new()) .expect("TBOR PartInfo round-trip"); @@ -65,10 +69,10 @@ fn round_trip() { /// returns a byte-identical response. Catches any regression that would /// silently introduce per-call state (e.g. a counter, a cached /// allocation) into the out-of-session handler. -#[cfg(feature = "emu")] +#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] #[test] -fn part_info_repeated_stable_emu() { - let ctx = TestCtx::new(); +fn part_info_repeated_stable() { + let ctx = Ctx::new(); let baseline = ctx .tbor(&TborPartInfoReq::new()) .expect("baseline PartInfo"); @@ -86,12 +90,12 @@ fn part_info_repeated_stable_emu() { /// occupies a session slot, after that slot transitions to Active, and /// again once it is closed. Catches any regression that would let /// session state leak into the out-of-session handler. -#[cfg(feature = "emu")] +#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] -fn part_info_independent_of_session_state_emu() { +fn part_info_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; - let ctx = TestCtx::new(); + let ctx = Ctx::new(); // No sessions outstanding. let pre = ctx @@ -148,7 +152,7 @@ fn part_info_reflects_part_init_transition_emu() { use crate::commands::part_init::pota_thumbprint; use crate::commands::part_init::ROTATED_CO_PSK; - let ctx = TestCtx::new(); + let ctx = Ctx::new(); // Before PartInit: default Enabled posture with a materialized // identity. @@ -200,7 +204,7 @@ fn part_info_reflects_part_init_transition_emu() { fn unsupported_on_mock() { use crate::harness::assertions::assert_unsupported_encoding; - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let err = ctx .tbor(&TborPartInfoReq::new()) .expect_err("mock backend must not implement exec_op_tbor"); diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 25ce32831..c043a6b61 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -3,11 +3,10 @@ //! Integration tests for the TBOR `PskChange` command. //! -//! Cross-test isolation comes from `open_dev`'s factory-reset; no -//! per-test cleanup is required (see -//! [`crate::harness::fixture`]). Live sessions are owned by a -//! [`SessionGuard`] that closes on `Drop`, including during panic -//! unwind. +//! Cross-test isolation comes from the ctx's factory-reset on +//! construction (`TestCtx::new` on emu/mock/sock, `HwCtx::new` NSSR +//! on hw-tests). Live sessions are owned by a [`SessionGuard`] that +//! closes on `Drop`, including during panic unwind. //! //! Coverage: //! * Happy paths (CO + CU), with explicit reopen using the rotated @@ -17,16 +16,21 @@ //! surfaces `TborStatus::InvalidPermissions`. //! * Envelope-tampering negatives: ciphertext bit-flip and AAD //! bit-flip both surface `TborStatus::AeadEnvelopeAuthFailed`. -//! * Empty / wrong-length envelope → `TborStatus::TborInvalidFixedLength` -//! (the schema pins `psk_envelope` to `PSK_CHANGE_ENVELOPE_LEN`). +//! * Empty / wrong-length envelope → a length-reject status. Emu's +//! decoder returns `TborInvalidFixedLength`; the hw decoder trips +//! the schema check earlier and returns `DdiDecodeFailed`. See +//! [`LENGTH_REJECT_STATUS`]. //! * AAD that encodes a session id other than the request's //! session id → `TborStatus::AeadEnvelopeAuthFailed`. //! * Envelope encrypted under a *different* session's `param_key` //! shipped through this session → `TborStatus::AeadEnvelopeAuthFailed`. //! * Plaintext not exactly `PSK_LEN` (→ wrong envelope length) → -//! `TborStatus::TborInvalidFixedLength`. +//! [`LENGTH_REJECT_STATUS`]. +//! +//! Uses the [`Ctx`](crate::harness::Ctx) alias — `TestCtx` under +//! emu/mock/sock and `HwCtx` under a pure `hw-tests` build. -#![cfg(feature = "emu")] +#![cfg(any(feature = "emu", feature = "hw-tests"))] use azihsm_crypto::aead_envelope; use azihsm_crypto::aead_envelope::AeadAlg; @@ -39,13 +43,25 @@ use azihsm_ddi_tbor_types::PSK_LEN; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; +use crate::harness::Ctx; use crate::harness::SessionOpenInitOptions; use crate::harness::TborPskChangeReq; -use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; +/// Backend-specific status for wire-decode length rejects. +/// +/// Emu's decoder returns the handler's specific +/// [`TborStatus::TborInvalidFixedLength`]. The hw decoder trips the +/// schema's `#[tbor(buffer, len = 100)]` check earlier and surfaces +/// the failure as [`TborStatus::DdiDecodeFailed`] before the +/// handler's defensive branch is reached. +#[cfg(feature = "emu")] +const LENGTH_REJECT_STATUS: TborStatus = TborStatus::TborInvalidFixedLength; +#[cfg(all(feature = "hw-tests", not(feature = "emu")))] +const LENGTH_REJECT_STATUS: TborStatus = TborStatus::DdiDecodeFailed; + /// Distinct, non-default 32-byte PSK used by the happy-path tests. const ROTATED_PSK: [u8; PSK_LEN] = [ 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, @@ -83,7 +99,7 @@ fn build_envelope(param_key: &AesKey, aad: &[u8], plaintext: &[u8]) -> Vec { /// then prove the rotation took effect by reopening under the new /// bytes. fn run_psk_change_happy(role: u8, sty: SessionType) { - let ctx = TestCtx::new(); + let ctx = Ctx::new(); let session = ctx.open_session(role, sty); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate to ROTATED_PSK"); @@ -101,12 +117,12 @@ fn run_psk_change_happy(role: u8, sty: SessionType) { } #[test] -fn psk_change_happy_cu_emu() { +fn psk_change_happy_cu() { run_psk_change_happy(CU, SessionType::PlainText); } #[test] -fn psk_change_happy_co_emu() { +fn psk_change_happy_co() { run_psk_change_happy(CO, SessionType::Authenticated); } @@ -115,8 +131,8 @@ fn psk_change_happy_co_emu() { // =========================================================================== #[test] -fn psk_change_reopen_with_old_psk_fails_emu() { - let ctx = TestCtx::new(); +fn psk_change_reopen_with_old_psk_fails() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate"); @@ -138,8 +154,8 @@ fn psk_change_reopen_with_old_psk_fails_emu() { // =========================================================================== #[test] -fn psk_change_second_attempt_same_session_fails_emu() { - let ctx = TestCtx::new(); +fn psk_change_second_attempt_same_session_fails() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("first rotate"); @@ -163,8 +179,8 @@ fn psk_change_second_attempt_same_session_fails_emu() { // =========================================================================== #[test] -fn psk_change_envelope_tampered_emu() { - let ctx = TestCtx::new(); +fn psk_change_envelope_tampered() { + let ctx = Ctx::new(); for (label, mutate) in [ ( @@ -201,16 +217,16 @@ fn psk_change_envelope_tampered_emu() { // =========================================================================== #[test] -fn psk_change_empty_envelope_emu() { - let ctx = TestCtx::new(); +fn psk_change_empty_envelope() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); let req = TborPskChangeReq { session_id: session.session_id(), psk_envelope: Vec::new(), }; - // The FW schema pins `psk_envelope` to PSK_CHANGE_ENVELOPE_LEN - // (100 B), so an empty envelope is rejected at decode. - ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); + // FW schema pins `psk_envelope` to PSK_CHANGE_ENVELOPE_LEN (100 B); + // reject status varies by decode path (see LENGTH_REJECT_STATUS). + ctx.expect_fw_reject(&req, LENGTH_REJECT_STATUS); } // =========================================================================== @@ -218,8 +234,8 @@ fn psk_change_empty_envelope_emu() { // =========================================================================== #[test] -fn psk_change_wrong_session_id_in_aad_emu() { - let ctx = TestCtx::new(); +fn psk_change_wrong_session_id_in_aad() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // Build an envelope whose AAD encodes a different (bogus) // session id. AEAD-GCM tag verifies (the FW recomputes the tag @@ -236,23 +252,28 @@ fn psk_change_wrong_session_id_in_aad_emu() { // =========================================================================== // Envelope built under a *different* session's param_key +// +// Two live sessions. Encrypt under A's param_key but ship the request +// through B (with B's session id in both the request header and the +// AAD). FW uses B's param_key to verify the AEAD-GCM tag → mismatch. +// On hw the request has to reach the fd that owns session B, so route +// via `expect_fw_reject_on_session` — a raw `expect_fw_reject` would +// land on the primary fd and the Linux driver would reject it before +// FW sees the request. // =========================================================================== #[test] -fn psk_change_envelope_from_other_session_emu() { - let ctx = TestCtx::new(); +fn psk_change_envelope_from_other_session() { + let ctx = Ctx::new(); let session_a = ctx.open_session(CU, SessionType::PlainText); let session_b = ctx.open_session(CU, SessionType::PlainText); - // Encrypt under A's param_key but ship through B (with B's - // session id in the request). FW uses B's param_key to verify - // the AEAD-GCM tag → mismatch. let aad_for_b = build_psk_change_aad(session_b.session_id()); let envelope = build_envelope(&session_a.handshake().param_key, &aad_for_b, &ROTATED_PSK); let req = TborPskChangeReq { session_id: session_b.session_id(), psk_envelope: envelope, }; - ctx.expect_fw_reject(&req, TborStatus::AeadEnvelopeAuthFailed); + ctx.expect_fw_reject_on_session(session_b.session_id(), &req, TborStatus::AeadEnvelopeAuthFailed); } // =========================================================================== @@ -260,12 +281,13 @@ fn psk_change_envelope_from_other_session_emu() { // =========================================================================== #[test] -fn psk_change_wrong_plaintext_length_emu() { - let ctx = TestCtx::new(); +fn psk_change_wrong_plaintext_length() { + let ctx = Ctx::new(); // PSK_LEN ± 1: shortest excursions either side of the canonical // length. A wrong plaintext length yields a wrong *envelope* length // (ciphertext tracks plaintext for GCM), so the FW schema's fixed - // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both at decode. + // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both — status varies by + // decode path (see LENGTH_REJECT_STATUS). for len in [PSK_LEN - 1, PSK_LEN + 1] { let session = ctx.open_session(CU, SessionType::PlainText); let bogus_psk = vec![0xCDu8; len]; @@ -278,7 +300,7 @@ fn psk_change_wrong_plaintext_length_emu() { let err = ctx.tbor(&req).expect_err(&format!( "plaintext length {len} (≠ PSK_LEN={PSK_LEN}) must be rejected", )); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::TborInvalidFixedLength); + crate::harness::assertions::assert_fw_rejects(&err, LENGTH_REJECT_STATUS); } } @@ -288,17 +310,17 @@ fn psk_change_wrong_plaintext_length_emu() { // =========================================================================== #[test] -fn psk_change_wrong_aad_length_emu() { - let ctx = TestCtx::new(); +fn psk_change_wrong_aad_length() { + let ctx = Ctx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // 64 bytes of arbitrary AAD (valid AEAD granularity) inflates the - // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B), so the FW schema's - // fixed-length check rejects it at decode. + // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B) — status varies + // by decode path (see LENGTH_REJECT_STATUS). let long_aad = vec![0u8; 64]; let envelope = build_envelope(&session.handshake().param_key, &long_aad, &ROTATED_PSK); let req = TborPskChangeReq { session_id: session.session_id(), psk_envelope: envelope, }; - ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); + ctx.expect_fw_reject(&req, LENGTH_REJECT_STATUS); } diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index bdba7df84..b22ba9031 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -111,6 +111,28 @@ impl TestCtx { self.dev.exec_op_tbor(req, Some(oob_items), &mut cookie) } + /// Session-scoped raw tbor exec — passthrough on emu (single dev + /// carries every session). Present here so tests can call the + /// same name on both backends; on hw it routes the op to the fd + /// that owns `session_id` (see `HwCtx::tbor_on_session`). + pub fn tbor_on_session( + &self, + _session_id: u16, + req: &R, + ) -> DdiResult { + self.tbor(req) + } + + /// Session-scoped OOB variant — passthrough on emu. + pub fn tbor_oob_on_session( + &self, + _session_id: u16, + req: &R, + oob_items: &[&[u8]], + ) -> DdiResult { + self.tbor_oob(req, oob_items) + } + /// Issue `req`, assert the FW dispatcher rejected it with exactly /// `expected`, and return the matched [`DdiError`] for any further /// caller-side inspection. @@ -136,6 +158,31 @@ impl TestCtx { } } + /// Session-scoped variant of [`Self::expect_fw_reject`] — + /// passthrough on emu; on hw routes via the fd owning + /// `session_id`. + #[track_caller] + pub fn expect_fw_reject_on_session( + &self, + session_id: u16, + req: &R, + expected: TborStatus, + ) -> DdiError + where + R::OpResp: core::fmt::Debug, + { + match self.tbor_on_session(session_id, req) { + Ok(resp) => panic!( + "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", + expected.0, + ), + Err(err) => { + assert_fw_rejects(&err, expected); + err + } + } + } + /// [`Self::expect_fw_reject`] for a request carrying out-of-band SGL /// items (see [`Self::tbor_oob`]). #[track_caller] diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs index cd5df5859..c7c6bd30e 100644 --- a/ddi/tbor/types/tests/harness/hw_ctx.rs +++ b/ddi/tbor/types/tests/harness/hw_ctx.rs @@ -19,7 +19,8 @@ //! Gated behind `feature = "hw-tests"`. Only compiled when the test //! binary is built against a real silicon backend. -use std::ops::Deref; +use std::collections::HashMap; +use std::sync::Arc; use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; @@ -65,65 +66,159 @@ static HW_TEST_LOCK: Mutex<()> = Mutex::new(()); /// [`azihsm_ddi_win::DdiWin`] on Windows. type HwDev = ::Dev; -/// Owned wrapper around an opened hw device that holds the -/// process-global test lock for its lifetime. -struct HwTestDev { - dev: HwDev, - _guard: MutexGuard<'static, ()>, -} - -impl Deref for HwTestDev { - type Target = HwDev; - fn deref(&self) -> &Self::Target { - &self.dev - } -} - /// Acquire the hw test lock, open the native backend device, and -/// NSSR-reset it so the test starts at pristine defaults. +/// NSSR-reset it so the test starts at pristine defaults. Returns +/// the lock guard alongside the primary fd (both must live for the +/// full `HwCtx` lifetime). /// /// Panics if the backend advertises no devices or if NSSR-on-entry /// fails — both are environment bugs (no device plugged in, or a /// wedged partition) and running a test against a dirty device would /// produce a misleading failure downstream. -fn open_hw_dev() -> HwTestDev { +fn open_hw_dev() -> (HwDev, MutexGuard<'static, ()>) { let guard = HW_TEST_LOCK.lock(); let ddi = AzihsmDdi::default(); let infos = ddi.dev_info_list(); let info = infos.first().expect("hw backend advertises no device"); let dev = ddi.open_dev(&info.path).expect("open hw backend device"); dev.erase().expect("open_hw_dev: NSSR must succeed before test"); - HwTestDev { dev, _guard: guard } + (dev, guard) +} + +/// Open an *additional* fd on the same physical device, without +/// re-acquiring [`HW_TEST_LOCK`] and without issuing NSSR. Used when +/// a test needs a second (third, …) concurrent session — the Linux +/// kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every +/// concurrent session past the first sits on its own fd. +/// +/// Takes `&HwDev` purely to borrow the primary fd's lifetime — the +/// caller must already hold the process-global lock via that fd for +/// the returned fd to be safe to use. +fn open_extra_hw_dev(_lock_holder: &HwDev) -> HwDev { + let ddi = AzihsmDdi::default(); + let infos = ddi.dev_info_list(); + let info = infos.first().expect("hw backend advertises no device"); + ddi.open_dev(&info.path).expect("open extra hw fd") } /// One-test fixture for the hw backend. Mirrors [`TestCtx`] method-for-method. /// +/// Every session opened on this ctx lands in [`Self::sessions`] keyed +/// by its FW-assigned id, and every session-scoped op looks up the +/// owning fd via the map — so callers can treat `HwCtx` exactly like +/// `TestCtx`. +/// +/// Fd placement policy: the first live session lives on the primary +/// fd (`primary`, the same fd that acquired [`HW_TEST_LOCK`] and did +/// NSSR at construction). This matches the pattern the hw test suite +/// relied on before the merge — driver behaviour with sessions on +/// only-extra fds (leaving primary idle) has proven fragile. When +/// primary already carries a live session, additional concurrent +/// sessions get their own fresh fds via [`open_extra_hw_dev`]. +/// Routing is always via [`Self::sessions`] — nothing session-scoped +/// ever bypasses it and targets `primary` directly. +/// /// [`TestCtx`]: crate::harness::ctx::TestCtx pub struct HwCtx { - dev: HwTestDev, + primary: Arc, + _guard: MutexGuard<'static, ()>, + /// `true` while `primary` currently owns a live session. Cleared + /// when that session's entry is removed from `sessions`. + primary_busy: Mutex, + /// Fd used by the most recent [`Self::session_open_init`] / + /// [`Self::session_open_init_with_options`], waiting for a + /// matching finish to promote it into [`Self::sessions`]. Same + /// `Arc` as either `primary` (when we grabbed primary) or a fresh + /// fd (when primary was busy). + pending_fd: Mutex>>, + /// Every live session, keyed by FW-assigned session id. Value is + /// the `Arc` for the fd that owns that session — same `Arc` as + /// `primary` or a fresh fd. + sessions: Mutex>>, } impl HwCtx { - /// Open the hw device via [`open_hw_dev`]. + /// Acquire the hw test lock, open the primary fd, NSSR, and set + /// up empty session tracking. pub fn new() -> Self { - Self { dev: open_hw_dev() } + let (dev, guard) = open_hw_dev(); + Self { + primary: Arc::new(dev), + _guard: guard, + primary_busy: Mutex::new(false), + pending_fd: Mutex::new(None), + sessions: Mutex::new(HashMap::new()), + } } /// NSSR / factory-reset the partition. Same call `TestCtx::erase` /// makes on emu; the trait method [`DdiDev::erase`] resolves to /// NSSR on the native backend. pub fn erase(&self) -> DdiResult<()> { - self.dev.erase() + self.primary.erase() + } + + /// Look up the fd that owns `session_id`, or return `None` for + /// unknown ids (negative-path tests exercising invalid ids, or + /// ids from a session that's already been closed). + fn dev_for_session(&self, session_id: u16) -> Option> { + self.sessions.lock().get(&session_id).cloned() + } + + /// Pick the fd for a fresh handshake: primary if free, else a + /// brand-new extra fd. Also flips `primary_busy` if we took + /// primary — the caller must roll that back on error and + /// [`Self::session_close`] must clear it on close. + fn take_fd_for_new_session(&self) -> (Arc, bool) { + let mut busy = self.primary_busy.lock(); + if !*busy { + *busy = true; + (Arc::clone(&self.primary), true) + } else { + (Arc::new(open_extra_hw_dev(&self.primary)), false) + } } pub fn tbor(&self, req: &R) -> DdiResult { let mut cookie = None; - self.dev.exec_op_tbor(req, None, &mut cookie) + self.primary.exec_op_tbor(req, None, &mut cookie) } pub fn tbor_oob(&self, req: &R, oob_items: &[&[u8]]) -> DdiResult { let mut cookie = None; - self.dev.exec_op_tbor(req, Some(oob_items), &mut cookie) + self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie) + } + + /// Session-scoped raw tbor exec: routes the op via the fd that + /// owns `session_id`, so requests referring to a specific session + /// (`TborPskChangeReq`, `TborPartInitReq`, …) reach the kernel + /// driver on the correct fd. Falls back to `primary` for unknown + /// ids — lets negative-path tests exercise "bogus session id" + /// paths without special-casing. + pub fn tbor_on_session( + &self, + session_id: u16, + req: &R, + ) -> DdiResult { + let mut cookie = None; + match self.dev_for_session(session_id) { + Some(dev) => dev.exec_op_tbor(req, None, &mut cookie), + None => self.primary.exec_op_tbor(req, None, &mut cookie), + } + } + + /// Session-scoped OOB variant of [`Self::tbor_on_session`]. + pub fn tbor_oob_on_session( + &self, + session_id: u16, + req: &R, + oob_items: &[&[u8]], + ) -> DdiResult { + let mut cookie = None; + match self.dev_for_session(session_id) { + Some(dev) => dev.exec_op_tbor(req, Some(oob_items), &mut cookie), + None => self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie), + } } #[track_caller] @@ -143,6 +238,28 @@ impl HwCtx { } } + #[track_caller] + pub fn expect_fw_reject_on_session( + &self, + session_id: u16, + req: &R, + expected: TborStatus, + ) -> DdiError + where + R::OpResp: core::fmt::Debug, + { + match self.tbor_on_session(session_id, req) { + Ok(resp) => panic!( + "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", + expected.0, + ), + Err(err) => { + assert_fw_rejects(&err, expected); + err + } + } + } + #[track_caller] pub fn expect_fw_reject_oob( &self, @@ -184,18 +301,59 @@ impl HwCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - session_open_init_helper(&self.dev, psk_id, session_type) + let (dev, took_primary) = self.take_fd_for_new_session(); + match session_open_init_helper(&dev, psk_id, session_type) { + Ok(pending) => { + *self.pending_fd.lock() = Some(dev); + Ok(pending) + } + Err(e) => { + if took_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } pub fn session_open_init_with_options( &self, opts: SessionOpenInitOptions<'_>, ) -> DdiResult { - session_open_init_with_options_helper(&self.dev, opts) + let (dev, took_primary) = self.take_fd_for_new_session(); + match session_open_init_with_options_helper(&dev, opts) { + Ok(pending) => { + *self.pending_fd.lock() = Some(dev); + Ok(pending) + } + Err(e) => { + if took_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - session_open_finish_helper(&self.dev, pending) + let dev = self + .pending_fd + .lock() + .take() + .expect("session_open_finish: no pending fd — call session_open_init first"); + let is_primary = Arc::ptr_eq(&dev, &self.primary); + match session_open_finish_helper(&dev, pending) { + Ok(handshake) => { + self.sessions.lock().insert(handshake.session_id, dev); + Ok(handshake) + } + Err(e) => { + if is_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } pub fn session_open_finish_with_mac( @@ -203,7 +361,24 @@ impl HwCtx { pending: PendingHandshake, mac_fin: [u8; 48], ) -> DdiResult { - session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) + let dev = self + .pending_fd + .lock() + .take() + .expect("session_open_finish_with_mac: no pending fd — call session_open_init first"); + let is_primary = Arc::ptr_eq(&dev, &self.primary); + match session_open_finish_with_mac_helper(&dev, pending, mac_fin) { + Ok(handshake) => { + self.sessions.lock().insert(handshake.session_id, dev); + Ok(handshake) + } + Err(e) => { + if is_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } pub fn open_session_raw( @@ -216,11 +391,31 @@ impl HwCtx { } pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - session_close_helper(&self.dev, session_id) + // Remove the entry so the owning fd drops (closing the kernel + // handle) after the close call — unless the fd is `primary`, + // in which case just clear the busy flag so the next session + // can reuse it. Fall back to `primary` on unknown ids so the + // FW/driver can surface its own error for negative-path tests. + let entry = self.sessions.lock().remove(&session_id); + match entry { + Some(dev) => { + let is_primary = Arc::ptr_eq(&dev, &self.primary); + let res = session_close_helper(&dev, session_id); + if is_primary { + *self.primary_busy.lock() = false; + } + drop(dev); + res + } + None => session_close_helper(&self.primary, session_id), + } } pub fn psk_change(&self, session: &SessionHandshake, new_psk: &[u8]) -> DdiResult<()> { - psk_change_helper(&self.dev, session, new_psk) + match self.dev_for_session(session.session_id) { + Some(dev) => psk_change_helper(&dev, session, new_psk), + None => psk_change_helper(&self.primary, session, new_psk), + } } pub fn part_init( @@ -230,8 +425,10 @@ impl HwCtx { part_policy: &[u8], pota_thumbprint: &[u8], ) -> DdiResult { + let dev = self.dev_for_session(session.session_id); + let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); part_init_helper( - &self.dev, + dev_ref, session, mach_seed, part_policy, @@ -250,8 +447,10 @@ impl HwCtx { sata_thumbprint: &[u8], sapota_thumbprint: Option<&[u8]>, ) -> DdiResult { + let dev = self.dev_for_session(session.session_id); + let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); part_init_helper( - &self.dev, + dev_ref, session, mach_seed, part_policy, @@ -268,19 +467,40 @@ impl HwCtx { prev_local_mk_backup: &[u8], certs: &[&[u8]], ) -> DdiResult { - part_final_helper(&self.dev, session, part_policy, prev_local_mk_backup, certs) + let dev = self.dev_for_session(session.session_id); + let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); + part_final_helper(dev_ref, session, part_policy, prev_local_mk_backup, certs) } pub fn api_rev(&self) -> DdiResult { - helper_api_rev_tbor(&self.dev) + helper_api_rev_tbor(&self.primary) } } -/// Best-effort NSSR so a panicking test still hands the next test a -/// clean device. Errors are intentionally swallowed — a wedged -/// device that rejects `erase` during unwind must not double-panic. +/// Panic-safe cleanup: close every session the ctx is still tracking, +/// then best-effort NSSR. Sessions **must** be closed one-by-one — +/// NSSR alone does not clear the FW session table, so relying on it +/// would leak slots into the next test. +/// +/// Errors are intentionally swallowed. Drop never panics — a wedged +/// device that rejects `session_close` during unwind must not +/// double-panic, and a leaked slot surfaces loudly on the next test's +/// `session_open` anyway. impl Drop for HwCtx { fn drop(&mut self) { - let _ = self.dev.erase(); + // Drain the map so each fd Arc's refcount hits 0 as we finish + // closing its session, releasing the extra kernel fds. + let live: Vec<(u16, Arc)> = + self.sessions.lock().drain().collect(); + for (id, dev) in live { + if let Err(e) = session_close_helper(&dev, id) { + eprintln!( + "HwCtx::drop: session_close({id}) failed: {e:?} \ + — session may leak on the device", + ); + } + } + *self.primary_busy.lock() = false; + let _ = self.primary.erase(); } } diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 1f56aba89..83098c1df 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -52,7 +52,6 @@ pub mod fixture; #[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] pub mod hw_ctx; pub mod session; -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub mod session_guard; #[cfg(feature = "emu")] pub mod x509_fixture; @@ -100,5 +99,4 @@ pub use session::session_open_init_with_options; pub use session::PendingHandshake; pub use session::SessionHandshake; pub use session::SessionOpenInitOptions; -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use session_guard::SessionGuard; diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index 6e201649a..fae0b1f21 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -4,45 +4,56 @@ //! RAII guard for a live TBOR session. //! //! A [`SessionGuard`] owns the handshake carrier produced by -//! [`TestCtx::open_session_raw`](crate::harness::TestCtx::open_session_raw) -//! and closes the session when dropped — including when the test is -//! unwinding from a failed assertion. The emulator's session table -//! is process-global and the per-test serialisation provided by -//! [`open_dev`](crate::harness::open_dev)'s `TEST_LOCK` only orders -//! execution; it does not clean up leaked slots. The guard -//! therefore makes panic-safe cleanup the default for every -//! happy-path session test. +//! `open_session_raw` on either [`TestCtx`](crate::harness::ctx::TestCtx) +//! (emu/mock/sock) or [`HwCtx`](crate::harness::hw_ctx::HwCtx) +//! (hw-tests) and closes the session when dropped — including when +//! the test is unwinding from a failed assertion. Backend session +//! tables are shared state (the emulator's is process-global, and a +//! real device has a single fixed session table); the per-test +//! serialisation provided by the respective backend lock only orders +//! execution, it does not clean up leaked slots. The guard therefore +//! makes panic-safe cleanup the default for every happy-path session +//! test. //! //! Negative-path tests that need to intercept the handshake mid-flight //! (e.g. ship a tampered `mac_fin`, double-close the same id, exercise -//! a pending-only slot) keep using -//! [`TestCtx::session_open_init`](crate::harness::TestCtx::session_open_init) / -//! [`TestCtx::session_open_finish`](crate::harness::TestCtx::session_open_finish) / -//! [`TestCtx::session_close`](crate::harness::TestCtx::session_close) -//! directly. The guard exists for the well-behaved 90% case, not for -//! those intentional misuses. +//! a pending-only slot) keep using `session_open_init` / +//! `session_open_finish` / `session_close` on the ctx directly. The +//! guard exists for the well-behaved 90% case, not for those +//! intentional misuses. use azihsm_ddi_interface::DdiResult; use azihsm_ddi_tbor_types::SessionType; use crate::harness::session::SessionHandshake; -use crate::harness::TestCtx; +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +use crate::harness::ctx::TestCtx; +#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +use crate::harness::hw_ctx::HwCtx; + +/// Backend-agnostic hook the guard needs at cleanup time. Implemented +/// by every ctx type whose `open_session` returns a [`SessionGuard`] +/// so the guard file itself stays free of backend `cfg` scaffolding. +pub trait SessionCloser { + /// Close the session identified by `session_id`. Called from + /// [`SessionGuard::close`] and (best-effort) from [`Drop`]. + fn close_session_by_id(&self, session_id: u16) -> DdiResult<()>; +} /// RAII handle to a live session. Closes on `Drop` unless explicitly -/// consumed via [`Self::close`]. Borrows the [`TestCtx`] for the -/// guard's lifetime — multiple guards from the same ctx are allowed -/// (the borrow is shared), which is how multi-session tests like -/// `open_session_multiple_concurrent_emu` will be expressed once -/// migrated. +/// consumed via [`Self::close`]. Borrows the ctx (as a +/// [`SessionCloser`] trait object) for the guard's lifetime — multiple +/// guards from the same ctx are allowed (the borrow is shared). pub struct SessionGuard<'ctx> { - ctx: &'ctx TestCtx, + ctx: &'ctx dyn SessionCloser, handshake: SessionHandshake, closed: bool, } impl<'ctx> SessionGuard<'ctx> { - /// Internal constructor — driven by [`TestCtx::open_session`]. - pub(crate) fn new(ctx: &'ctx TestCtx, handshake: SessionHandshake) -> Self { + /// Internal constructor — driven by the ctx's inherent + /// `open_session` method. + pub(crate) fn new(ctx: &'ctx dyn SessionCloser, handshake: SessionHandshake) -> Self { Self { ctx, handshake, @@ -65,11 +76,11 @@ impl<'ctx> SessionGuard<'ctx> { /// /// Consuming `self` makes double-close a *compile* error rather /// than a runtime one — tests that *want* to assert the FW - /// rejects a double-close must drive the second - /// [`TestCtx::session_close`] call themselves. + /// rejects a double-close must drive the second `session_close` + /// call themselves. pub fn close(mut self) -> DdiResult<()> { self.closed = true; - self.ctx.session_close(self.handshake.session_id) + self.ctx.close_session_by_id(self.handshake.session_id) } } @@ -82,7 +93,7 @@ impl Drop for SessionGuard<'_> { // slot corrupts the next serial test's starting state. // Drop never panics — failure is logged so the original panic // (if any) keeps its place at the top of the stack trace. - if let Err(e) = self.ctx.session_close(self.handshake.session_id) { + if let Err(e) = self.ctx.close_session_by_id(self.handshake.session_id) { eprintln!( "SessionGuard: session_close({}) failed during drop: {e:?}", self.handshake.session_id, @@ -91,6 +102,14 @@ impl Drop for SessionGuard<'_> { } } +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +impl SessionCloser for TestCtx { + fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { + self.session_close(session_id) + } +} + +#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] impl TestCtx { /// Open a session via the happy-path two-phase handshake and /// return a [`SessionGuard`] that will close it on `Drop`. @@ -105,3 +124,31 @@ impl TestCtx { SessionGuard::new(self, handshake) } } + +#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +impl SessionCloser for HwCtx { + fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { + self.session_close(session_id) + } +} + +#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +impl HwCtx { + /// Open a session via the happy-path two-phase handshake and + /// return a [`SessionGuard`] that will close it on `Drop`. + /// + /// On hw every session lives on its own fd (kernel driver + /// enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`); the ctx tracks + /// session_id → fd so subsequent session-scoped ops route to the + /// right fd. + /// + /// Panics on any FW or transport error; negative-path tests must + /// call [`HwCtx::session_open_init`] (etc.) directly so they can + /// inspect the failure mode. + pub fn open_session(&self, psk_id: u8, session_type: SessionType) -> SessionGuard<'_> { + let handshake = self + .open_session_raw(psk_id, session_type) + .expect("HwCtx::open_session: handshake must succeed on the happy path"); + SessionGuard::new(self, handshake) + } +} diff --git a/ddi/tbor/types/tests/hw/assertions.rs b/ddi/tbor/types/tests/hw/assertions.rs deleted file mode 100644 index b31973d05..000000000 --- a/ddi/tbor/types/tests/hw/assertions.rs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Re-export of `harness/assertions.rs` for hardware tests. -//! -//! The `harness` module tree is `#[cfg]`'d off in the hardware build, -//! so we pull the file in directly via `#[path]` and re-export its -//! public surface. This keeps hw and emu tests using the same -//! `assert_fw_rejects` predicate so a wire-contract change touches -//! one file. - -#[path = "../harness/assertions.rs"] -mod _inner; -pub use _inner::*; diff --git a/ddi/tbor/types/tests/hw/default_psk_gate.rs b/ddi/tbor/types/tests/hw/default_psk_gate.rs deleted file mode 100644 index 36e421ba1..000000000 --- a/ddi/tbor/types/tests/hw/default_psk_gate.rs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware end-to-end tests for the TBOR dispatcher's default-PSK -//! gate (see `fw/core/lib/src/ddi/tbor/mod.rs::dispatch`). -//! -//! Mirrors the emu suite in `commands::default_psk_gate` and adds -//! the E4 case (in-session, non-allow-listed opcode rejected with -//! `DefaultPskMustRotate`) using `PartInit`. -//! -//! Cross-test isolation comes from -//! [`hw_test_reset`](crate::hw::harness::hw_test_reset) — NSSR -//! before + after each test body so every test starts and ends with -//! the partition at pristine defaults. - -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_tbor_types::PolicyKeyKind; -use azihsm_ddi_tbor_types::SessionType; -use azihsm_ddi_tbor_types::TborApiRevReq; -use azihsm_ddi_tbor_types::TborStatus; -use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; -use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; -use azihsm_ddi_tbor_types::MACH_SEED_LEN; -use azihsm_ddi_tbor_types::PART_POLICY_LEN; -use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; -use azihsm_ddi_tbor_types::SATA_THUMBPRINT_LEN; - -use crate::hw::assertions::assert_fw_rejects; -use crate::hw::harness::hw_test_reset; -use crate::hw::session_helper::open_session; -use crate::hw::session_helper::part_init; -use crate::hw::session_helper::session_close; -use crate::hw::session_helper::session_open_finish; -use crate::hw::session_helper::session_open_init_with_options; -use crate::hw::session_helper::SessionOpenInitOptions; - -const CO: u8 = 0; -const CU: u8 = 1; - -/// Build a 484-byte `PartPolicy` blob that passes wire decode so -/// the request reaches the dispatcher's gate. Mirrors -/// `commands::part_init::known_good_part_policy`. -fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - const OFF_SATA: usize = 102; - const OFF_FLAGS: usize = 418; - const OFF_INFO: usize = 419; - - fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { - bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); - bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); - for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { - *b = (fill.wrapping_add(i as u8)) | 0x80; - } - } - - let mut bytes = [0u8; PART_POLICY_LEN]; - bytes[0] = 1; - bytes[1] = 0; - write_pubkey(&mut bytes, OFF_POTA, 0x10); - write_pubkey(&mut bytes, OFF_SATA, 0x20); - bytes[OFF_FLAGS] = 0; - for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { - *b = 0xAB; - } - bytes -} - -fn mach_seed() -> [u8; MACH_SEED_LEN] { - let mut v = [0u8; MACH_SEED_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x40 + i as u8; - } - v -} - -fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { - [0x5Au8; POTA_THUMBPRINT_LEN] -} - -fn sata_thumbprint() -> [u8; SATA_THUMBPRINT_LEN] { - [0x6Au8; SATA_THUMBPRINT_LEN] -} - -/// E5: out-of-session `ApiRev` is never gated. -#[test] -fn api_rev_bypass() { - hw_test_reset(|dev| { - let mut cookie = None; - let _ = dev - .exec_op_tbor(&TborApiRevReq::new(), None, &mut cookie) - .expect("ApiRev bypasses the gate on any PSK state"); - }); -} - -/// E2 + E3: SessionOpenInit / SessionOpenFinish / SessionClose all -/// bypass the gate under the default PSK. -#[test] -fn session_open_and_close_bypass() { - hw_test_reset(|dev| { - let opts_co = - SessionOpenInitOptions::new(CO, SessionType::Authenticated).with_psk(&DEFAULT_PSK_CO); - let pending_co = session_open_init_with_options(dev, opts_co).expect("CO init"); - let session_co = session_open_finish(dev, pending_co).expect("CO finish"); - session_close(dev, session_co.session_id).expect("CO close"); - - let opts_cu = - SessionOpenInitOptions::new(CU, SessionType::PlainText).with_psk(&DEFAULT_PSK_CU); - let pending_cu = session_open_init_with_options(dev, opts_cu).expect("CU init"); - let session_cu = session_open_finish(dev, pending_cu).expect("CU finish"); - session_close(dev, session_cu.session_id).expect("CU close"); - }); -} - -/// E4: in-session non-allow-listed opcode (`PartInit`) is rejected -/// with `DefaultPskMustRotate`. Gate rejects at dispatch, before -/// any handler runs. -#[test] -fn part_init_rejected_under_default_psk() { - hw_test_reset(|dev| { - let session = open_session(dev, CO, SessionType::Authenticated).expect("open CO"); - let session_id = session.session_id; - - let err = part_init( - dev, - &session, - &mach_seed(), - &known_good_part_policy(), - &pota_thumbprint(), - &sata_thumbprint(), - None, - ) - .expect_err("PartInit under default PSK must be gated"); - assert_fw_rejects(&err, TborStatus::DefaultPskMustRotate); - - session_close(dev, session_id).expect("close CO"); - }); -} diff --git a/ddi/tbor/types/tests/hw/harness.rs b/ddi/tbor/types/tests/hw/harness.rs deleted file mode 100644 index e00fe0d99..000000000 --- a/ddi/tbor/types/tests/hw/harness.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Setup / execute / cleanup harness for hardware tests. -//! -//! Modelled on the mbor integration-test `ddi_dev_test` helper -//! (`ddi/mbor/types/tests/integration/common.rs`) but panic-safe: -//! cleanup runs even if `execute` panics, and any panic is -//! re-raised after cleanup so the failure is still reported. -//! -//! The default cleanup is [`cleanup_nssr`], which issues NSSR / -//! factory-reset via [`DdiDev::erase`]. Combined with -//! [`setup_nssr`] on entry, each test starts and ends with the -//! partition at pristine defaults (PSKs at `DEFAULT_PSK_*`, -//! partition `Enabled`, session table empty) so tests are -//! idempotent and order-independent. - -use std::panic::AssertUnwindSafe; - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use azihsm_ddi_interface::DdiDev; - -use crate::hw::open_hw_dev; - -/// Backend device type shared across all hw tests. -pub type HwDevInner = ::Dev; - -/// Run `setup` -> `execute` -> `cleanup` against a locked hw device. -/// -/// The value returned by `setup` is passed by reference to `execute` -/// and moved into `cleanup`, mirroring the mbor pattern of -/// `setup -> u16 -> test -> cleanup(Some(u16))`. -/// -/// `cleanup` runs even if `execute` panics; the panic is re-raised -/// after cleanup so the test still fails. -pub fn hw_test(setup: S, execute: T, cleanup: C) -where - S: FnOnce(&HwDevInner) -> U, - T: FnOnce(&HwDevInner, &U), - C: FnOnce(&HwDevInner, U), -{ - let dev = open_hw_dev(); - let state = setup(&dev); - let result = std::panic::catch_unwind(AssertUnwindSafe(|| execute(&dev, &state))); - cleanup(&dev, state); - if let Err(p) = result { - std::panic::resume_unwind(p); - } -} - -/// Default setup: NSSR / factory-reset before the test runs so the -/// partition is guaranteed at pristine defaults regardless of any -/// prior test's state. -pub fn setup_nssr(dev: &HwDevInner) { - dev.erase().expect("NSSR setup: dev.erase() must succeed"); -} - -/// Default cleanup: NSSR / factory-reset after the test. Best-effort -/// (ignores errors) so a panic in `execute` still propagates cleanly -/// even if the device is in a state that rejects `erase`. -pub fn cleanup_nssr(dev: &HwDevInner, _state: U) { - let _ = dev.erase(); -} - -/// Convenience wrapper: NSSR on entry, run `execute`, NSSR on exit. -/// Use for tests that don't need to thread state from setup to -/// cleanup (i.e. sessions are opened and closed inside `execute`). -pub fn hw_test_reset(execute: T) { - hw_test(setup_nssr, |dev, _| execute(dev), cleanup_nssr); -} diff --git a/ddi/tbor/types/tests/hw/mod.rs b/ddi/tbor/types/tests/hw/mod.rs deleted file mode 100644 index 8dff75846..000000000 --- a/ddi/tbor/types/tests/hw/mod.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware-only TBOR smoke tests. -//! -//! Runs against the native OS backend (`azihsm_ddi_nix::DdiNix` on -//! Linux, `azihsm_ddi_win::DdiWin` on Windows) selected by -//! [`azihsm_ddi::AzihsmDdi`]. Gated behind the `hw-tests` cargo -//! feature so a plain `cargo test` on machines without a physical -//! device does not attempt to open the backend. Invoke with: -//! -//! ```text -//! cargo test --no-default-features --features hw-tests \ -//! -p azihsm_ddi_tbor_types \ -//! --test azihsm_ddi_tbor_tests hw:: -//! ``` -//! -//! # Why a separate module (vs. the `commands/` harness) -//! -//! * The `commands/*` tests are file-gated on -//! `any(feature = "emu", feature = "mock", feature = "sock")` and -//! drive the [`TestCtx`](crate::harness::TestCtx) which itself is -//! only compiled under those features. `TestCtx::new` also relies -//! on `dev.erase()` (an emu-only factory-reset) for cross-test -//! isolation — real silicon cannot be reset from a test binary. -//! * Hardware tests therefore need their own thin fixture: a -//! process-global serialisation lock so parallel `cargo test` -//! workers don't stomp on the single physical device, plus an -//! open-and-return helper that does **no** state-mutating setup. -//! * Keeping these under `hw::` also documents which tests are -//! safe to run against a live board (sessionless / read-only, or -//! with explicit end-of-test cleanup) — everything else stays -//! confined to the emu-backed harness. -//! -//! # What belongs here -//! -//! Sessionless / read-only TBOR commands, e.g. `ApiRev`, `PartInfo` -//! before any `PartInit`. Anything that opens a session slot, -//! rotates a PSK, or mutates persistent state should either land in -//! the emu harness (with factory reset) or ship with its own -//! explicit cleanup path. - -use std::ops::Deref; - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use parking_lot::Mutex; -use parking_lot::MutexGuard; - -pub mod assertions; -pub mod default_psk_gate; -pub mod harness; -pub mod open_session; -pub mod part_info; -pub mod psk_change; -pub mod session_helper; - -/// Process-global serialisation lock for hardware tests. -/// -/// The single physical HSM is shared across the whole test binary, so -/// concurrent `cargo test` workers must not issue overlapping TBOR -/// commands. `parking_lot::Mutex` matches the workspace convention -/// (std's variant is disallowed by `clippy.toml`) and does not -/// poison, so a panicking test cannot wedge subsequent runs. -static HW_TEST_LOCK: Mutex<()> = Mutex::new(()); - -/// Owned wrapper around an opened native-backend device that holds -/// [`HW_TEST_LOCK`] for its lifetime. -/// -/// `Deref`s to `::Dev` so test bodies can call -/// [`DdiDev`](azihsm_ddi_interface::DdiDev) methods -/// (`exec_op_tbor`, ...) directly on the guard. -pub(crate) struct HwDev { - dev: ::Dev, - _guard: MutexGuard<'static, ()>, -} - -impl Deref for HwDev { - type Target = ::Dev; - fn deref(&self) -> &Self::Target { - &self.dev - } -} - -/// Acquire [`HW_TEST_LOCK`] and open the first device advertised by -/// the native backend. -/// -/// Panics if the backend lists no devices or if `open_dev` fails — -/// both are environmental (driver not loaded, board not present), -/// not test bugs, and surfacing them immediately gives a clearer -/// signal than downstream `exec_op_tbor` failures. -pub(crate) fn open_hw_dev() -> HwDev { - let guard = HW_TEST_LOCK.lock(); - let dev = open_backend_dev(); - HwDev { dev, _guard: guard } -} - -/// Open an *additional* fd on the same physical device without -/// re-acquiring [`HW_TEST_LOCK`]. Intended for tests that need two -/// concurrent file descriptors on the same board (the Linux kernel -/// driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so multi-session -/// tests must span multiple fds). -/// -/// The caller must already hold the lock via a live [`HwDev`], which -/// is why this helper takes `&HwDev` — it borrows the guard's -/// lifetime to statically prevent lock-free use. -pub(crate) fn open_additional_hw_dev_fd(_lock_holder: &HwDev) -> ::Dev { - open_backend_dev() -} - -fn open_backend_dev() -> ::Dev { - let ddi = AzihsmDdi::default(); - let infos = ddi.dev_info_list(); - let info = infos - .first() - .expect("hw test: backend advertised no device (driver loaded? board present?)"); - ddi.open_dev(&info.path) - .expect("hw test: failed to open backend device") -} diff --git a/ddi/tbor/types/tests/hw/open_session.rs b/ddi/tbor/types/tests/hw/open_session.rs deleted file mode 100644 index dfbf94f04..000000000 --- a/ddi/tbor/types/tests/hw/open_session.rs +++ /dev/null @@ -1,582 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware end-to-end smoke tests for the TBOR -//! `SessionOpenInit` / `SessionOpenFinish` two-phase handshake. -//! -//! Exercises the full host -> nix/win backend -> silicon fw -//! `handle_tbor_op` pipeline against a live board. Each test either -//! -//! * runs a happy-path handshake and **explicitly** closes the -//! resulting Active session via `SessionClose` (real silicon can't -//! be factory-reset from a test binary — leaks would eventually -//! exhaust the session table), or -//! * exercises a negative path that the firmware **already** cleans -//! up as part of its error-handling contract (parse-stage rejects -//! never allocate a slot; Phase-2 auth failures destroy the -//! Pending slot before returning). -//! -//! Cross-worker safety is provided by [`crate::hw::open_hw_dev`]'s -//! process-global [`HW_TEST_LOCK`](crate::hw::HW_TEST_LOCK). -//! -//! Coverage mirrors the emu suite in `commands::open_session`: -//! happy paths for both permitted (role, session_type) pairings, -//! role/type mismatches, parse-stage negatives (psk_id, -//! session_type byte, suite_id), Phase-2 MAC and seed-envelope -//! tampering, and a concurrent-sessions distinctness check. -//! -//! Invoke with: -//! -//! ```text -//! cargo test --no-default-features \ -//! -p azihsm_ddi_tbor_types \ -//! --test azihsm_ddi_tbor_tests hw::open_session -//! ``` - -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_tbor_types::SessionType; -use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; -use azihsm_ddi_tbor_types::TborSessionOpenInitReq; -use azihsm_ddi_tbor_types::TborStatus; -use azihsm_ddi_tbor_types::PK_INIT_LEN; -use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; -use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; - -use crate::hw::assertions::assert_fw_rejects; -use crate::hw::open_additional_hw_dev_fd; -use crate::hw::open_hw_dev; -use crate::hw::session_helper::build_mac_fin; -use crate::hw::session_helper::open_session; -use crate::hw::session_helper::session_close; -use crate::hw::session_helper::session_open_finish_with_mac; -use crate::hw::session_helper::session_open_init; - -const CO: u8 = 0; -const CU: u8 = 1; - -/// Ship `req` and expect an FW-side rejection with the given -/// `TborStatus`. Small local wrapper so each negative-path test -/// reads as a single call. -#[track_caller] -fn expect_init_rejects(req: &TborSessionOpenInitReq, expected: TborStatus) { - let dev = open_hw_dev(); - let mut cookie = None; - let err = dev - .exec_op_tbor::(req, None, &mut cookie) - .expect_err("FW must reject the malformed SessionOpenInit request"); - assert_fw_rejects(&err, expected); -} - -// --------------------------------------------------------------------------- -// Happy paths — full two-phase handshake against real silicon. -// Each test explicitly closes the session; a bare `assert!` failure -// still runs `SessionClose` because we call it inline before the -// assertion (or in a helper that scopes cleanup around the check). -// --------------------------------------------------------------------------- - -#[test] -fn co_authenticated_happy() { - let dev = open_hw_dev(); - let handshake = open_session(&dev, CO, SessionType::Authenticated) - .expect("hw handshake CO+Authenticated must succeed"); - let session_id = handshake.session_id; - - // Snapshot the invariants we want to check, then close the - // session on the device before running assertions so a failure - // never leaks a slot on the physical board. - let psk_id = handshake.psk_id; - let is_auth = handshake.session_type.is_authenticated(); - let bmk_len = handshake.bmk_session.len(); - let tx = handshake.derive_mac_tx_key().expect("derive mac tx key"); - let rx = handshake.derive_mac_rx_key().expect("derive mac rx key"); - drop(handshake); - session_close(&dev, session_id).expect("SessionClose must succeed on hw"); - - assert_eq!(psk_id, CO, "handshake carrier must round-trip psk_id"); - assert!(is_auth, "CO handshake must yield an Authenticated channel"); - assert!( - bmk_len > 0, - "FW must return a non-empty bmk_session envelope", - ); - assert_eq!(tx.len(), 48, "mac tx key length"); - assert_eq!(rx.len(), 48, "mac rx key length"); - assert_ne!(tx, rx, "mac tx and rx keys must differ per direction"); -} - -#[test] -fn cu_plaintext_happy() { - let dev = open_hw_dev(); - let handshake = open_session(&dev, CU, SessionType::PlainText) - .expect("hw handshake CU+PlainText must succeed"); - let session_id = handshake.session_id; - let psk_id = handshake.psk_id; - let is_auth = handshake.session_type.is_authenticated(); - let bmk_len = handshake.bmk_session.len(); - drop(handshake); - session_close(&dev, session_id).expect("SessionClose must succeed on hw"); - - assert_eq!(psk_id, CU); - assert!(!is_auth, "CU handshake must yield a PlainText channel"); - assert!(bmk_len > 0); -} - -// --------------------------------------------------------------------------- -// Role / session_type mismatches — parse-stage rejections in -// `validate_for_role`. No pending slot is allocated, no cleanup -// needed. -// --------------------------------------------------------------------------- - -#[test] -fn co_plaintext_rejected() { - let dev = open_hw_dev(); - let err = session_open_init(&dev, CO, SessionType::PlainText) - .expect_err("CO + PlainText must be rejected by validate_for_role"); - assert_fw_rejects(&err, TborStatus::InvalidSessionType); -} - -#[test] -fn cu_authenticated_rejected() { - let dev = open_hw_dev(); - let err = session_open_init(&dev, CU, SessionType::Authenticated) - .expect_err("CU + Authenticated must be rejected by validate_for_role"); - assert_fw_rejects(&err, TborStatus::InvalidSessionType); -} - -// --------------------------------------------------------------------------- -// Parse-stage negatives — FW rejects before any HPKE work; no -// pending slot allocated. -// --------------------------------------------------------------------------- - -#[test] -fn invalid_psk_id_rejected() { - // Bypass the typed `PskId(0|1)` guard by shipping raw bytes. - // Spot-check a small set of out-of-range values covering: the - // smallest invalid value (`2`), a mid-range value (`0x7F`), and - // the all-ones byte (`0xFF`). All must surface `InvalidPskId` - // from the FW dispatcher before any HPKE work. - for bad in [2u8, 0x7F, 0xFF] { - let req = TborSessionOpenInitReq { - psk_id: bad, - session_type: SessionType::PlainText.to_u8(), - suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, - pk_init: [0x04u8; PK_INIT_LEN], - }; - expect_init_rejects(&req, TborStatus::InvalidPskId); - } -} - -#[test] -fn invalid_session_type_byte_rejected() { - // Bypass the typed `SessionType` enum — ship an out-of-range byte - // directly so `SessionType::from_u8` in the FW rejects. - let req = TborSessionOpenInitReq { - psk_id: CU, - session_type: 42, - suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, - pk_init: [0x04u8; PK_INIT_LEN], - }; - expect_init_rejects(&req, TborStatus::InvalidSessionType); -} - -#[test] -fn unsupported_suite_id_rejected() { - // Only 0x01 is supported; spot-check reserved (0x02), zero, and - // the all-ones byte. - for bad in [0x00u8, 0x02, 0xFF] { - let req = TborSessionOpenInitReq { - psk_id: CU, - session_type: SessionType::PlainText.to_u8(), - suite_id: bad, - pk_init: [0x04u8; PK_INIT_LEN], - }; - expect_init_rejects(&req, TborStatus::UnsupportedSessionSuite); - } -} - -// --------------------------------------------------------------------------- -// Phase-2 auth failures — FW's contract is to destroy the pending -// slot on either MAC mismatch or AEAD-open failure, so no explicit -// cleanup is needed. Regression for the destroy-on-auth-failure -// wiring. -// --------------------------------------------------------------------------- - -#[test] -fn finish_mac_tampered() { - let dev = open_hw_dev(); - let pending = - session_open_init(&dev, CU, SessionType::PlainText).expect("Phase 1 must succeed"); - let mut mac_fin = build_mac_fin(&pending).expect("build phase-2 mac"); - mac_fin[0] ^= 0x01; - let err = session_open_finish_with_mac(&dev, pending, mac_fin) - .expect_err("tampered mac_fin must be rejected by the FW"); - assert_fw_rejects(&err, TborStatus::SessionAuthFailure); - // No SessionClose — FW destroyed the slot as part of the auth - // failure path (see `session_open_finish::on_start` + - // `TborSessionAuthFailure` arm). -} - -#[test] -fn finish_seed_envelope_tampered() { - let dev = open_hw_dev(); - let pending = - session_open_init(&dev, CU, SessionType::PlainText).expect("Phase 1 must succeed"); - let session_id = pending.session_id; - let mac_fin = build_mac_fin(&pending).expect("build phase-2 mac"); - // Consume pending so it isn't reused; then hand-build a - // syntactically-valid-but-cryptographically-bogus envelope. - drop(pending); - - let mut seed_envelope = [0u8; SEED_ENVELOPE_LEN]; - seed_envelope[0..4].copy_from_slice(b"AEAD"); - seed_envelope[4] = 0x03; // AeadAlg::AesGcm256 - // Bytes 5..8 remain zero (rsv=0, aad_len_be=0); IV/CT/TAG all - // zero — the AES-GCM tag will fail to verify. - - let req = TborSessionOpenFinishReq { - session_id, - mac_fin, - seed_envelope, - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("tampered seed_envelope must be rejected by the FW"); - assert_fw_rejects(&err, TborStatus::SessionAuthFailure); - // FW destroyed the slot on AEAD auth failure — no manual cleanup. -} - -// --------------------------------------------------------------------------- -// Concurrency -// --------------------------------------------------------------------------- - -#[test] -fn multiple_concurrent_sessions_have_distinct_ids() { - // The Linux kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1` - // (see drivers/linux/drvsrc/azihsm_hsm.h), so two concurrent - // sessions must be opened on two distinct file descriptors on - // the same physical device. `open_hw_dev()` takes the shared - // HW_TEST_LOCK; use `open_additional_hw_dev_fd()` for the - // second fd so we don''t deadlock re-acquiring a non-reentrant - // mutex. - let dev_a = open_hw_dev(); - let dev_b = open_additional_hw_dev_fd(&dev_a); - let a = open_session(&dev_a, CU, SessionType::PlainText).expect("first session must open"); - let b = open_session(&dev_b, CU, SessionType::PlainText).expect("second session must open"); - let id_a = a.session_id; - let id_b = b.session_id; - drop(a); - drop(b); - // Close both regardless of the assertion outcome so nothing - // leaks on the physical board. - let close_a = session_close(&dev_a, id_a); - let close_b = session_close(&dev_b, id_b); - close_a.expect("close session A"); - close_b.expect("close session B"); - assert_ne!(id_a, id_b, "concurrent sessions must have distinct ids"); -} - -// --------------------------------------------------------------------------- -// Finish-side error paths not already covered above. -// Ported from the emu suite (`commands::open_session`) so both suites -// stay wire-identical. -// --------------------------------------------------------------------------- - -/// `SessionOpenFinish` against a session id that does not match -/// the pending slot on this fd must be rejected. The Linux kernel -/// driver enforces fd-scoping (`FileHandleNoExistingSession`) so we -/// establish a real pending slot first, then send the finish with -/// a mismatched id. Either the driver (id mismatch) or the FW -/// (pending-blob load fails) is a valid rejection surface — accept -/// both. -#[test] -fn finish_unknown_session_id_rejected() { - let dev = open_hw_dev(); - let pending = session_open_init(&dev, CU, SessionType::PlainText) - .expect("phase-1 to establish a real pending slot must succeed"); - let real_id = pending.session_id; - // Consume `pending` so it isn't reused. The FW pending blob - // remains until we either finish it or `SessionClose` it. - drop(pending); - - // Mismatched id: flip the high bit so the value can never - // collide with `real_id` on any realistic slot count. - let bogus_id = real_id ^ 0x8000; - - let req = TborSessionOpenFinishReq { - session_id: bogus_id, - mac_fin: [0u8; 48], - seed_envelope: [0u8; SEED_ENVELOPE_LEN], - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("finish against mismatched session_id must fail"); - assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), - "expected FW or driver rejection, got {err:?}", - ); - - // Clean up the real pending slot we established up front. - let _ = session_close(&dev, real_id); -} - -/// Replaying `SessionOpenFinish` after a successful handshake must -/// fail: the pending blob has been consumed and the slot is Active, -/// so the finish-side pre-check refuses to load it as pending. This -/// is the regression for the "pending blob consumed on success" -/// wiring. -#[test] -fn double_finish_rejected() { - let dev = open_hw_dev(); - let handshake = open_session(&dev, CU, SessionType::PlainText) - .expect("hw handshake CU+PlainText must succeed"); - let session_id = handshake.session_id; - drop(handshake); - - let req = TborSessionOpenFinishReq { - session_id, - mac_fin: [0u8; 48], - seed_envelope: [0u8; SEED_ENVELOPE_LEN], - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("second finish against the same slot must fail"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection on double-finish, got {err:?}", - ); - // Close the Active slot we established up front so this test - // does not leak on the physical board. - session_close(&dev, session_id).expect("SessionClose must succeed on hw"); -} - -// --------------------------------------------------------------------------- -// Hardware-specific lifecycle: real silicon retains session-table -// state across tests, so an explicit "close fully frees the slot" -// regression only makes sense on hw. The emu harness gets a fresh -// factory reset per test and can't observe this. -// --------------------------------------------------------------------------- - -/// Open a session, close it, then open again on the same fd. The -/// second open must succeed — this guards against a stale slot or -/// leaked FSM state that would otherwise wedge the second attempt. -#[test] -fn open_close_reopen_same_slot() { - let dev = open_hw_dev(); - let first = - open_session(&dev, CU, SessionType::PlainText).expect("first hw handshake must succeed"); - let first_id = first.session_id; - drop(first); - session_close(&dev, first_id).expect("first SessionClose must succeed"); - - let second = - open_session(&dev, CU, SessionType::PlainText).expect("reopen after close must succeed"); - let second_id = second.session_id; - drop(second); - session_close(&dev, second_id).expect("second SessionClose must succeed"); -} - -/// `SessionClose` against a session id that does not match the -/// active slot on this fd must be rejected. The Linux kernel driver -/// enforces fd-scoping (`FileHandleNoExistingSession`) so we open a -/// real session first, then close a mismatched id. Either the -/// driver or the FW is a valid rejection surface. -#[test] -fn session_close_unknown_session_id_rejected() { - let dev = open_hw_dev(); - let handshake = open_session(&dev, CU, SessionType::PlainText) - .expect("hw handshake to establish an Active slot must succeed"); - let real_id = handshake.session_id; - drop(handshake); - - let bogus_id = real_id ^ 0x8000; - let err = - session_close(&dev, bogus_id).expect_err("SessionClose against mismatched id must fail"); - assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), - "expected FW or driver rejection on close-unknown, got {err:?}", - ); - - // Close the real slot we opened for fixture setup. - session_close(&dev, real_id).expect("close of the real session must succeed"); -} - -// --------------------------------------------------------------------------- -// Point-validation negatives. The FW `EccPublicKeyValidation` stage -// checks that `pk_init` is a valid, non-identity point on the P-384 -// curve; the emu suite can't meaningfully hit the on-curve check -// (SoftAES + mocks accept whatever), so these are genuine hw-only -// coverage. -// -// We only assert a generic FW-side rejection rather than pinning -// `EccPointValidationFailed` in case a future FW change collapses -// validation errors into a broader category (e.g. `InvalidArg`). -// --------------------------------------------------------------------------- - -/// `pk_init` all zeros is trivially off-curve (and encodes the point -/// at infinity in some conventions) — FW must reject. -#[test] -fn pk_init_all_zero_rejected() { - let dev = open_hw_dev(); - let req = TborSessionOpenInitReq { - psk_id: CU, - session_type: SessionType::PlainText.to_u8(), - suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, - pk_init: [0u8; PK_INIT_LEN], - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("all-zero pk_init must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection for all-zero pk_init, got {err:?}", - ); -} - -/// `pk_init` with the SEC1 uncompressed prefix (`0x04`) but garbage -/// coordinates that do not satisfy the P-384 curve equation. FW's -/// on-curve validation must reject. -#[test] -fn pk_init_not_on_curve_rejected() { - let dev = open_hw_dev(); - let mut pk_init = [0xFFu8; PK_INIT_LEN]; - pk_init[0] = 0x04; // SEC1 uncompressed prefix - let req = TborSessionOpenInitReq { - psk_id: CU, - session_type: SessionType::PlainText.to_u8(), - suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, - pk_init, - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("off-curve pk_init must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection for off-curve pk_init, got {err:?}", - ); -} - -// --------------------------------------------------------------------------- -// Session table exhaustion + recovery. Iteratively opens sessions on -// fresh fds until the FW reports the table is full, then closes them -// all and confirms one more open succeeds — the pending/active slot -// cleanup path must fully reclaim capacity. -// -// We bound the loop at 16 to avoid running forever on future FW -// builds with a much larger table. If the loop completes without a -// rejection we still validate the cleanup path (close-all + reopen) -// but skip the "at least one rejection observed" invariant with a -// diagnostic message so the test remains meaningful across FW -// capacity changes. -// --------------------------------------------------------------------------- - -#[test] -fn open_session_fills_table_then_recovers() { - let dev = open_hw_dev(); - let mut fds: Vec<::Dev> = Vec::new(); - let mut ids: Vec = Vec::new(); - let mut rejection_seen = false; - - for _ in 0..16 { - let fd = open_additional_hw_dev_fd(&dev); - match open_session(&fd, CU, SessionType::PlainText) { - Ok(h) => { - ids.push(h.session_id); - fds.push(fd); - drop(h); - } - Err(e) => { - // FW rejected — treat as "table full". Verify it is - // an FW-side rejection (not a driver / decode fault) - // before ending the ramp-up. - assert!( - matches!(e, azihsm_ddi_interface::DdiError::TborStatus(_)), - "table-full rejection must be FW-side, got {e:?}", - ); - rejection_seen = true; - break; - } - } - } - - // Close everything we opened before running the recovery check, - // so a recovery-side failure does not leak slots on the board. - for (fd, id) in fds.iter().zip(ids.iter()) { - let _ = session_close(fd, *id); - } - - // Recovery: one fresh open must succeed after the batch close. - let recovered = open_session(&dev, CU, SessionType::PlainText) - .expect("session table must recover after close-all"); - let recovered_id = recovered.session_id; - drop(recovered); - session_close(&dev, recovered_id).expect("SessionClose after recovery must succeed"); - - if !rejection_seen { - eprintln!( - "open_session_fills_table_then_recovers: FW accepted {} concurrent sessions without emitting a table-full rejection; capacity limit not observed", - ids.len(), - ); - } -} - -// --------------------------------------------------------------------------- -// Two independent handshakes must derive distinct exported material, -// which — for authenticated sessions — surfaces as distinct MAC -// keys. This is the invariant that lets the host use per-session -// MAC keys as replay-window nonces without cross-session collisions. -// --------------------------------------------------------------------------- - -/// Two independent CO+Authenticated handshakes must derive distinct -/// exported material — which surfaces as distinct per-direction MAC -/// keys. Runs sequentially (open, snapshot, close, open again) -/// because real hw refuses to hold two Authenticated sessions -/// concurrently (`VaultSessionLimitReached`); the ephemeral-per- -/// handshake invariant this test guards is unchanged by that. -#[test] -fn co_authenticated_derives_unique_keys_per_session() { - let dev = open_hw_dev(); - - // First handshake — snapshot keys then close. - let a = open_session(&dev, CO, SessionType::Authenticated) - .expect("first CO+Authenticated handshake must succeed"); - let a_id = a.session_id; - let a_tx = a.derive_mac_tx_key().expect("derive a tx"); - let a_rx = a.derive_mac_rx_key().expect("derive a rx"); - let a_exported = a.exported.clone(); - drop(a); - session_close(&dev, a_id).expect("close first session must succeed"); - - // Second handshake on the same fd — must derive fresh material - // because the VM ephemeral keypair is regenerated per Phase-1. - let b = open_session(&dev, CO, SessionType::Authenticated) - .expect("second CO+Authenticated handshake must succeed"); - let b_id = b.session_id; - let b_tx = b.derive_mac_tx_key().expect("derive b tx"); - let b_rx = b.derive_mac_rx_key().expect("derive b rx"); - let b_exported = b.exported.clone(); - drop(b); - // Close before asserting so a failing assertion never leaks. - session_close(&dev, b_id).expect("close second session must succeed"); - - assert_ne!( - a_exported, b_exported, - "two handshakes must derive distinct HPKE exported material", - ); - assert_ne!(a_tx, b_tx, "per-session mac tx keys must differ"); - assert_ne!(a_rx, b_rx, "per-session mac rx keys must differ"); - // Sanity: within a single session, tx and rx are distinct. - assert_ne!(a_tx, a_rx); - assert_ne!(b_tx, b_rx); -} diff --git a/ddi/tbor/types/tests/hw/part_info.rs b/ddi/tbor/types/tests/hw/part_info.rs deleted file mode 100644 index 3d5db8e5e..000000000 --- a/ddi/tbor/types/tests/hw/part_info.rs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware smoke test for the out-of-session TBOR `PartInfo` -//! command. -//! -//! Exercises the full host -> nix/win backend -> silicon fw -//! `handle_tbor_op` -> response path and asserts the invariant -//! device/partition fields the firmware reports for the default -//! provisioned partition. Safe to run against a live board because -//! `PartInfo` is sessionless and does not mutate persistent state. - -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_tbor_types::TborPartInfoReq; -use azihsm_ddi_tbor_types::TborPartInfoResp; - -use crate::hw::open_hw_dev; - -/// `DdiDeviceKind::Physical` discriminant. Mirrors the emu-harness -/// constant in `commands::part_info` so both paths pin the same -/// contract. -const DEVICE_KIND_PHYSICAL: u8 = 2; - -/// `PartState::Enabled` discriminant — the default provisioned state -/// of a partition before any `PartInit`. -const PART_STATE_ENABLED: u8 = 2; - -fn assert_default_part_info(resp: &TborPartInfoResp) { - assert_eq!( - resp.device_kind, DEVICE_KIND_PHYSICAL, - "uno firmware must report a physical device kind", - ); - assert_eq!( - resp.part_state, PART_STATE_ENABLED, - "default provisioned partition must be Enabled", - ); - assert!( - resp.pid_pub_key.iter().any(|&b| b != 0), - "identity public key must be materialized (non-zero)", - ); -} - -#[test] -fn round_trip() { - let dev = open_hw_dev(); - let mut cookie = None; - let resp = dev - .exec_op_tbor(&TborPartInfoReq::new(), None, &mut cookie) - .expect("TBOR PartInfo round-trip on hardware"); - assert_default_part_info(&resp); -} diff --git a/ddi/tbor/types/tests/hw/psk_change.rs b/ddi/tbor/types/tests/hw/psk_change.rs deleted file mode 100644 index b7b059f75..000000000 --- a/ddi/tbor/types/tests/hw/psk_change.rs +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Hardware end-to-end tests for the TBOR `PskChange` command. -//! -//! Mirrors the emu suite in `commands::psk_change`. Cross-test -//! isolation comes from [`hw_test_reset`](crate::hw::harness::hw_test_reset) -//! (NSSR before + after each `execute` body via `dev.erase()`), so -//! every test starts with the partition at pristine defaults and -//! leaves it that way even on panic. -//! -//! Invoke with: -//! -//! ```text -//! cargo test --no-default-features \ -//! -p azihsm_ddi_tbor_types \ -//! --test azihsm_ddi_tbor_tests hw::psk_change -//! ``` - -use azihsm_crypto::aead_envelope; -use azihsm_crypto::aead_envelope::AeadAlg; -use azihsm_crypto::AesKey; -use azihsm_crypto::Rng; -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_tbor_types::build_psk_change_aad; -use azihsm_ddi_tbor_types::SessionType; -use azihsm_ddi_tbor_types::TborPskChangeReq; -use azihsm_ddi_tbor_types::TborStatus; -use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; -use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; -use azihsm_ddi_tbor_types::PSK_LEN; - -use crate::hw::assertions::assert_fw_rejects; -use crate::hw::harness::hw_test_reset; -use crate::hw::session_helper::open_session; -use crate::hw::session_helper::psk_change; -use crate::hw::session_helper::session_close; -use crate::hw::session_helper::session_open_finish; -use crate::hw::session_helper::session_open_init_with_options; -use crate::hw::session_helper::SessionOpenInitOptions; - -const CO: u8 = 0; -const CU: u8 = 1; - -/// Non-default PSK used as the rotation target. Distinct per-role. -const ROTATED_PSK_CO: [u8; PSK_LEN] = [ - 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, - 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, -]; -const ROTATED_PSK_CU: [u8; PSK_LEN] = [ - 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, - 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE0, -]; - -/// Build an AEAD-GCM envelope under `param_key` with caller-supplied -/// AAD + plaintext. Negatives use this to construct envelopes the FW -/// must reject. -fn build_envelope(param_key: &AesKey, aad: &[u8], plaintext: &[u8]) -> Vec { - let iv = Rng::rand_vec(12).expect("rng iv"); - let total = aead_envelope::seal(AeadAlg::AesGcm256, param_key, &iv, aad, plaintext, None) - .expect("aead size"); - let mut out = vec![0u8; total]; - let n = aead_envelope::seal( - AeadAlg::AesGcm256, - param_key, - &iv, - aad, - plaintext, - Some(&mut out), - ) - .expect("aead seal"); - out.truncate(n); - out -} - -// --------------------------------------------------------------------------- -// Happy-path lifecycle: open under default -> rotate -> close -> -// reopen under new PSK succeeds -> reopen under default fails. -// --------------------------------------------------------------------------- - -fn run_lifecycle(role: u8, sty: SessionType, default_psk: &[u8; PSK_LEN], rotated: &[u8; PSK_LEN]) { - hw_test_reset(|dev| { - let session = open_session(dev, role, sty).expect("open under default PSK"); - let session_id = session.session_id; - psk_change(dev, &session, rotated).expect("rotate to new PSK"); - session_close(dev, session_id).expect("close rotating session"); - - let opts = SessionOpenInitOptions::new(role, sty).with_psk(rotated); - let pending = session_open_init_with_options(dev, opts).expect("reopen under rotated PSK"); - let resumed = session_open_finish(dev, pending).expect("finish reopen under rotated PSK"); - session_close(dev, resumed.session_id).expect("close resumed session"); - - let opts_default = SessionOpenInitOptions::new(role, sty).with_psk(default_psk); - let result = session_open_init_with_options(dev, opts_default) - .and_then(|p| session_open_finish(dev, p)); - assert!( - result.is_err(), - "reopen with old default PSK must fail after rotation", - ); - }); -} - -#[test] -fn lifecycle_co() { - run_lifecycle( - CO, - SessionType::Authenticated, - &DEFAULT_PSK_CO, - &ROTATED_PSK_CO, - ); -} - -#[test] -fn lifecycle_cu() { - run_lifecycle(CU, SessionType::PlainText, &DEFAULT_PSK_CU, &ROTATED_PSK_CU); -} - -/// After a successful rotation, a second `PskChange` on the same -/// session must fail with `InvalidPermissions` (one-shot budget). -#[test] -fn second_attempt_same_session_fails() { - hw_test_reset(|dev| { - let session = open_session(dev, CU, SessionType::PlainText).expect("open under default CU"); - let session_id = session.session_id; - - psk_change(dev, &session, &ROTATED_PSK_CU).expect("first rotate"); - let err = psk_change(dev, &session, &DEFAULT_PSK_CU) - .expect_err("second PskChange on same session must fail"); - assert_fw_rejects(&err, TborStatus::InvalidPermissions); - - session_close(dev, session_id).expect("close"); - }); -} - -// --------------------------------------------------------------------------- -// Envelope-shape negatives (pre-commit rejects). -// --------------------------------------------------------------------------- - -#[test] -fn envelope_ciphertext_bit_flip_rejected() { - hw_test_reset(|dev| { - let session = open_session(dev, CU, SessionType::PlainText).expect("open"); - let session_id = session.session_id; - - let aad = build_psk_change_aad(session_id); - let mut envelope = build_envelope(&session.param_key, &aad, &ROTATED_PSK_CU); - let target = envelope.len() / 2; - envelope[target] ^= 0x01; - - let req = TborPskChangeReq { - session_id, - psk_envelope: envelope, - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("bit-flipped ciphertext must be rejected"); - assert_fw_rejects(&err, TborStatus::AeadEnvelopeAuthFailed); - - session_close(dev, session_id).expect("close"); - }); -} - -#[test] -fn envelope_wrong_session_id_in_aad_rejected() { - hw_test_reset(|dev| { - let session = open_session(dev, CU, SessionType::PlainText).expect("open"); - let session_id = session.session_id; - - let bogus_aad = build_psk_change_aad(session_id ^ 0x1234); - let envelope = build_envelope(&session.param_key, &bogus_aad, &ROTATED_PSK_CU); - let req = TborPskChangeReq { - session_id, - psk_envelope: envelope, - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("mismatched session_id in AAD must be rejected"); - assert_fw_rejects(&err, TborStatus::AeadEnvelopeAuthFailed); - - session_close(dev, session_id).expect("close"); - }); -} - -#[test] -fn envelope_wrong_plaintext_length_rejected() { - hw_test_reset(|dev| { - for len in [PSK_LEN - 1, PSK_LEN + 1] { - let session = open_session(dev, CU, SessionType::PlainText).expect("open"); - let session_id = session.session_id; - let bogus_psk = vec![0xCDu8; len]; - let aad = build_psk_change_aad(session_id); - let envelope = build_envelope(&session.param_key, &aad, &bogus_psk); - let req = TborPskChangeReq { - session_id, - psk_envelope: envelope, - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("wrong plaintext length must be rejected"); - // Hardware: wire decoder catches length mismatch (schema pins - // `psk_envelope` at 100 B via `#[tbor(buffer, len = 100)]`) and - // surfaces it as `DdiDecodeFailed` before the handler's defensive - // `TborInvalidFixedLength` branch is reached. Emu decoder returns - // the more specific `TborInvalidFixedLength`. - assert_fw_rejects(&err, TborStatus::DdiDecodeFailed); - session_close(dev, session_id).expect("close"); - } - }); -} - -#[test] -fn envelope_empty_rejected() { - hw_test_reset(|dev| { - let session = open_session(dev, CU, SessionType::PlainText).expect("open"); - let session_id = session.session_id; - - let req = TborPskChangeReq { - session_id, - psk_envelope: Vec::new(), - }; - let mut cookie = None; - let err = dev - .exec_op_tbor::(&req, None, &mut cookie) - .expect_err("empty envelope must be rejected"); - // See `envelope_wrong_plaintext_length_rejected` for the hw-vs-emu - // decode-order rationale — empty envelope hits the same decoder path. - assert_fw_rejects(&err, TborStatus::DdiDecodeFailed); - - session_close(dev, session_id).expect("close"); - }); -} diff --git a/ddi/tbor/types/tests/hw/session_helper.rs b/ddi/tbor/types/tests/hw/session_helper.rs deleted file mode 100644 index f276405cd..000000000 --- a/ddi/tbor/types/tests/hw/session_helper.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Session-establishment helpers for hardware smoke tests. -//! -//! Re-uses the same crypto/plumbing modules that back the emu harness' -//! session helpers by pulling them in via `#[path]` — the files live -//! under `../harness/session/` but the harness module tree itself is -//! `#[cfg]`'d off in the hardware build (see -//! `azihsm_ddi_tbor_tests.rs`), so we can't `use crate::harness::…` -//! from here. Everything below is a thin surface over those shared -//! files plus the one-shot [`open_session`] convenience wrapper. -//! -//! Only compiled in hardware mode (`hw::` is itself gated on the -//! no-backend-feature build). - -#[path = "../harness/session/crypto.rs"] -mod crypto; -#[path = "../harness/session/finish.rs"] -pub mod finish; -#[path = "../harness/session/init.rs"] -pub mod init; -#[path = "../harness/session/part_init.rs"] -pub mod part_init; -#[path = "../harness/session/psk_change.rs"] -pub mod psk_change; -#[path = "../harness/session/session_close.rs"] -pub mod session_close; - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use azihsm_ddi_interface::DdiError; -use azihsm_ddi_tbor_types::SessionType; -pub use finish::build_mac_fin; -pub use finish::session_open_finish; -pub use finish::session_open_finish_with_mac; -pub use finish::SessionHandshake; -pub use init::session_open_init; -pub use init::session_open_init_with_options; -pub use init::PendingHandshake; -pub use init::SessionOpenInitOptions; -pub use part_init::build_part_init_mach_seed_aad; -pub use part_init::encrypt_mach_seed_envelope; -pub use part_init::part_init; -pub use psk_change::encrypt_psk_envelope; -pub use psk_change::psk_change; -pub use session_close::session_close; - -/// One-shot happy-path handshake: `SessionOpenInit` + -/// `SessionOpenFinish`. Mirrors `crate::harness::session::open_session` -/// so hw tests and emu tests read the same at the call site. -pub fn open_session( - dev: &::Dev, - psk_id: u8, - session_type: SessionType, -) -> Result { - let pending = session_open_init(dev, psk_id, session_type)?; - session_open_finish(dev, pending) -} From 6f4ff5b598151b85762faf959d36c602a557b987 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 20 Jul 2026 22:18:56 -0700 Subject: [PATCH 03/27] Add more tests for open_session and psk_change --- ddi/tbor/types/tests/commands/open_session.rs | 344 +++++++++++++++++- ddi/tbor/types/tests/commands/psk_change.rs | 63 ++++ ddi/tbor/types/tests/harness/hw_ctx.rs | 31 +- 3 files changed, 418 insertions(+), 20 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 89a608bb5..5abb124f1 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -381,13 +381,129 @@ fn co_authenticated_derives_unique_keys_per_session() { assert_ne!(b_tx, b_rx); } +// --------------------------------------------------------------------------- +// Partition identity public key stability +// --------------------------------------------------------------------------- + +/// `pk_hsm` (the partition identity pub key surfaced on Phase-1 +/// `PendingHandshake`) must be stable across handshakes — it identifies +/// the partition, not the ephemeral session. Two sequential Phase-1s +/// must observe byte-identical `pk_hsm`; a rotation between handshakes +/// would break long-term binding assumed by higher-layer protocols. +#[test] +fn partition_pk_hsm_stable_across_handshakes() { + let ctx = Ctx::new(); + + let pending_a = ctx + .session_open_init(CU, SessionType::PlainText) + .expect("first Phase-1 must succeed"); + let pk_hsm_a = pending_a.pk_hsm; + let handshake_a = ctx + .session_open_finish(pending_a) + .expect("first Phase-2 must succeed"); + let a_id = handshake_a.session_id; + ctx.session_close(a_id) + .expect("close first session must succeed"); + + let pending_b = ctx + .session_open_init(CU, SessionType::PlainText) + .expect("second Phase-1 must succeed"); + let pk_hsm_b = pending_b.pk_hsm; + let handshake_b = ctx + .session_open_finish(pending_b) + .expect("second Phase-2 must succeed"); + let b_id = handshake_b.session_id; + ctx.session_close(b_id) + .expect("close second session must succeed"); + + assert_eq!( + pk_hsm_a, pk_hsm_b, + "partition identity pub key must be stable across handshakes", + ); +} + +// --------------------------------------------------------------------------- +// Concurrency: multi-threaded races on the ctx +// +// Threads drive `ctx.session_open_init` / `ctx.session_open_finish` +// directly — the same map-based fd allocation used by every other +// test, so nothing bypasses the tracking. HwCtx grabs a fresh fd per +// concurrent handshake internally and stashes it in `pending_fds` +// keyed by the FW-assigned session id. +// --------------------------------------------------------------------------- + +const MULTI_THREADED_TOTAL: usize = 12; + +/// Race N concurrent opens; winners must have distinct session ids, +/// and any losers must surface as clean FW/driver rejections. +#[test] +fn open_session_multi_threaded_all_should_open() { + let ctx = Ctx::new(); + + let (winners, rejections) = std::thread::scope(|s| { + let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); + for _ in 0..MULTI_THREADED_TOTAL { + handles.push(s.spawn(|| -> Result { + let pending = ctx.session_open_init(CU, SessionType::PlainText)?; + let handshake = ctx.session_open_finish(pending)?; + Ok(handshake.session_id) + })); + } + let mut winners: Vec = Vec::new(); + let mut rejections: Vec = Vec::new(); + for h in handles { + match h.join().expect("worker thread must not panic") { + Ok(sid) => winners.push(sid), + Err(e) => rejections.push(e), + } + } + (winners, rejections) + }); + + let mut sorted_ids = winners.clone(); + sorted_ids.sort_unstable(); + sorted_ids.dedup(); + let unique_wins = sorted_ids.len(); + + // Close winners through the ctx before asserting so a failing + // assert never leaves the session table dirty. + for sid in &winners { + let _ = ctx.session_close(*sid); + } + + assert!( + !winners.is_empty(), + "at least one concurrent open_session must succeed; rejections = {rejections:?}", + ); + assert_eq!( + unique_wins, + winners.len(), + "concurrent winners must have distinct session ids: {winners:?}", + ); + for err in &rejections { + assert!( + matches!( + err, + azihsm_ddi_interface::DdiError::DdiError(_) + | azihsm_ddi_interface::DdiError::DdiStatus(_) + ), + "concurrent open_session rejections must be FW/driver rejections, got {err:?}", + ); + } + if rejections.is_empty() { + eprintln!( + "open_session_multi_threaded_all_should_open: FW accepted all {MULTI_THREADED_TOTAL} \ + concurrent sessions without emitting any table-full rejection; race between success \ + and rejection branches not exercised at this thread count", + ); + } +} + // --------------------------------------------------------------------------- // Point-validation negatives // // FW `EccPublicKeyValidation` checks that `pk_init` is a valid, -// non-identity point on the P-384 curve. The emu backend uses -// SoftAES + curve mocks that accept whatever is passed, so these -// two tests are hw-only. +// non-identity point on the P-384 curve. // // Assert a generic FW-side rejection rather than pinning // `EccPointValidationFailed` in case a future FW change collapses @@ -396,7 +512,6 @@ fn co_authenticated_derives_unique_keys_per_session() { /// `pk_init` all zeros is trivially off-curve (and encodes the point /// at infinity in some conventions) — FW must reject. -#[cfg(feature = "hw-tests")] #[test] fn pk_init_all_zero_rejected() { let ctx = Ctx::new(); @@ -418,7 +533,6 @@ fn pk_init_all_zero_rejected() { /// `pk_init` with the SEC1 uncompressed prefix (`0x04`) but garbage /// coordinates that do not satisfy the P-384 curve equation. FW's /// on-curve validation must reject. -#[cfg(feature = "hw-tests")] #[test] fn pk_init_not_on_curve_rejected() { let ctx = Ctx::new(); @@ -438,3 +552,223 @@ fn pk_init_not_on_curve_rejected() { "expected FW-side rejection for off-curve pk_init, got {err:?}", ); } + +// --------------------------------------------------------------------------- +// P-384 point-validation vectors: coordinate == field prime `p` +// +// Verbatim from MBOR `TEST_ECC_384_PUBLIC_KEY_X_AS_PRIME` / +// `TEST_ECC_384_PUBLIC_KEY_Y_AS_PRIME`. A coordinate equal to the +// field prime is a valid field element but off-curve — targeted +// regression for the on-curve validation stage. +// --------------------------------------------------------------------------- + +/// P-384 field prime `p`, big-endian. +const P384_PRIME_BE: [u8; 48] = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, +]; + +/// Filler Y paired with prime-as-X (verbatim from MBOR vectors). +const P384_INVALID_Y_FOR_X_AS_PRIME_BE: [u8; 48] = [ + 0x53, 0xbf, 0xf4, 0x76, 0x31, 0x31, 0x33, 0xa3, 0x58, 0x3c, 0x11, 0x3d, 0xeb, 0x8d, 0xb6, 0xb7, + 0x47, 0x4a, 0xe3, 0x51, 0xd0, 0x38, 0x26, 0xac, 0xec, 0x11, 0x34, 0x33, 0x04, 0x0d, 0xc6, 0xc3, + 0x75, 0x37, 0xa1, 0x89, 0xdd, 0x4f, 0x66, 0x57, 0x72, 0xac, 0xc5, 0x3b, 0xb6, 0xc6, 0xb8, 0x0c, +]; + +/// Filler X paired with prime-as-Y (verbatim from MBOR vectors). +const P384_INVALID_X_FOR_Y_AS_PRIME_BE: [u8; 48] = [ + 0xcf, 0x6b, 0x8d, 0x9a, 0x48, 0x75, 0xa9, 0x5a, 0x19, 0x89, 0x72, 0x18, 0xa4, 0x94, 0x4d, 0xef, + 0x0a, 0x93, 0xce, 0x5b, 0x8b, 0x8d, 0xf1, 0x37, 0x54, 0x09, 0x17, 0x89, 0xbc, 0xef, 0x69, 0xdb, + 0x6c, 0xa7, 0x9e, 0xf6, 0xb6, 0x4b, 0x5c, 0x13, 0xed, 0x3c, 0xbf, 0xed, 0x0b, 0x3d, 0xf1, 0x7e, +]; + +/// Pack (X, Y) as SEC1 uncompressed: `0x04 || X_be || Y_be`. +fn pack_sec1_uncompressed(x_be: &[u8; 48], y_be: &[u8; 48]) -> [u8; PK_INIT_LEN] { + let mut out = [0u8; PK_INIT_LEN]; + out[0] = 0x04; + out[1..49].copy_from_slice(x_be); + out[49..97].copy_from_slice(y_be); + out +} + +/// `pk_init` with X = P-384 prime `p`: valid field element, off-curve; +/// FW must reject. +#[test] +fn pk_init_x_as_prime_rejected() { + let ctx = Ctx::new(); + let pk_init = pack_sec1_uncompressed(&P384_PRIME_BE, &P384_INVALID_Y_FOR_X_AS_PRIME_BE); + let req = TborSessionOpenInitReq { + psk_id: CU, + session_type: SessionType::PlainText.to_u8(), + suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, + pk_init, + }; + let err = ctx + .tbor(&req) + .expect_err("pk_init with X = P-384 prime must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + "expected FW-side rejection for X-as-prime pk_init, got {err:?}", + ); +} + +/// Symmetric to `pk_init_x_as_prime_rejected` — guards against +/// X-only validation bugs. +#[test] +fn pk_init_y_as_prime_rejected() { + let ctx = Ctx::new(); + let pk_init = pack_sec1_uncompressed(&P384_INVALID_X_FOR_Y_AS_PRIME_BE, &P384_PRIME_BE); + let req = TborSessionOpenInitReq { + psk_id: CU, + session_type: SessionType::PlainText.to_u8(), + suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, + pk_init, + }; + let err = ctx + .tbor(&req) + .expect_err("pk_init with Y = P-384 prime must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + "expected FW-side rejection for Y-as-prime pk_init, got {err:?}", + ); +} + +// --------------------------------------------------------------------------- +// Single-byte bit-flip on a valid ephemeral pk_init +// +// Generate a legitimate P-384 ephemeral, flip one bit in X, ship it. +// FW's on-curve validation must reject. +// --------------------------------------------------------------------------- + +#[test] +fn pk_init_single_byte_tampered_rejected() { + let ctx = Ctx::new(); + let ephemeral = azihsm_session_ex_crypto::generate_vm_ephemeral() + .expect("generate_vm_ephemeral must succeed on the test host"); + let mut pk_init = ephemeral.pk_sec1; + pk_init[8] ^= 0x01; // Inside X (X is bytes 1..49); avoids prefix + X/Y boundary. + + let req = TborSessionOpenInitReq { + psk_id: CU, + session_type: SessionType::PlainText.to_u8(), + suite_id: SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256, + pk_init, + }; + let err = ctx + .tbor(&req) + .expect_err("single-bit-tampered pk_init must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + "expected FW-side rejection for tampered pk_init, got {err:?}", + ); +} + +// =========================================================================== +// Hardware-only tests +// =========================================================================== + +#[cfg(feature = "hw-tests")] +const SINGLE_WINNER_RACERS: usize = 8; + +/// Fill to one free slot then race N threads for it. Regression for +/// FW's undo-on-loser path: every losing racer must see a clean +/// rejection and leave the session table intact for retry. +/// +/// Hw-only because the emu backend cannot model this. Emu is a +/// process-global `LazyLock` singleton (`ddi/emu/src/ddi.rs`) +/// with a single shared `StdHsm` and no per-fd separation — every +/// "device handle" aliases the same in-process FW state. Concurrent +/// `session_open_init` calls on that singleton clobber its session +/// table mid-handshake, surfacing as `SessionNotFound` / +/// `SessionAuthFailure` at finish rather than the "table full" that +/// real FW returns to losers. Real hw has genuine per-fd state in +/// the kernel driver. +#[cfg(feature = "hw-tests")] +#[test] +fn open_session_multi_threaded_single_winner() { + let ctx = Ctx::new(); + + // Phase 1: probe capacity sequentially; ceiling matches + // fills_table_then_recovers so we don't loop forever on a + // pathological build. + let mut filler_ids: Vec = Vec::new(); + let probe_ceiling: usize = 16; + for _ in 0..probe_ceiling { + match ctx + .session_open_init(CU, SessionType::PlainText) + .and_then(|pending| ctx.session_open_finish(pending)) + { + Ok(handshake) => filler_ids.push(handshake.session_id), + Err(_) => break, + } + } + + // Capacity exceeds ceiling: cannot set up a single-slot race — clean up + skip. + if filler_ids.len() >= probe_ceiling { + for sid in &filler_ids { + let _ = ctx.session_close(*sid); + } + eprintln!( + "open_session_multi_threaded_single_winner: FW capacity exceeds probe ceiling of \ + {probe_ceiling}; skipping single-slot race", + ); + return; + } + + // Phase 2: free exactly one slot. + let freed_id = filler_ids.pop().expect("at least one filler must exist"); + ctx.session_close(freed_id) + .expect("close of freed filler slot must succeed"); + + // Phase 3: race SINGLE_WINNER_RACERS threads for the one slot. + let (winners, rejections) = std::thread::scope(|s| { + let mut handles = Vec::with_capacity(SINGLE_WINNER_RACERS); + for _ in 0..SINGLE_WINNER_RACERS { + handles.push(s.spawn(|| -> Result { + let pending = ctx.session_open_init(CU, SessionType::PlainText)?; + let handshake = ctx.session_open_finish(pending)?; + Ok(handshake.session_id) + })); + } + let mut winners: Vec = Vec::new(); + let mut rejections: Vec = Vec::new(); + for h in handles { + match h.join().expect("racer thread must not panic") { + Ok(sid) => winners.push(sid), + Err(e) => rejections.push(e), + } + } + (winners, rejections) + }); + + let winner_count = winners.len(); + let rejection_count = rejections.len(); + for sid in &winners { + let _ = ctx.session_close(*sid); + } + for sid in &filler_ids { + let _ = ctx.session_close(*sid); + } + + assert_eq!( + winner_count, 1, + "exactly one racer must win the single free slot (got {winner_count} winners, \ + {rejection_count} rejections: {rejections:?})", + ); + assert_eq!( + rejection_count, + SINGLE_WINNER_RACERS - 1, + "all non-winning racers must fail cleanly", + ); + for err in &rejections { + assert!( + matches!( + err, + azihsm_ddi_interface::DdiError::DdiError(_) + | azihsm_ddi_interface::DdiError::DdiStatus(_) + ), + "single-winner losers must surface FW/driver rejections, got {err:?}", + ); + } +} diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index c043a6b61..47e77afd7 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -38,9 +38,13 @@ use azihsm_crypto::AesKey; use azihsm_crypto::Rng; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; +#[cfg(feature = "hw-tests")] +use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; +#[cfg(feature = "hw-tests")] +use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; use crate::harness::Ctx; @@ -324,3 +328,62 @@ fn psk_change_wrong_aad_length() { }; ctx.expect_fw_reject(&req, LENGTH_REJECT_STATUS); } + +// =========================================================================== +// Hardware-only tests +// =========================================================================== + +// --------------------------------------------------------------------------- +// Rotation to a default PSK is rejected as invalid +// +// Security-critical: allowing the host to rotate a partition's PSK +// back to the well-known `DEFAULT_PSK_CO` / `DEFAULT_PSK_CU` bytes +// would let anyone with default-PSK knowledge re-establish a +// session, defeating the whole point of rotation. FW must treat a +// default value as an invalid `new_psk` and reject with +// `TborStatus::InvalidArg`, regardless of which role's session +// requests the change and which default value is targeted (so a CU +// can't sneak the CO PSK back to default either). +// +// Hw-only: the emu backend does not implement the default-PSK +// check and accepts these rotations, so the guarantee can only be +// verified against real FW. +// --------------------------------------------------------------------------- + +#[cfg(feature = "hw-tests")] +fn run_psk_change_to_default_rejected( + role: u8, + sty: SessionType, + new_psk: &[u8; PSK_LEN], +) { + let ctx = Ctx::new(); + let session = ctx.open_session(role, sty); + let err = ctx + .psk_change(session.handshake(), new_psk) + .expect_err("psk_change to a default PSK must be rejected"); + assert_fw_rejects(&err, TborStatus::InvalidArg); +} + +#[cfg(feature = "hw-tests")] +#[test] +fn psk_change_cu_to_default_cu_rejected() { + run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CU); +} + +#[cfg(feature = "hw-tests")] +#[test] +fn psk_change_cu_to_default_co_rejected() { + run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CO); +} + +#[cfg(feature = "hw-tests")] +#[test] +fn psk_change_co_to_default_co_rejected() { + run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CO); +} + +#[cfg(feature = "hw-tests")] +#[test] +fn psk_change_co_to_default_cu_rejected() { + run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CU); +} diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs index c7c6bd30e..2bbb4d879 100644 --- a/ddi/tbor/types/tests/harness/hw_ctx.rs +++ b/ddi/tbor/types/tests/harness/hw_ctx.rs @@ -125,12 +125,13 @@ pub struct HwCtx { /// `true` while `primary` currently owns a live session. Cleared /// when that session's entry is removed from `sessions`. primary_busy: Mutex, - /// Fd used by the most recent [`Self::session_open_init`] / - /// [`Self::session_open_init_with_options`], waiting for a - /// matching finish to promote it into [`Self::sessions`]. Same - /// `Arc` as either `primary` (when we grabbed primary) or a fresh - /// fd (when primary was busy). - pending_fd: Mutex>>, + /// Fds waiting for a matching finish to promote them into + /// [`Self::sessions`], keyed by the FW-assigned session id that + /// [`Self::session_open_init`] returned. Keying by id (rather + /// than a single slot) lets concurrent handshakes coexist. Each + /// value is the same `Arc` as either `primary` (when we grabbed + /// primary) or a fresh fd (when primary was busy). + pending_fds: Mutex>>, /// Every live session, keyed by FW-assigned session id. Value is /// the `Arc` for the fd that owns that session — same `Arc` as /// `primary` or a fresh fd. @@ -146,7 +147,7 @@ impl HwCtx { primary: Arc::new(dev), _guard: guard, primary_busy: Mutex::new(false), - pending_fd: Mutex::new(None), + pending_fds: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), } } @@ -304,7 +305,7 @@ impl HwCtx { let (dev, took_primary) = self.take_fd_for_new_session(); match session_open_init_helper(&dev, psk_id, session_type) { Ok(pending) => { - *self.pending_fd.lock() = Some(dev); + self.pending_fds.lock().insert(pending.session_id, dev); Ok(pending) } Err(e) => { @@ -323,7 +324,7 @@ impl HwCtx { let (dev, took_primary) = self.take_fd_for_new_session(); match session_open_init_with_options_helper(&dev, opts) { Ok(pending) => { - *self.pending_fd.lock() = Some(dev); + self.pending_fds.lock().insert(pending.session_id, dev); Ok(pending) } Err(e) => { @@ -337,10 +338,10 @@ impl HwCtx { pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { let dev = self - .pending_fd + .pending_fds .lock() - .take() - .expect("session_open_finish: no pending fd — call session_open_init first"); + .remove(&pending.session_id) + .expect("session_open_finish: no pending fd for this session_id — call session_open_init first"); let is_primary = Arc::ptr_eq(&dev, &self.primary); match session_open_finish_helper(&dev, pending) { Ok(handshake) => { @@ -362,10 +363,10 @@ impl HwCtx { mac_fin: [u8; 48], ) -> DdiResult { let dev = self - .pending_fd + .pending_fds .lock() - .take() - .expect("session_open_finish_with_mac: no pending fd — call session_open_init first"); + .remove(&pending.session_id) + .expect("session_open_finish_with_mac: no pending fd for this session_id — call session_open_init first"); let is_primary = Arc::ptr_eq(&dev, &self.primary); match session_open_finish_with_mac_helper(&dev, pending, mac_fin) { Ok(handshake) => { From 5829fcf8967f343a078e88731a1df34cd119373c Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 20 Jul 2026 22:22:45 -0700 Subject: [PATCH 04/27] Format --- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 7 ++++++- ddi/tbor/types/tests/commands/api_rev.rs | 7 ++++++- ddi/tbor/types/tests/commands/part_info.rs | 7 ++++++- ddi/tbor/types/tests/commands/psk_change.rs | 12 +++++------ ddi/tbor/types/tests/harness/ctx.rs | 6 +----- ddi/tbor/types/tests/harness/hw_ctx.rs | 20 +++++++----------- ddi/tbor/types/tests/harness/mod.rs | 21 +++++++++++++------ ddi/tbor/types/tests/harness/session_guard.rs | 17 +++++++++++---- 8 files changed, 60 insertions(+), 37 deletions(-) diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index e238a1807..41004286c 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -14,7 +14,12 @@ //! OS backend (`nix` on Linux / `win` on Windows) for build-only //! checks; no tests are compiled in that mode. -#[cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] +#[cfg(any( + feature = "emu", + feature = "mock", + feature = "sock", + feature = "hw-tests" +))] pub mod harness; pub mod commands; diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index 9bd62c87d..7d07f0f4b 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -17,7 +17,12 @@ //! `hw-tests` build, so the same test bodies run on the in-process //! firmware and against a live board. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] +#![cfg(any( + feature = "emu", + feature = "mock", + feature = "sock", + feature = "hw-tests" +))] use azihsm_ddi_tbor_types::TborApiRevReq; diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index af99fb64d..36441e26a 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -15,7 +15,12 @@ //! Uses the [`Ctx`](crate::harness::Ctx) alias — `TestCtx` under //! emu/mock/sock and `HwCtx` under a pure `hw-tests` build. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] +#![cfg(any( + feature = "emu", + feature = "mock", + feature = "sock", + feature = "hw-tests" +))] use azihsm_ddi_tbor_types::TborPartInfoReq; diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 47e77afd7..43b5de2c7 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -277,7 +277,11 @@ fn psk_change_envelope_from_other_session() { session_id: session_b.session_id(), psk_envelope: envelope, }; - ctx.expect_fw_reject_on_session(session_b.session_id(), &req, TborStatus::AeadEnvelopeAuthFailed); + ctx.expect_fw_reject_on_session( + session_b.session_id(), + &req, + TborStatus::AeadEnvelopeAuthFailed, + ); } // =========================================================================== @@ -351,11 +355,7 @@ fn psk_change_wrong_aad_length() { // --------------------------------------------------------------------------- #[cfg(feature = "hw-tests")] -fn run_psk_change_to_default_rejected( - role: u8, - sty: SessionType, - new_psk: &[u8; PSK_LEN], -) { +fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; PSK_LEN]) { let ctx = Ctx::new(); let session = ctx.open_session(role, sty); let err = ctx diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index b22ba9031..eb460f9ec 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -115,11 +115,7 @@ impl TestCtx { /// carries every session). Present here so tests can call the /// same name on both backends; on hw it routes the op to the fd /// that owns `session_id` (see `HwCtx::tbor_on_session`). - pub fn tbor_on_session( - &self, - _session_id: u16, - req: &R, - ) -> DdiResult { + pub fn tbor_on_session(&self, _session_id: u16, req: &R) -> DdiResult { self.tbor(req) } diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs index 2bbb4d879..c847f55c5 100644 --- a/ddi/tbor/types/tests/harness/hw_ctx.rs +++ b/ddi/tbor/types/tests/harness/hw_ctx.rs @@ -81,7 +81,8 @@ fn open_hw_dev() -> (HwDev, MutexGuard<'static, ()>) { let infos = ddi.dev_info_list(); let info = infos.first().expect("hw backend advertises no device"); let dev = ddi.open_dev(&info.path).expect("open hw backend device"); - dev.erase().expect("open_hw_dev: NSSR must succeed before test"); + dev.erase() + .expect("open_hw_dev: NSSR must succeed before test"); (dev, guard) } @@ -196,11 +197,7 @@ impl HwCtx { /// driver on the correct fd. Falls back to `primary` for unknown /// ids — lets negative-path tests exercise "bogus session id" /// paths without special-casing. - pub fn tbor_on_session( - &self, - session_id: u16, - req: &R, - ) -> DdiResult { + pub fn tbor_on_session(&self, session_id: u16, req: &R) -> DdiResult { let mut cookie = None; match self.dev_for_session(session_id) { Some(dev) => dev.exec_op_tbor(req, None, &mut cookie), @@ -337,11 +334,9 @@ impl HwCtx { } pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - let dev = self - .pending_fds - .lock() - .remove(&pending.session_id) - .expect("session_open_finish: no pending fd for this session_id — call session_open_init first"); + let dev = self.pending_fds.lock().remove(&pending.session_id).expect( + "session_open_finish: no pending fd for this session_id — call session_open_init first", + ); let is_primary = Arc::ptr_eq(&dev, &self.primary); match session_open_finish_helper(&dev, pending) { Ok(handshake) => { @@ -491,8 +486,7 @@ impl Drop for HwCtx { fn drop(&mut self) { // Drain the map so each fd Arc's refcount hits 0 as we finish // closing its session, releasing the extra kernel fds. - let live: Vec<(u16, Arc)> = - self.sessions.lock().drain().collect(); + let live: Vec<(u16, Arc)> = self.sessions.lock().drain().collect(); for (id, dev) in live { if let Err(e) = session_close_helper(&dev, id) { eprintln!( diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 83098c1df..b9d74c416 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -42,14 +42,22 @@ //! `get_certificate`) carry per-method `#[cfg(feature = "emu")]` and //! are unavailable under `--features mock`. -#![cfg(any(feature = "emu", feature = "mock", feature = "sock", feature = "hw-tests"))] +#![cfg(any( + feature = "emu", + feature = "mock", + feature = "sock", + feature = "hw-tests" +))] pub mod api_rev; pub mod assertions; #[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub mod ctx; pub mod fixture; -#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +#[cfg(all( + feature = "hw-tests", + not(any(feature = "emu", feature = "mock", feature = "sock")) +))] pub mod hw_ctx; pub mod session; pub mod session_guard; @@ -69,7 +77,6 @@ pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; #[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use ctx::TestCtx; - // `Ctx` is the backend-agnostic per-test fixture used by files // migrated to run against both the emu/mock/sock harnesses and the // native hw-tests backend. Under emu/mock/sock it aliases the @@ -80,10 +87,12 @@ pub use ctx::TestCtx; // `--features emu,hw-tests` still builds against emu. #[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use ctx::TestCtx as Ctx; -#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] -pub use hw_ctx::HwCtx as Ctx; - pub use fixture::open_dev; +#[cfg(all( + feature = "hw-tests", + not(any(feature = "emu", feature = "mock", feature = "sock")) +))] +pub use hw_ctx::HwCtx as Ctx; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index fae0b1f21..a8e5bf15d 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -25,11 +25,14 @@ use azihsm_ddi_interface::DdiResult; use azihsm_ddi_tbor_types::SessionType; -use crate::harness::session::SessionHandshake; #[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] use crate::harness::ctx::TestCtx; -#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +#[cfg(all( + feature = "hw-tests", + not(any(feature = "emu", feature = "mock", feature = "sock")) +))] use crate::harness::hw_ctx::HwCtx; +use crate::harness::session::SessionHandshake; /// Backend-agnostic hook the guard needs at cleanup time. Implemented /// by every ctx type whose `open_session` returns a [`SessionGuard`] @@ -125,14 +128,20 @@ impl TestCtx { } } -#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +#[cfg(all( + feature = "hw-tests", + not(any(feature = "emu", feature = "mock", feature = "sock")) +))] impl SessionCloser for HwCtx { fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { self.session_close(session_id) } } -#[cfg(all(feature = "hw-tests", not(any(feature = "emu", feature = "mock", feature = "sock"))))] +#[cfg(all( + feature = "hw-tests", + not(any(feature = "emu", feature = "mock", feature = "sock")) +))] impl HwCtx { /// Open a session via the happy-path two-phase handshake and /// return a [`SessionGuard`] that will close it on `Drop`. From 4b7d21c0b0ad54fe441fd56a2515422eeea807f9 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 20 Jul 2026 22:42:33 -0700 Subject: [PATCH 05/27] Fix mock tests --- ddi/tbor/types/tests/commands/open_session.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 5abb124f1..70860b7db 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -14,6 +14,8 @@ //! `Drop`; negative-path tests intercept the handshake through the //! raw `session_open_init` / `session_open_finish` methods on `Ctx`. +#![cfg(any(feature = "emu", feature = "hw-tests"))] + use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; use azihsm_ddi_tbor_types::TborSessionOpenInitReq; From 37b94027305456675ebe16541f62246f9cb4ab32 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 21 Jul 2026 07:35:28 -0700 Subject: [PATCH 06/27] Fix CodeQL --- ddi/tbor/types/tests/harness/hw_ctx.rs | 2 +- ddi/tbor/types/tests/harness/session_guard.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs index c847f55c5..eae7871a7 100644 --- a/ddi/tbor/types/tests/harness/hw_ctx.rs +++ b/ddi/tbor/types/tests/harness/hw_ctx.rs @@ -490,7 +490,7 @@ impl Drop for HwCtx { for (id, dev) in live { if let Err(e) = session_close_helper(&dev, id) { eprintln!( - "HwCtx::drop: session_close({id}) failed: {e:?} \ + "HwCtx::drop: session_close failed: {e:?} \ — session may leak on the device", ); } diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index a8e5bf15d..869b828d0 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -97,10 +97,7 @@ impl Drop for SessionGuard<'_> { // Drop never panics — failure is logged so the original panic // (if any) keeps its place at the top of the stack trace. if let Err(e) = self.ctx.close_session_by_id(self.handshake.session_id) { - eprintln!( - "SessionGuard: session_close({}) failed during drop: {e:?}", - self.handshake.session_id, - ); + eprintln!("SessionGuard: session_close failed during drop: {e:?}"); } } } From 7246b551f43ed36c69e86fedf4c467aded3b9864 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 21 Jul 2026 14:02:04 -0700 Subject: [PATCH 07/27] Resolve feedbacks --- .github/codeql/codeql-config.yml | 16 + ddi/tbor/types/Cargo.toml | 5 - ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 16 +- ddi/tbor/types/tests/commands/api_rev.rs | 43 +- .../types/tests/commands/default_psk_gate.rs | 27 +- ddi/tbor/types/tests/commands/open_session.rs | 70 +-- ddi/tbor/types/tests/commands/part_info.rs | 42 +- ddi/tbor/types/tests/commands/psk_change.rs | 50 +- ddi/tbor/types/tests/harness/ctx.rs | 370 ++++++++++--- ddi/tbor/types/tests/harness/fixture.rs | 49 +- ddi/tbor/types/tests/harness/hw_ctx.rs | 501 ------------------ ddi/tbor/types/tests/harness/mod.rs | 55 +- ddi/tbor/types/tests/harness/session_guard.rs | 65 +-- 13 files changed, 498 insertions(+), 811 deletions(-) delete mode 100644 ddi/tbor/types/tests/harness/hw_ctx.rs diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index e1b6324de..983770e7a 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -31,3 +31,19 @@ query-filters: # deterministic IVs to make negative-path tests reproducible. - "ddi/**/tests/**" - "ddi/tbor/test_helpers/**" + + # The "Cleartext logging of sensitive information" query + # (`rust/cleartext-logging`) flags drop-time `eprintln!` calls in + # the TBOR test harness that include the FW session-slot id in + # cleanup diagnostics (e.g. "SessionGuard: session_close({id}) …"). + # The session id is a small u16 slot number — not a secret — but + # CodeQL's Rust heuristic keys on the `session_id` identifier + # regardless of type or context. + # + # Suppressed for test trees only. Production drivers under + # `ddi/*/src/**` and `fw/**/src/**` remain covered. + - exclude: + id: rust/cleartext-logging + paths: + - "ddi/**/tests/**" + - "crates/**/tests/**" diff --git a/ddi/tbor/types/Cargo.toml b/ddi/tbor/types/Cargo.toml index 5d9e737fb..f647e4391 100644 --- a/ddi/tbor/types/Cargo.toml +++ b/ddi/tbor/types/Cargo.toml @@ -39,11 +39,6 @@ path = "tests/azihsm_ddi_tbor_tests.rs" emu = ["azihsm_ddi/emu"] mock = ["azihsm_ddi/mock"] sock = ["azihsm_ddi/sock"] -# Opt-in feature for the `hw::` smoke tests. These tests drive real -# silicon via the native OS backend and require a physical device; -# excluded from the default `cargo test` build so contributor / CI -# runs on machines without a board don't panic in `open_hw_dev`. -hw-tests = [] table-4 = ["azihsm_ddi/table-4"] table-64 = ["azihsm_ddi/table-64"] diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index 41004286c..dc1b246d5 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -6,20 +6,10 @@ //! Backend selection is feature-gated; the same tests run across every //! transport. Run with `--features emu` (in-process firmware), //! `--features sock` (firmware behind a socket server), or -//! `--features mock` (transport-contract probes). `--features -//! hw-tests` compiles the same `commands/*` tests against real -//! silicon through the shared [`harness::Ctx`] alias. -//! -//! With **no** feature enabled the crate falls through to the native -//! OS backend (`nix` on Linux / `win` on Windows) for build-only -//! checks; no tests are compiled in that mode. +//! `--features mock` (transport-contract probes). With **no** feature +//! enabled the crate falls through to the native OS backend (`nix` +//! on Linux / `win` on Windows) for on-silicon test runs. -#[cfg(any( - feature = "emu", - feature = "mock", - feature = "sock", - feature = "hw-tests" -))] pub mod harness; pub mod commands; diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index 7d07f0f4b..5219c7b41 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -3,41 +3,32 @@ //! Integration tests for TBOR `ApiRev`. //! -//! `round_trip` exercises the full path host → backend (`emu`, -//! `sock`, or `hw-tests` for real silicon) → fw `handle_tbor_op` → -//! response, so it is transport-agnostic. +//! `round_trip` exercises the full path host → backend → fw +//! `handle_tbor_op` → response, so it is transport-agnostic. //! `unsupported_on_mock` asserts the design contract that backends opt //! in to TBOR. //! -//! Pilot module for the [`Ctx`](crate::harness::Ctx) alias — every -//! test in this file constructs the ctx once and drives every device -//! interaction through its methods. `Ctx` resolves to -//! [`TestCtx`](crate::harness::ctx::TestCtx) under emu/mock/sock and -//! to [`HwCtx`](crate::harness::hw_ctx::HwCtx) under a pure -//! `hw-tests` build, so the same test bodies run on the in-process -//! firmware and against a live board. - -#![cfg(any( - feature = "emu", - feature = "mock", - feature = "sock", - feature = "hw-tests" -))] +//! Pilot module for the [`TestCtx`](crate::harness::TestCtx) fixture: +//! every test in this file constructs the ctx once and drives every +//! device interaction through its methods. The backend is selected at +//! compile time by [`azihsm_ddi::AzihsmDdi::default`], so the same +//! test bodies run on the in-process firmware and against a live +//! board. use azihsm_ddi_tbor_types::TborApiRevReq; -use crate::harness::Ctx; +use crate::harness::TestCtx; -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] const EXPECTED: azihsm_ddi_tbor_types::TborApiRevResp = azihsm_ddi_tbor_types::TborApiRevResp { min_ver: 1, max_ver: 1, }; -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] #[test] fn round_trip() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let resp = ctx .tbor(&TborApiRevReq::new()) .expect("TBOR ApiRev round-trip"); @@ -52,10 +43,10 @@ fn round_trip() { /// regression that would silently introduce per-call state (e.g. a /// version negotiation cache, a session-dependent code path) in the /// dispatcher's only out-of-session in-band handler. -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] #[test] fn api_rev_repeated_stable() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let baseline = ctx.tbor(&TborApiRevReq::new()).expect("baseline ApiRev"); assert_eq!(baseline, EXPECTED, "baseline must match expected"); for i in 1..16 { @@ -70,12 +61,12 @@ fn api_rev_repeated_stable() { /// transitions to Active. Together with the gate test in /// `default_psk_gate.rs` this proves the dispatcher never lets /// session state leak into the out-of-session handler. -#[cfg(any(feature = "emu", feature = "hw-tests"))] +#[cfg(not(any(feature = "mock", feature = "sock")))] #[test] fn api_rev_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // No sessions outstanding. let pre = ctx @@ -113,7 +104,7 @@ fn api_rev_independent_of_session_state() { fn unsupported_on_mock() { use crate::harness::assertions::assert_unsupported_encoding; - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let err = ctx .tbor(&TborApiRevReq::new()) .expect_err("mock backend must not implement exec_op_tbor"); diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index b055ce42f..4bf9563d7 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![cfg(not(any(feature = "mock", feature = "sock")))] + //! Integration tests for the TBOR dispatcher's default-PSK gate. //! //! The gate (see `fw/core/lib/src/ddi/tbor/mod.rs::dispatch`) rejects @@ -26,20 +28,13 @@ //! Each test inherits a factory-reset device from the ctx constructor, //! so partition PSKs are at their canonical defaults on entry. -#![cfg(any( - feature = "emu", - feature = "mock", - feature = "sock", - feature = "hw-tests" -))] - use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; -use crate::harness::Ctx; use crate::harness::SessionOpenInitOptions; +use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; @@ -47,7 +42,6 @@ const CU: u8 = 1; /// Non-default PSK used as the rotation target for the `PskChange` /// bypass test. Distinct from the constant used in `psk_change.rs` so /// a leaked rotation from this file is trivially identifiable. -#[cfg(any(feature = "emu", feature = "hw-tests"))] const GATE_ROTATED_PSK: [u8; PSK_LEN] = [ 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, @@ -56,10 +50,9 @@ const GATE_ROTATED_PSK: [u8; PSK_LEN] = [ /// E5: `ApiRev` is an out-of-session opcode and therefore never /// gated. It must succeed even when both partition PSKs are at their /// compiled-in defaults. -#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] fn default_psk_gate_api_rev_bypass() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // Two probes back-to-back to confirm the call is genuinely // repeatable (gate is stateless) rather than passing on first // call by luck of ordering. @@ -70,10 +63,9 @@ fn default_psk_gate_api_rev_bypass() { /// E3: `SessionOpenInit` is out-of-session and therefore never gated. /// Verified for both roles since each is bound to a distinct PSK /// slot. -#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] fn default_psk_gate_session_open_init_bypass() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // CO + Authenticated under default CO PSK. let opts_co = @@ -102,10 +94,9 @@ fn default_psk_gate_session_open_init_bypass() { /// E2: `SessionClose` is on the allow-list — it must succeed while /// the role's PSK is still default. Exercised for both roles. -#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] fn default_psk_gate_session_close_bypass() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session_co = ctx.open_session(CO, SessionType::Authenticated); session_co @@ -125,10 +116,9 @@ fn default_psk_gate_session_close_bypass() { /// Exercised for the CO role; the CU role's bootstrap path is /// functionally identical and is already exercised by /// `psk_change_happy_cu_emu` in `psk_change.rs`. -#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] fn default_psk_gate_psk_change_bypass() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); ctx.psk_change(session.handshake(), &GATE_ROTATED_PSK) .expect("PskChange must bypass gate while CO PSK is default"); @@ -138,7 +128,6 @@ fn default_psk_gate_psk_change_bypass() { /// rejected at dispatch with `DefaultPskMustRotate`. The FW returns /// the gate error before any partition-state mutation, so this test /// is safe to run on real silicon. -#[cfg(any(feature = "emu", feature = "hw-tests"))] #[test] fn default_psk_gate_part_init_rejected() { use azihsm_ddi_tbor_types::PolicyKeyKind; @@ -180,7 +169,7 @@ fn default_psk_gate_part_init_rejected() { bytes } - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); let mach_seed = { diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 70860b7db..85ac2598c 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -4,17 +4,17 @@ //! Integration tests for the TBOR `SessionOpenInit` / //! `SessionOpenFinish` two-phase session handshake. //! -//! Runs against both backends via the [`Ctx`] alias: `TestCtx` under -//! `feature = "emu"` (soft-crypto backend, factory-reset per test), -//! `HwCtx` under `feature = "hw-tests"` (native OS backend against a -//! live board, NSSR per test). +//! Runs on any backend that exposes the FW handler (`emu`, `sock`, +//! or the native OS backend when no backend feature is enabled). +//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected +//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. //! //! Happy-path sessions are owned by a //! [`SessionGuard`](crate::harness::SessionGuard) that closes on //! `Drop`; negative-path tests intercept the handshake through the -//! raw `session_open_init` / `session_open_finish` methods on `Ctx`. +//! raw `session_open_init` / `session_open_finish` methods on `TestCtx`. -#![cfg(any(feature = "emu", feature = "hw-tests"))] +#![cfg(not(any(feature = "mock", feature = "sock")))] use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; @@ -25,7 +25,7 @@ use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; -use crate::harness::Ctx; +use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; @@ -36,7 +36,7 @@ const CU: u8 = 1; #[test] fn open_session_co_authenticated_happy() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); let h = session.handshake(); assert_eq!(h.psk_id, CO); @@ -56,7 +56,7 @@ fn open_session_co_authenticated_happy() { #[test] fn open_session_cu_plaintext_happy() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); let h = session.handshake(); assert_eq!(h.psk_id, CU); @@ -69,7 +69,7 @@ fn open_session_cu_plaintext_happy() { #[test] fn open_session_co_plaintext_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let err = ctx .session_open_init(CO, SessionType::PlainText) .expect_err("CO + PlainText is not a permitted pairing"); @@ -78,7 +78,7 @@ fn open_session_co_plaintext_rejected() { #[test] fn open_session_cu_authenticated_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let err = ctx .session_open_init(CU, SessionType::Authenticated) .expect_err("CU + Authenticated is not a permitted pairing"); @@ -96,7 +96,7 @@ fn open_session_invalid_psk_id() { // value (`2`), a mid-range value (`0x7F`), and the all-ones byte // (`0xFF`). All must surface `InvalidPskId` from the FW // dispatcher's `psk_id`-validation arm before any HPKE work. - let ctx = Ctx::new(); + let ctx = TestCtx::new(); for bad in [2u8, 0x7F, 0xFF] { let err = ctx .session_open_init(bad, SessionType::PlainText) @@ -109,7 +109,7 @@ fn open_session_invalid_psk_id() { fn open_session_invalid_session_type_byte() { // Bypass the typed `SessionType` enum to ship an out-of-range // byte directly. The FW `SessionType::from_u8` must reject. - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let req = TborSessionOpenInitReq { psk_id: CU, session_type: 42, @@ -129,7 +129,7 @@ fn open_session_unsupported_suite_id() { // before any HPKE work happens. Spot-check three boundary values // covering "reserved-but-not-yet-implemented" (0x02), zero (0x00), // and the all-ones byte (0xFF). - let ctx = Ctx::new(); + let ctx = TestCtx::new(); for bad in [0x00u8, 0x02, 0xff] { let req = TborSessionOpenInitReq { psk_id: CU, @@ -147,7 +147,7 @@ fn open_session_unsupported_suite_id() { #[test] fn session_open_finish_mac_tampered() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let pending = ctx .session_open_init(CU, SessionType::PlainText) .expect("phase 1 must succeed"); @@ -172,7 +172,7 @@ fn session_open_finish_unknown_session_id() { // per-fd session scoping and rejects the ioctl with // `FileHandleNoExistingSession` (`DdiError::DdiStatus`) before // the FW sees it — either surface is a valid rejection. - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let req = TborSessionOpenFinishReq { session_id: 0xFFFF, mac_fin: [0u8; 48], @@ -193,7 +193,7 @@ fn session_open_finish_unknown_session_id() { #[test] fn open_session_double_finish() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // Replay the finish: pending slot is gone, FW must refuse. let req = TborSessionOpenFinishReq { @@ -218,7 +218,7 @@ fn open_session_double_finish() { fn session_open_finish_seed_envelope_tampered() { // Build a finish request by hand so we can corrupt the seed_envelope // ciphertext byte after Phase-1 succeeds but before shipping it. - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let pending = ctx .session_open_init(CU, SessionType::PlainText) .expect("phase 1 must succeed"); @@ -246,7 +246,7 @@ fn session_open_finish_seed_envelope_tampered() { #[test] fn open_session_multiple_concurrent() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let a = ctx.open_session(CU, SessionType::PlainText); let b = ctx.open_session(CU, SessionType::PlainText); assert_ne!( @@ -271,7 +271,7 @@ fn open_session_multiple_concurrent() { #[test] fn open_session_fills_table_then_recovers() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let mut ids: Vec = Vec::new(); let mut rejection_seen = false; @@ -323,7 +323,7 @@ fn open_session_fills_table_then_recovers() { /// would otherwise wedge the second attempt. #[test] fn open_close_reopen_same_slot() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let first = ctx .open_session_raw(CU, SessionType::PlainText) .expect("first handshake must succeed"); @@ -349,7 +349,7 @@ fn open_close_reopen_same_slot() { /// handshake invariant this test guards is unchanged by that. #[test] fn co_authenticated_derives_unique_keys_per_session() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let a = ctx .open_session_raw(CO, SessionType::Authenticated) @@ -394,7 +394,7 @@ fn co_authenticated_derives_unique_keys_per_session() { /// would break long-term binding assumed by higher-layer protocols. #[test] fn partition_pk_hsm_stable_across_handshakes() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let pending_a = ctx .session_open_init(CU, SessionType::PlainText) @@ -429,9 +429,9 @@ fn partition_pk_hsm_stable_across_handshakes() { // // Threads drive `ctx.session_open_init` / `ctx.session_open_finish` // directly — the same map-based fd allocation used by every other -// test, so nothing bypasses the tracking. HwCtx grabs a fresh fd per -// concurrent handshake internally and stashes it in `pending_fds` -// keyed by the FW-assigned session id. +// test, so nothing bypasses the tracking. On the native OS backend +// each concurrent handshake gets its own fresh fd, stashed in +// `pending_fds` keyed by the FW-assigned session id. // --------------------------------------------------------------------------- const MULTI_THREADED_TOTAL: usize = 12; @@ -440,7 +440,7 @@ const MULTI_THREADED_TOTAL: usize = 12; /// and any losers must surface as clean FW/driver rejections. #[test] fn open_session_multi_threaded_all_should_open() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let (winners, rejections) = std::thread::scope(|s| { let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); @@ -516,7 +516,7 @@ fn open_session_multi_threaded_all_should_open() { /// at infinity in some conventions) — FW must reject. #[test] fn pk_init_all_zero_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let req = TborSessionOpenInitReq { psk_id: CU, session_type: SessionType::PlainText.to_u8(), @@ -537,7 +537,7 @@ fn pk_init_all_zero_rejected() { /// on-curve validation must reject. #[test] fn pk_init_not_on_curve_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let mut pk_init = [0xFFu8; PK_INIT_LEN]; pk_init[0] = 0x04; // SEC1 uncompressed prefix let req = TborSessionOpenInitReq { @@ -598,7 +598,7 @@ fn pack_sec1_uncompressed(x_be: &[u8; 48], y_be: &[u8; 48]) -> [u8; PK_INIT_LEN] /// FW must reject. #[test] fn pk_init_x_as_prime_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let pk_init = pack_sec1_uncompressed(&P384_PRIME_BE, &P384_INVALID_Y_FOR_X_AS_PRIME_BE); let req = TborSessionOpenInitReq { psk_id: CU, @@ -619,7 +619,7 @@ fn pk_init_x_as_prime_rejected() { /// X-only validation bugs. #[test] fn pk_init_y_as_prime_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let pk_init = pack_sec1_uncompressed(&P384_INVALID_X_FOR_Y_AS_PRIME_BE, &P384_PRIME_BE); let req = TborSessionOpenInitReq { psk_id: CU, @@ -645,7 +645,7 @@ fn pk_init_y_as_prime_rejected() { #[test] fn pk_init_single_byte_tampered_rejected() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let ephemeral = azihsm_session_ex_crypto::generate_vm_ephemeral() .expect("generate_vm_ephemeral must succeed on the test host"); let mut pk_init = ephemeral.pk_sec1; @@ -670,7 +670,7 @@ fn pk_init_single_byte_tampered_rejected() { // Hardware-only tests // =========================================================================== -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] const SINGLE_WINNER_RACERS: usize = 8; /// Fill to one free slot then race N threads for it. Regression for @@ -686,10 +686,10 @@ const SINGLE_WINNER_RACERS: usize = 8; /// `SessionAuthFailure` at finish rather than the "table full" that /// real FW returns to losers. Real hw has genuine per-fd state in /// the kernel driver. -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn open_session_multi_threaded_single_winner() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // Phase 1: probe capacity sequentially; ceiling matches // fills_table_then_recovers so we don't loop forever on a diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index 36441e26a..ff9584f0c 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -3,36 +3,28 @@ //! Integration tests for the out-of-session TBOR `PartInfo` command. //! -//! `round_trip` exercises the full host → backend (`emu`, `sock`, or -//! `hw-tests` for real silicon) → fw `handle_tbor_op` → response path -//! and asserts the device/partition fields the firmware reports for -//! the default provisioned partition. +//! `round_trip` exercises the full host → backend → fw +//! `handle_tbor_op` → response path and asserts the device/partition +//! fields the firmware reports for the default provisioned partition. //! `part_info_independent_of_session_state` proves the dispatcher //! never lets session-machine state leak into the out-of-session //! handler. `unsupported_on_mock` asserts the design contract that //! backends opt in to TBOR. //! -//! Uses the [`Ctx`](crate::harness::Ctx) alias — `TestCtx` under -//! emu/mock/sock and `HwCtx` under a pure `hw-tests` build. - -#![cfg(any( - feature = "emu", - feature = "mock", - feature = "sock", - feature = "hw-tests" -))] +//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected +//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. use azihsm_ddi_tbor_types::TborPartInfoReq; -use crate::harness::Ctx; +use crate::harness::TestCtx; /// `DdiDeviceKind::Physical` discriminant — uno is a physical device. -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] const DEVICE_KIND_PHYSICAL: u8 = 2; /// `PartState::Enabled` discriminant — the default provisioned state of /// the emulator partition before any `PartInit`. -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] const PART_STATE_ENABLED: u8 = 2; /// `PartState::Initializing` discriminant — the state a partition enters @@ -43,7 +35,7 @@ const PART_STATE_INITIALIZING: u8 = 4; /// Assert the invariant device-level fields PartInfo reports for the /// default provisioned partition, plus that the identity public key is /// materialized (not all-zero). -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { assert_eq!( resp.device_kind, DEVICE_KIND_PHYSICAL, @@ -59,10 +51,10 @@ fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { ); } -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] #[test] fn round_trip() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let resp = ctx .tbor(&TborPartInfoReq::new()) .expect("TBOR PartInfo round-trip"); @@ -74,10 +66,10 @@ fn round_trip() { /// returns a byte-identical response. Catches any regression that would /// silently introduce per-call state (e.g. a counter, a cached /// allocation) into the out-of-session handler. -#[cfg(any(feature = "emu", feature = "sock", feature = "hw-tests"))] +#[cfg(not(feature = "mock"))] #[test] fn part_info_repeated_stable() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let baseline = ctx .tbor(&TborPartInfoReq::new()) .expect("baseline PartInfo"); @@ -95,12 +87,12 @@ fn part_info_repeated_stable() { /// occupies a session slot, after that slot transitions to Active, and /// again once it is closed. Catches any regression that would let /// session state leak into the out-of-session handler. -#[cfg(any(feature = "emu", feature = "hw-tests"))] +#[cfg(not(any(feature = "mock", feature = "sock")))] #[test] fn part_info_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // No sessions outstanding. let pre = ctx @@ -157,7 +149,7 @@ fn part_info_reflects_part_init_transition_emu() { use crate::commands::part_init::pota_thumbprint; use crate::commands::part_init::ROTATED_CO_PSK; - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // Before PartInit: default Enabled posture with a materialized // identity. @@ -209,7 +201,7 @@ fn part_info_reflects_part_init_transition_emu() { fn unsupported_on_mock() { use crate::harness::assertions::assert_unsupported_encoding; - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let err = ctx .tbor(&TborPartInfoReq::new()) .expect_err("mock backend must not implement exec_op_tbor"); diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 43b5de2c7..33aa2a785 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -4,9 +4,9 @@ //! Integration tests for the TBOR `PskChange` command. //! //! Cross-test isolation comes from the ctx's factory-reset on -//! construction (`TestCtx::new` on emu/mock/sock, `HwCtx::new` NSSR -//! on hw-tests). Live sessions are owned by a [`SessionGuard`] that -//! closes on `Drop`, including during panic unwind. +//! construction ([`TestCtx::new`]). Live sessions are owned by a +//! [`SessionGuard`] that closes on `Drop`, including during panic +//! unwind. //! //! Coverage: //! * Happy paths (CO + CU), with explicit reopen using the rotated @@ -27,10 +27,10 @@ //! * Plaintext not exactly `PSK_LEN` (→ wrong envelope length) → //! [`LENGTH_REJECT_STATUS`]. //! -//! Uses the [`Ctx`](crate::harness::Ctx) alias — `TestCtx` under -//! emu/mock/sock and `HwCtx` under a pure `hw-tests` build. +//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected +//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. -#![cfg(any(feature = "emu", feature = "hw-tests"))] +#![cfg(not(any(feature = "mock", feature = "sock")))] use azihsm_crypto::aead_envelope; use azihsm_crypto::aead_envelope::AeadAlg; @@ -38,18 +38,18 @@ use azihsm_crypto::AesKey; use azihsm_crypto::Rng; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; -use crate::harness::Ctx; use crate::harness::SessionOpenInitOptions; use crate::harness::TborPskChangeReq; +use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; @@ -63,7 +63,7 @@ const CU: u8 = 1; /// handler's defensive branch is reached. #[cfg(feature = "emu")] const LENGTH_REJECT_STATUS: TborStatus = TborStatus::TborInvalidFixedLength; -#[cfg(all(feature = "hw-tests", not(feature = "emu")))] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] const LENGTH_REJECT_STATUS: TborStatus = TborStatus::DdiDecodeFailed; /// Distinct, non-default 32-byte PSK used by the happy-path tests. @@ -103,7 +103,7 @@ fn build_envelope(param_key: &AesKey, aad: &[u8], plaintext: &[u8]) -> Vec { /// then prove the rotation took effect by reopening under the new /// bytes. fn run_psk_change_happy(role: u8, sty: SessionType) { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(role, sty); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate to ROTATED_PSK"); @@ -136,7 +136,7 @@ fn psk_change_happy_co() { #[test] fn psk_change_reopen_with_old_psk_fails() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate"); @@ -159,7 +159,7 @@ fn psk_change_reopen_with_old_psk_fails() { #[test] fn psk_change_second_attempt_same_session_fails() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("first rotate"); @@ -184,7 +184,7 @@ fn psk_change_second_attempt_same_session_fails() { #[test] fn psk_change_envelope_tampered() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); for (label, mutate) in [ ( @@ -222,7 +222,7 @@ fn psk_change_envelope_tampered() { #[test] fn psk_change_empty_envelope() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); let req = TborPskChangeReq { session_id: session.session_id(), @@ -239,7 +239,7 @@ fn psk_change_empty_envelope() { #[test] fn psk_change_wrong_session_id_in_aad() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // Build an envelope whose AAD encodes a different (bogus) // session id. AEAD-GCM tag verifies (the FW recomputes the tag @@ -268,7 +268,7 @@ fn psk_change_wrong_session_id_in_aad() { #[test] fn psk_change_envelope_from_other_session() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session_a = ctx.open_session(CU, SessionType::PlainText); let session_b = ctx.open_session(CU, SessionType::PlainText); let aad_for_b = build_psk_change_aad(session_b.session_id()); @@ -290,7 +290,7 @@ fn psk_change_envelope_from_other_session() { #[test] fn psk_change_wrong_plaintext_length() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); // PSK_LEN ± 1: shortest excursions either side of the canonical // length. A wrong plaintext length yields a wrong *envelope* length // (ciphertext tracks plaintext for GCM), so the FW schema's fixed @@ -319,7 +319,7 @@ fn psk_change_wrong_plaintext_length() { #[test] fn psk_change_wrong_aad_length() { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // 64 bytes of arbitrary AAD (valid AEAD granularity) inflates the // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B) — status varies @@ -354,9 +354,9 @@ fn psk_change_wrong_aad_length() { // verified against real FW. // --------------------------------------------------------------------------- -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; PSK_LEN]) { - let ctx = Ctx::new(); + let ctx = TestCtx::new(); let session = ctx.open_session(role, sty); let err = ctx .psk_change(session.handshake(), new_psk) @@ -364,25 +364,25 @@ fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; assert_fw_rejects(&err, TborStatus::InvalidArg); } -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn psk_change_cu_to_default_cu_rejected() { run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CU); } -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn psk_change_cu_to_default_co_rejected() { run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CO); } -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn psk_change_co_to_default_co_rejected() { run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CO); } -#[cfg(feature = "hw-tests")] +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn psk_change_co_to_default_cu_rejected() { run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CU); diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index eb460f9ec..d403a6fb7 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -23,8 +23,19 @@ //! //! Cross-test isolation (process-global lock + factory reset) lives //! in [`crate::harness::fixture::open_dev`], which this type calls -//! through. Tests that mix-and-match raw [`open_dev`] calls and -//! [`TestCtx`] both get the same guarantee. +//! through. +//! +//! # Multi-fd routing +//! +//! On the native OS backend the kernel driver enforces +//! `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every concurrent session past +//! the first sits on its own fd. `TestCtx` tracks +//! `session_id → owning Dev` so session-scoped ops +//! (`session_open_finish`, `session_close`, `psk_change`, `part_init`, +//! …) reach the driver on the right fd. On emu / mock / sock the +//! extra "fds" are just extra handles onto the same in-process +//! backend — harmless, and lets the same test source run identically +//! on every backend. //! //! The raw device handle deliberately has **no public accessor** on //! this type. All device interactions must flow through one of the @@ -33,6 +44,11 @@ //! `get_certificate`). This forces every test path through the //! shared assertion funnel. +use std::collections::HashMap; +use std::sync::Arc; + +use azihsm_ddi::AzihsmDdi; +use azihsm_ddi_interface::Ddi; use azihsm_ddi_interface::DdiDev; use azihsm_ddi_interface::DdiError; use azihsm_ddi_interface::DdiResult; @@ -42,12 +58,14 @@ use azihsm_ddi_tbor_types::TborOpReq; use azihsm_ddi_tbor_types::TborPartFinalResp; use azihsm_ddi_tbor_types::TborPartInitResp; use azihsm_ddi_tbor_types::TborStatus; +use parking_lot::Mutex; +use parking_lot::MutexGuard; use crate::harness::api_rev::helper_api_rev_tbor; use crate::harness::assertions::assert_fw_rejects; use crate::harness::assertions::assert_tbor_decode_error; -use crate::harness::fixture::open_dev; -use crate::harness::fixture::TestDev; +use crate::harness::fixture::open_dev_parts; +use crate::harness::fixture::open_extra_dev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; @@ -65,26 +83,131 @@ use crate::harness::session::SessionOpenInitOptions; /// security-domain inputs. const DEFAULT_SATA_THUMBPRINT: [u8; 48] = [0x5A; 48]; +/// Backend device handle, compile-time-selected by +/// [`AzihsmDdi::default()`] — `DdiEmu` under `--features emu`, +/// `DdiSock` under `--features sock`, `DdiMock` under `--features +/// mock`, and the native `DdiNix` / `DdiWin` when no backend feature +/// is enabled. +type Dev = ::Dev; + /// One-test fixture: an opened backend device handle (with the /// process-global test lock held for its lifetime) plus a thin layer /// of error-shape assertions. Constructed once per `#[test]`. +/// +/// Fd placement policy: the first live session lives on the primary +/// fd (the same fd that acquired the test lock via +/// [`open_dev_parts`]). When primary already carries a live session, +/// additional concurrent sessions get their own fresh fds via +/// [`open_extra_dev`]. Session-scoped ops always route via the +/// [`Self::sessions`] map — nothing session-scoped bypasses it and +/// targets the primary directly. +/// +/// The primary fd sits behind `Arc` (not `Arc`) +/// deliberately: `TestDev` bundles the `parking_lot::MutexGuard` and +/// is therefore `!Send`; wrapping it in `Arc` would make `TestCtx` +/// `!Sync`, which in turn breaks `std::thread::scope`-based tests +/// that share `&TestCtx` across threads. Keeping the guard as a +/// separate field of `TestCtx` leaves the ctx `!Send` (the guard +/// still can't cross a `Send` boundary) but `Sync`, which is what +/// `s.spawn(|| ctx.foo())` actually needs. pub struct TestCtx { - dev: TestDev, + primary: Arc, + _guard: MutexGuard<'static, ()>, + /// `true` while `primary` currently owns a live session. Cleared + /// when that session's entry is removed from `sessions`. + primary_busy: Mutex, + /// Fds waiting for a matching finish to promote them into + /// [`Self::sessions`], keyed by the FW-assigned session id that + /// [`Self::session_open_init`] returned. Keying by id (rather + /// than a single slot) lets concurrent handshakes coexist. + pending_fds: Mutex>, + /// Every live session, keyed by FW-assigned session id. + sessions: Mutex>, +} + +/// One entry in the session/pending map: either "the primary fd" (in +/// which case no owned handle is stored) or an extra fd opened via +/// [`open_extra_dev`]. Kept as an enum rather than a bare `Arc` +/// so we don't have to wrap the primary [`TestDev`] in an `Arc` just +/// to satisfy the map's ownership. +enum FdSlot { + Primary, + Extra(Arc), +} + +impl FdSlot { + fn is_primary(&self) -> bool { + matches!(self, FdSlot::Primary) + } } impl TestCtx { - /// Open the backend device via [`open_dev`] — see its docs for - /// the locking + factory-reset semantics. + /// Open the backend device via [`open_dev_parts`] — see its docs + /// for the locking + factory-reset semantics. pub fn new() -> Self { - Self { dev: open_dev() } + let (dev, guard) = open_dev_parts(); + Self { + primary: Arc::new(dev), + _guard: guard, + primary_busy: Mutex::new(false), + pending_fds: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + } } - /// Factory-reset the partition. Available only on `emu`; the - /// determinism tests in `commands::part_init` call this between - /// cold-restart iterations. - #[cfg(feature = "emu")] + /// Factory-reset the partition. On emu this issues the emulator's + /// reset; on the native backend it issues NSSR. Under `--features + /// mock` this call is unavailable (the mock backend has no state + /// to reset). + #[cfg(not(feature = "mock"))] pub fn erase(&self) -> DdiResult<()> { - self.dev.erase() + self.primary.erase() + } + + /// Look up the fd that owns `session_id`, or return `None` for + /// unknown ids (negative-path tests exercising invalid ids, or + /// ids from a session that's already been closed). + /// + /// Callers that need a `&Dev` for a helper should use + /// [`Self::with_session_dev`] instead so the primary borrow stays + /// live across the call. + fn with_session_dev(&self, session_id: u16, f: impl FnOnce(&Dev) -> R) -> R { + // Take a snapshot of the owning slot while holding the map + // lock briefly, then release it before running `f` (which + // may itself call back into `self` for the primary fd on + // unknown-id fallback). + let slot = self.sessions.lock().get(&session_id).map(|s| match s { + FdSlot::Primary => FdSlot::Primary, + FdSlot::Extra(arc) => FdSlot::Extra(Arc::clone(arc)), + }); + match slot { + Some(FdSlot::Extra(arc)) => f(&arc), + Some(FdSlot::Primary) | None => f(&self.primary), + } + } + + /// Pick the fd for a fresh handshake: primary if free, else a + /// brand-new extra fd. Also flips `primary_busy` if we took + /// primary — the caller must roll that back on error and + /// [`Self::session_close`] must clear it on close. + fn take_fd_for_new_session(&self) -> FdSlot { + let mut busy = self.primary_busy.lock(); + if !*busy { + *busy = true; + FdSlot::Primary + } else { + FdSlot::Extra(Arc::new(open_extra_dev())) + } + } + + /// Resolve an [`FdSlot`] snapshot down to a `&Dev` borrow. The + /// snapshot must outlive the returned reference (it holds the + /// `Arc` alive for the extra-fd case). + fn dev_of<'a>(&'a self, slot: &'a FdSlot) -> &'a Dev { + match slot { + FdSlot::Primary => &self.primary, + FdSlot::Extra(arc) => arc, + } } /// Issue an `OP_TBOR` request and return the raw `DdiResult`. @@ -96,7 +219,7 @@ impl TestCtx { /// [`Self::expect_fw_reject`]. pub fn tbor(&self, req: &R) -> DdiResult { let mut cookie = None; - self.dev.exec_op_tbor(req, None, &mut cookie) + self.primary.exec_op_tbor(req, None, &mut cookie) } /// Issue an `OP_TBOR` request carrying out-of-band SGL items. @@ -108,25 +231,31 @@ impl TestCtx { /// buffer (e.g. `SdCreateRemoteBackup`'s receiver `KeyReport`). pub fn tbor_oob(&self, req: &R, oob_items: &[&[u8]]) -> DdiResult { let mut cookie = None; - self.dev.exec_op_tbor(req, Some(oob_items), &mut cookie) + self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie) } - /// Session-scoped raw tbor exec — passthrough on emu (single dev - /// carries every session). Present here so tests can call the - /// same name on both backends; on hw it routes the op to the fd - /// that owns `session_id` (see `HwCtx::tbor_on_session`). - pub fn tbor_on_session(&self, _session_id: u16, req: &R) -> DdiResult { - self.tbor(req) + /// Session-scoped raw tbor exec: routes the op via the fd that + /// owns `session_id`. Falls back to `primary` for unknown ids — + /// lets negative-path tests exercise "bogus session id" paths + /// without special-casing. + pub fn tbor_on_session(&self, session_id: u16, req: &R) -> DdiResult { + self.with_session_dev(session_id, |dev| { + let mut cookie = None; + dev.exec_op_tbor(req, None, &mut cookie) + }) } - /// Session-scoped OOB variant — passthrough on emu. + /// Session-scoped OOB variant of [`Self::tbor_on_session`]. pub fn tbor_oob_on_session( &self, - _session_id: u16, + session_id: u16, req: &R, oob_items: &[&[u8]], ) -> DdiResult { - self.tbor_oob(req, oob_items) + self.with_session_dev(session_id, |dev| { + let mut cookie = None; + dev.exec_op_tbor(req, Some(oob_items), &mut cookie) + }) } /// Issue `req`, assert the FW dispatcher rejected it with exactly @@ -154,9 +283,8 @@ impl TestCtx { } } - /// Session-scoped variant of [`Self::expect_fw_reject`] — - /// passthrough on emu; on hw routes via the fd owning - /// `session_id`. + /// Session-scoped variant of [`Self::expect_fw_reject`] — routes + /// via the fd owning `session_id`. #[track_caller] pub fn expect_fw_reject_on_session( &self, @@ -229,10 +357,9 @@ impl TestCtx { // // Thin wrappers around the free helpers in `harness::session` so // tests can write `ctx.psk_change(&session, &psk)` instead of - // reaching through a raw device handle. The free helpers remain - // in place for documentation purposes (their signatures describe - // what bytes reach the wire); the methods are the ergonomic - // test-facing API. + // reaching through a raw device handle. Every session-scoped + // wrapper routes via the multi-fd map so hw callers hit the + // driver on the correct fd. // ------------------------------------------------------------------- /// Run Phase 1 of the TBOR session handshake with happy-path @@ -243,7 +370,23 @@ impl TestCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - session_open_init_helper(&self.dev, psk_id, session_type) + let slot = self.take_fd_for_new_session(); + let res = { + let dev = self.dev_of(&slot); + session_open_init_helper(dev, psk_id, session_type) + }; + match res { + Ok(pending) => { + self.pending_fds.lock().insert(pending.session_id, slot); + Ok(pending) + } + Err(e) => { + if slot.is_primary() { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } /// Full-control Phase 1 entry point: honours every override in @@ -252,14 +395,49 @@ impl TestCtx { &self, opts: SessionOpenInitOptions<'_>, ) -> DdiResult { - session_open_init_with_options_helper(&self.dev, opts) + let slot = self.take_fd_for_new_session(); + let res = { + let dev = self.dev_of(&slot); + session_open_init_with_options_helper(dev, opts) + }; + match res { + Ok(pending) => { + self.pending_fds.lock().insert(pending.session_id, slot); + Ok(pending) + } + Err(e) => { + if slot.is_primary() { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } /// Run Phase 2 of the TBOR session handshake with the canonical /// confirm MAC. Consumes `pending` so callers cannot reuse stale /// state. pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - session_open_finish_helper(&self.dev, pending) + let slot = self.pending_fds.lock().remove(&pending.session_id).expect( + "session_open_finish: no pending fd for this session_id — call session_open_init first", + ); + let is_primary = slot.is_primary(); + let res = { + let dev = self.dev_of(&slot); + session_open_finish_helper(dev, pending) + }; + match res { + Ok(handshake) => { + self.sessions.lock().insert(handshake.session_id, slot); + Ok(handshake) + } + Err(e) => { + if is_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } /// Phase 2 entry point that ships a caller-supplied `mac_fin`, @@ -269,7 +447,26 @@ impl TestCtx { pending: PendingHandshake, mac_fin: [u8; 48], ) -> DdiResult { - session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) + let slot = self.pending_fds.lock().remove(&pending.session_id).expect( + "session_open_finish_with_mac: no pending fd for this session_id — call session_open_init first", + ); + let is_primary = slot.is_primary(); + let res = { + let dev = self.dev_of(&slot); + session_open_finish_with_mac_helper(dev, pending, mac_fin) + }; + match res { + Ok(handshake) => { + self.sessions.lock().insert(handshake.session_id, slot); + Ok(handshake) + } + Err(e) => { + if is_primary { + *self.primary_busy.lock() = false; + } + Err(e) + } + } } /// One-shot happy-path handshake that returns the raw @@ -287,18 +484,37 @@ impl TestCtx { self.session_open_finish(pending) } - /// Issue `SessionClose(session_id)`. Used by negative-path - /// tests (double-close, unknown id) and by callers that hold a - /// raw [`SessionHandshake`] outside of a [`SessionGuard`]. + /// Issue `SessionClose(session_id)`. Removes the fd binding so + /// extra fds drop (closing the kernel handle) after the close + /// call; primary is retained but marked free for the next + /// handshake. Falls back to `primary` for unknown ids so the + /// FW/driver can surface its own error for negative-path tests. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - session_close_helper(&self.dev, session_id) + let entry = self.sessions.lock().remove(&session_id); + match entry { + Some(slot) => { + let is_primary = slot.is_primary(); + let res = { + let dev = self.dev_of(&slot); + session_close_helper(dev, session_id) + }; + if is_primary { + *self.primary_busy.lock() = false; + } + drop(slot); + res + } + None => session_close_helper(&self.primary, session_id), + } } /// Issue `PskChange` on `session` with `new_psk` as the /// plaintext. The 32-byte length check is performed by the free /// helper before any wire bytes are emitted. pub fn psk_change(&self, session: &SessionHandshake, new_psk: &[u8]) -> DdiResult<()> { - psk_change_helper(&self.dev, session, new_psk) + self.with_session_dev(session.session_id, |dev| { + psk_change_helper(dev, session, new_psk) + }) } /// Issue `PartInit` on the CO `session` with the canonical @@ -311,15 +527,17 @@ impl TestCtx { part_policy: &[u8], pota_thumbprint: &[u8], ) -> DdiResult { - part_init_helper( - &self.dev, - session, - mach_seed, - part_policy, - pota_thumbprint, - &DEFAULT_SATA_THUMBPRINT, - None, - ) + self.with_session_dev(session.session_id, |dev| { + part_init_helper( + dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + &DEFAULT_SATA_THUMBPRINT, + None, + ) + }) } /// Issue `PartInit` with explicit security-domain thumbprint inputs @@ -333,15 +551,17 @@ impl TestCtx { sata_thumbprint: &[u8], sapota_thumbprint: Option<&[u8]>, ) -> DdiResult { - part_init_helper( - &self.dev, - session, - mach_seed, - part_policy, - pota_thumbprint, - sata_thumbprint, - sapota_thumbprint, - ) + self.with_session_dev(session.session_id, |dev| { + part_init_helper( + dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + sata_thumbprint, + sapota_thumbprint, + ) + }) } /// Issue `PartFinal` re-supplying `part_policy` (must match the one @@ -354,13 +574,15 @@ impl TestCtx { prev_local_mk_backup: &[u8], certs: &[&[u8]], ) -> DdiResult { - part_final_helper(&self.dev, session, part_policy, prev_local_mk_backup, certs) + self.with_session_dev(session.session_id, |dev| { + part_final_helper(dev, session, part_policy, prev_local_mk_backup, certs) + }) } /// Issue `ApiRev` and return the decoded response. Thin /// pass-through over the free helper. pub fn api_rev(&self) -> DdiResult { - helper_api_rev_tbor(&self.dev) + helper_api_rev_tbor(&self.primary) } // ------------------------------------------------------------------- @@ -376,7 +598,7 @@ impl TestCtx { /// MBOR `GetCertChainInfo(slot_id=0)`. #[cfg(feature = "emu")] pub fn cert_chain_info(&self) -> DdiResult { - azihsm_ddi_mbor_test_helpers::helper_get_cert_chain_info(&self.dev) + azihsm_ddi_mbor_test_helpers::helper_get_cert_chain_info(&self.primary) } /// MBOR `GetCertificate(slot_id=0, cert_id)`. @@ -385,6 +607,30 @@ impl TestCtx { &self, cert_id: u8, ) -> DdiResult { - azihsm_ddi_mbor_test_helpers::helper_get_certificate(&self.dev, cert_id) + azihsm_ddi_mbor_test_helpers::helper_get_certificate(&self.primary, cert_id) + } +} + +/// Panic-safe cleanup: close every session the ctx is still tracking. +/// Sessions **must** be closed one-by-one so their kernel-side +/// tracking (and, on hw, the extra fds) is torn down cleanly. Errors +/// are swallowed — drop never panics; a wedged device that rejects +/// `session_close` during unwind must not double-panic. +impl Drop for TestCtx { + fn drop(&mut self) { + let live: Vec<(u16, FdSlot)> = self.sessions.lock().drain().collect(); + for (id, slot) in live { + let dev: &Dev = match &slot { + FdSlot::Primary => &self.primary, + FdSlot::Extra(arc) => arc, + }; + if let Err(e) = session_close_helper(dev, id) { + eprintln!( + "TestCtx::drop: session_close({id}) failed: {e:?} \ + — session may leak on the device", + ); + } + } + *self.primary_busy.lock() = false; } } diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index 81905e633..a910129fc 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -28,7 +28,7 @@ use std::ops::Deref; use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; -#[cfg(feature = "emu")] +#[cfg(not(feature = "mock"))] use azihsm_ddi_interface::DdiDev; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; @@ -73,13 +73,54 @@ impl Deref for TestDev { /// are backend bugs, not test bugs, and surfacing them immediately /// is preferable to running a test against a dirty device. pub fn open_dev() -> TestDev { + let (dev, guard) = open_dev_parts(); + TestDev { dev, _guard: guard } +} + +/// Same as [`open_dev`] but returns the raw `::Dev` +/// and the lock guard as separate values. +/// +/// Used by [`TestCtx`](crate::harness::ctx::TestCtx), which stores +/// the guard as its own field so the `Dev` can sit inside an `Arc` +/// (needed for multi-fd routing) without dragging the `!Send` +/// `parking_lot::MutexGuard` into the `Arc`'s payload. That in turn +/// keeps `TestCtx: Sync` so `std::thread::scope`-based tests can +/// share `&TestCtx` across threads. +pub fn open_dev_parts() -> (::Dev, MutexGuard<'static, ()>) { let guard = TEST_LOCK.lock(); let ddi = AzihsmDdi::default(); let infos = ddi.dev_info_list(); let info = infos.first().expect("backend should advertise a device"); let dev = ddi.open_dev(&info.path).expect("open test backend device"); - #[cfg(feature = "emu")] + // Factory-reset every backend that owns partition state so each + // test starts from byte-identical defaults. `emu` resets the + // in-process HSM, `sock` propagates a reset through the socket + // server, and on the native OS backend the trait method resolves + // to NSSR. `mock` has no state to reset and no `erase` handler, + // so it is skipped. + #[cfg(not(feature = "mock"))] dev.erase() - .expect("open_dev: factory-reset emu backend before test"); - TestDev { dev, _guard: guard } + .expect("open_dev: factory-reset backend before test"); + (dev, guard) +} + +/// Open an *additional* backend Dev without re-acquiring [`TEST_LOCK`]. +/// +/// Used when a single `#[test]` needs multiple concurrent sessions: +/// on the native OS backend the kernel driver enforces +/// `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every concurrent session past +/// the first sits on its own fd. On `emu` / `mock` / `sock` the extra +/// handle is just another view onto the process-global instance — +/// harmless. +/// +/// The caller must already hold the process-global lock via the +/// primary [`TestDev`]; that is what makes it safe for this function +/// to bypass [`TEST_LOCK`]. Never call this without a live primary in +/// scope. +pub fn open_extra_dev() -> ::Dev { + let ddi = AzihsmDdi::default(); + let infos = ddi.dev_info_list(); + let info = infos.first().expect("backend should advertise a device"); + ddi.open_dev(&info.path) + .expect("open extra test backend device") } diff --git a/ddi/tbor/types/tests/harness/hw_ctx.rs b/ddi/tbor/types/tests/harness/hw_ctx.rs deleted file mode 100644 index eae7871a7..000000000 --- a/ddi/tbor/types/tests/harness/hw_ctx.rs +++ /dev/null @@ -1,501 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! [`HwCtx`] — hardware-backend twin of [`crate::harness::ctx::TestCtx`]. -//! -//! Same method surface as `TestCtx` so every `commands/*` test can be -//! written once and pick a backend via the `Ctx` type alias exported -//! from [`crate::harness`]. The differences are all in the lifecycle: -//! -//! * **Setup:** acquires the process-global test lock, opens the -//! native OS backend (`DdiNix` / `DdiWin`) selected at compile time -//! by [`AzihsmDdi::default()`], and issues NSSR / factory-reset via -//! [`DdiDev::erase`] so the partition starts at pristine defaults. -//! * **Teardown:** [`Drop`] issues a best-effort NSSR so a panicking -//! test still leaves the device clean for the next one. Errors are -//! swallowed — a wedged device that rejects `erase` during unwind -//! must not double-panic. -//! -//! Gated behind `feature = "hw-tests"`. Only compiled when the test -//! binary is built against a real silicon backend. - -use std::collections::HashMap; -use std::sync::Arc; - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_interface::DdiError; -use azihsm_ddi_interface::DdiResult; -use azihsm_ddi_tbor_types::SessionType; -use azihsm_ddi_tbor_types::TborApiRevResp; -use azihsm_ddi_tbor_types::TborOpReq; -use azihsm_ddi_tbor_types::TborPartFinalResp; -use azihsm_ddi_tbor_types::TborPartInitResp; -use azihsm_ddi_tbor_types::TborStatus; -use parking_lot::Mutex; -use parking_lot::MutexGuard; - -use crate::harness::api_rev::helper_api_rev_tbor; -use crate::harness::assertions::assert_fw_rejects; -use crate::harness::assertions::assert_tbor_decode_error; -use crate::harness::session::part_final as part_final_helper; -use crate::harness::session::part_init as part_init_helper; -use crate::harness::session::psk_change as psk_change_helper; -use crate::harness::session::session_close as session_close_helper; -use crate::harness::session::session_open_finish as session_open_finish_helper; -use crate::harness::session::session_open_finish_with_mac as session_open_finish_with_mac_helper; -use crate::harness::session::session_open_init as session_open_init_helper; -use crate::harness::session::session_open_init_with_options as session_open_init_with_options_helper; -use crate::harness::session::PendingHandshake; -use crate::harness::session::SessionHandshake; -use crate::harness::session::SessionOpenInitOptions; - -/// Fixed default 48-byte SATA thumbprint used by the convenience -/// [`HwCtx::part_init`] wrapper. -const DEFAULT_SATA_THUMBPRINT: [u8; 48] = [0x5A; 48]; - -/// Process-global serialisation lock. A single physical device is -/// shared by every `#[test]` in the binary; the lock keeps parallel -/// nextest workers from stomping on each other. `parking_lot` per the -/// workspace convention (std lock is banned in clippy.toml). -static HW_TEST_LOCK: Mutex<()> = Mutex::new(()); - -/// Native OS backend device handle, compile-time-selected by -/// [`AzihsmDdi::default()`] to [`azihsm_ddi_nix::DdiNix`] on Linux and -/// [`azihsm_ddi_win::DdiWin`] on Windows. -type HwDev = ::Dev; - -/// Acquire the hw test lock, open the native backend device, and -/// NSSR-reset it so the test starts at pristine defaults. Returns -/// the lock guard alongside the primary fd (both must live for the -/// full `HwCtx` lifetime). -/// -/// Panics if the backend advertises no devices or if NSSR-on-entry -/// fails — both are environment bugs (no device plugged in, or a -/// wedged partition) and running a test against a dirty device would -/// produce a misleading failure downstream. -fn open_hw_dev() -> (HwDev, MutexGuard<'static, ()>) { - let guard = HW_TEST_LOCK.lock(); - let ddi = AzihsmDdi::default(); - let infos = ddi.dev_info_list(); - let info = infos.first().expect("hw backend advertises no device"); - let dev = ddi.open_dev(&info.path).expect("open hw backend device"); - dev.erase() - .expect("open_hw_dev: NSSR must succeed before test"); - (dev, guard) -} - -/// Open an *additional* fd on the same physical device, without -/// re-acquiring [`HW_TEST_LOCK`] and without issuing NSSR. Used when -/// a test needs a second (third, …) concurrent session — the Linux -/// kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every -/// concurrent session past the first sits on its own fd. -/// -/// Takes `&HwDev` purely to borrow the primary fd's lifetime — the -/// caller must already hold the process-global lock via that fd for -/// the returned fd to be safe to use. -fn open_extra_hw_dev(_lock_holder: &HwDev) -> HwDev { - let ddi = AzihsmDdi::default(); - let infos = ddi.dev_info_list(); - let info = infos.first().expect("hw backend advertises no device"); - ddi.open_dev(&info.path).expect("open extra hw fd") -} - -/// One-test fixture for the hw backend. Mirrors [`TestCtx`] method-for-method. -/// -/// Every session opened on this ctx lands in [`Self::sessions`] keyed -/// by its FW-assigned id, and every session-scoped op looks up the -/// owning fd via the map — so callers can treat `HwCtx` exactly like -/// `TestCtx`. -/// -/// Fd placement policy: the first live session lives on the primary -/// fd (`primary`, the same fd that acquired [`HW_TEST_LOCK`] and did -/// NSSR at construction). This matches the pattern the hw test suite -/// relied on before the merge — driver behaviour with sessions on -/// only-extra fds (leaving primary idle) has proven fragile. When -/// primary already carries a live session, additional concurrent -/// sessions get their own fresh fds via [`open_extra_hw_dev`]. -/// Routing is always via [`Self::sessions`] — nothing session-scoped -/// ever bypasses it and targets `primary` directly. -/// -/// [`TestCtx`]: crate::harness::ctx::TestCtx -pub struct HwCtx { - primary: Arc, - _guard: MutexGuard<'static, ()>, - /// `true` while `primary` currently owns a live session. Cleared - /// when that session's entry is removed from `sessions`. - primary_busy: Mutex, - /// Fds waiting for a matching finish to promote them into - /// [`Self::sessions`], keyed by the FW-assigned session id that - /// [`Self::session_open_init`] returned. Keying by id (rather - /// than a single slot) lets concurrent handshakes coexist. Each - /// value is the same `Arc` as either `primary` (when we grabbed - /// primary) or a fresh fd (when primary was busy). - pending_fds: Mutex>>, - /// Every live session, keyed by FW-assigned session id. Value is - /// the `Arc` for the fd that owns that session — same `Arc` as - /// `primary` or a fresh fd. - sessions: Mutex>>, -} - -impl HwCtx { - /// Acquire the hw test lock, open the primary fd, NSSR, and set - /// up empty session tracking. - pub fn new() -> Self { - let (dev, guard) = open_hw_dev(); - Self { - primary: Arc::new(dev), - _guard: guard, - primary_busy: Mutex::new(false), - pending_fds: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - } - } - - /// NSSR / factory-reset the partition. Same call `TestCtx::erase` - /// makes on emu; the trait method [`DdiDev::erase`] resolves to - /// NSSR on the native backend. - pub fn erase(&self) -> DdiResult<()> { - self.primary.erase() - } - - /// Look up the fd that owns `session_id`, or return `None` for - /// unknown ids (negative-path tests exercising invalid ids, or - /// ids from a session that's already been closed). - fn dev_for_session(&self, session_id: u16) -> Option> { - self.sessions.lock().get(&session_id).cloned() - } - - /// Pick the fd for a fresh handshake: primary if free, else a - /// brand-new extra fd. Also flips `primary_busy` if we took - /// primary — the caller must roll that back on error and - /// [`Self::session_close`] must clear it on close. - fn take_fd_for_new_session(&self) -> (Arc, bool) { - let mut busy = self.primary_busy.lock(); - if !*busy { - *busy = true; - (Arc::clone(&self.primary), true) - } else { - (Arc::new(open_extra_hw_dev(&self.primary)), false) - } - } - - pub fn tbor(&self, req: &R) -> DdiResult { - let mut cookie = None; - self.primary.exec_op_tbor(req, None, &mut cookie) - } - - pub fn tbor_oob(&self, req: &R, oob_items: &[&[u8]]) -> DdiResult { - let mut cookie = None; - self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie) - } - - /// Session-scoped raw tbor exec: routes the op via the fd that - /// owns `session_id`, so requests referring to a specific session - /// (`TborPskChangeReq`, `TborPartInitReq`, …) reach the kernel - /// driver on the correct fd. Falls back to `primary` for unknown - /// ids — lets negative-path tests exercise "bogus session id" - /// paths without special-casing. - pub fn tbor_on_session(&self, session_id: u16, req: &R) -> DdiResult { - let mut cookie = None; - match self.dev_for_session(session_id) { - Some(dev) => dev.exec_op_tbor(req, None, &mut cookie), - None => self.primary.exec_op_tbor(req, None, &mut cookie), - } - } - - /// Session-scoped OOB variant of [`Self::tbor_on_session`]. - pub fn tbor_oob_on_session( - &self, - session_id: u16, - req: &R, - oob_items: &[&[u8]], - ) -> DdiResult { - let mut cookie = None; - match self.dev_for_session(session_id) { - Some(dev) => dev.exec_op_tbor(req, Some(oob_items), &mut cookie), - None => self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie), - } - } - - #[track_caller] - pub fn expect_fw_reject(&self, req: &R, expected: TborStatus) -> DdiError - where - R::OpResp: core::fmt::Debug, - { - match self.tbor(req) { - Ok(resp) => panic!( - "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", - expected.0, - ), - Err(err) => { - assert_fw_rejects(&err, expected); - err - } - } - } - - #[track_caller] - pub fn expect_fw_reject_on_session( - &self, - session_id: u16, - req: &R, - expected: TborStatus, - ) -> DdiError - where - R::OpResp: core::fmt::Debug, - { - match self.tbor_on_session(session_id, req) { - Ok(resp) => panic!( - "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", - expected.0, - ), - Err(err) => { - assert_fw_rejects(&err, expected); - err - } - } - } - - #[track_caller] - pub fn expect_fw_reject_oob( - &self, - req: &R, - oob_items: &[&[u8]], - expected: TborStatus, - ) -> DdiError - where - R::OpResp: core::fmt::Debug, - { - match self.tbor_oob(req, oob_items) { - Ok(_resp) => panic!( - "expected FW reject {expected:?} (0x{:08X}), got unexpected success", - expected.0, - ), - Err(err) => { - assert_fw_rejects(&err, expected); - err - } - } - } - - #[track_caller] - pub fn expect_decode_error(&self, req: &R) -> DdiError - where - R::OpResp: core::fmt::Debug, - { - match self.tbor(req) { - Ok(resp) => panic!("expected DdiError::TborDecodeError, got Ok({resp:?})"), - Err(err) => { - assert_tbor_decode_error(&err); - err - } - } - } - - pub fn session_open_init( - &self, - psk_id: u8, - session_type: SessionType, - ) -> DdiResult { - let (dev, took_primary) = self.take_fd_for_new_session(); - match session_open_init_helper(&dev, psk_id, session_type) { - Ok(pending) => { - self.pending_fds.lock().insert(pending.session_id, dev); - Ok(pending) - } - Err(e) => { - if took_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } - } - - pub fn session_open_init_with_options( - &self, - opts: SessionOpenInitOptions<'_>, - ) -> DdiResult { - let (dev, took_primary) = self.take_fd_for_new_session(); - match session_open_init_with_options_helper(&dev, opts) { - Ok(pending) => { - self.pending_fds.lock().insert(pending.session_id, dev); - Ok(pending) - } - Err(e) => { - if took_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } - } - - pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - let dev = self.pending_fds.lock().remove(&pending.session_id).expect( - "session_open_finish: no pending fd for this session_id — call session_open_init first", - ); - let is_primary = Arc::ptr_eq(&dev, &self.primary); - match session_open_finish_helper(&dev, pending) { - Ok(handshake) => { - self.sessions.lock().insert(handshake.session_id, dev); - Ok(handshake) - } - Err(e) => { - if is_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } - } - - pub fn session_open_finish_with_mac( - &self, - pending: PendingHandshake, - mac_fin: [u8; 48], - ) -> DdiResult { - let dev = self - .pending_fds - .lock() - .remove(&pending.session_id) - .expect("session_open_finish_with_mac: no pending fd for this session_id — call session_open_init first"); - let is_primary = Arc::ptr_eq(&dev, &self.primary); - match session_open_finish_with_mac_helper(&dev, pending, mac_fin) { - Ok(handshake) => { - self.sessions.lock().insert(handshake.session_id, dev); - Ok(handshake) - } - Err(e) => { - if is_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } - } - - pub fn open_session_raw( - &self, - psk_id: u8, - session_type: SessionType, - ) -> DdiResult { - let pending = self.session_open_init(psk_id, session_type)?; - self.session_open_finish(pending) - } - - pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - // Remove the entry so the owning fd drops (closing the kernel - // handle) after the close call — unless the fd is `primary`, - // in which case just clear the busy flag so the next session - // can reuse it. Fall back to `primary` on unknown ids so the - // FW/driver can surface its own error for negative-path tests. - let entry = self.sessions.lock().remove(&session_id); - match entry { - Some(dev) => { - let is_primary = Arc::ptr_eq(&dev, &self.primary); - let res = session_close_helper(&dev, session_id); - if is_primary { - *self.primary_busy.lock() = false; - } - drop(dev); - res - } - None => session_close_helper(&self.primary, session_id), - } - } - - pub fn psk_change(&self, session: &SessionHandshake, new_psk: &[u8]) -> DdiResult<()> { - match self.dev_for_session(session.session_id) { - Some(dev) => psk_change_helper(&dev, session, new_psk), - None => psk_change_helper(&self.primary, session, new_psk), - } - } - - pub fn part_init( - &self, - session: &SessionHandshake, - mach_seed: &[u8], - part_policy: &[u8], - pota_thumbprint: &[u8], - ) -> DdiResult { - let dev = self.dev_for_session(session.session_id); - let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); - part_init_helper( - dev_ref, - session, - mach_seed, - part_policy, - pota_thumbprint, - &DEFAULT_SATA_THUMBPRINT, - None, - ) - } - - pub fn part_init_sd( - &self, - session: &SessionHandshake, - mach_seed: &[u8], - part_policy: &[u8], - pota_thumbprint: &[u8], - sata_thumbprint: &[u8], - sapota_thumbprint: Option<&[u8]>, - ) -> DdiResult { - let dev = self.dev_for_session(session.session_id); - let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); - part_init_helper( - dev_ref, - session, - mach_seed, - part_policy, - pota_thumbprint, - sata_thumbprint, - sapota_thumbprint, - ) - } - - pub fn part_final( - &self, - session: &SessionHandshake, - part_policy: &[u8], - prev_local_mk_backup: &[u8], - certs: &[&[u8]], - ) -> DdiResult { - let dev = self.dev_for_session(session.session_id); - let dev_ref: &HwDev = dev.as_deref().unwrap_or(&self.primary); - part_final_helper(dev_ref, session, part_policy, prev_local_mk_backup, certs) - } - - pub fn api_rev(&self) -> DdiResult { - helper_api_rev_tbor(&self.primary) - } -} - -/// Panic-safe cleanup: close every session the ctx is still tracking, -/// then best-effort NSSR. Sessions **must** be closed one-by-one — -/// NSSR alone does not clear the FW session table, so relying on it -/// would leak slots into the next test. -/// -/// Errors are intentionally swallowed. Drop never panics — a wedged -/// device that rejects `session_close` during unwind must not -/// double-panic, and a leaked slot surfaces loudly on the next test's -/// `session_open` anyway. -impl Drop for HwCtx { - fn drop(&mut self) { - // Drain the map so each fd Arc's refcount hits 0 as we finish - // closing its session, releasing the extra kernel fds. - let live: Vec<(u16, Arc)> = self.sessions.lock().drain().collect(); - for (id, dev) in live { - if let Err(e) = session_close_helper(&dev, id) { - eprintln!( - "HwCtx::drop: session_close failed: {e:?} \ - — session may leak on the device", - ); - } - } - *self.primary_busy.lock() = false; - let _ = self.primary.erase(); - } -} diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index b9d74c416..1b2a68e27 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -16,7 +16,7 @@ //! //! # Backend feature regimes //! -//! The test binary supports three build modes; each disables a +//! The test binary supports four build modes; each disables a //! different subset of tests via `#![cfg(...)]` so failures show up //! as "no test compiled" instead of as silent passes: //! @@ -26,39 +26,24 @@ //! actually present in the std/emu PAL build. //! * `--features mock` (transport-contract probes only). //! `commands::api_rev::unsupported_on_mock` exercises that the -//! mock backend rejects TBOR opcodes at the transport layer; it -//! is gated `#[cfg(feature = "mock")]`. -//! * No backend feature. The pure host-side codec tests (everything -//! in `commands::fw_error_decode` and `commands::unexpected_toc_type`) -//! compile and run because they do not touch the harness; this -//! module is gated `#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))]` -//! so the harness itself disappears in this mode. -//! -//! The `sock` backend joins `emu` in the in-session command suite: it -//! drives the same TBOR round-trips over the socket transport, so the -//! harness is also built under `--features sock`. +//! mock backend rejects TBOR opcodes at the transport layer. +//! * `--features sock` runs the same TBOR round-trips as `emu` but +//! over the socket transport. +//! * **No backend feature** falls through to the native OS backend +//! (`DdiNix` on Linux / `DdiWin` on Windows) via +//! [`azihsm_ddi::AzihsmDdi::default()`]. This is the mode used +//! for on-silicon test runs; the harness routes concurrent +//! sessions onto separate fds because the kernel driver enforces +//! `AZIHSM_MAX_SESSIONS_PER_FD = 1`. //! //! Backend-specific [`TestCtx`] methods (`erase`, `cert_chain_info`, -//! `get_certificate`) carry per-method `#[cfg(feature = "emu")]` and -//! are unavailable under `--features mock`. - -#![cfg(any( - feature = "emu", - feature = "mock", - feature = "sock", - feature = "hw-tests" -))] +//! `get_certificate`) carry per-method `#[cfg(...)]` and are +//! unavailable under `--features mock`. pub mod api_rev; pub mod assertions; -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub mod ctx; pub mod fixture; -#[cfg(all( - feature = "hw-tests", - not(any(feature = "emu", feature = "mock", feature = "sock")) -))] -pub mod hw_ctx; pub mod session; pub mod session_guard; #[cfg(feature = "emu")] @@ -75,24 +60,8 @@ pub use azihsm_ddi_tbor_types::build_psk_change_aad; pub use azihsm_ddi_tbor_types::TborPskChangeReq; pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] pub use ctx::TestCtx; -// `Ctx` is the backend-agnostic per-test fixture used by files -// migrated to run against both the emu/mock/sock harnesses and the -// native hw-tests backend. Under emu/mock/sock it aliases the -// full-featured [`TestCtx`]; under a pure `hw-tests` build (no other -// backend feature) it aliases [`hw_ctx::HwCtx`], which mirrors -// `TestCtx`'s method surface with NSSR-based setup/teardown. Emu -// takes precedence in the combined-features case so a stray -// `--features emu,hw-tests` still builds against emu. -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] -pub use ctx::TestCtx as Ctx; pub use fixture::open_dev; -#[cfg(all( - feature = "hw-tests", - not(any(feature = "emu", feature = "mock", feature = "sock")) -))] -pub use hw_ctx::HwCtx as Ctx; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index 869b828d0..db909298f 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -4,16 +4,14 @@ //! RAII guard for a live TBOR session. //! //! A [`SessionGuard`] owns the handshake carrier produced by -//! `open_session_raw` on either [`TestCtx`](crate::harness::ctx::TestCtx) -//! (emu/mock/sock) or [`HwCtx`](crate::harness::hw_ctx::HwCtx) -//! (hw-tests) and closes the session when dropped — including when -//! the test is unwinding from a failed assertion. Backend session -//! tables are shared state (the emulator's is process-global, and a -//! real device has a single fixed session table); the per-test -//! serialisation provided by the respective backend lock only orders -//! execution, it does not clean up leaked slots. The guard therefore -//! makes panic-safe cleanup the default for every happy-path session -//! test. +//! [`TestCtx::open_session_raw`](crate::harness::ctx::TestCtx::open_session_raw) +//! and closes the session when dropped — including when the test is +//! unwinding from a failed assertion. Backend session tables are +//! shared state (the emulator's is process-global; a real device has +//! a single fixed session table); the per-test serialisation +//! provided by the test lock only orders execution, it does not +//! clean up leaked slots. The guard therefore makes panic-safe +//! cleanup the default for every happy-path session test. //! //! Negative-path tests that need to intercept the handshake mid-flight //! (e.g. ship a tampered `mac_fin`, double-close the same id, exercise @@ -25,13 +23,7 @@ use azihsm_ddi_interface::DdiResult; use azihsm_ddi_tbor_types::SessionType; -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] use crate::harness::ctx::TestCtx; -#[cfg(all( - feature = "hw-tests", - not(any(feature = "emu", feature = "mock", feature = "sock")) -))] -use crate::harness::hw_ctx::HwCtx; use crate::harness::session::SessionHandshake; /// Backend-agnostic hook the guard needs at cleanup time. Implemented @@ -97,19 +89,20 @@ impl Drop for SessionGuard<'_> { // Drop never panics — failure is logged so the original panic // (if any) keeps its place at the top of the stack trace. if let Err(e) = self.ctx.close_session_by_id(self.handshake.session_id) { - eprintln!("SessionGuard: session_close failed during drop: {e:?}"); + eprintln!( + "SessionGuard: session_close({}) failed during drop: {e:?}", + self.handshake.session_id, + ); } } } -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] impl SessionCloser for TestCtx { fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { self.session_close(session_id) } } -#[cfg(any(feature = "emu", feature = "mock", feature = "sock"))] impl TestCtx { /// Open a session via the happy-path two-phase handshake and /// return a [`SessionGuard`] that will close it on `Drop`. @@ -124,37 +117,3 @@ impl TestCtx { SessionGuard::new(self, handshake) } } - -#[cfg(all( - feature = "hw-tests", - not(any(feature = "emu", feature = "mock", feature = "sock")) -))] -impl SessionCloser for HwCtx { - fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { - self.session_close(session_id) - } -} - -#[cfg(all( - feature = "hw-tests", - not(any(feature = "emu", feature = "mock", feature = "sock")) -))] -impl HwCtx { - /// Open a session via the happy-path two-phase handshake and - /// return a [`SessionGuard`] that will close it on `Drop`. - /// - /// On hw every session lives on its own fd (kernel driver - /// enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`); the ctx tracks - /// session_id → fd so subsequent session-scoped ops route to the - /// right fd. - /// - /// Panics on any FW or transport error; negative-path tests must - /// call [`HwCtx::session_open_init`] (etc.) directly so they can - /// inspect the failure mode. - pub fn open_session(&self, psk_id: u8, session_type: SessionType) -> SessionGuard<'_> { - let handshake = self - .open_session_raw(psk_id, session_type) - .expect("HwCtx::open_session: handshake must succeed on the happy path"); - SessionGuard::new(self, handshake) - } -} From 0befcb6faade49341d96ffb4baeb90519f860948 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 21 Jul 2026 15:34:58 -0700 Subject: [PATCH 08/27] Clean up --- .github/codeql/codeql-config.yml | 16 -- ddi/tbor/types/tests/commands/api_rev.rs | 17 +- .../types/tests/commands/default_psk_gate.rs | 62 +++--- ddi/tbor/types/tests/commands/open_session.rs | 183 ++++++++---------- ddi/tbor/types/tests/commands/part_info.rs | 20 +- ddi/tbor/types/tests/commands/psk_change.rs | 88 ++++----- 6 files changed, 165 insertions(+), 221 deletions(-) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 983770e7a..e1b6324de 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -31,19 +31,3 @@ query-filters: # deterministic IVs to make negative-path tests reproducible. - "ddi/**/tests/**" - "ddi/tbor/test_helpers/**" - - # The "Cleartext logging of sensitive information" query - # (`rust/cleartext-logging`) flags drop-time `eprintln!` calls in - # the TBOR test harness that include the FW session-slot id in - # cleanup diagnostics (e.g. "SessionGuard: session_close({id}) …"). - # The session id is a small u16 slot number — not a secret — but - # CodeQL's Rust heuristic keys on the `session_id` identifier - # regardless of type or context. - # - # Suppressed for test trees only. Production drivers under - # `ddi/*/src/**` and `fw/**/src/**` remain covered. - - exclude: - id: rust/cleartext-logging - paths: - - "ddi/**/tests/**" - - "crates/**/tests/**" diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index 5219c7b41..10752c776 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -3,17 +3,14 @@ //! Integration tests for TBOR `ApiRev`. //! -//! `round_trip` exercises the full path host → backend → fw -//! `handle_tbor_op` → response, so it is transport-agnostic. -//! `unsupported_on_mock` asserts the design contract that backends opt -//! in to TBOR. +//! `round_trip` exercises host → backend → fw `handle_tbor_op` → +//! response. `api_rev_repeated_stable` and +//! `api_rev_independent_of_session_state` guard against per-call or +//! session-scoped state leaking into the out-of-session handler. +//! `unsupported_on_mock` asserts that mock does not implement TBOR. //! -//! Pilot module for the [`TestCtx`](crate::harness::TestCtx) fixture: -//! every test in this file constructs the ctx once and drives every -//! device interaction through its methods. The backend is selected at -//! compile time by [`azihsm_ddi::AzihsmDdi::default`], so the same -//! test bodies run on the in-process firmware and against a live -//! board. +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]. use azihsm_ddi_tbor_types::TborApiRevReq; diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index 4bf9563d7..c2348f2dc 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -7,26 +7,23 @@ //! //! The gate (see `fw/core/lib/src/ddi/tbor/mod.rs::dispatch`) rejects //! in-session commands not on the bootstrap allow-list when the -//! calling role's partition PSK still matches the compiled-in default +//! calling role's partition PSK still matches its compiled-in default //! (`DEFAULT_PSK_CO` / `DEFAULT_PSK_CU`). Out-of-session opcodes //! (`ApiRev`, `SessionOpenInit`, `SessionOpenFinish`) are never -//! gated; in-session opcodes on the allow-list (`PskChange`, -//! `SessionClose`) are always permitted. +//! gated; the in-session opcodes `PskChange` and `SessionClose` are +//! on the allow-list and are always permitted. //! -//! Coverage in this file (positive bypass cases E1, E2, E3, E5 plus -//! the negative case E4): +//! Coverage: +//! * Out-of-session opcodes bypass the gate: `ApiRev`, +//! `SessionOpenInit`. +//! * Allow-listed in-session opcodes bypass the gate: `PskChange`, +//! `SessionClose`. +//! * A non-allow-listed in-session opcode (`PartInit`) is rejected +//! at dispatch with `DefaultPskMustRotate` before any handler +//! mutation — safe to run on real silicon. //! -//! * E5: `ApiRev` reaches its handler with PSKs at default. -//! * E3: `SessionOpenInit` succeeds with PSKs at default. -//! * E1: `PskChange` is allow-listed — succeeds while PSK is default. -//! * E2: `SessionClose` is allow-listed — succeeds while PSK is default. -//! * E4: a non-allow-listed in-session opcode (`PartInit`) is -//! rejected at dispatch with `DefaultPskMustRotate`, before any -//! handler mutation. Runs against emu and hw because the FW rejects -//! before any state change. -//! -//! Each test inherits a factory-reset device from the ctx constructor, -//! so partition PSKs are at their canonical defaults on entry. +//! Each test inherits a factory-reset device from `TestCtx::new`, so +//! partition PSKs are at their canonical defaults on entry. use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; @@ -47,22 +44,18 @@ const GATE_ROTATED_PSK: [u8; PSK_LEN] = [ 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, 0xA5, ]; -/// E5: `ApiRev` is an out-of-session opcode and therefore never -/// gated. It must succeed even when both partition PSKs are at their -/// compiled-in defaults. +/// `ApiRev` is out-of-session and therefore never gated. It must +/// succeed even when both partition PSKs are at their compiled-in +/// defaults. Two probes back-to-back confirm the gate is stateless. #[test] fn default_psk_gate_api_rev_bypass() { let ctx = TestCtx::new(); - // Two probes back-to-back to confirm the call is genuinely - // repeatable (gate is stateless) rather than passing on first - // call by luck of ordering. let _ = ctx.api_rev().expect("first ApiRev under default PSK"); let _ = ctx.api_rev().expect("second ApiRev under default PSK"); } -/// E3: `SessionOpenInit` is out-of-session and therefore never gated. -/// Verified for both roles since each is bound to a distinct PSK -/// slot. +/// `SessionOpenInit` is out-of-session and therefore never gated. +/// Verified for both roles since each is bound to a distinct PSK slot. #[test] fn default_psk_gate_session_open_init_bypass() { let ctx = TestCtx::new(); @@ -92,8 +85,8 @@ fn default_psk_gate_session_open_init_bypass() { .expect("close CU session"); } -/// E2: `SessionClose` is on the allow-list — it must succeed while -/// the role's PSK is still default. Exercised for both roles. +/// `SessionClose` is on the allow-list — it must succeed while the +/// role's PSK is still default. Exercised for both roles. #[test] fn default_psk_gate_session_close_bypass() { let ctx = TestCtx::new(); @@ -109,13 +102,12 @@ fn default_psk_gate_session_close_bypass() { .expect("SessionClose must bypass gate while CU PSK is default"); } -/// E1: `PskChange` is on the allow-list — it must succeed while the +/// `PskChange` is on the allow-list — it must succeed while the /// role's PSK is still default. This is exactly the bootstrap flow: /// open under default, rotate. /// -/// Exercised for the CO role; the CU role's bootstrap path is -/// functionally identical and is already exercised by -/// `psk_change_happy_cu_emu` in `psk_change.rs`. +/// Exercised for the CO role; the CU path is functionally identical +/// and is already covered by `psk_change_happy_cu` in `psk_change.rs`. #[test] fn default_psk_gate_psk_change_bypass() { let ctx = TestCtx::new(); @@ -124,10 +116,10 @@ fn default_psk_gate_psk_change_bypass() { .expect("PskChange must bypass gate while CO PSK is default"); } -/// E4: an in-session, non-allow-listed opcode (`PartInit`) is -/// rejected at dispatch with `DefaultPskMustRotate`. The FW returns -/// the gate error before any partition-state mutation, so this test -/// is safe to run on real silicon. +/// A non-allow-listed in-session opcode (`PartInit`) is rejected at +/// dispatch with `DefaultPskMustRotate`. The FW returns the gate +/// error before any partition-state mutation, so this is safe to run +/// on real silicon. #[test] fn default_psk_gate_part_init_rejected() { use azihsm_ddi_tbor_types::PolicyKeyKind; diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 85ac2598c..299aaf63e 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -4,15 +4,11 @@ //! Integration tests for the TBOR `SessionOpenInit` / //! `SessionOpenFinish` two-phase session handshake. //! -//! Runs on any backend that exposes the FW handler (`emu`, `sock`, -//! or the native OS backend when no backend feature is enabled). -//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected -//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. -//! -//! Happy-path sessions are owned by a -//! [`SessionGuard`](crate::harness::SessionGuard) that closes on -//! `Drop`; negative-path tests intercept the handshake through the -//! raw `session_open_init` / `session_open_finish` methods on `TestCtx`. +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]. Happy-path sessions are owned +//! by a [`SessionGuard`](crate::harness::SessionGuard) that closes on +//! `Drop`; negative paths drive `session_open_init` / +//! `session_open_finish` on `TestCtx` directly. #![cfg(not(any(feature = "mock", feature = "sock")))] @@ -424,83 +420,6 @@ fn partition_pk_hsm_stable_across_handshakes() { ); } -// --------------------------------------------------------------------------- -// Concurrency: multi-threaded races on the ctx -// -// Threads drive `ctx.session_open_init` / `ctx.session_open_finish` -// directly — the same map-based fd allocation used by every other -// test, so nothing bypasses the tracking. On the native OS backend -// each concurrent handshake gets its own fresh fd, stashed in -// `pending_fds` keyed by the FW-assigned session id. -// --------------------------------------------------------------------------- - -const MULTI_THREADED_TOTAL: usize = 12; - -/// Race N concurrent opens; winners must have distinct session ids, -/// and any losers must surface as clean FW/driver rejections. -#[test] -fn open_session_multi_threaded_all_should_open() { - let ctx = TestCtx::new(); - - let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); - for _ in 0..MULTI_THREADED_TOTAL { - handles.push(s.spawn(|| -> Result { - let pending = ctx.session_open_init(CU, SessionType::PlainText)?; - let handshake = ctx.session_open_finish(pending)?; - Ok(handshake.session_id) - })); - } - let mut winners: Vec = Vec::new(); - let mut rejections: Vec = Vec::new(); - for h in handles { - match h.join().expect("worker thread must not panic") { - Ok(sid) => winners.push(sid), - Err(e) => rejections.push(e), - } - } - (winners, rejections) - }); - - let mut sorted_ids = winners.clone(); - sorted_ids.sort_unstable(); - sorted_ids.dedup(); - let unique_wins = sorted_ids.len(); - - // Close winners through the ctx before asserting so a failing - // assert never leaves the session table dirty. - for sid in &winners { - let _ = ctx.session_close(*sid); - } - - assert!( - !winners.is_empty(), - "at least one concurrent open_session must succeed; rejections = {rejections:?}", - ); - assert_eq!( - unique_wins, - winners.len(), - "concurrent winners must have distinct session ids: {winners:?}", - ); - for err in &rejections { - assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::DdiError(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), - "concurrent open_session rejections must be FW/driver rejections, got {err:?}", - ); - } - if rejections.is_empty() { - eprintln!( - "open_session_multi_threaded_all_should_open: FW accepted all {MULTI_THREADED_TOTAL} \ - concurrent sessions without emitting any table-full rejection; race between success \ - and rejection branches not exercised at this thread count", - ); - } -} - // --------------------------------------------------------------------------- // Point-validation negatives // @@ -666,9 +585,85 @@ fn pk_init_single_byte_tampered_rejected() { ); } -// =========================================================================== -// Hardware-only tests -// =========================================================================== +// --------------------------------------------------------------------------- +// Concurrency: multi-threaded races on the ctx (hw-only) +// +// Emu's `Arc` is a process-global singleton and its TBOR +// handler chain is not reentrant across the two-phase handshake: +// concurrent inits clobber shared state mid-flight and surface as +// `SessionAuthFailure` / `SessionNotFound` at finish instead of the +// clean table-full / winner-takes-slot behaviour real FW produces. +// The property under test only holds on the native OS backend. +// --------------------------------------------------------------------------- + +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +const MULTI_THREADED_TOTAL: usize = 12; + +/// Race N concurrent opens; winners must have distinct session ids, +/// and any losers must surface as clean FW/driver rejections. +#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[test] +fn open_session_multi_threaded_all_should_open() { + let ctx = TestCtx::new(); + + let (winners, rejections) = std::thread::scope(|s| { + let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); + for _ in 0..MULTI_THREADED_TOTAL { + handles.push(s.spawn(|| -> Result { + let pending = ctx.session_open_init(CU, SessionType::PlainText)?; + let handshake = ctx.session_open_finish(pending)?; + Ok(handshake.session_id) + })); + } + let mut winners: Vec = Vec::new(); + let mut rejections: Vec = Vec::new(); + for h in handles { + match h.join().expect("worker thread must not panic") { + Ok(sid) => winners.push(sid), + Err(e) => rejections.push(e), + } + } + (winners, rejections) + }); + + let mut sorted_ids = winners.clone(); + sorted_ids.sort_unstable(); + sorted_ids.dedup(); + let unique_wins = sorted_ids.len(); + + // Close winners through the ctx before asserting so a failing + // assert never leaves the session table dirty. + for sid in &winners { + let _ = ctx.session_close(*sid); + } + + assert!( + !winners.is_empty(), + "at least one concurrent open_session must succeed; rejections = {rejections:?}", + ); + assert_eq!( + unique_wins, + winners.len(), + "concurrent winners must have distinct session ids: {winners:?}", + ); + for err in &rejections { + assert!( + matches!( + err, + azihsm_ddi_interface::DdiError::DdiError(_) + | azihsm_ddi_interface::DdiError::DdiStatus(_) + ), + "concurrent open_session rejections must be FW/driver rejections, got {err:?}", + ); + } + if rejections.is_empty() { + eprintln!( + "open_session_multi_threaded_all_should_open: FW accepted all {MULTI_THREADED_TOTAL} \ + concurrent sessions without emitting any table-full rejection; race between success \ + and rejection branches not exercised at this thread count", + ); + } +} #[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] const SINGLE_WINNER_RACERS: usize = 8; @@ -676,16 +671,6 @@ const SINGLE_WINNER_RACERS: usize = 8; /// Fill to one free slot then race N threads for it. Regression for /// FW's undo-on-loser path: every losing racer must see a clean /// rejection and leave the session table intact for retry. -/// -/// Hw-only because the emu backend cannot model this. Emu is a -/// process-global `LazyLock` singleton (`ddi/emu/src/ddi.rs`) -/// with a single shared `StdHsm` and no per-fd separation — every -/// "device handle" aliases the same in-process FW state. Concurrent -/// `session_open_init` calls on that singleton clobber its session -/// table mid-handshake, surfacing as `SessionNotFound` / -/// `SessionAuthFailure` at finish rather than the "table full" that -/// real FW returns to losers. Real hw has genuine per-fd state in -/// the kernel driver. #[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] #[test] fn open_session_multi_threaded_single_winner() { diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index ff9584f0c..1be55eef4 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -3,16 +3,18 @@ //! Integration tests for the out-of-session TBOR `PartInfo` command. //! -//! `round_trip` exercises the full host → backend → fw -//! `handle_tbor_op` → response path and asserts the device/partition -//! fields the firmware reports for the default provisioned partition. -//! `part_info_independent_of_session_state` proves the dispatcher -//! never lets session-machine state leak into the out-of-session -//! handler. `unsupported_on_mock` asserts the design contract that -//! backends opt in to TBOR. +//! `round_trip` asserts the device/partition fields the firmware +//! reports for the default provisioned partition. +//! `part_info_repeated_stable` and +//! `part_info_independent_of_session_state` guard against per-call +//! or session-scoped state leaking into the out-of-session handler. +//! `part_info_reflects_part_init_transition_emu` covers the +//! `Enabled → Initializing` lifecycle transition (emu only, since it +//! is destructive to partition state). `unsupported_on_mock` asserts +//! that mock does not implement TBOR. //! -//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected -//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]. use azihsm_ddi_tbor_types::TborPartInfoReq; diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 33aa2a785..a42da45ba 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -3,32 +3,27 @@ //! Integration tests for the TBOR `PskChange` command. //! -//! Cross-test isolation comes from the ctx's factory-reset on -//! construction ([`TestCtx::new`]). Live sessions are owned by a -//! [`SessionGuard`] that closes on `Drop`, including during panic -//! unwind. +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]; cross-test isolation comes +//! from the factory-reset performed by [`TestCtx::new`], and live +//! sessions close on `Drop` via [`SessionGuard`]. //! //! Coverage: -//! * Happy paths (CO + CU), with explicit reopen using the rotated -//! PSK to prove the rotation took effect. +//! * Happy paths (CO + CU), with an explicit reopen under the +//! rotated PSK to prove the rotation took effect. //! * Reopen with the old (default) PSK fails after rotation. -//! * One-shot enforcement: second `PskChange` on the same session +//! * One-shot enforcement: a second `PskChange` on the same session //! surfaces `TborStatus::InvalidPermissions`. -//! * Envelope-tampering negatives: ciphertext bit-flip and AAD -//! bit-flip both surface `TborStatus::AeadEnvelopeAuthFailed`. -//! * Empty / wrong-length envelope → a length-reject status. Emu's -//! decoder returns `TborInvalidFixedLength`; the hw decoder trips -//! the schema check earlier and returns `DdiDecodeFailed`. See -//! [`LENGTH_REJECT_STATUS`]. -//! * AAD that encodes a session id other than the request's -//! session id → `TborStatus::AeadEnvelopeAuthFailed`. -//! * Envelope encrypted under a *different* session's `param_key` -//! shipped through this session → `TborStatus::AeadEnvelopeAuthFailed`. -//! * Plaintext not exactly `PSK_LEN` (→ wrong envelope length) → -//! [`LENGTH_REJECT_STATUS`]. -//! -//! Uses [`TestCtx`](crate::harness::TestCtx); the backend is selected -//! at compile time by [`azihsm_ddi::AzihsmDdi::default`]. +//! * Envelope tampering (ciphertext bit-flip, AAD bit-flip) → +//! `TborStatus::AeadEnvelopeAuthFailed`. +//! * AAD that encodes a different session id → same auth failure. +//! * Envelope encrypted under a different session's `param_key` → +//! same auth failure. +//! * Wrong envelope length (empty, wrong plaintext length, wrong +//! AAD length) → [`LENGTH_REJECT_STATUS`] (backend-specific; see +//! its docstring). +//! * Hw-only: rotation to a well-known default PSK is rejected with +//! `TborStatus::InvalidArg`. #![cfg(not(any(feature = "mock", feature = "sock")))] @@ -54,13 +49,12 @@ use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; -/// Backend-specific status for wire-decode length rejects. +/// Backend-specific status for wire-length rejects. /// -/// Emu's decoder returns the handler's specific -/// [`TborStatus::TborInvalidFixedLength`]. The hw decoder trips the +/// Emu's decoder passes the frame through to the handler, which +/// returns `TborInvalidFixedLength`. The hw decoder trips the /// schema's `#[tbor(buffer, len = 100)]` check earlier and surfaces -/// the failure as [`TborStatus::DdiDecodeFailed`] before the -/// handler's defensive branch is reached. +/// `DdiDecodeFailed` before the handler runs. #[cfg(feature = "emu")] const LENGTH_REJECT_STATUS: TborStatus = TborStatus::TborInvalidFixedLength; #[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] @@ -255,15 +249,12 @@ fn psk_change_wrong_session_id_in_aad() { } // =========================================================================== -// Envelope built under a *different* session's param_key +// Envelope built under a different session's param_key // -// Two live sessions. Encrypt under A's param_key but ship the request -// through B (with B's session id in both the request header and the -// AAD). FW uses B's param_key to verify the AEAD-GCM tag → mismatch. -// On hw the request has to reach the fd that owns session B, so route -// via `expect_fw_reject_on_session` — a raw `expect_fw_reject` would -// land on the primary fd and the Linux driver would reject it before -// FW sees the request. +// Encrypt under session A's param_key but ship the request through +// session B (with B's id in both header and AAD). FW verifies with +// B's key and the tag fails. Routed via `expect_fw_reject_on_session` +// so the request lands on B's fd on hw. // =========================================================================== #[test] @@ -334,25 +325,18 @@ fn psk_change_wrong_aad_length() { } // =========================================================================== -// Hardware-only tests -// =========================================================================== - -// --------------------------------------------------------------------------- -// Rotation to a default PSK is rejected as invalid +// Hardware-only: rotation to a default PSK must be rejected // -// Security-critical: allowing the host to rotate a partition's PSK -// back to the well-known `DEFAULT_PSK_CO` / `DEFAULT_PSK_CU` bytes -// would let anyone with default-PSK knowledge re-establish a -// session, defeating the whole point of rotation. FW must treat a -// default value as an invalid `new_psk` and reject with -// `TborStatus::InvalidArg`, regardless of which role's session -// requests the change and which default value is targeted (so a CU -// can't sneak the CO PSK back to default either). +// Rotating a partition's PSK back to `DEFAULT_PSK_CO` / `DEFAULT_PSK_CU` +// would let anyone with default-PSK knowledge re-establish a session, +// defeating the point of rotation. FW must reject with +// `TborStatus::InvalidArg` regardless of which role requests it and +// which default value is targeted (a CU must not be able to push the +// CO PSK back to default either). // -// Hw-only: the emu backend does not implement the default-PSK -// check and accepts these rotations, so the guarantee can only be -// verified against real FW. -// --------------------------------------------------------------------------- +// Hw-only: the reject lives in the silicon firmware, not in the +// in-tree Rust FW that emu runs. Emu accepts the rotation. +// =========================================================================== #[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; PSK_LEN]) { From ea0db0762a4f86a55a95f440088657834b47e14c Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 21 Jul 2026 16:07:47 -0700 Subject: [PATCH 09/27] Resolve comments --- ddi/tbor/types/tests/harness/ctx.rs | 18 +++++++++++------- ddi/tbor/types/tests/harness/fixture.rs | 7 +++++-- ddi/tbor/types/tests/harness/mod.rs | 15 ++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index d403a6fb7..7810a03e6 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -611,22 +611,26 @@ impl TestCtx { } } -/// Panic-safe cleanup: close every session the ctx is still tracking. -/// Sessions **must** be closed one-by-one so their kernel-side -/// tracking (and, on hw, the extra fds) is torn down cleanly. Errors -/// are swallowed — drop never panics; a wedged device that rejects -/// `session_close` during unwind must not double-panic. +/// Panic-safe cleanup: close every session the ctx is still tracking, +/// including handshakes that finished phase 1 but never reached phase +/// 2 (a test that panics between `session_open_init` and +/// `session_open_finish` leaves its slot in `pending_fds`). Sessions +/// **must** be closed one-by-one so their kernel-side tracking (and, +/// on hw, the extra fds) is torn down cleanly. Errors are swallowed — +/// drop never panics; a wedged device that rejects `session_close` +/// during unwind must not double-panic. impl Drop for TestCtx { fn drop(&mut self) { + let pending: Vec<(u16, FdSlot)> = self.pending_fds.lock().drain().collect(); let live: Vec<(u16, FdSlot)> = self.sessions.lock().drain().collect(); - for (id, slot) in live { + for (id, slot) in pending.into_iter().chain(live) { let dev: &Dev = match &slot { FdSlot::Primary => &self.primary, FdSlot::Extra(arc) => arc, }; if let Err(e) = session_close_helper(dev, id) { eprintln!( - "TestCtx::drop: session_close({id}) failed: {e:?} \ + "TestCtx::drop: session_close failed: {e:?} \ — session may leak on the device", ); } diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index a910129fc..8ef0a488a 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -66,8 +66,11 @@ impl Deref for TestDev { } } -/// Acquire the test lock, open the configured backend device, and -/// (under `feature = "emu"`) factory-reset it. See module docs. +/// Acquire the test lock, open the configured backend device, and — +/// for every backend that owns partition state (`emu`, `sock`, and +/// the native OS backend, where `erase()` resolves to NSSR) — +/// factory-reset it. `mock` has no state and is skipped. See module +/// docs. /// /// Panics if the backend lists no devices or if `erase` fails — both /// are backend bugs, not test bugs, and surfacing them immediately diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 1b2a68e27..2e42d3e5c 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -21,9 +21,7 @@ //! as "no test compiled" instead of as silent passes: //! //! * `--features emu` (the canonical configuration; runs the full -//! suite). All in-session command tests are gated -//! `#![cfg(feature = "emu")]` because they require the FW handler -//! actually present in the std/emu PAL build. +//! suite in-process against the std/emu PAL FW build). //! * `--features mock` (transport-contract probes only). //! `commands::api_rev::unsupported_on_mock` exercises that the //! mock backend rejects TBOR opcodes at the transport layer. @@ -31,10 +29,13 @@ //! over the socket transport. //! * **No backend feature** falls through to the native OS backend //! (`DdiNix` on Linux / `DdiWin` on Windows) via -//! [`azihsm_ddi::AzihsmDdi::default()`]. This is the mode used -//! for on-silicon test runs; the harness routes concurrent -//! sessions onto separate fds because the kernel driver enforces -//! `AZIHSM_MAX_SESSIONS_PER_FD = 1`. +//! [`azihsm_ddi::AzihsmDdi::default()`]. This is the mode used for +//! on-silicon test runs; the in-session command tests run under +//! this backend too (they are gated +//! `#![cfg(not(any(feature = "mock", feature = "sock")))]`, which +//! admits both `emu` and the no-feature native OS build). The +//! harness routes concurrent sessions onto separate fds because +//! the kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`. //! //! Backend-specific [`TestCtx`] methods (`erase`, `cert_chain_info`, //! `get_certificate`) carry per-method `#[cfg(...)]` and are From b865d5ed61408340e2dde6c95c8ef5d0880aedaa Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Wed, 22 Jul 2026 17:46:51 -0700 Subject: [PATCH 10/27] Resolve comments --- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 6 + ddi/tbor/types/tests/commands/api_rev.rs | 37 +- .../types/tests/commands/default_psk_gate.rs | 13 +- ddi/tbor/types/tests/commands/open_session.rs | 210 +++++---- ddi/tbor/types/tests/commands/part_info.rs | 23 +- ddi/tbor/types/tests/commands/psk_change.rs | 83 ++-- ddi/tbor/types/tests/harness/ctx.rs | 432 +++--------------- ddi/tbor/types/tests/harness/fixture.rs | 119 ++--- ddi/tbor/types/tests/harness/mod.rs | 14 +- 9 files changed, 329 insertions(+), 608 deletions(-) diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index dc1b246d5..d20dfd3c4 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -9,7 +9,13 @@ //! `--features mock` (transport-contract probes). With **no** feature //! enabled the crate falls through to the native OS backend (`nix` //! on Linux / `win` on Windows) for on-silicon test runs. +//! +//! `mock` disables the whole `commands` tree — mock rejects TBOR at +//! the transport layer, so command-level integration tests are +//! meaningless there. Under `mock` this binary compiles the harness +//! only and runs zero tests. pub mod harness; +#[cfg(not(feature = "mock"))] pub mod commands; diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index 10752c776..f8d400166 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -7,7 +7,6 @@ //! response. `api_rev_repeated_stable` and //! `api_rev_independent_of_session_state` guard against per-call or //! session-scoped state leaking into the out-of-session handler. -//! `unsupported_on_mock` asserts that mock does not implement TBOR. //! //! Backend is selected at compile time by //! [`azihsm_ddi::AzihsmDdi::default`]. @@ -16,13 +15,11 @@ use azihsm_ddi_tbor_types::TborApiRevReq; use crate::harness::TestCtx; -#[cfg(not(feature = "mock"))] const EXPECTED: azihsm_ddi_tbor_types::TborApiRevResp = azihsm_ddi_tbor_types::TborApiRevResp { min_ver: 1, max_ver: 1, }; -#[cfg(not(feature = "mock"))] #[test] fn round_trip() { let ctx = TestCtx::new(); @@ -35,12 +32,11 @@ fn round_trip() { ); } -/// A1: `ApiRev` is stateless — repeated invocations on the same -/// device handle return byte-identical responses. Catches any -/// regression that would silently introduce per-call state (e.g. a -/// version negotiation cache, a session-dependent code path) in the +/// `ApiRev` is stateless — repeated invocations on the same device +/// handle return byte-identical responses. Catches any regression +/// that would silently introduce per-call state (e.g. a version +/// negotiation cache, a session-dependent code path) in the /// dispatcher's only out-of-session in-band handler. -#[cfg(not(feature = "mock"))] #[test] fn api_rev_repeated_stable() { let ctx = TestCtx::new(); @@ -52,13 +48,12 @@ fn api_rev_repeated_stable() { } } -/// A2: `ApiRev` is independent of session-machine state — it -/// returns the same response while a Pending (init-only) handshake -/// occupies a session slot, and continues to do so after the slot -/// transitions to Active. Together with the gate test in -/// `default_psk_gate.rs` this proves the dispatcher never lets -/// session state leak into the out-of-session handler. -#[cfg(not(any(feature = "mock", feature = "sock")))] +/// `ApiRev` is independent of session-machine state — it returns the +/// same response while a Pending (init-only) handshake occupies a +/// session slot, and continues to do so after the slot transitions +/// to Active. Together with the gate test in `default_psk_gate.rs` +/// this proves the dispatcher never lets session state leak into the +/// out-of-session handler. #[test] fn api_rev_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; @@ -95,15 +90,3 @@ fn api_rev_independent_of_session_state() { let post = ctx.tbor(&TborApiRevReq::new()).expect("ApiRev after close"); assert_eq!(post, EXPECTED); } - -#[cfg(all(feature = "mock", not(feature = "emu")))] -#[test] -fn unsupported_on_mock() { - use crate::harness::assertions::assert_unsupported_encoding; - - let ctx = TestCtx::new(); - let err = ctx - .tbor(&TborApiRevReq::new()) - .expect_err("mock backend must not implement exec_op_tbor"); - assert_unsupported_encoding(&err); -} diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index c2348f2dc..2cea3bf55 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#![cfg(not(any(feature = "mock", feature = "sock")))] - //! Integration tests for the TBOR dispatcher's default-PSK gate. //! //! The gate (see `fw/core/lib/src/ddi/tbor/mod.rs::dispatch`) rejects @@ -60,6 +58,12 @@ fn default_psk_gate_api_rev_bypass() { fn default_psk_gate_session_open_init_bypass() { let ctx = TestCtx::new(); + // Each role is probed sequentially on the same fd: hw enforces + // `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so the CO session must be + // closed before CU's `SessionOpenInit` is issued. The gate is a + // per-role property, so sequential probes prove it for both roles + // without needing overlapping sessions. + // CO + Authenticated under default CO PSK. let opts_co = SessionOpenInitOptions::new(CO, SessionType::Authenticated).with_psk(&DEFAULT_PSK_CO); @@ -69,6 +73,8 @@ fn default_psk_gate_session_open_init_bypass() { let session_co = ctx .session_open_finish(pending_co) .expect("CO finish under default PSK"); + ctx.session_close(session_co.session_id) + .expect("close CO session"); // CU + PlainText under default CU PSK. let opts_cu = SessionOpenInitOptions::new(CU, SessionType::PlainText).with_psk(&DEFAULT_PSK_CU); @@ -78,9 +84,6 @@ fn default_psk_gate_session_open_init_bypass() { let session_cu = ctx .session_open_finish(pending_cu) .expect("CU finish under default PSK"); - - ctx.session_close(session_co.session_id) - .expect("close CO session"); ctx.session_close(session_cu.session_id) .expect("close CU session"); } diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 299aaf63e..6f16a2e0a 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -10,8 +10,6 @@ //! `Drop`; negative paths drive `session_open_init` / //! `session_open_finish` on `TestCtx` directly. -#![cfg(not(any(feature = "mock", feature = "sock")))] - use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; use azihsm_ddi_tbor_types::TborSessionOpenInitReq; @@ -21,6 +19,13 @@ use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; +use crate::harness::open_extra_dev; +use crate::harness::session::open_session as open_session_on_dev; +use crate::harness::session::session_close as session_close_on_dev; +#[cfg(not(feature = "emu"))] +use crate::harness::session::session_open_finish as session_open_finish_on_dev; +#[cfg(not(feature = "emu"))] +use crate::harness::session::session_open_init as session_open_init_on_dev; use crate::harness::TestCtx; const CO: u8 = 0; @@ -244,12 +249,22 @@ fn session_open_finish_seed_envelope_tampered() { fn open_session_multiple_concurrent() { let ctx = TestCtx::new(); let a = ctx.open_session(CU, SessionType::PlainText); - let b = ctx.open_session(CU, SessionType::PlainText); + + // Second session lives on its own Dev — on hw the kernel driver + // enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so overlapping + // sessions must sit on separate fds bound to the same underlying + // device (`ctx.path()`). + let dev_b = open_extra_dev(ctx.path()); + let b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + .expect("open second session on extra dev"); + assert_ne!( a.session_id(), - b.session_id(), + b.session_id, "concurrent sessions must have distinct ids", ); + + session_close_on_dev(&dev_b, b.session_id).expect("close session B on extra dev"); } // --------------------------------------------------------------------------- @@ -268,16 +283,24 @@ fn open_session_multiple_concurrent() { #[test] fn open_session_fills_table_then_recovers() { let ctx = TestCtx::new(); - let mut ids: Vec = Vec::new(); + // Each concurrent session lives on its own extra `Dev` bound to + // the same underlying device — required on hw where + // `AZIHSM_MAX_SESSIONS_PER_FD = 1`. Devs are kept alive alongside + // their session ids so we can issue the matching close on the + // same fd. + type Dev = ::Dev; + let mut open_slots: Vec<(Dev, u16)> = Vec::new(); let mut rejection_seen = false; for _ in 0..16 { - match ctx.open_session_raw(CU, SessionType::PlainText) { - Ok(h) => ids.push(h.session_id), + let dev = open_extra_dev(ctx.path()); + match open_session_on_dev(&dev, CU, SessionType::PlainText) { + Ok(h) => open_slots.push((dev, h.session_id)), Err(e) => { // FW rejected — treat as "table full". Verify it is // an FW-side rejection (not a driver / decode fault) - // before ending the ramp-up. + // before ending the ramp-up. `dev` drops here, + // releasing the fd cleanly (no session was allocated). assert!( matches!(e, azihsm_ddi_interface::DdiError::DdiError(_)), "table-full rejection must be FW-side, got {e:?}", @@ -290,11 +313,16 @@ fn open_session_fills_table_then_recovers() { // Close everything we opened before running the recovery check, // so a recovery-side failure does not leak slots on the board. - for id in &ids { - let _ = ctx.session_close(*id); + for (dev, id) in &open_slots { + let _ = session_close_on_dev(dev, *id); } + let slot_count = open_slots.len(); + drop(open_slots); // Recovery: one fresh open must succeed after the batch close. + // The recovery session runs on the primary ctx dev — every extra + // fd has been dropped, so the driver's per-fd session slot on the + // primary is definitely free. let recovered = ctx .open_session_raw(CU, SessionType::PlainText) .expect("session table must recover after close-all"); @@ -303,9 +331,8 @@ fn open_session_fills_table_then_recovers() { if !rejection_seen { eprintln!( - "open_session_fills_table_then_recovers: backend accepted {} concurrent sessions \ + "open_session_fills_table_then_recovers: backend accepted {slot_count} concurrent sessions \ without emitting a table-full rejection; capacity limit not observed", - ids.len(), ); } } @@ -596,55 +623,69 @@ fn pk_init_single_byte_tampered_rejected() { // The property under test only holds on the native OS backend. // --------------------------------------------------------------------------- -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] const MULTI_THREADED_TOTAL: usize = 12; /// Race N concurrent opens; winners must have distinct session ids, -/// and any losers must surface as clean FW/driver rejections. -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +/// and any losers must surface as clean FW/driver rejections. Each +/// racing thread owns its own [`open_extra_dev`] fd — hw requires one +/// fd per concurrent session (`AZIHSM_MAX_SESSIONS_PER_FD = 1`), so +/// `TestCtx` itself is not touched by the racers. +#[cfg(not(feature = "emu"))] #[test] fn open_session_multi_threaded_all_should_open() { let ctx = TestCtx::new(); - - let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); - for _ in 0..MULTI_THREADED_TOTAL { - handles.push(s.spawn(|| -> Result { - let pending = ctx.session_open_init(CU, SessionType::PlainText)?; - let handshake = ctx.session_open_finish(pending)?; - Ok(handshake.session_id) - })); - } - let mut winners: Vec = Vec::new(); - let mut rejections: Vec = Vec::new(); - for h in handles { - match h.join().expect("worker thread must not panic") { - Ok(sid) => winners.push(sid), - Err(e) => rejections.push(e), + let path: &str = ctx.path(); + + type Dev = ::Dev; + + // Each entry holds the owning Dev + the session id so we can + // close on the correct fd once the race resolves. + let winners_devs: (Vec<(Dev, u16)>, Vec) = + std::thread::scope(|s| { + let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); + for _ in 0..MULTI_THREADED_TOTAL { + handles.push( + s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { + let dev = open_extra_dev(path); + let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; + let handshake = session_open_finish_on_dev(&dev, pending)?; + Ok((dev, handshake.session_id)) + }), + ); } - } - (winners, rejections) - }); + let mut winners: Vec<(Dev, u16)> = Vec::new(); + let mut rejections: Vec = Vec::new(); + for h in handles { + match h.join().expect("worker thread must not panic") { + Ok(w) => winners.push(w), + Err(e) => rejections.push(e), + } + } + (winners, rejections) + }); + let (winners, rejections) = winners_devs; - let mut sorted_ids = winners.clone(); + let mut sorted_ids: Vec = winners.iter().map(|(_, sid)| *sid).collect(); + let winner_ids = sorted_ids.clone(); sorted_ids.sort_unstable(); sorted_ids.dedup(); let unique_wins = sorted_ids.len(); - // Close winners through the ctx before asserting so a failing + // Close winners on their owning fds before asserting so a failing // assert never leaves the session table dirty. - for sid in &winners { - let _ = ctx.session_close(*sid); + for (dev, sid) in &winners { + let _ = session_close_on_dev(dev, *sid); } assert!( - !winners.is_empty(), + !winner_ids.is_empty(), "at least one concurrent open_session must succeed; rejections = {rejections:?}", ); assert_eq!( unique_wins, - winners.len(), - "concurrent winners must have distinct session ids: {winners:?}", + winner_ids.len(), + "concurrent winners must have distinct session ids: {winner_ids:?}", ); for err in &rejections { assert!( @@ -665,36 +706,41 @@ fn open_session_multi_threaded_all_should_open() { } } -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] const SINGLE_WINNER_RACERS: usize = 8; /// Fill to one free slot then race N threads for it. Regression for /// FW's undo-on-loser path: every losing racer must see a clean -/// rejection and leave the session table intact for retry. -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +/// rejection and leave the session table intact for retry. Each +/// filler + racer holds its own `open_extra_dev` fd — hw requires +/// one fd per concurrent session. +#[cfg(not(feature = "emu"))] #[test] fn open_session_multi_threaded_single_winner() { let ctx = TestCtx::new(); + let path: &str = ctx.path(); + + type Dev = ::Dev; // Phase 1: probe capacity sequentially; ceiling matches // fills_table_then_recovers so we don't loop forever on a - // pathological build. - let mut filler_ids: Vec = Vec::new(); + // pathological build. Each filler session lives on its own Dev. + let mut fillers: Vec<(Dev, u16)> = Vec::new(); let probe_ceiling: usize = 16; for _ in 0..probe_ceiling { - match ctx - .session_open_init(CU, SessionType::PlainText) - .and_then(|pending| ctx.session_open_finish(pending)) + let dev = open_extra_dev(path); + match session_open_init_on_dev(&dev, CU, SessionType::PlainText) + .and_then(|pending| session_open_finish_on_dev(&dev, pending)) { - Ok(handshake) => filler_ids.push(handshake.session_id), - Err(_) => break, + Ok(handshake) => fillers.push((dev, handshake.session_id)), + Err(_) => break, // `dev` drops here — fd released cleanly. } } // Capacity exceeds ceiling: cannot set up a single-slot race — clean up + skip. - if filler_ids.len() >= probe_ceiling { - for sid in &filler_ids { - let _ = ctx.session_close(*sid); + if fillers.len() >= probe_ceiling { + for (dev, sid) in &fillers { + let _ = session_close_on_dev(dev, *sid); } eprintln!( "open_session_multi_threaded_single_winner: FW capacity exceeds probe ceiling of \ @@ -703,39 +749,43 @@ fn open_session_multi_threaded_single_winner() { return; } - // Phase 2: free exactly one slot. - let freed_id = filler_ids.pop().expect("at least one filler must exist"); - ctx.session_close(freed_id) - .expect("close of freed filler slot must succeed"); + // Phase 2: free exactly one slot (close and drop the tail filler's Dev). + let (freed_dev, freed_id) = fillers.pop().expect("at least one filler must exist"); + session_close_on_dev(&freed_dev, freed_id).expect("close of freed filler slot must succeed"); + drop(freed_dev); // Phase 3: race SINGLE_WINNER_RACERS threads for the one slot. - let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(SINGLE_WINNER_RACERS); - for _ in 0..SINGLE_WINNER_RACERS { - handles.push(s.spawn(|| -> Result { - let pending = ctx.session_open_init(CU, SessionType::PlainText)?; - let handshake = ctx.session_open_finish(pending)?; - Ok(handshake.session_id) - })); - } - let mut winners: Vec = Vec::new(); - let mut rejections: Vec = Vec::new(); - for h in handles { - match h.join().expect("racer thread must not panic") { - Ok(sid) => winners.push(sid), - Err(e) => rejections.push(e), + let (winners, rejections): (Vec<(Dev, u16)>, Vec) = + std::thread::scope(|s| { + let mut handles = Vec::with_capacity(SINGLE_WINNER_RACERS); + for _ in 0..SINGLE_WINNER_RACERS { + handles.push( + s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { + let dev = open_extra_dev(path); + let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; + let handshake = session_open_finish_on_dev(&dev, pending)?; + Ok((dev, handshake.session_id)) + }), + ); } - } - (winners, rejections) - }); + let mut winners: Vec<(Dev, u16)> = Vec::new(); + let mut rejections: Vec = Vec::new(); + for h in handles { + match h.join().expect("racer thread must not panic") { + Ok(w) => winners.push(w), + Err(e) => rejections.push(e), + } + } + (winners, rejections) + }); let winner_count = winners.len(); let rejection_count = rejections.len(); - for sid in &winners { - let _ = ctx.session_close(*sid); + for (dev, sid) in &winners { + let _ = session_close_on_dev(dev, *sid); } - for sid in &filler_ids { - let _ = ctx.session_close(*sid); + for (dev, sid) in &fillers { + let _ = session_close_on_dev(dev, *sid); } assert_eq!( diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index 1be55eef4..710ee445d 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -10,8 +10,7 @@ //! or session-scoped state leaking into the out-of-session handler. //! `part_info_reflects_part_init_transition_emu` covers the //! `Enabled → Initializing` lifecycle transition (emu only, since it -//! is destructive to partition state). `unsupported_on_mock` asserts -//! that mock does not implement TBOR. +//! is destructive to partition state). //! //! Backend is selected at compile time by //! [`azihsm_ddi::AzihsmDdi::default`]. @@ -21,12 +20,10 @@ use azihsm_ddi_tbor_types::TborPartInfoReq; use crate::harness::TestCtx; /// `DdiDeviceKind::Physical` discriminant — uno is a physical device. -#[cfg(not(feature = "mock"))] const DEVICE_KIND_PHYSICAL: u8 = 2; /// `PartState::Enabled` discriminant — the default provisioned state of /// the emulator partition before any `PartInit`. -#[cfg(not(feature = "mock"))] const PART_STATE_ENABLED: u8 = 2; /// `PartState::Initializing` discriminant — the state a partition enters @@ -37,7 +34,6 @@ const PART_STATE_INITIALIZING: u8 = 4; /// Assert the invariant device-level fields PartInfo reports for the /// default provisioned partition, plus that the identity public key is /// materialized (not all-zero). -#[cfg(not(feature = "mock"))] fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { assert_eq!( resp.device_kind, DEVICE_KIND_PHYSICAL, @@ -53,7 +49,6 @@ fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { ); } -#[cfg(not(feature = "mock"))] #[test] fn round_trip() { let ctx = TestCtx::new(); @@ -68,7 +63,6 @@ fn round_trip() { /// returns a byte-identical response. Catches any regression that would /// silently introduce per-call state (e.g. a counter, a cached /// allocation) into the out-of-session handler. -#[cfg(not(feature = "mock"))] #[test] fn part_info_repeated_stable() { let ctx = TestCtx::new(); @@ -87,9 +81,8 @@ fn part_info_repeated_stable() { /// `PartInfo` is independent of session-machine state — it returns a /// byte-identical response while a Pending (init-only) handshake /// occupies a session slot, after that slot transitions to Active, and -/// again once it is closed. Catches any regression that would let +/// again once it is closed. Catches any regression that would let /// session state leak into the out-of-session handler. -#[cfg(not(any(feature = "mock", feature = "sock")))] #[test] fn part_info_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; @@ -197,15 +190,3 @@ fn part_info_reflects_part_init_transition_emu() { ctx.session_close(session.session_id) .expect("close CO session"); } - -#[cfg(all(feature = "mock", not(feature = "emu")))] -#[test] -fn unsupported_on_mock() { - use crate::harness::assertions::assert_unsupported_encoding; - - let ctx = TestCtx::new(); - let err = ctx - .tbor(&TborPartInfoReq::new()) - .expect_err("mock backend must not implement exec_op_tbor"); - assert_unsupported_encoding(&err); -} diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index a42da45ba..2a83b0f82 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -20,28 +20,33 @@ //! * Envelope encrypted under a different session's `param_key` → //! same auth failure. //! * Wrong envelope length (empty, wrong plaintext length, wrong -//! AAD length) → [`LENGTH_REJECT_STATUS`] (backend-specific; see -//! its docstring). +//! AAD length) → `TborStatus::TborInvalidFixedLength`. The +//! derive-generated schema decoder in `azihsm_fw_ddi_tbor_types` +//! trips the `#[tbor(buffer, len = 100)]` check on both emu and +//! hw; the FW response preserves the exact structural fault via +//! the `HsmError::Tbor*` → `HsmErr::Tbor*` mapping. //! * Hw-only: rotation to a well-known default PSK is rejected with //! `TborStatus::InvalidArg`. -#![cfg(not(any(feature = "mock", feature = "sock")))] - use azihsm_crypto::aead_envelope; use azihsm_crypto::aead_envelope::AeadAlg; use azihsm_crypto::AesKey; use azihsm_crypto::Rng; +use azihsm_ddi_interface::DdiDev; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; +use crate::harness::open_extra_dev; +use crate::harness::session::open_session as open_session_on_dev; +use crate::harness::session::session_close as session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TborPskChangeReq; use crate::harness::TestCtx; @@ -49,17 +54,6 @@ use crate::harness::TestCtx; const CO: u8 = 0; const CU: u8 = 1; -/// Backend-specific status for wire-length rejects. -/// -/// Emu's decoder passes the frame through to the handler, which -/// returns `TborInvalidFixedLength`. The hw decoder trips the -/// schema's `#[tbor(buffer, len = 100)]` check earlier and surfaces -/// `DdiDecodeFailed` before the handler runs. -#[cfg(feature = "emu")] -const LENGTH_REJECT_STATUS: TborStatus = TborStatus::TborInvalidFixedLength; -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] -const LENGTH_REJECT_STATUS: TborStatus = TborStatus::DdiDecodeFailed; - /// Distinct, non-default 32-byte PSK used by the happy-path tests. const ROTATED_PSK: [u8; PSK_LEN] = [ 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, @@ -223,8 +217,9 @@ fn psk_change_empty_envelope() { psk_envelope: Vec::new(), }; // FW schema pins `psk_envelope` to PSK_CHANGE_ENVELOPE_LEN (100 B); - // reject status varies by decode path (see LENGTH_REJECT_STATUS). - ctx.expect_fw_reject(&req, LENGTH_REJECT_STATUS); + // the derive-generated decoder rejects a wrong length with + // `TborInvalidFixedLength` before the handler runs. + ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); } // =========================================================================== @@ -253,26 +248,34 @@ fn psk_change_wrong_session_id_in_aad() { // // Encrypt under session A's param_key but ship the request through // session B (with B's id in both header and AAD). FW verifies with -// B's key and the tag fails. Routed via `expect_fw_reject_on_session` -// so the request lands on B's fd on hw. +// B's key and the tag fails. Session B is opened on a **second Dev** +// (via `open_extra_dev(ctx.path())`) so on hw the crafted request +// lands on B's fd — `AZIHSM_MAX_SESSIONS_PER_FD = 1` requires the +// second concurrent session to live on its own fd. // =========================================================================== #[test] fn psk_change_envelope_from_other_session() { let ctx = TestCtx::new(); let session_a = ctx.open_session(CU, SessionType::PlainText); - let session_b = ctx.open_session(CU, SessionType::PlainText); - let aad_for_b = build_psk_change_aad(session_b.session_id()); + + let dev_b = open_extra_dev(ctx.path()); + let session_b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + .expect("open session B on extra dev"); + + let aad_for_b = build_psk_change_aad(session_b.session_id); let envelope = build_envelope(&session_a.handshake().param_key, &aad_for_b, &ROTATED_PSK); let req = TborPskChangeReq { - session_id: session_b.session_id(), + session_id: session_b.session_id, psk_envelope: envelope, }; - ctx.expect_fw_reject_on_session( - session_b.session_id(), - &req, - TborStatus::AeadEnvelopeAuthFailed, - ); + let mut cookie = None; + let err = dev_b + .exec_op_tbor(&req, None, &mut cookie) + .expect_err("PskChange envelope from session A must be rejected on session B"); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::AeadEnvelopeAuthFailed); + + session_close_on_dev(&dev_b, session_b.session_id).expect("close session B on extra dev"); } // =========================================================================== @@ -285,8 +288,8 @@ fn psk_change_wrong_plaintext_length() { // PSK_LEN ± 1: shortest excursions either side of the canonical // length. A wrong plaintext length yields a wrong *envelope* length // (ciphertext tracks plaintext for GCM), so the FW schema's fixed - // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both — status varies by - // decode path (see LENGTH_REJECT_STATUS). + // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both with + // `TborInvalidFixedLength` in the derive-generated decoder. for len in [PSK_LEN - 1, PSK_LEN + 1] { let session = ctx.open_session(CU, SessionType::PlainText); let bogus_psk = vec![0xCDu8; len]; @@ -299,7 +302,7 @@ fn psk_change_wrong_plaintext_length() { let err = ctx.tbor(&req).expect_err(&format!( "plaintext length {len} (≠ PSK_LEN={PSK_LEN}) must be rejected", )); - crate::harness::assertions::assert_fw_rejects(&err, LENGTH_REJECT_STATUS); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::TborInvalidFixedLength); } } @@ -313,15 +316,15 @@ fn psk_change_wrong_aad_length() { let ctx = TestCtx::new(); let session = ctx.open_session(CU, SessionType::PlainText); // 64 bytes of arbitrary AAD (valid AEAD granularity) inflates the - // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B) — status varies - // by decode path (see LENGTH_REJECT_STATUS). + // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B); the derive- + // generated decoder rejects with `TborInvalidFixedLength`. let long_aad = vec![0u8; 64]; let envelope = build_envelope(&session.handshake().param_key, &long_aad, &ROTATED_PSK); let req = TborPskChangeReq { session_id: session.session_id(), psk_envelope: envelope, }; - ctx.expect_fw_reject(&req, LENGTH_REJECT_STATUS); + ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); } // =========================================================================== @@ -338,7 +341,7 @@ fn psk_change_wrong_aad_length() { // in-tree Rust FW that emu runs. Emu accepts the rotation. // =========================================================================== -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; PSK_LEN]) { let ctx = TestCtx::new(); let session = ctx.open_session(role, sty); @@ -348,25 +351,25 @@ fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; assert_fw_rejects(&err, TborStatus::InvalidArg); } -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] #[test] fn psk_change_cu_to_default_cu_rejected() { run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CU); } -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] #[test] fn psk_change_cu_to_default_co_rejected() { run_psk_change_to_default_rejected(CU, SessionType::PlainText, &DEFAULT_PSK_CO); } -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] #[test] fn psk_change_co_to_default_co_rejected() { run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CO); } -#[cfg(not(any(feature = "emu", feature = "mock", feature = "sock")))] +#[cfg(not(feature = "emu"))] #[test] fn psk_change_co_to_default_cu_rejected() { run_psk_change_to_default_rejected(CO, SessionType::Authenticated, &DEFAULT_PSK_CU); diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 7810a03e6..6ffa0f7b4 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -3,49 +3,21 @@ //! [`TestCtx`] — the single entry point per integration test. //! -//! Wraps an opened backend device and offers three small primitives -//! that capture the only three outcomes a TBOR command test ever -//! cares about: -//! -//! * [`TestCtx::tbor`] — issue an `OP_TBOR` request and return the -//! decoded response or a [`DdiError`] for the caller to inspect. -//! * [`TestCtx::expect_fw_reject`] — issue a request that *must* be -//! rejected by the FW dispatcher with a specific [`TborStatus`], -//! panicking with diagnostic context otherwise. -//! * [`TestCtx::expect_decode_error`] — issue a request whose response -//! *must* fail host-side TBOR decoding, panicking otherwise. -//! -//! Test files therefore never reach for the bare `Dev` handle or the -//! `assert_*` helpers in [`crate::harness::assertions`] directly; the -//! ctx is the single funnel that future cross-cutting changes (tracing, -//! retry policy, fault injection) can hook into without touching every -//! test. +//! Wraps **one** opened backend device (`Dev`) and offers thin +//! primitives for issuing TBOR ops on it. Tests that need concurrent +//! or overlapping sessions across multiple fds open additional +//! [`Dev`]s **outside** of `TestCtx` via +//! [`crate::harness::fixture::open_extra_dev`], passing +//! [`TestCtx::path`] to bind to the same underlying device. `TestCtx` +//! itself does not track those extra fds. //! //! Cross-test isolation (process-global lock + factory reset) lives -//! in [`crate::harness::fixture::open_dev`], which this type calls -//! through. -//! -//! # Multi-fd routing +//! in [`crate::harness::fixture::open_dev_parts`], which this type +//! calls through. //! -//! On the native OS backend the kernel driver enforces -//! `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every concurrent session past -//! the first sits on its own fd. `TestCtx` tracks -//! `session_id → owning Dev` so session-scoped ops -//! (`session_open_finish`, `session_close`, `psk_change`, `part_init`, -//! …) reach the driver on the right fd. On emu / mock / sock the -//! extra "fds" are just extra handles onto the same in-process -//! backend — harmless, and lets the same test source run identically -//! on every backend. -//! -//! The raw device handle deliberately has **no public accessor** on -//! this type. All device interactions must flow through one of the -//! TBOR methods (`tbor`, `session_open_init`, `psk_change`, ...) or -//! the narrow non-TBOR pass-throughs (`erase`, `cert_chain_info`, -//! `get_certificate`). This forces every test path through the -//! shared assertion funnel. - -use std::collections::HashMap; -use std::sync::Arc; +//! `TestCtx` owns exactly one `Dev`. Tests that need overlapping +//! sessions across multiple fds open extra `Dev`s themselves at the +//! call site so this type stays free of fd-routing state. use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; @@ -58,14 +30,12 @@ use azihsm_ddi_tbor_types::TborOpReq; use azihsm_ddi_tbor_types::TborPartFinalResp; use azihsm_ddi_tbor_types::TborPartInitResp; use azihsm_ddi_tbor_types::TborStatus; -use parking_lot::Mutex; use parking_lot::MutexGuard; use crate::harness::api_rev::helper_api_rev_tbor; use crate::harness::assertions::assert_fw_rejects; use crate::harness::assertions::assert_tbor_decode_error; use crate::harness::fixture::open_dev_parts; -use crate::harness::fixture::open_extra_dev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; @@ -94,132 +64,60 @@ type Dev = ::Dev; /// process-global test lock held for its lifetime) plus a thin layer /// of error-shape assertions. Constructed once per `#[test]`. /// -/// Fd placement policy: the first live session lives on the primary -/// fd (the same fd that acquired the test lock via -/// [`open_dev_parts`]). When primary already carries a live session, -/// additional concurrent sessions get their own fresh fds via -/// [`open_extra_dev`]. Session-scoped ops always route via the -/// [`Self::sessions`] map — nothing session-scoped bypasses it and -/// targets the primary directly. -/// -/// The primary fd sits behind `Arc` (not `Arc`) -/// deliberately: `TestDev` bundles the `parking_lot::MutexGuard` and -/// is therefore `!Send`; wrapping it in `Arc` would make `TestCtx` -/// `!Sync`, which in turn breaks `std::thread::scope`-based tests -/// that share `&TestCtx` across threads. Keeping the guard as a -/// separate field of `TestCtx` leaves the ctx `!Send` (the guard -/// still can't cross a `Send` boundary) but `Sync`, which is what -/// `s.spawn(|| ctx.foo())` actually needs. +/// Tests that need extra concurrent fds open them themselves via +/// [`crate::harness::fixture::open_extra_dev`] using +/// [`Self::path`] — those extra `Dev`s are not tracked by `TestCtx`. pub struct TestCtx { - primary: Arc, + dev: Dev, + /// Path the primary `Dev` was opened on — captured at open time. + /// Tests that need additional fds pass this to + /// [`crate::harness::fixture::open_extra_dev`] so every extra fd + /// binds to the **same** underlying device as the primary. + /// Reusing the path (instead of re-enumerating via + /// `dev_info_list().first()`) avoids relying on backend-order + /// stability on a multi-device rig. + path: String, _guard: MutexGuard<'static, ()>, - /// `true` while `primary` currently owns a live session. Cleared - /// when that session's entry is removed from `sessions`. - primary_busy: Mutex, - /// Fds waiting for a matching finish to promote them into - /// [`Self::sessions`], keyed by the FW-assigned session id that - /// [`Self::session_open_init`] returned. Keying by id (rather - /// than a single slot) lets concurrent handshakes coexist. - pending_fds: Mutex>, - /// Every live session, keyed by FW-assigned session id. - sessions: Mutex>, -} - -/// One entry in the session/pending map: either "the primary fd" (in -/// which case no owned handle is stored) or an extra fd opened via -/// [`open_extra_dev`]. Kept as an enum rather than a bare `Arc` -/// so we don't have to wrap the primary [`TestDev`] in an `Arc` just -/// to satisfy the map's ownership. -enum FdSlot { - Primary, - Extra(Arc), -} - -impl FdSlot { - fn is_primary(&self) -> bool { - matches!(self, FdSlot::Primary) - } } impl TestCtx { /// Open the backend device via [`open_dev_parts`] — see its docs /// for the locking + factory-reset semantics. pub fn new() -> Self { - let (dev, guard) = open_dev_parts(); + let (dev, guard, path) = open_dev_parts(); Self { - primary: Arc::new(dev), + dev, + path, _guard: guard, - primary_busy: Mutex::new(false), - pending_fds: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), } } + /// Path (backend-specific string, e.g. `/dev/azihsm0` on nix, + /// `\\.\AZIHSM0` on win, an emu handle on emu) the primary + /// `Dev` was opened on. Tests that need an additional fd on the + /// same underlying device pass this to + /// [`crate::harness::fixture::open_extra_dev`]. + pub fn path(&self) -> &str { + &self.path + } + /// Factory-reset the partition. On emu this issues the emulator's /// reset; on the native backend it issues NSSR. Under `--features /// mock` this call is unavailable (the mock backend has no state /// to reset). #[cfg(not(feature = "mock"))] pub fn erase(&self) -> DdiResult<()> { - self.primary.erase() - } - - /// Look up the fd that owns `session_id`, or return `None` for - /// unknown ids (negative-path tests exercising invalid ids, or - /// ids from a session that's already been closed). - /// - /// Callers that need a `&Dev` for a helper should use - /// [`Self::with_session_dev`] instead so the primary borrow stays - /// live across the call. - fn with_session_dev(&self, session_id: u16, f: impl FnOnce(&Dev) -> R) -> R { - // Take a snapshot of the owning slot while holding the map - // lock briefly, then release it before running `f` (which - // may itself call back into `self` for the primary fd on - // unknown-id fallback). - let slot = self.sessions.lock().get(&session_id).map(|s| match s { - FdSlot::Primary => FdSlot::Primary, - FdSlot::Extra(arc) => FdSlot::Extra(Arc::clone(arc)), - }); - match slot { - Some(FdSlot::Extra(arc)) => f(&arc), - Some(FdSlot::Primary) | None => f(&self.primary), - } - } - - /// Pick the fd for a fresh handshake: primary if free, else a - /// brand-new extra fd. Also flips `primary_busy` if we took - /// primary — the caller must roll that back on error and - /// [`Self::session_close`] must clear it on close. - fn take_fd_for_new_session(&self) -> FdSlot { - let mut busy = self.primary_busy.lock(); - if !*busy { - *busy = true; - FdSlot::Primary - } else { - FdSlot::Extra(Arc::new(open_extra_dev())) - } - } - - /// Resolve an [`FdSlot`] snapshot down to a `&Dev` borrow. The - /// snapshot must outlive the returned reference (it holds the - /// `Arc` alive for the extra-fd case). - fn dev_of<'a>(&'a self, slot: &'a FdSlot) -> &'a Dev { - match slot { - FdSlot::Primary => &self.primary, - FdSlot::Extra(arc) => arc, - } + self.dev.erase() } /// Issue an `OP_TBOR` request and return the raw `DdiResult`. /// /// Use this when the test needs to inspect both `Ok` and `Err` - /// arms itself (e.g. asserting a specific response field on - /// success, or matching on a structural decode error variant). - /// For the common "must reject with status X" shape, prefer - /// [`Self::expect_fw_reject`]. + /// arms itself. For the common "must reject with status X" shape, + /// prefer [`Self::expect_fw_reject`]. pub fn tbor(&self, req: &R) -> DdiResult { let mut cookie = None; - self.primary.exec_op_tbor(req, None, &mut cookie) + self.dev.exec_op_tbor(req, None, &mut cookie) } /// Issue an `OP_TBOR` request carrying out-of-band SGL items. @@ -231,31 +129,7 @@ impl TestCtx { /// buffer (e.g. `SdCreateRemoteBackup`'s receiver `KeyReport`). pub fn tbor_oob(&self, req: &R, oob_items: &[&[u8]]) -> DdiResult { let mut cookie = None; - self.primary.exec_op_tbor(req, Some(oob_items), &mut cookie) - } - - /// Session-scoped raw tbor exec: routes the op via the fd that - /// owns `session_id`. Falls back to `primary` for unknown ids — - /// lets negative-path tests exercise "bogus session id" paths - /// without special-casing. - pub fn tbor_on_session(&self, session_id: u16, req: &R) -> DdiResult { - self.with_session_dev(session_id, |dev| { - let mut cookie = None; - dev.exec_op_tbor(req, None, &mut cookie) - }) - } - - /// Session-scoped OOB variant of [`Self::tbor_on_session`]. - pub fn tbor_oob_on_session( - &self, - session_id: u16, - req: &R, - oob_items: &[&[u8]], - ) -> DdiResult { - self.with_session_dev(session_id, |dev| { - let mut cookie = None; - dev.exec_op_tbor(req, Some(oob_items), &mut cookie) - }) + self.dev.exec_op_tbor(req, Some(oob_items), &mut cookie) } /// Issue `req`, assert the FW dispatcher rejected it with exactly @@ -283,30 +157,6 @@ impl TestCtx { } } - /// Session-scoped variant of [`Self::expect_fw_reject`] — routes - /// via the fd owning `session_id`. - #[track_caller] - pub fn expect_fw_reject_on_session( - &self, - session_id: u16, - req: &R, - expected: TborStatus, - ) -> DdiError - where - R::OpResp: core::fmt::Debug, - { - match self.tbor_on_session(session_id, req) { - Ok(resp) => panic!( - "expected FW reject {expected:?} (0x{:08X}), got Ok({resp:?})", - expected.0, - ), - Err(err) => { - assert_fw_rejects(&err, expected); - err - } - } - } - /// [`Self::expect_fw_reject`] for a request carrying out-of-band SGL /// items (see [`Self::tbor_oob`]). #[track_caller] @@ -334,10 +184,6 @@ impl TestCtx { /// Issue `req`, assert the response failed host-side TBOR decoding /// (i.e. surfaced as [`DdiError::TborDecodeError`]), and return /// the matched error. - /// - /// This is distinct from [`Self::expect_fw_reject`]: a decode - /// error means the response was structurally invalid relative to - /// the schema, not that the FW logically rejected the request. #[track_caller] pub fn expect_decode_error(&self, req: &R) -> DdiError where @@ -357,9 +203,8 @@ impl TestCtx { // // Thin wrappers around the free helpers in `harness::session` so // tests can write `ctx.psk_change(&session, &psk)` instead of - // reaching through a raw device handle. Every session-scoped - // wrapper routes via the multi-fd map so hw callers hit the - // driver on the correct fd. + // reaching through a raw device handle. All operate on the single + // primary `Dev`. // ------------------------------------------------------------------- /// Run Phase 1 of the TBOR session handshake with happy-path @@ -370,23 +215,7 @@ impl TestCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - let slot = self.take_fd_for_new_session(); - let res = { - let dev = self.dev_of(&slot); - session_open_init_helper(dev, psk_id, session_type) - }; - match res { - Ok(pending) => { - self.pending_fds.lock().insert(pending.session_id, slot); - Ok(pending) - } - Err(e) => { - if slot.is_primary() { - *self.primary_busy.lock() = false; - } - Err(e) - } - } + session_open_init_helper(&self.dev, psk_id, session_type) } /// Full-control Phase 1 entry point: honours every override in @@ -395,49 +224,14 @@ impl TestCtx { &self, opts: SessionOpenInitOptions<'_>, ) -> DdiResult { - let slot = self.take_fd_for_new_session(); - let res = { - let dev = self.dev_of(&slot); - session_open_init_with_options_helper(dev, opts) - }; - match res { - Ok(pending) => { - self.pending_fds.lock().insert(pending.session_id, slot); - Ok(pending) - } - Err(e) => { - if slot.is_primary() { - *self.primary_busy.lock() = false; - } - Err(e) - } - } + session_open_init_with_options_helper(&self.dev, opts) } /// Run Phase 2 of the TBOR session handshake with the canonical /// confirm MAC. Consumes `pending` so callers cannot reuse stale /// state. pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - let slot = self.pending_fds.lock().remove(&pending.session_id).expect( - "session_open_finish: no pending fd for this session_id — call session_open_init first", - ); - let is_primary = slot.is_primary(); - let res = { - let dev = self.dev_of(&slot); - session_open_finish_helper(dev, pending) - }; - match res { - Ok(handshake) => { - self.sessions.lock().insert(handshake.session_id, slot); - Ok(handshake) - } - Err(e) => { - if is_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } + session_open_finish_helper(&self.dev, pending) } /// Phase 2 entry point that ships a caller-supplied `mac_fin`, @@ -447,34 +241,12 @@ impl TestCtx { pending: PendingHandshake, mac_fin: [u8; 48], ) -> DdiResult { - let slot = self.pending_fds.lock().remove(&pending.session_id).expect( - "session_open_finish_with_mac: no pending fd for this session_id — call session_open_init first", - ); - let is_primary = slot.is_primary(); - let res = { - let dev = self.dev_of(&slot); - session_open_finish_with_mac_helper(dev, pending, mac_fin) - }; - match res { - Ok(handshake) => { - self.sessions.lock().insert(handshake.session_id, slot); - Ok(handshake) - } - Err(e) => { - if is_primary { - *self.primary_busy.lock() = false; - } - Err(e) - } - } + session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) } /// One-shot happy-path handshake that returns the raw /// [`SessionHandshake`] *without* a `SessionGuard`. Callers are - /// responsible for the matching [`Self::session_close`]. Used - /// when the test needs to compare two open sessions opened under - /// a non-default PSK, or to inspect the handshake before closing - /// it explicitly. + /// responsible for the matching [`Self::session_close`]. pub fn open_session_raw( &self, psk_id: u8, @@ -484,37 +256,16 @@ impl TestCtx { self.session_open_finish(pending) } - /// Issue `SessionClose(session_id)`. Removes the fd binding so - /// extra fds drop (closing the kernel handle) after the close - /// call; primary is retained but marked free for the next - /// handshake. Falls back to `primary` for unknown ids so the - /// FW/driver can surface its own error for negative-path tests. + /// Issue `SessionClose(session_id)` on the primary `Dev`. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - let entry = self.sessions.lock().remove(&session_id); - match entry { - Some(slot) => { - let is_primary = slot.is_primary(); - let res = { - let dev = self.dev_of(&slot); - session_close_helper(dev, session_id) - }; - if is_primary { - *self.primary_busy.lock() = false; - } - drop(slot); - res - } - None => session_close_helper(&self.primary, session_id), - } + session_close_helper(&self.dev, session_id) } /// Issue `PskChange` on `session` with `new_psk` as the /// plaintext. The 32-byte length check is performed by the free /// helper before any wire bytes are emitted. pub fn psk_change(&self, session: &SessionHandshake, new_psk: &[u8]) -> DdiResult<()> { - self.with_session_dev(session.session_id, |dev| { - psk_change_helper(dev, session, new_psk) - }) + psk_change_helper(&self.dev, session, new_psk) } /// Issue `PartInit` on the CO `session` with the canonical @@ -527,17 +278,15 @@ impl TestCtx { part_policy: &[u8], pota_thumbprint: &[u8], ) -> DdiResult { - self.with_session_dev(session.session_id, |dev| { - part_init_helper( - dev, - session, - mach_seed, - part_policy, - pota_thumbprint, - &DEFAULT_SATA_THUMBPRINT, - None, - ) - }) + part_init_helper( + &self.dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + &DEFAULT_SATA_THUMBPRINT, + None, + ) } /// Issue `PartInit` with explicit security-domain thumbprint inputs @@ -551,17 +300,15 @@ impl TestCtx { sata_thumbprint: &[u8], sapota_thumbprint: Option<&[u8]>, ) -> DdiResult { - self.with_session_dev(session.session_id, |dev| { - part_init_helper( - dev, - session, - mach_seed, - part_policy, - pota_thumbprint, - sata_thumbprint, - sapota_thumbprint, - ) - }) + part_init_helper( + &self.dev, + session, + mach_seed, + part_policy, + pota_thumbprint, + sata_thumbprint, + sapota_thumbprint, + ) } /// Issue `PartFinal` re-supplying `part_policy` (must match the one @@ -574,15 +321,12 @@ impl TestCtx { prev_local_mk_backup: &[u8], certs: &[&[u8]], ) -> DdiResult { - self.with_session_dev(session.session_id, |dev| { - part_final_helper(dev, session, part_policy, prev_local_mk_backup, certs) - }) + part_final_helper(&self.dev, session, part_policy, prev_local_mk_backup, certs) } - /// Issue `ApiRev` and return the decoded response. Thin - /// pass-through over the free helper. + /// Issue `ApiRev` and return the decoded response. pub fn api_rev(&self) -> DdiResult { - helper_api_rev_tbor(&self.primary) + helper_api_rev_tbor(&self.dev) } // ------------------------------------------------------------------- @@ -598,7 +342,7 @@ impl TestCtx { /// MBOR `GetCertChainInfo(slot_id=0)`. #[cfg(feature = "emu")] pub fn cert_chain_info(&self) -> DdiResult { - azihsm_ddi_mbor_test_helpers::helper_get_cert_chain_info(&self.primary) + azihsm_ddi_mbor_test_helpers::helper_get_cert_chain_info(&self.dev) } /// MBOR `GetCertificate(slot_id=0, cert_id)`. @@ -607,34 +351,6 @@ impl TestCtx { &self, cert_id: u8, ) -> DdiResult { - azihsm_ddi_mbor_test_helpers::helper_get_certificate(&self.primary, cert_id) - } -} - -/// Panic-safe cleanup: close every session the ctx is still tracking, -/// including handshakes that finished phase 1 but never reached phase -/// 2 (a test that panics between `session_open_init` and -/// `session_open_finish` leaves its slot in `pending_fds`). Sessions -/// **must** be closed one-by-one so their kernel-side tracking (and, -/// on hw, the extra fds) is torn down cleanly. Errors are swallowed — -/// drop never panics; a wedged device that rejects `session_close` -/// during unwind must not double-panic. -impl Drop for TestCtx { - fn drop(&mut self) { - let pending: Vec<(u16, FdSlot)> = self.pending_fds.lock().drain().collect(); - let live: Vec<(u16, FdSlot)> = self.sessions.lock().drain().collect(); - for (id, slot) in pending.into_iter().chain(live) { - let dev: &Dev = match &slot { - FdSlot::Primary => &self.primary, - FdSlot::Extra(arc) => arc, - }; - if let Err(e) = session_close_helper(dev, id) { - eprintln!( - "TestCtx::drop: session_close failed: {e:?} \ - — session may leak on the device", - ); - } - } - *self.primary_busy.lock() = false; + azihsm_ddi_mbor_test_helpers::helper_get_certificate(&self.dev, cert_id) } } diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index 8ef0a488a..632ee11b6 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -4,27 +4,24 @@ //! Backend setup + canonical fixture constants shared by every TBOR //! integration test. //! -//! Calling [`open_dev`] does three things, in order: +//! The single entry point is [`open_dev_parts`], which: //! -//! 1. Acquires the process-global `TEST_LOCK` (held for the -//! returned handle's lifetime). The `StdHsm` is a single shared +//! 1. Acquires the process-global [`TEST_LOCK`] (held for the +//! returned guard's lifetime). The `StdHsm` is a single shared //! instance for the whole test binary, so any in-flight FW work //! from another test would be corrupted by this one's `erase`. //! 2. Opens the device advertised by the configured backend -//! (`emu` or `mock`). -//! 3. Factory-resets the device under `feature = "emu"` so every -//! test starts from byte-identical state — no inherited session -//! slots, no inherited PSK rotations, no implicit ordering -//! dependency on other tests' cleanup discipline. +//! (`emu` / `mock` / `sock` / native OS). +//! 3. Factory-resets the device on every backend that owns partition +//! state (all but `mock`) so every test starts from byte-identical +//! state — no inherited session slots, no inherited PSK rotations, +//! no implicit ordering dependency on other tests' cleanup +//! discipline. //! -//! Tests therefore become self-contained by construction. The -//! returned [`TestDev`] wraps the backend handle in a type that -//! `Deref`s to the underlying `::Dev`, so existing -//! call sites (`let dev = open_dev(); helper(&dev, ...)`) keep -//! compiling without modification — deref coercion supplies the -//! `&` automatically. - -use std::ops::Deref; +//! Tests never call this directly — they construct a +//! [`TestCtx`](crate::harness::ctx::TestCtx), which owns the returned +//! `Dev`, caches the `path`, and holds the lock guard for its +//! lifetime. use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; @@ -43,58 +40,33 @@ use parking_lot::MutexGuard; /// disallowed by `clippy.toml`). parking_lot's `Mutex` does not /// poison, so a panicking test cannot cause subsequent tests to fail /// at the lock acquisition step — the next test acquires the lock -/// cleanly and `open_dev`'s `erase` puts the FW back to a known state. +/// cleanly and [`open_dev_parts`]'s `erase` puts the FW back to a +/// known state. static TEST_LOCK: Mutex<()> = Mutex::new(()); -/// Owned wrapper around an opened backend device that holds the -/// process-global test lock for its lifetime. +/// Acquire the test lock, open the configured backend device, factory- +/// reset it (on every backend that owns partition state — all but +/// `mock`), and return the raw `Dev`, the lock guard, and the +/// [`DevInfo`](azihsm_ddi_interface::DevInfo) `path` the device was +/// opened on. /// -/// Derefs to `::Dev` so call sites that previously -/// took `&::Dev` keep compiling unchanged. -pub struct TestDev { - dev: ::Dev, - // Lifetime parameter is `'static` because `TEST_LOCK` is - // `static`. Underscore-prefixed to mark it as "held purely for - // the side-effect of locking". - _guard: MutexGuard<'static, ()>, -} - -impl Deref for TestDev { - type Target = ::Dev; - fn deref(&self) -> &Self::Target { - &self.dev - } -} - -/// Acquire the test lock, open the configured backend device, and — -/// for every backend that owns partition state (`emu`, `sock`, and -/// the native OS backend, where `erase()` resolves to NSSR) — -/// factory-reset it. `mock` has no state and is skipped. See module -/// docs. +/// The three parts are returned separately (rather than bundled in a +/// wrapper type) so [`TestCtx`](crate::harness::ctx::TestCtx) can +/// store the `Dev` alongside the cached `path` and the lock guard as +/// individual fields. The `path` is threaded into [`open_extra_dev`] +/// so every extra fd binds to the **same** underlying device as the +/// primary. /// /// Panics if the backend lists no devices or if `erase` fails — both /// are backend bugs, not test bugs, and surfacing them immediately /// is preferable to running a test against a dirty device. -pub fn open_dev() -> TestDev { - let (dev, guard) = open_dev_parts(); - TestDev { dev, _guard: guard } -} - -/// Same as [`open_dev`] but returns the raw `::Dev` -/// and the lock guard as separate values. -/// -/// Used by [`TestCtx`](crate::harness::ctx::TestCtx), which stores -/// the guard as its own field so the `Dev` can sit inside an `Arc` -/// (needed for multi-fd routing) without dragging the `!Send` -/// `parking_lot::MutexGuard` into the `Arc`'s payload. That in turn -/// keeps `TestCtx: Sync` so `std::thread::scope`-based tests can -/// share `&TestCtx` across threads. -pub fn open_dev_parts() -> (::Dev, MutexGuard<'static, ()>) { +pub fn open_dev_parts() -> (::Dev, MutexGuard<'static, ()>, String) { let guard = TEST_LOCK.lock(); let ddi = AzihsmDdi::default(); let infos = ddi.dev_info_list(); let info = infos.first().expect("backend should advertise a device"); - let dev = ddi.open_dev(&info.path).expect("open test backend device"); + let path = info.path.clone(); + let dev = ddi.open_dev(&path).expect("open test backend device"); // Factory-reset every backend that owns partition state so each // test starts from byte-identical defaults. `emu` resets the // in-process HSM, `sock` propagates a reset through the socket @@ -103,27 +75,32 @@ pub fn open_dev_parts() -> (::Dev, MutexGuard<'static, ()>) { // so it is skipped. #[cfg(not(feature = "mock"))] dev.erase() - .expect("open_dev: factory-reset backend before test"); - (dev, guard) + .expect("open_dev_parts: factory-reset backend before test"); + (dev, guard, path) } -/// Open an *additional* backend Dev without re-acquiring [`TEST_LOCK`]. +/// Open an *additional* backend Dev on the **same underlying device** +/// as the primary, without re-acquiring [`TEST_LOCK`]. +/// +/// The caller passes the primary's cached `path` (captured at +/// [`open_dev_parts`] time) so we skip re-enumeration — the backend's +/// `dev_info_list()` ordering is not contractually stable across +/// calls, so a naive `list.first()` here could silently open a +/// different device on a multi-device rig. /// /// Used when a single `#[test]` needs multiple concurrent sessions: /// on the native OS backend the kernel driver enforces /// `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every concurrent session past -/// the first sits on its own fd. On `emu` / `mock` / `sock` the extra -/// handle is just another view onto the process-global instance — -/// harmless. +/// the first sits on its own fd against the same device. On +/// `emu` / `mock` / `sock` the extra handle is just another view onto +/// the process-global instance — harmless. /// /// The caller must already hold the process-global lock via the -/// primary [`TestDev`]; that is what makes it safe for this function -/// to bypass [`TEST_LOCK`]. Never call this without a live primary in -/// scope. -pub fn open_extra_dev() -> ::Dev { +/// primary `Dev` (i.e. the `Dev` returned by [`open_dev_parts`] +/// and stored inside [`TestCtx`](crate::harness::ctx::TestCtx)); +/// that is what makes it safe for this function to bypass +/// [`TEST_LOCK`]. Never call this without a live primary in scope. +pub fn open_extra_dev(path: &str) -> ::Dev { let ddi = AzihsmDdi::default(); - let infos = ddi.dev_info_list(); - let info = infos.first().expect("backend should advertise a device"); - ddi.open_dev(&info.path) - .expect("open extra test backend device") + ddi.open_dev(path).expect("open extra test backend device") } diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 2e42d3e5c..6a5ac976c 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -22,9 +22,9 @@ //! //! * `--features emu` (the canonical configuration; runs the full //! suite in-process against the std/emu PAL FW build). -//! * `--features mock` (transport-contract probes only). -//! `commands::api_rev::unsupported_on_mock` exercises that the -//! mock backend rejects TBOR opcodes at the transport layer. +//! * `--features mock` compiles the harness only and runs zero +//! tests — mock rejects TBOR at the transport layer, so +//! command-level integration tests are meaningless there. //! * `--features sock` runs the same TBOR round-trips as `emu` but //! over the socket transport. //! * **No backend feature** falls through to the native OS backend @@ -34,8 +34,10 @@ //! this backend too (they are gated //! `#![cfg(not(any(feature = "mock", feature = "sock")))]`, which //! admits both `emu` and the no-feature native OS build). The -//! harness routes concurrent sessions onto separate fds because -//! the kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`. +//! kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so +//! tests that need concurrent sessions open extra fds themselves +//! via [`fixture::open_extra_dev`] — the harness [`ctx::TestCtx`] +//! itself always owns exactly one `Dev`. //! //! Backend-specific [`TestCtx`] methods (`erase`, `cert_chain_info`, //! `get_certificate`) carry per-method `#[cfg(...)]` and are @@ -62,7 +64,7 @@ pub use azihsm_ddi_tbor_types::TborPskChangeReq; pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; -pub use fixture::open_dev; +pub use fixture::open_extra_dev; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; From b8b75179ade545ba61f9c3bd55d07daefdafb5c7 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Wed, 22 Jul 2026 21:44:03 -0700 Subject: [PATCH 11/27] Cleanup the code --- ddi/tbor/types/tests/harness/ctx.rs | 124 +++++++++--------- ddi/tbor/types/tests/harness/fixture.rs | 117 ++++++++++------- ddi/tbor/types/tests/harness/mod.rs | 43 +++--- ddi/tbor/types/tests/harness/session_guard.rs | 64 ++++----- 4 files changed, 176 insertions(+), 172 deletions(-) diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 6ffa0f7b4..a05e271bb 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -3,24 +3,36 @@ //! [`TestCtx`] — the single entry point per integration test. //! -//! Wraps **one** opened backend device (`Dev`) and offers thin -//! primitives for issuing TBOR ops on it. Tests that need concurrent -//! or overlapping sessions across multiple fds open additional -//! [`Dev`]s **outside** of `TestCtx` via -//! [`crate::harness::fixture::open_extra_dev`], passing -//! [`TestCtx::path`] to bind to the same underlying device. `TestCtx` -//! itself does not track those extra fds. +//! Wraps an opened backend device and offers three small primitives +//! that capture the only three outcomes a TBOR command test ever +//! cares about: +//! +//! * [`TestCtx::tbor`] — issue an `OP_TBOR` request and return the +//! decoded response or a [`DdiError`] for the caller to inspect. +//! * [`TestCtx::expect_fw_reject`] — issue a request that *must* be +//! rejected by the FW dispatcher with a specific [`TborStatus`], +//! panicking with diagnostic context otherwise. +//! * [`TestCtx::expect_decode_error`] — issue a request whose response +//! *must* fail host-side TBOR decoding, panicking otherwise. +//! +//! Test files therefore never reach for the bare `Dev` handle or the +//! `assert_*` helpers in [`crate::harness::assertions`] directly; the +//! ctx is the single funnel that future cross-cutting changes (tracing, +//! retry policy, fault injection) can hook into without touching every +//! test. //! //! Cross-test isolation (process-global lock + factory reset) lives -//! in [`crate::harness::fixture::open_dev_parts`], which this type -//! calls through. +//! in [`crate::harness::fixture::open_dev`], which this type calls +//! through. Tests that mix-and-match raw [`open_dev`] calls and +//! [`TestCtx`] both get the same guarantee. //! -//! `TestCtx` owns exactly one `Dev`. Tests that need overlapping -//! sessions across multiple fds open extra `Dev`s themselves at the -//! call site so this type stays free of fd-routing state. +//! The raw device handle deliberately has **no public accessor** on +//! this type. All device interactions must flow through one of the +//! TBOR methods (`tbor`, `session_open_init`, `psk_change`, ...) or +//! the narrow non-TBOR pass-throughs (`erase`, `cert_chain_info`, +//! `get_certificate`). This forces every test path through the +//! shared assertion funnel. -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; use azihsm_ddi_interface::DdiDev; use azihsm_ddi_interface::DdiError; use azihsm_ddi_interface::DdiResult; @@ -30,12 +42,12 @@ use azihsm_ddi_tbor_types::TborOpReq; use azihsm_ddi_tbor_types::TborPartFinalResp; use azihsm_ddi_tbor_types::TborPartInitResp; use azihsm_ddi_tbor_types::TborStatus; -use parking_lot::MutexGuard; use crate::harness::api_rev::helper_api_rev_tbor; use crate::harness::assertions::assert_fw_rejects; use crate::harness::assertions::assert_tbor_decode_error; -use crate::harness::fixture::open_dev_parts; +use crate::harness::fixture::open_dev; +use crate::harness::fixture::TestDev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; @@ -53,59 +65,31 @@ use crate::harness::session::SessionOpenInitOptions; /// security-domain inputs. const DEFAULT_SATA_THUMBPRINT: [u8; 48] = [0x5A; 48]; -/// Backend device handle, compile-time-selected by -/// [`AzihsmDdi::default()`] — `DdiEmu` under `--features emu`, -/// `DdiSock` under `--features sock`, `DdiMock` under `--features -/// mock`, and the native `DdiNix` / `DdiWin` when no backend feature -/// is enabled. -type Dev = ::Dev; - /// One-test fixture: an opened backend device handle (with the /// process-global test lock held for its lifetime) plus a thin layer /// of error-shape assertions. Constructed once per `#[test]`. -/// -/// Tests that need extra concurrent fds open them themselves via -/// [`crate::harness::fixture::open_extra_dev`] using -/// [`Self::path`] — those extra `Dev`s are not tracked by `TestCtx`. pub struct TestCtx { - dev: Dev, - /// Path the primary `Dev` was opened on — captured at open time. - /// Tests that need additional fds pass this to - /// [`crate::harness::fixture::open_extra_dev`] so every extra fd - /// binds to the **same** underlying device as the primary. - /// Reusing the path (instead of re-enumerating via - /// `dev_info_list().first()`) avoids relying on backend-order - /// stability on a multi-device rig. - path: String, - _guard: MutexGuard<'static, ()>, + dev: TestDev, } impl TestCtx { - /// Open the backend device via [`open_dev_parts`] — see its docs - /// for the locking + factory-reset semantics. + /// Open the backend device via [`open_dev`] — see its docs for + /// the locking + factory-reset semantics. pub fn new() -> Self { - let (dev, guard, path) = open_dev_parts(); - Self { - dev, - path, - _guard: guard, - } + Self { dev: open_dev() } } - /// Path (backend-specific string, e.g. `/dev/azihsm0` on nix, - /// `\\.\AZIHSM0` on win, an emu handle on emu) the primary - /// `Dev` was opened on. Tests that need an additional fd on the - /// same underlying device pass this to - /// [`crate::harness::fixture::open_extra_dev`]. + /// The backend path this ctx's [`TestDev`] was opened on. Multi-fd + /// tests thread it into [`crate::harness::open_extra_dev`] so every + /// extra `Dev` binds to the same underlying device as the primary. pub fn path(&self) -> &str { - &self.path + self.dev.path() } - /// Factory-reset the partition. On emu this issues the emulator's - /// reset; on the native backend it issues NSSR. Under `--features - /// mock` this call is unavailable (the mock backend has no state - /// to reset). - #[cfg(not(feature = "mock"))] + /// Factory-reset the partition. Available only on `emu`; the + /// determinism tests in `commands::part_init` call this between + /// cold-restart iterations. + #[cfg(feature = "emu")] pub fn erase(&self) -> DdiResult<()> { self.dev.erase() } @@ -113,8 +97,10 @@ impl TestCtx { /// Issue an `OP_TBOR` request and return the raw `DdiResult`. /// /// Use this when the test needs to inspect both `Ok` and `Err` - /// arms itself. For the common "must reject with status X" shape, - /// prefer [`Self::expect_fw_reject`]. + /// arms itself (e.g. asserting a specific response field on + /// success, or matching on a structural decode error variant). + /// For the common "must reject with status X" shape, prefer + /// [`Self::expect_fw_reject`]. pub fn tbor(&self, req: &R) -> DdiResult { let mut cookie = None; self.dev.exec_op_tbor(req, None, &mut cookie) @@ -184,6 +170,10 @@ impl TestCtx { /// Issue `req`, assert the response failed host-side TBOR decoding /// (i.e. surfaced as [`DdiError::TborDecodeError`]), and return /// the matched error. + /// + /// This is distinct from [`Self::expect_fw_reject`]: a decode + /// error means the response was structurally invalid relative to + /// the schema, not that the FW logically rejected the request. #[track_caller] pub fn expect_decode_error(&self, req: &R) -> DdiError where @@ -203,8 +193,10 @@ impl TestCtx { // // Thin wrappers around the free helpers in `harness::session` so // tests can write `ctx.psk_change(&session, &psk)` instead of - // reaching through a raw device handle. All operate on the single - // primary `Dev`. + // reaching through a raw device handle. The free helpers remain + // in place for documentation purposes (their signatures describe + // what bytes reach the wire); the methods are the ergonomic + // test-facing API. // ------------------------------------------------------------------- /// Run Phase 1 of the TBOR session handshake with happy-path @@ -246,7 +238,10 @@ impl TestCtx { /// One-shot happy-path handshake that returns the raw /// [`SessionHandshake`] *without* a `SessionGuard`. Callers are - /// responsible for the matching [`Self::session_close`]. + /// responsible for the matching [`Self::session_close`]. Used + /// when the test needs to compare two open sessions opened under + /// a non-default PSK, or to inspect the handshake before closing + /// it explicitly. pub fn open_session_raw( &self, psk_id: u8, @@ -256,7 +251,9 @@ impl TestCtx { self.session_open_finish(pending) } - /// Issue `SessionClose(session_id)` on the primary `Dev`. + /// Issue `SessionClose(session_id)`. Used by negative-path + /// tests (double-close, unknown id) and by callers that hold a + /// raw [`SessionHandshake`] outside of a [`SessionGuard`]. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { session_close_helper(&self.dev, session_id) } @@ -324,7 +321,8 @@ impl TestCtx { part_final_helper(&self.dev, session, part_policy, prev_local_mk_backup, certs) } - /// Issue `ApiRev` and return the decoded response. + /// Issue `ApiRev` and return the decoded response. Thin + /// pass-through over the free helper. pub fn api_rev(&self) -> DdiResult { helper_api_rev_tbor(&self.dev) } diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index 632ee11b6..bea3135e2 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -4,24 +4,28 @@ //! Backend setup + canonical fixture constants shared by every TBOR //! integration test. //! -//! The single entry point is [`open_dev_parts`], which: +//! Calling [`open_dev`] does three things, in order: //! -//! 1. Acquires the process-global [`TEST_LOCK`] (held for the -//! returned guard's lifetime). The `StdHsm` is a single shared +//! 1. Acquires the process-global `TEST_LOCK` (held for the +//! returned handle's lifetime). The `StdHsm` is a single shared //! instance for the whole test binary, so any in-flight FW work //! from another test would be corrupted by this one's `erase`. //! 2. Opens the device advertised by the configured backend -//! (`emu` / `mock` / `sock` / native OS). +//! (`emu` or `mock`). //! 3. Factory-resets the device on every backend that owns partition //! state (all but `mock`) so every test starts from byte-identical //! state — no inherited session slots, no inherited PSK rotations, //! no implicit ordering dependency on other tests' cleanup //! discipline. //! -//! Tests never call this directly — they construct a -//! [`TestCtx`](crate::harness::ctx::TestCtx), which owns the returned -//! `Dev`, caches the `path`, and holds the lock guard for its -//! lifetime. +//! Tests therefore become self-contained by construction. The +//! returned [`TestDev`] wraps the backend handle in a type that +//! `Deref`s to the underlying `::Dev`, so existing +//! call sites (`let dev = open_dev(); helper(&dev, ...)`) keep +//! compiling without modification — deref coercion supplies the +//! `&` automatically. + +use std::ops::Deref; use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; @@ -40,67 +44,78 @@ use parking_lot::MutexGuard; /// disallowed by `clippy.toml`). parking_lot's `Mutex` does not /// poison, so a panicking test cannot cause subsequent tests to fail /// at the lock acquisition step — the next test acquires the lock -/// cleanly and [`open_dev_parts`]'s `erase` puts the FW back to a -/// known state. +/// cleanly and `open_dev`'s `erase` puts the FW back to a known state. static TEST_LOCK: Mutex<()> = Mutex::new(()); -/// Acquire the test lock, open the configured backend device, factory- -/// reset it (on every backend that owns partition state — all but -/// `mock`), and return the raw `Dev`, the lock guard, and the -/// [`DevInfo`](azihsm_ddi_interface::DevInfo) `path` the device was -/// opened on. +/// Owned wrapper around an opened backend device that holds the +/// process-global test lock for its lifetime. /// -/// The three parts are returned separately (rather than bundled in a -/// wrapper type) so [`TestCtx`](crate::harness::ctx::TestCtx) can -/// store the `Dev` alongside the cached `path` and the lock guard as -/// individual fields. The `path` is threaded into [`open_extra_dev`] -/// so every extra fd binds to the **same** underlying device as the -/// primary. +/// Derefs to `::Dev` so call sites that previously +/// took `&::Dev` keep compiling unchanged. +pub struct TestDev { + dev: ::Dev, + // Lifetime parameter is `'static` because `TEST_LOCK` is + // `static`. Underscore-prefixed to mark it as "held purely for + // the side-effect of locking". + _guard: MutexGuard<'static, ()>, + /// Backend `DevInfo::path` this handle was opened on. Cached so + /// multi-fd tests can bind extra `Dev`s to the *same* underlying + /// device via [`open_extra_dev`]. + path: String, +} + +impl TestDev { + /// The backend path this device was opened on. + pub fn path(&self) -> &str { + &self.path + } +} + +impl Deref for TestDev { + type Target = ::Dev; + fn deref(&self) -> &Self::Target { + &self.dev + } +} + +/// Acquire the test lock, open the configured backend device, and +/// factory-reset it on every backend that owns partition state +/// (all but `mock`). See module docs. /// /// Panics if the backend lists no devices or if `erase` fails — both /// are backend bugs, not test bugs, and surfacing them immediately /// is preferable to running a test against a dirty device. -pub fn open_dev_parts() -> (::Dev, MutexGuard<'static, ()>, String) { +pub fn open_dev() -> TestDev { let guard = TEST_LOCK.lock(); let ddi = AzihsmDdi::default(); let infos = ddi.dev_info_list(); let info = infos.first().expect("backend should advertise a device"); let path = info.path.clone(); let dev = ddi.open_dev(&path).expect("open test backend device"); - // Factory-reset every backend that owns partition state so each - // test starts from byte-identical defaults. `emu` resets the - // in-process HSM, `sock` propagates a reset through the socket - // server, and on the native OS backend the trait method resolves - // to NSSR. `mock` has no state to reset and no `erase` handler, - // so it is skipped. #[cfg(not(feature = "mock"))] dev.erase() - .expect("open_dev_parts: factory-reset backend before test"); - (dev, guard, path) + .expect("open_dev: factory-reset backend before test"); + TestDev { + dev, + _guard: guard, + path, + } } -/// Open an *additional* backend Dev on the **same underlying device** -/// as the primary, without re-acquiring [`TEST_LOCK`]. +/// Open an additional `Dev` bound to the same backend path as an +/// already-open [`TestDev`]. /// -/// The caller passes the primary's cached `path` (captured at -/// [`open_dev_parts`] time) so we skip re-enumeration — the backend's -/// `dev_info_list()` ordering is not contractually stable across -/// calls, so a naive `list.first()` here could silently open a -/// different device on a multi-device rig. +/// Used by multi-fd tests (e.g. `open_session_multiple_concurrent`) +/// whose semantics require overlapping sessions on distinct fds — on +/// hw the driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so the +/// second concurrent session must live on its own handle. No lock is +/// acquired: the primary `TestDev` already holds `TEST_LOCK`, and +/// extras are conceptually part of the same test's device set. /// -/// Used when a single `#[test]` needs multiple concurrent sessions: -/// on the native OS backend the kernel driver enforces -/// `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so every concurrent session past -/// the first sits on its own fd against the same device. On -/// `emu` / `mock` / `sock` the extra handle is just another view onto -/// the process-global instance — harmless. -/// -/// The caller must already hold the process-global lock via the -/// primary `Dev` (i.e. the `Dev` returned by [`open_dev_parts`] -/// and stored inside [`TestCtx`](crate::harness::ctx::TestCtx)); -/// that is what makes it safe for this function to bypass -/// [`TEST_LOCK`]. Never call this without a live primary in scope. +/// Caller must ensure the primary `TestDev` outlives every extra +/// `Dev`, otherwise the lock guard drops mid-test. pub fn open_extra_dev(path: &str) -> ::Dev { - let ddi = AzihsmDdi::default(); - ddi.open_dev(path).expect("open extra test backend device") + AzihsmDdi::default() + .open_dev(path) + .expect("open extra backend device on the same path as the primary TestDev") } diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 6a5ac976c..1d4f2cb71 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -16,32 +16,34 @@ //! //! # Backend feature regimes //! -//! The test binary supports four build modes; each disables a +//! The test binary supports three build modes; each disables a //! different subset of tests via `#![cfg(...)]` so failures show up //! as "no test compiled" instead of as silent passes: //! //! * `--features emu` (the canonical configuration; runs the full -//! suite in-process against the std/emu PAL FW build). -//! * `--features mock` compiles the harness only and runs zero -//! tests — mock rejects TBOR at the transport layer, so -//! command-level integration tests are meaningless there. -//! * `--features sock` runs the same TBOR round-trips as `emu` but -//! over the socket transport. -//! * **No backend feature** falls through to the native OS backend -//! (`DdiNix` on Linux / `DdiWin` on Windows) via -//! [`azihsm_ddi::AzihsmDdi::default()`]. This is the mode used for -//! on-silicon test runs; the in-session command tests run under -//! this backend too (they are gated -//! `#![cfg(not(any(feature = "mock", feature = "sock")))]`, which -//! admits both `emu` and the no-feature native OS build). The -//! kernel driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so -//! tests that need concurrent sessions open extra fds themselves -//! via [`fixture::open_extra_dev`] — the harness [`ctx::TestCtx`] -//! itself always owns exactly one `Dev`. +//! suite). All in-session command tests are gated +//! `#![cfg(feature = "emu")]` because they require the FW handler +//! actually present in the std/emu PAL build. +//! * `--features mock` (transport-contract probes only). +//! `commands::api_rev::unsupported_on_mock` exercises that the +//! mock backend rejects TBOR opcodes at the transport layer; it +//! is gated `#[cfg(feature = "mock")]`. +//! * No backend feature. The pure host-side codec tests (everything +//! in `commands::fw_error_decode` and `commands::unexpected_toc_type`) +//! compile and run because they do not touch the harness; this +//! module is gated `#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))]` +//! so the harness itself disappears in this mode. +//! +//! The `sock` backend joins `emu` in the in-session command suite: it +//! drives the same TBOR round-trips over the socket transport, so the +//! harness is also built under `--features sock`. Under no backend +//! feature the harness still compiles and targets the native OS +//! backend (`nix` / `win`) so the merged hw-eligible tests in +//! [`crate::commands`] run against real silicon. //! //! Backend-specific [`TestCtx`] methods (`erase`, `cert_chain_info`, -//! `get_certificate`) carry per-method `#[cfg(...)]` and are -//! unavailable under `--features mock`. +//! `get_certificate`) carry per-method `#[cfg(feature = "emu")]` and +//! are unavailable under `--features mock`. pub mod api_rev; pub mod assertions; @@ -64,6 +66,7 @@ pub use azihsm_ddi_tbor_types::TborPskChangeReq; pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; +pub use fixture::open_dev; pub use fixture::open_extra_dev; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index db909298f..6e201649a 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -4,51 +4,45 @@ //! RAII guard for a live TBOR session. //! //! A [`SessionGuard`] owns the handshake carrier produced by -//! [`TestCtx::open_session_raw`](crate::harness::ctx::TestCtx::open_session_raw) +//! [`TestCtx::open_session_raw`](crate::harness::TestCtx::open_session_raw) //! and closes the session when dropped — including when the test is -//! unwinding from a failed assertion. Backend session tables are -//! shared state (the emulator's is process-global; a real device has -//! a single fixed session table); the per-test serialisation -//! provided by the test lock only orders execution, it does not -//! clean up leaked slots. The guard therefore makes panic-safe -//! cleanup the default for every happy-path session test. +//! unwinding from a failed assertion. The emulator's session table +//! is process-global and the per-test serialisation provided by +//! [`open_dev`](crate::harness::open_dev)'s `TEST_LOCK` only orders +//! execution; it does not clean up leaked slots. The guard +//! therefore makes panic-safe cleanup the default for every +//! happy-path session test. //! //! Negative-path tests that need to intercept the handshake mid-flight //! (e.g. ship a tampered `mac_fin`, double-close the same id, exercise -//! a pending-only slot) keep using `session_open_init` / -//! `session_open_finish` / `session_close` on the ctx directly. The -//! guard exists for the well-behaved 90% case, not for those -//! intentional misuses. +//! a pending-only slot) keep using +//! [`TestCtx::session_open_init`](crate::harness::TestCtx::session_open_init) / +//! [`TestCtx::session_open_finish`](crate::harness::TestCtx::session_open_finish) / +//! [`TestCtx::session_close`](crate::harness::TestCtx::session_close) +//! directly. The guard exists for the well-behaved 90% case, not for +//! those intentional misuses. use azihsm_ddi_interface::DdiResult; use azihsm_ddi_tbor_types::SessionType; -use crate::harness::ctx::TestCtx; use crate::harness::session::SessionHandshake; - -/// Backend-agnostic hook the guard needs at cleanup time. Implemented -/// by every ctx type whose `open_session` returns a [`SessionGuard`] -/// so the guard file itself stays free of backend `cfg` scaffolding. -pub trait SessionCloser { - /// Close the session identified by `session_id`. Called from - /// [`SessionGuard::close`] and (best-effort) from [`Drop`]. - fn close_session_by_id(&self, session_id: u16) -> DdiResult<()>; -} +use crate::harness::TestCtx; /// RAII handle to a live session. Closes on `Drop` unless explicitly -/// consumed via [`Self::close`]. Borrows the ctx (as a -/// [`SessionCloser`] trait object) for the guard's lifetime — multiple -/// guards from the same ctx are allowed (the borrow is shared). +/// consumed via [`Self::close`]. Borrows the [`TestCtx`] for the +/// guard's lifetime — multiple guards from the same ctx are allowed +/// (the borrow is shared), which is how multi-session tests like +/// `open_session_multiple_concurrent_emu` will be expressed once +/// migrated. pub struct SessionGuard<'ctx> { - ctx: &'ctx dyn SessionCloser, + ctx: &'ctx TestCtx, handshake: SessionHandshake, closed: bool, } impl<'ctx> SessionGuard<'ctx> { - /// Internal constructor — driven by the ctx's inherent - /// `open_session` method. - pub(crate) fn new(ctx: &'ctx dyn SessionCloser, handshake: SessionHandshake) -> Self { + /// Internal constructor — driven by [`TestCtx::open_session`]. + pub(crate) fn new(ctx: &'ctx TestCtx, handshake: SessionHandshake) -> Self { Self { ctx, handshake, @@ -71,11 +65,11 @@ impl<'ctx> SessionGuard<'ctx> { /// /// Consuming `self` makes double-close a *compile* error rather /// than a runtime one — tests that *want* to assert the FW - /// rejects a double-close must drive the second `session_close` - /// call themselves. + /// rejects a double-close must drive the second + /// [`TestCtx::session_close`] call themselves. pub fn close(mut self) -> DdiResult<()> { self.closed = true; - self.ctx.close_session_by_id(self.handshake.session_id) + self.ctx.session_close(self.handshake.session_id) } } @@ -88,7 +82,7 @@ impl Drop for SessionGuard<'_> { // slot corrupts the next serial test's starting state. // Drop never panics — failure is logged so the original panic // (if any) keeps its place at the top of the stack trace. - if let Err(e) = self.ctx.close_session_by_id(self.handshake.session_id) { + if let Err(e) = self.ctx.session_close(self.handshake.session_id) { eprintln!( "SessionGuard: session_close({}) failed during drop: {e:?}", self.handshake.session_id, @@ -97,12 +91,6 @@ impl Drop for SessionGuard<'_> { } } -impl SessionCloser for TestCtx { - fn close_session_by_id(&self, session_id: u16) -> DdiResult<()> { - self.session_close(session_id) - } -} - impl TestCtx { /// Open a session via the happy-path two-phase handshake and /// return a [`SessionGuard`] that will close it on `Drop`. From a7c92e586d9af4390d73d0770c48128bab09c828 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Thu, 23 Jul 2026 11:17:19 -0700 Subject: [PATCH 12/27] Resolve comments --- .../types/tests/commands/default_psk_gate.rs | 14 +- ddi/tbor/types/tests/commands/open_session.rs | 175 +++++++++--------- ddi/tbor/types/tests/commands/psk_change.rs | 6 +- ddi/tbor/types/tests/harness/ctx.rs | 2 +- ddi/tbor/types/tests/harness/fixture.rs | 4 +- ddi/tbor/types/tests/harness/mod.rs | 2 +- 6 files changed, 96 insertions(+), 107 deletions(-) diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index 2cea3bf55..a20beb9cd 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -23,11 +23,17 @@ //! Each test inherits a factory-reset device from `TestCtx::new`, so //! partition PSKs are at their canonical defaults on entry. +use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; +use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; +use azihsm_ddi_tbor_types::MACH_SEED_LEN; +use azihsm_ddi_tbor_types::PART_POLICY_LEN; +use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::PSK_LEN; +use crate::harness::assertions::assert_fw_rejects; use crate::harness::SessionOpenInitOptions; use crate::harness::TestCtx; @@ -125,14 +131,6 @@ fn default_psk_gate_psk_change_bypass() { /// on real silicon. #[test] fn default_psk_gate_part_init_rejected() { - use azihsm_ddi_tbor_types::PolicyKeyKind; - use azihsm_ddi_tbor_types::TborStatus; - use azihsm_ddi_tbor_types::MACH_SEED_LEN; - use azihsm_ddi_tbor_types::PART_POLICY_LEN; - use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; - - use crate::harness::assertions::assert_fw_rejects; - // Build a 484-byte `PartPolicy` blob that passes wire decode so // the request reaches the dispatcher's gate. Mirrored from // `commands::part_init::known_good_part_policy` (kept inline so diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 6f16a2e0a..a8e6c3160 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -19,7 +19,7 @@ use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; -use crate::harness::open_extra_dev; +use crate::harness::open_dev_with_path; use crate::harness::session::open_session as open_session_on_dev; use crate::harness::session::session_close as session_close_on_dev; #[cfg(not(feature = "emu"))] @@ -254,7 +254,7 @@ fn open_session_multiple_concurrent() { // enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so overlapping // sessions must sit on separate fds bound to the same underlying // device (`ctx.path()`). - let dev_b = open_extra_dev(ctx.path()); + let dev_b = open_dev_with_path(ctx.path()); let b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) .expect("open second session on extra dev"); @@ -271,29 +271,33 @@ fn open_session_multiple_concurrent() { // Session-table exhaustion + recovery // // Iteratively opens sessions until the FW reports the table is full, -// then closes them all and confirms one more open succeeds — the +// then drops every extra fd and confirms one more open succeeds — the // pending/active slot cleanup path must fully reclaim capacity. // // The table size differs by backend (emu FW has a small hardcoded // table; hw silicon a larger one), so the loop is bounded generously -// at 16 and the "at least one rejection observed" invariant is a soft -// diagnostic — the value of the test is the close-all + reopen path. +// at `MULTI_THREADED_TOTAL` and we assert a rejection was observed +// within that ceiling. // --------------------------------------------------------------------------- +const MULTI_THREADED_TOTAL: usize = 12; + #[test] fn open_session_fills_table_then_recovers() { let ctx = TestCtx::new(); // Each concurrent session lives on its own extra `Dev` bound to // the same underlying device — required on hw where - // `AZIHSM_MAX_SESSIONS_PER_FD = 1`. Devs are kept alive alongside - // their session ids so we can issue the matching close on the - // same fd. - type Dev = ::Dev; - let mut open_slots: Vec<(Dev, u16)> = Vec::new(); + // `AZIHSM_MAX_SESSIONS_PER_FD = 1`. The Dev handles are kept + // alive in the vec so their fds stay open while we probe capacity; + // dropping the vec later closes every fd in one shot, which the + // kernel driver must translate into per-session slot reclaim on + // the FW side (that reclaim path is exactly what the recovery + // assertion below exercises). + let mut open_slots = Vec::new(); let mut rejection_seen = false; - for _ in 0..16 { - let dev = open_extra_dev(ctx.path()); + for _ in 0..MULTI_THREADED_TOTAL { + let dev = open_dev_with_path(ctx.path()); match open_session_on_dev(&dev, CU, SessionType::PlainText) { Ok(h) => open_slots.push((dev, h.session_id)), Err(e) => { @@ -311,30 +315,27 @@ fn open_session_fills_table_then_recovers() { } } - // Close everything we opened before running the recovery check, - // so a recovery-side failure does not leak slots on the board. - for (dev, id) in &open_slots { - let _ = session_close_on_dev(dev, *id); - } let slot_count = open_slots.len(); + assert!( + rejection_seen, + "capacity limit not observed within {slot_count} probes — either the backend supports \ + more concurrent CU sessions than the probe ceiling (bump the loop bound), or the \ + session table exhaustion path regressed", + ); + + // Recovery: drop every extra fd in one shot so the driver reclaims + // their FW-side session slots, then one fresh open on the primary + // ctx dev must succeed. Relying on fd-drop (rather than an + // explicit close loop) is deliberate — the invariant we care + // about is that fd-drop == slot-reclaim; if that isn't true, it's + // a product bug worth surfacing. drop(open_slots); - // Recovery: one fresh open must succeed after the batch close. - // The recovery session runs on the primary ctx dev — every extra - // fd has been dropped, so the driver's per-fd session slot on the - // primary is definitely free. let recovered = ctx .open_session_raw(CU, SessionType::PlainText) - .expect("session table must recover after close-all"); + .expect("session table must recover after all extra fds are dropped"); ctx.session_close(recovered.session_id) .expect("SessionClose after recovery must succeed"); - - if !rejection_seen { - eprintln!( - "open_session_fills_table_then_recovers: backend accepted {slot_count} concurrent sessions \ - without emitting a table-full rejection; capacity limit not observed", - ); - } } // --------------------------------------------------------------------------- @@ -623,14 +624,11 @@ fn pk_init_single_byte_tampered_rejected() { // The property under test only holds on the native OS backend. // --------------------------------------------------------------------------- -#[cfg(not(feature = "emu"))] -const MULTI_THREADED_TOTAL: usize = 12; - /// Race N concurrent opens; winners must have distinct session ids, /// and any losers must surface as clean FW/driver rejections. Each -/// racing thread owns its own [`open_extra_dev`] fd — hw requires one -/// fd per concurrent session (`AZIHSM_MAX_SESSIONS_PER_FD = 1`), so -/// `TestCtx` itself is not touched by the racers. +/// racing thread owns its own [`open_dev_with_path`] fd — hw requires +/// one fd per concurrent session (`AZIHSM_MAX_SESSIONS_PER_FD = 1`), +/// so `TestCtx` itself is not touched by the racers. #[cfg(not(feature = "emu"))] #[test] fn open_session_multi_threaded_all_should_open() { @@ -641,30 +639,28 @@ fn open_session_multi_threaded_all_should_open() { // Each entry holds the owning Dev + the session id so we can // close on the correct fd once the race resolves. - let winners_devs: (Vec<(Dev, u16)>, Vec) = - std::thread::scope(|s| { - let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); - for _ in 0..MULTI_THREADED_TOTAL { - handles.push( - s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { - let dev = open_extra_dev(path); - let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; - let handshake = session_open_finish_on_dev(&dev, pending)?; - Ok((dev, handshake.session_id)) - }), - ); - } - let mut winners: Vec<(Dev, u16)> = Vec::new(); - let mut rejections: Vec = Vec::new(); - for h in handles { - match h.join().expect("worker thread must not panic") { - Ok(w) => winners.push(w), - Err(e) => rejections.push(e), - } + let (winners, rejections) = std::thread::scope(|s| { + let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); + for _ in 0..MULTI_THREADED_TOTAL { + handles.push( + s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { + let dev = open_dev_with_path(path); + let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; + let handshake = session_open_finish_on_dev(&dev, pending)?; + Ok((dev, handshake.session_id)) + }), + ); + } + let mut winners = Vec::new(); + let mut rejections = Vec::new(); + for h in handles { + match h.join().expect("worker thread must not panic") { + Ok(w) => winners.push(w), + Err(e) => rejections.push(e), } - (winners, rejections) - }); - let (winners, rejections) = winners_devs; + } + (winners, rejections) + }); let mut sorted_ids: Vec = winners.iter().map(|(_, sid)| *sid).collect(); let winner_ids = sorted_ids.clone(); @@ -706,13 +702,10 @@ fn open_session_multi_threaded_all_should_open() { } } -#[cfg(not(feature = "emu"))] -const SINGLE_WINNER_RACERS: usize = 8; - /// Fill to one free slot then race N threads for it. Regression for /// FW's undo-on-loser path: every losing racer must see a clean /// rejection and leave the session table intact for retry. Each -/// filler + racer holds its own `open_extra_dev` fd — hw requires +/// filler + racer holds its own `open_dev_with_path` fd — hw requires /// one fd per concurrent session. #[cfg(not(feature = "emu"))] #[test] @@ -725,10 +718,9 @@ fn open_session_multi_threaded_single_winner() { // Phase 1: probe capacity sequentially; ceiling matches // fills_table_then_recovers so we don't loop forever on a // pathological build. Each filler session lives on its own Dev. - let mut fillers: Vec<(Dev, u16)> = Vec::new(); - let probe_ceiling: usize = 16; - for _ in 0..probe_ceiling { - let dev = open_extra_dev(path); + let mut fillers = Vec::new(); + for _ in 0..MULTI_THREADED_TOTAL { + let dev = open_dev_with_path(path); match session_open_init_on_dev(&dev, CU, SessionType::PlainText) .and_then(|pending| session_open_finish_on_dev(&dev, pending)) { @@ -738,13 +730,13 @@ fn open_session_multi_threaded_single_winner() { } // Capacity exceeds ceiling: cannot set up a single-slot race — clean up + skip. - if fillers.len() >= probe_ceiling { + if fillers.len() >= MULTI_THREADED_TOTAL { for (dev, sid) in &fillers { let _ = session_close_on_dev(dev, *sid); } eprintln!( "open_session_multi_threaded_single_winner: FW capacity exceeds probe ceiling of \ - {probe_ceiling}; skipping single-slot race", + {MULTI_THREADED_TOTAL}; skipping single-slot race", ); return; } @@ -754,30 +746,29 @@ fn open_session_multi_threaded_single_winner() { session_close_on_dev(&freed_dev, freed_id).expect("close of freed filler slot must succeed"); drop(freed_dev); - // Phase 3: race SINGLE_WINNER_RACERS threads for the one slot. - let (winners, rejections): (Vec<(Dev, u16)>, Vec) = - std::thread::scope(|s| { - let mut handles = Vec::with_capacity(SINGLE_WINNER_RACERS); - for _ in 0..SINGLE_WINNER_RACERS { - handles.push( - s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { - let dev = open_extra_dev(path); - let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; - let handshake = session_open_finish_on_dev(&dev, pending)?; - Ok((dev, handshake.session_id)) - }), - ); - } - let mut winners: Vec<(Dev, u16)> = Vec::new(); - let mut rejections: Vec = Vec::new(); - for h in handles { - match h.join().expect("racer thread must not panic") { - Ok(w) => winners.push(w), - Err(e) => rejections.push(e), - } + // Phase 3: race MULTI_THREADED_TOTAL threads for the one slot. + let (winners, rejections) = std::thread::scope(|s| { + let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); + for _ in 0..MULTI_THREADED_TOTAL { + handles.push( + s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { + let dev = open_dev_with_path(path); + let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; + let handshake = session_open_finish_on_dev(&dev, pending)?; + Ok((dev, handshake.session_id)) + }), + ); + } + let mut winners = Vec::new(); + let mut rejections = Vec::new(); + for h in handles { + match h.join().expect("racer thread must not panic") { + Ok(w) => winners.push(w), + Err(e) => rejections.push(e), } - (winners, rejections) - }); + } + (winners, rejections) + }); let winner_count = winners.len(); let rejection_count = rejections.len(); @@ -795,7 +786,7 @@ fn open_session_multi_threaded_single_winner() { ); assert_eq!( rejection_count, - SINGLE_WINNER_RACERS - 1, + MULTI_THREADED_TOTAL - 1, "all non-winning racers must fail cleanly", ); for err in &rejections { diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 2a83b0f82..18695e345 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -44,7 +44,7 @@ use azihsm_ddi_tbor_types::PSK_LEN; use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; -use crate::harness::open_extra_dev; +use crate::harness::open_dev_with_path; use crate::harness::session::open_session as open_session_on_dev; use crate::harness::session::session_close as session_close_on_dev; use crate::harness::SessionOpenInitOptions; @@ -249,7 +249,7 @@ fn psk_change_wrong_session_id_in_aad() { // Encrypt under session A's param_key but ship the request through // session B (with B's id in both header and AAD). FW verifies with // B's key and the tag fails. Session B is opened on a **second Dev** -// (via `open_extra_dev(ctx.path())`) so on hw the crafted request +// (via `open_dev_with_path(ctx.path())`) so on hw the crafted request // lands on B's fd — `AZIHSM_MAX_SESSIONS_PER_FD = 1` requires the // second concurrent session to live on its own fd. // =========================================================================== @@ -259,7 +259,7 @@ fn psk_change_envelope_from_other_session() { let ctx = TestCtx::new(); let session_a = ctx.open_session(CU, SessionType::PlainText); - let dev_b = open_extra_dev(ctx.path()); + let dev_b = open_dev_with_path(ctx.path()); let session_b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) .expect("open session B on extra dev"); diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index a05e271bb..91cb51a64 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -80,7 +80,7 @@ impl TestCtx { } /// The backend path this ctx's [`TestDev`] was opened on. Multi-fd - /// tests thread it into [`crate::harness::open_extra_dev`] so every + /// tests thread it into [`crate::harness::open_dev_with_path`] so every /// extra `Dev` binds to the same underlying device as the primary. pub fn path(&self) -> &str { self.dev.path() diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index bea3135e2..a5137f371 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -60,7 +60,7 @@ pub struct TestDev { _guard: MutexGuard<'static, ()>, /// Backend `DevInfo::path` this handle was opened on. Cached so /// multi-fd tests can bind extra `Dev`s to the *same* underlying - /// device via [`open_extra_dev`]. + /// device via [`open_dev_with_path`]. path: String, } @@ -114,7 +114,7 @@ pub fn open_dev() -> TestDev { /// /// Caller must ensure the primary `TestDev` outlives every extra /// `Dev`, otherwise the lock guard drops mid-test. -pub fn open_extra_dev(path: &str) -> ::Dev { +pub fn open_dev_with_path(path: &str) -> ::Dev { AzihsmDdi::default() .open_dev(path) .expect("open extra backend device on the same path as the primary TestDev") diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 1d4f2cb71..ed0cad0a0 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -67,7 +67,7 @@ pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; pub use fixture::open_dev; -pub use fixture::open_extra_dev; +pub use fixture::open_dev_with_path; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; From 346ac18b34fdee00d0cce8ff1bfd833a2ff615a7 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Thu, 23 Jul 2026 18:16:30 -0700 Subject: [PATCH 13/27] Fix test break --- ddi/tbor/types/tests/commands/open_session.rs | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index a8e6c3160..ad4e6a897 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -302,12 +302,13 @@ fn open_session_fills_table_then_recovers() { Ok(h) => open_slots.push((dev, h.session_id)), Err(e) => { // FW rejected — treat as "table full". Verify it is - // an FW-side rejection (not a driver / decode fault) - // before ending the ramp-up. `dev` drops here, - // releasing the fd cleanly (no session was allocated). - assert!( - matches!(e, azihsm_ddi_interface::DdiError::DdiError(_)), - "table-full rejection must be FW-side, got {e:?}", + // a FW-side `VaultSessionLimitReached` (not a driver + // / decode fault) before ending the ramp-up. `dev` + // drops here, releasing the fd cleanly (no session + // was allocated). + crate::harness::assertions::assert_fw_rejects( + &e, + TborStatus::VaultSessionLimitReached, ); rejection_seen = true; break; @@ -473,10 +474,7 @@ fn pk_init_all_zero_rejected() { let err = ctx .tbor(&req) .expect_err("all-zero pk_init must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), - "expected FW-side rejection for all-zero pk_init, got {err:?}", - ); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::InvalidArg); } /// `pk_init` with the SEC1 uncompressed prefix (`0x04`) but garbage @@ -496,10 +494,7 @@ fn pk_init_not_on_curve_rejected() { let err = ctx .tbor(&req) .expect_err("off-curve pk_init must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), - "expected FW-side rejection for off-curve pk_init, got {err:?}", - ); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } // --------------------------------------------------------------------------- @@ -556,10 +551,7 @@ fn pk_init_x_as_prime_rejected() { let err = ctx .tbor(&req) .expect_err("pk_init with X = P-384 prime must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), - "expected FW-side rejection for X-as-prime pk_init, got {err:?}", - ); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } /// Symmetric to `pk_init_x_as_prime_rejected` — guards against @@ -577,10 +569,7 @@ fn pk_init_y_as_prime_rejected() { let err = ctx .tbor(&req) .expect_err("pk_init with Y = P-384 prime must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), - "expected FW-side rejection for Y-as-prime pk_init, got {err:?}", - ); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } // --------------------------------------------------------------------------- @@ -607,10 +596,7 @@ fn pk_init_single_byte_tampered_rejected() { let err = ctx .tbor(&req) .expect_err("single-bit-tampered pk_init must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), - "expected FW-side rejection for tampered pk_init, got {err:?}", - ); + crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPointValidationFailed); } // --------------------------------------------------------------------------- From 697d1c50188e1a21836b5edd965ec2dafbbf3319 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Thu, 23 Jul 2026 19:17:21 -0700 Subject: [PATCH 14/27] Fix tests for HW --- ddi/tbor/types/tests/commands/open_session.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index ad4e6a897..695c9795f 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -301,14 +301,13 @@ fn open_session_fills_table_then_recovers() { match open_session_on_dev(&dev, CU, SessionType::PlainText) { Ok(h) => open_slots.push((dev, h.session_id)), Err(e) => { - // FW rejected — treat as "table full". Verify it is - // a FW-side `VaultSessionLimitReached` (not a driver - // / decode fault) before ending the ramp-up. `dev` - // drops here, releasing the fd cleanly (no session - // was allocated). - crate::harness::assertions::assert_fw_rejects( - &e, - TborStatus::VaultSessionLimitReached, + assert!( + matches!( + e, + azihsm_ddi_interface::DdiError::TborStatus(_) + | azihsm_ddi_interface::DdiError::DdiStatus(_) + ), + "expected FW/driver rejection, got {e:?}", ); rejection_seen = true; break; @@ -673,7 +672,7 @@ fn open_session_multi_threaded_all_should_open() { assert!( matches!( err, - azihsm_ddi_interface::DdiError::DdiError(_) + azihsm_ddi_interface::DdiError::TborStatus(_) | azihsm_ddi_interface::DdiError::DdiStatus(_) ), "concurrent open_session rejections must be FW/driver rejections, got {err:?}", @@ -779,7 +778,7 @@ fn open_session_multi_threaded_single_winner() { assert!( matches!( err, - azihsm_ddi_interface::DdiError::DdiError(_) + azihsm_ddi_interface::DdiError::TborStatus(_) | azihsm_ddi_interface::DdiError::DdiStatus(_) ), "single-winner losers must surface FW/driver rejections, got {err:?}", From d1fce640f6562606e554f2115b0bba3cac275501 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Fri, 24 Jul 2026 13:48:02 -0700 Subject: [PATCH 15/27] Resolve comments --- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 20 ++--- .../types/tests/commands/default_psk_gate.rs | 87 +++++++++---------- .../types/tests/commands/part_init/mod.rs | 75 +++------------- ddi/tbor/types/tests/harness/fixture.rs | 7 +- ddi/tbor/types/tests/harness/mod.rs | 45 ++++------ ddi/tbor/types/tests/harness/part_policy.rs | 81 +++++++++++++++++ 6 files changed, 164 insertions(+), 151 deletions(-) create mode 100644 ddi/tbor/types/tests/harness/part_policy.rs diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index d20dfd3c4..3f97b6051 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -4,18 +4,18 @@ //! Integration test binary for `azihsm_ddi_tbor_types`. //! //! Backend selection is feature-gated; the same tests run across every -//! transport. Run with `--features emu` (in-process firmware), -//! `--features sock` (firmware behind a socket server), or -//! `--features mock` (transport-contract probes). With **no** feature -//! enabled the crate falls through to the native OS backend (`nix` -//! on Linux / `win` on Windows) for on-silicon test runs. +//! transport. Run with `--features emu` (in-process firmware) or with +//! **no** feature (native OS backend — `nix` on Linux / `win` on +//! Windows — for on-silicon test runs). //! -//! `mock` disables the whole `commands` tree — mock rejects TBOR at -//! the transport layer, so command-level integration tests are -//! meaningless there. Under `mock` this binary compiles the harness -//! only and runs zero tests. +//! `mock` and `sock` disable the whole harness + `commands` tree: +//! mock rejects TBOR at the transport layer, and sock's `erase` is a +//! stub, so command-level integration tests are meaningless there. +//! Under `mock` or `sock` this binary compiles the crate only and +//! runs zero tests. +#[cfg(not(any(feature = "mock", feature = "sock")))] pub mod harness; -#[cfg(not(feature = "mock"))] +#[cfg(not(any(feature = "mock", feature = "sock")))] pub mod commands; diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index a20beb9cd..362764f7c 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -23,17 +23,19 @@ //! Each test inherits a factory-reset device from `TestCtx::new`, so //! partition PSKs are at their canonical defaults on entry. -use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; -use azihsm_ddi_tbor_types::MACH_SEED_LEN; -use azihsm_ddi_tbor_types::PART_POLICY_LEN; -use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::PSK_LEN; use crate::harness::assertions::assert_fw_rejects; +use crate::harness::known_good_part_policy; +use crate::harness::mach_seed; +use crate::harness::open_dev_with_path; +use crate::harness::pota_thumbprint; +use crate::harness::session::open_session as open_session_on_dev; +use crate::harness::session::session_close as session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TestCtx; @@ -131,56 +133,49 @@ fn default_psk_gate_psk_change_bypass() { /// on real silicon. #[test] fn default_psk_gate_part_init_rejected() { - // Build a 484-byte `PartPolicy` blob that passes wire decode so - // the request reaches the dispatcher's gate. Mirrored from - // `commands::part_init::known_good_part_policy` (kept inline so - // this file stays runnable on hw without ungating the destructive - // part_init/ submodules). - fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - const OFF_SATA: usize = 102; - const OFF_FLAGS: usize = 418; - const OFF_INFO: usize = 419; - - fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { - bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); - bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); - for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { - *b = (fill.wrapping_add(i as u8)) | 0x80; - } - } - - let mut bytes = [0u8; PART_POLICY_LEN]; - bytes[0] = 1; - bytes[1] = 0; - write_pubkey(&mut bytes, OFF_POTA, 0x10); - write_pubkey(&mut bytes, OFF_SATA, 0x20); - bytes[OFF_FLAGS] = 0; - for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { - *b = 0xAB; - } - bytes - } - let ctx = TestCtx::new(); let session = ctx.open_session(CO, SessionType::Authenticated); - let mach_seed = { - let mut v = [0u8; MACH_SEED_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x40 + i as u8; - } - v - }; - let pota_thumbprint = [0x5Au8; POTA_THUMBPRINT_LEN]; - let err = ctx .part_init( session.handshake(), - &mach_seed, + &mach_seed(), &known_good_part_policy(), - &pota_thumbprint, + &pota_thumbprint(), ) .expect_err("PartInit under default PSK must be gated"); assert_fw_rejects(&err, TborStatus::DefaultPskMustRotate); } + +/// `SessionOpenInit` is out-of-session and per-role, so parallel opens +/// against distinct roles on distinct fds must both succeed while both +/// partition PSKs are still at their compiled-in defaults. Regression +/// for the gate bleeding across roles: a CO handshake in flight must +/// not fault a concurrent CU handshake (or vice versa). +/// +/// hw enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so the CO and CU +/// sessions sit on separate fds bound to the same underlying device +/// (`ctx.path()`). Both sessions stay live simultaneously until +/// asserted, then are closed on their owning fds. +#[test] +fn default_psk_gate_co_and_cu_parallel_on_separate_devs_bypass() { + let ctx = TestCtx::new(); + + // Session A: CO + Authenticated on the ctx's fd, under default CO PSK. + let session_co = ctx.open_session(CO, SessionType::Authenticated); + + // Session B: CU + PlainText on a second fd bound to the same + // device, under default CU PSK. Held concurrently with A. + let dev_b = open_dev_with_path(ctx.path()); + let session_cu = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + .expect("CU open on second fd under default CU PSK must succeed while CO session is live"); + + assert_ne!( + session_co.session_id(), + session_cu.session_id, + "parallel CO and CU sessions must have distinct ids", + ); + + // Close B on its owning fd; A closes on drop via SessionGuard. + session_close_on_dev(&dev_b, session_cu.session_id).expect("close CU session on second fd"); +} diff --git a/ddi/tbor/types/tests/commands/part_init/mod.rs b/ddi/tbor/types/tests/commands/part_init/mod.rs index b35182969..073e66e51 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -26,11 +26,7 @@ #![cfg(feature = "emu")] -use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; -use azihsm_ddi_tbor_types::MACH_SEED_LEN; -use azihsm_ddi_tbor_types::PART_POLICY_LEN; -use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::PSK_LEN; use azihsm_ddi_tbor_types::SATA_THUMBPRINT_LEN; @@ -45,6 +41,16 @@ mod sd_config; pub(crate) const CO: u8 = 0; +// Wire-format `PartInit` fixtures live in `crate::harness::part_policy` +// so the hw-eligible `default_psk_gate` tests can reuse them without +// ungating this emu-only module. Re-exported at the same paths the +// submodules already import through (`super::known_good_part_policy`, +// `super::mach_seed`, etc.). +pub(crate) use crate::harness::known_good_part_policy; +pub(crate) use crate::harness::mach_seed; +pub(crate) use crate::harness::part_policy_with_pota; +pub(crate) use crate::harness::pota_thumbprint; + /// Non-default 32-byte CO PSK used so PartInit clears the /// default-PSK-gate. Pinned to a fixed value so the smoke test is /// fully deterministic. @@ -53,67 +59,6 @@ pub(crate) const ROTATED_CO_PSK: [u8; PSK_LEN] = [ 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, ]; -/// Build a 484-byte unified `PartPolicy` blob that passes -/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. Layout mirrors -/// the canonical wire format defined in -/// `fw/core/ddi/tbor/types/src/policy.rs`: POTA + SATA trust anchors are -/// populated Ecc384 keys; SAPOTA + backing-partition keys are left -/// absent (zero `len`); flags are clear; `info` is filled. -pub(crate) fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - const OFF_SATA: usize = 102; - const OFF_FLAGS: usize = 418; - const OFF_INFO: usize = 419; - - // Write an Ecc384 (kind 0) raw X‖Y pubkey at `off` (no SEC1 prefix). - fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { - bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); - bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); - for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { - *b = (fill.wrapping_add(i as u8)) | 0x80; - } - } - - let mut bytes = [0u8; PART_POLICY_LEN]; - bytes[0] = 1; // version major - bytes[1] = 0; // version minor - write_pubkey(&mut bytes, OFF_POTA, 0x10); - write_pubkey(&mut bytes, OFF_SATA, 0x20); - // SAPOTA + backup-part pubkeys left absent (len 0). - bytes[OFF_FLAGS] = 0; - for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { - *b = 0xAB; - } - bytes -} - -/// Like [`known_good_part_policy`] but with a caller-supplied **real** -/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can validate -/// a PTA certificate chain anchored to it. -pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - let mut bytes = known_good_part_policy(); - // POTA slot layout: kind(2) ‖ len(2) ‖ data(96); overwrite the data. - bytes[OFF_POTA + 4..OFF_POTA + 4 + 96].copy_from_slice(pota_raw); - bytes -} - -pub(crate) fn mach_seed() -> [u8; MACH_SEED_LEN] { - let mut v = [0u8; MACH_SEED_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x40 + i as u8; - } - v -} - -pub(crate) fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { - let mut v = [0u8; POTA_THUMBPRINT_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x80 ^ i as u8; - } - v -} - pub(super) fn sata_thumbprint() -> [u8; SATA_THUMBPRINT_LEN] { let mut v = [0u8; SATA_THUMBPRINT_LEN]; for (i, b) in v.iter_mut().enumerate() { diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index a5137f371..789fc8be7 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -29,7 +29,6 @@ use std::ops::Deref; use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; -#[cfg(not(feature = "mock"))] use azihsm_ddi_interface::DdiDev; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; @@ -79,8 +78,9 @@ impl Deref for TestDev { } /// Acquire the test lock, open the configured backend device, and -/// factory-reset it on every backend that owns partition state -/// (all but `mock`). See module docs. +/// factory-reset it before use. Only reachable under `--features emu` +/// or the no-feature (hw) build — mock/sock builds gate the whole +/// harness out at the crate root. /// /// Panics if the backend lists no devices or if `erase` fails — both /// are backend bugs, not test bugs, and surfacing them immediately @@ -92,7 +92,6 @@ pub fn open_dev() -> TestDev { let info = infos.first().expect("backend should advertise a device"); let path = info.path.clone(); let dev = ddi.open_dev(&path).expect("open test backend device"); - #[cfg(not(feature = "mock"))] dev.erase() .expect("open_dev: factory-reset backend before test"); TestDev { diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index ed0cad0a0..318252bc9 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -16,39 +16,28 @@ //! //! # Backend feature regimes //! -//! The test binary supports three build modes; each disables a -//! different subset of tests via `#![cfg(...)]` so failures show up -//! as "no test compiled" instead of as silent passes: +//! The test binary supports two active build modes: //! -//! * `--features emu` (the canonical configuration; runs the full -//! suite). All in-session command tests are gated -//! `#![cfg(feature = "emu")]` because they require the FW handler -//! actually present in the std/emu PAL build. -//! * `--features mock` (transport-contract probes only). -//! `commands::api_rev::unsupported_on_mock` exercises that the -//! mock backend rejects TBOR opcodes at the transport layer; it -//! is gated `#[cfg(feature = "mock")]`. -//! * No backend feature. The pure host-side codec tests (everything -//! in `commands::fw_error_decode` and `commands::unexpected_toc_type`) -//! compile and run because they do not touch the harness; this -//! module is gated `#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))]` -//! so the harness itself disappears in this mode. +//! * `--features emu` — the canonical configuration; runs the full +//! suite against the in-process std-PAL firmware. +//! * **No backend feature** — targets the native OS backend (`nix` on +//! Linux / `win` on Windows) so the hw-eligible tests in +//! [`crate::commands`] run against real silicon. Destructive +//! emu-only tests remain gated `#[cfg(feature = "emu")]` at the +//! test-item level. //! -//! The `sock` backend joins `emu` in the in-session command suite: it -//! drives the same TBOR round-trips over the socket transport, so the -//! harness is also built under `--features sock`. Under no backend -//! feature the harness still compiles and targets the native OS -//! backend (`nix` / `win`) so the merged hw-eligible tests in -//! [`crate::commands`] run against real silicon. -//! -//! Backend-specific [`TestCtx`] methods (`erase`, `cert_chain_info`, -//! `get_certificate`) carry per-method `#[cfg(feature = "emu")]` and -//! are unavailable under `--features mock`. +//! `--features mock` and `--features sock` are compilable but disable +//! both this harness and the `commands` tree at the crate root +//! (`tests/azihsm_ddi_tbor_tests.rs`). Under either feature the test +//! binary compiles the crate only and runs zero tests: mock rejects +//! TBOR at the transport layer, and sock's `erase` is a stub — neither +//! can drive command-level integration tests. pub mod api_rev; pub mod assertions; pub mod ctx; pub mod fixture; +pub mod part_policy; pub mod session; pub mod session_guard; #[cfg(feature = "emu")] @@ -68,6 +57,10 @@ pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; pub use fixture::open_dev; pub use fixture::open_dev_with_path; +pub use part_policy::known_good_part_policy; +pub use part_policy::mach_seed; +pub use part_policy::part_policy_with_pota; +pub use part_policy::pota_thumbprint; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; diff --git a/ddi/tbor/types/tests/harness/part_policy.rs b/ddi/tbor/types/tests/harness/part_policy.rs new file mode 100644 index 000000000..fc9792335 --- /dev/null +++ b/ddi/tbor/types/tests/harness/part_policy.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Wire-format fixtures for the TBOR `PartInit` command. +//! +//! Pure byte-array builders — no backend / no session state — so they +//! are safe to share between the emu-only per-command tests in +//! [`crate::commands::part_init`] and the hw-eligible dispatcher-gate +//! tests in [`crate::commands::default_psk_gate`]. The layout mirrors +//! the canonical wire format in +//! `fw/core/ddi/tbor/types/src/policy.rs`. + +use azihsm_ddi_tbor_types::PolicyKeyKind; +use azihsm_ddi_tbor_types::MACH_SEED_LEN; +use azihsm_ddi_tbor_types::PART_POLICY_LEN; +use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; + +/// Build a 484-byte unified `PartPolicy` blob that passes +/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. Layout mirrors +/// the canonical wire format defined in +/// `fw/core/ddi/tbor/types/src/policy.rs`: POTA + SATA trust anchors +/// are populated Ecc384 keys; SAPOTA + backing-partition keys are left +/// absent (zero `len`); flags are clear; `info` is filled. +pub fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { + const OFF_POTA: usize = 2; + const OFF_SATA: usize = 102; + const OFF_FLAGS: usize = 418; + const OFF_INFO: usize = 419; + + // Write an Ecc384 (kind 0) raw X‖Y pubkey at `off` (no SEC1 prefix). + fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { + bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); + bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); + for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { + *b = (fill.wrapping_add(i as u8)) | 0x80; + } + } + + let mut bytes = [0u8; PART_POLICY_LEN]; + bytes[0] = 1; // version major + bytes[1] = 0; // version minor + write_pubkey(&mut bytes, OFF_POTA, 0x10); + write_pubkey(&mut bytes, OFF_SATA, 0x20); + // SAPOTA + backup-part pubkeys left absent (len 0). + bytes[OFF_FLAGS] = 0; + for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { + *b = 0xAB; + } + bytes +} + +/// Like [`known_good_part_policy`] but with a caller-supplied **real** +/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can +/// validate a PTA certificate chain anchored to it. +pub fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { + const OFF_POTA: usize = 2; + let mut bytes = known_good_part_policy(); + // POTA slot layout: kind(2) ‖ len(2) ‖ data(96); overwrite the data. + bytes[OFF_POTA + 4..OFF_POTA + 4 + 96].copy_from_slice(pota_raw); + bytes +} + +/// Canonical `mach_seed` for `PartInit`: deterministic ramp so failing +/// fixtures are trivially identifiable in a hex dump. +pub fn mach_seed() -> [u8; MACH_SEED_LEN] { + let mut v = [0u8; MACH_SEED_LEN]; + for (i, b) in v.iter_mut().enumerate() { + *b = 0x40 + i as u8; + } + v +} + +/// Canonical `pota_thumbprint` for `PartInit`: deterministic pattern +/// distinct from [`mach_seed`] so a mis-plumbed field is obvious. +pub fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { + let mut v = [0u8; POTA_THUMBPRINT_LEN]; + for (i, b) in v.iter_mut().enumerate() { + *b = 0x80 ^ i as u8; + } + v +} From bd1147d3aaa348d4a803e515bf82cbb7de0061b1 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Fri, 24 Jul 2026 22:23:25 -0700 Subject: [PATCH 16/27] Rename --- .../types/tests/commands/default_psk_gate.rs | 4 ++-- ddi/tbor/types/tests/commands/open_session.rs | 8 ++++---- ddi/tbor/types/tests/commands/psk_change.rs | 4 ++-- ddi/tbor/types/tests/harness/ctx.rs | 20 +++++++++---------- ddi/tbor/types/tests/harness/mod.rs | 8 ++++---- ddi/tbor/types/tests/harness/session.rs | 20 ++++++++++++------- .../types/tests/harness/session/finish.rs | 13 ++++++++---- ddi/tbor/types/tests/harness/session/init.rs | 10 +++++++--- .../tests/harness/session/session_close.rs | 11 ++++++++-- 9 files changed, 60 insertions(+), 38 deletions(-) diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index 362764f7c..9013dc8d8 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -33,9 +33,9 @@ use crate::harness::assertions::assert_fw_rejects; use crate::harness::known_good_part_policy; use crate::harness::mach_seed; use crate::harness::open_dev_with_path; +use crate::harness::open_session_on_dev; use crate::harness::pota_thumbprint; -use crate::harness::session::open_session as open_session_on_dev; -use crate::harness::session::session_close as session_close_on_dev; +use crate::harness::session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TestCtx; diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 695c9795f..3bdb28239 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -20,12 +20,12 @@ use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; use crate::harness::open_dev_with_path; -use crate::harness::session::open_session as open_session_on_dev; -use crate::harness::session::session_close as session_close_on_dev; +use crate::harness::open_session_on_dev; +use crate::harness::session_close_on_dev; #[cfg(not(feature = "emu"))] -use crate::harness::session::session_open_finish as session_open_finish_on_dev; +use crate::harness::session_open_finish_on_dev; #[cfg(not(feature = "emu"))] -use crate::harness::session::session_open_init as session_open_init_on_dev; +use crate::harness::session_open_init_on_dev; use crate::harness::TestCtx; const CO: u8 = 0; diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 18695e345..5b3284853 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -45,8 +45,8 @@ use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; use crate::harness::open_dev_with_path; -use crate::harness::session::open_session as open_session_on_dev; -use crate::harness::session::session_close as session_close_on_dev; +use crate::harness::open_session_on_dev; +use crate::harness::session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TborPskChangeReq; use crate::harness::TestCtx; diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 91cb51a64..015683872 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -51,11 +51,11 @@ use crate::harness::fixture::TestDev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; -use crate::harness::session::session_close as session_close_helper; -use crate::harness::session::session_open_finish as session_open_finish_helper; -use crate::harness::session::session_open_finish_with_mac as session_open_finish_with_mac_helper; -use crate::harness::session::session_open_init as session_open_init_helper; -use crate::harness::session::session_open_init_with_options as session_open_init_with_options_helper; +use crate::harness::session::session_close_on_dev; +use crate::harness::session::session_open_finish_on_dev; +use crate::harness::session::session_open_finish_with_mac; +use crate::harness::session::session_open_init_on_dev; +use crate::harness::session::session_open_init_with_options; use crate::harness::session::PendingHandshake; use crate::harness::session::SessionHandshake; use crate::harness::session::SessionOpenInitOptions; @@ -207,7 +207,7 @@ impl TestCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - session_open_init_helper(&self.dev, psk_id, session_type) + session_open_init_on_dev(&self.dev, psk_id, session_type) } /// Full-control Phase 1 entry point: honours every override in @@ -216,14 +216,14 @@ impl TestCtx { &self, opts: SessionOpenInitOptions<'_>, ) -> DdiResult { - session_open_init_with_options_helper(&self.dev, opts) + session_open_init_with_options(&self.dev, opts) } /// Run Phase 2 of the TBOR session handshake with the canonical /// confirm MAC. Consumes `pending` so callers cannot reuse stale /// state. pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - session_open_finish_helper(&self.dev, pending) + session_open_finish_on_dev(&self.dev, pending) } /// Phase 2 entry point that ships a caller-supplied `mac_fin`, @@ -233,7 +233,7 @@ impl TestCtx { pending: PendingHandshake, mac_fin: [u8; 48], ) -> DdiResult { - session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) + session_open_finish_with_mac(&self.dev, pending, mac_fin) } /// One-shot happy-path handshake that returns the raw @@ -255,7 +255,7 @@ impl TestCtx { /// tests (double-close, unknown id) and by callers that hold a /// raw [`SessionHandshake`] outside of a [`SessionGuard`]. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - session_close_helper(&self.dev, session_id) + session_close_on_dev(&self.dev, session_id) } /// Issue `PskChange` on `session` with `new_psk` as the diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 318252bc9..9d4403f21 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -65,13 +65,13 @@ pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; pub use session::encrypt_psk_envelope; -pub use session::open_session; +pub use session::open_session_on_dev; pub use session::part_init; pub use session::psk_change; -pub use session::session_close; -pub use session::session_open_finish; +pub use session::session_close_on_dev; +pub use session::session_open_finish_on_dev; pub use session::session_open_finish_with_mac; -pub use session::session_open_init; +pub use session::session_open_init_on_dev; pub use session::session_open_init_with_options; pub use session::PendingHandshake; pub use session::SessionHandshake; diff --git a/ddi/tbor/types/tests/harness/session.rs b/ddi/tbor/types/tests/harness/session.rs index ee273b5a7..b9f4ddb11 100644 --- a/ddi/tbor/types/tests/harness/session.rs +++ b/ddi/tbor/types/tests/harness/session.rs @@ -28,10 +28,10 @@ use azihsm_ddi_interface::Ddi; use azihsm_ddi_interface::DdiError; use azihsm_ddi_tbor_types::SessionType; pub use finish::build_mac_fin; -pub use finish::session_open_finish; +pub use finish::session_open_finish_on_dev; pub use finish::session_open_finish_with_mac; pub use finish::SessionHandshake; -pub use init::session_open_init; +pub use init::session_open_init_on_dev; pub use init::session_open_init_with_options; pub use init::PendingHandshake; pub use init::SessionOpenInitOptions; @@ -41,15 +41,21 @@ pub use part_init::encrypt_mach_seed_envelope; pub use part_init::part_init; pub use psk_change::encrypt_psk_envelope; pub use psk_change::psk_change; -pub use session_close::session_close; +pub use session_close::session_close_on_dev; /// One-shot helper: run both phases of the session handshake against -/// `dev`. Equivalent to `session_open_init(...)? → session_open_finish(...)`. -pub fn open_session( +/// a specific `dev`. Equivalent to +/// `session_open_init_on_dev(...)? → session_open_finish_on_dev(...)`. +/// +/// Named with an `_on_dev` suffix to disambiguate from the +/// [`TestCtx::open_session`](crate::harness::TestCtx::open_session) +/// method which acts on the ctx's implicit fd and returns a +/// [`SessionGuard`](crate::harness::SessionGuard). +pub fn open_session_on_dev( dev: &::Dev, psk_id: u8, session_type: SessionType, ) -> Result { - let pending = session_open_init(dev, psk_id, session_type)?; - session_open_finish(dev, pending) + let pending = session_open_init_on_dev(dev, psk_id, session_type)?; + session_open_finish_on_dev(dev, pending) } diff --git a/ddi/tbor/types/tests/harness/session/finish.rs b/ddi/tbor/types/tests/harness/session/finish.rs index 20077831d..021541077 100644 --- a/ddi/tbor/types/tests/harness/session/finish.rs +++ b/ddi/tbor/types/tests/harness/session/finish.rs @@ -114,10 +114,15 @@ fn fresh_seed() -> Result<[u8; SESSION_SEED_LEN], DdiError> { Ok(seed) } -/// Run Phase 2 of the handshake. Consumes the [`PendingHandshake`] -/// so callers cannot accidentally reuse stale state for a second -/// `SessionOpenFinish` against the same Pending slot. -pub fn session_open_finish( +/// Run Phase 2 of the handshake on a specific `dev`. Consumes the +/// [`PendingHandshake`] so callers cannot accidentally reuse stale +/// state for a second `SessionOpenFinish` against the same Pending +/// slot. +/// +/// Named with an `_on_dev` suffix to disambiguate from the +/// [`TestCtx::session_open_finish`](crate::harness::TestCtx::session_open_finish) +/// method which acts on the ctx's implicit fd. +pub fn session_open_finish_on_dev( dev: &::Dev, pending: PendingHandshake, ) -> Result { diff --git a/ddi/tbor/types/tests/harness/session/init.rs b/ddi/tbor/types/tests/harness/session/init.rs index a2fc3972e..adf1a85c7 100644 --- a/ddi/tbor/types/tests/harness/session/init.rs +++ b/ddi/tbor/types/tests/harness/session/init.rs @@ -120,12 +120,16 @@ impl<'a> SessionOpenInitOptions<'a> { } } -/// Convenience wrapper: happy-path `SessionOpenInit` with fresh -/// ephemeral and partition default PSK. +/// Convenience wrapper: happy-path `SessionOpenInit` on a specific +/// `dev` with fresh ephemeral and partition default PSK. /// /// Equivalent to /// `session_open_init_with_options(dev, SessionOpenInitOptions::new(psk_id, session_type))`. -pub fn session_open_init( +/// +/// Named with an `_on_dev` suffix to disambiguate from the +/// [`TestCtx::session_open_init`](crate::harness::TestCtx::session_open_init) +/// method which acts on the ctx's implicit fd. +pub fn session_open_init_on_dev( dev: &::Dev, psk_id: u8, session_type: SessionType, diff --git a/ddi/tbor/types/tests/harness/session/session_close.rs b/ddi/tbor/types/tests/harness/session/session_close.rs index 1c30a9999..878a7325a 100644 --- a/ddi/tbor/types/tests/harness/session/session_close.rs +++ b/ddi/tbor/types/tests/harness/session/session_close.rs @@ -15,8 +15,15 @@ use azihsm_ddi_interface::DdiError; use azihsm_ddi_tbor_types::TborSessionCloseReq; use azihsm_ddi_tbor_types::TborSessionCloseResp; -/// Issue `SessionClose(session_id)` and return on success. -pub fn session_close(dev: &::Dev, session_id: u16) -> Result<(), DdiError> { +/// Issue `SessionClose(session_id)` against a specific `dev`. +/// +/// Named with an `_on_dev` suffix to disambiguate from the +/// [`TestCtx::session_close`](crate::harness::TestCtx::session_close) +/// method which acts on the ctx's implicit fd. +pub fn session_close_on_dev( + dev: &::Dev, + session_id: u16, +) -> Result<(), DdiError> { let req = TborSessionCloseReq { session_id }; let mut cookie = None; let _resp: TborSessionCloseResp = dev.exec_op_tbor(&req, None, &mut cookie)?; From 8f0da164976a911fb30b9aca802bb86a374a85f0 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 27 Jul 2026 21:47:55 -0700 Subject: [PATCH 17/27] Resolve comments --- ddi/nix/src/dev.rs | 37 +++++- .../types/tests/commands/default_psk_gate.rs | 43 ++++--- ddi/tbor/types/tests/commands/key_report.rs | 8 +- ddi/tbor/types/tests/commands/open_session.rs | 97 ++++++++------ .../commands/part_init/crypto_rejects.rs | 10 +- .../tests/commands/part_init/fw_rejects.rs | 14 +- .../tests/commands/part_init/happy_path.rs | 16 ++- .../types/tests/commands/part_init/mod.rs | 120 +++++++++++++++--- .../tests/commands/part_init/sd_config.rs | 4 +- ddi/tbor/types/tests/commands/psk_change.rs | 64 ++++++---- .../tests/commands/sd_sealing_key_gen.rs | 8 +- .../types/tests/commands/session_close.rs | 20 ++- ddi/tbor/types/tests/harness/ctx.rs | 26 +++- ddi/tbor/types/tests/harness/fixture.rs | 34 ++++- ddi/tbor/types/tests/harness/mod.rs | 6 +- ddi/tbor/types/tests/harness/part_policy.rs | 81 ------------ ddi/tbor/types/tests/harness/session_guard.rs | 19 +-- ddi/win/src/dev.rs | 50 +++++++- 18 files changed, 426 insertions(+), 231 deletions(-) delete mode 100644 ddi/tbor/types/tests/harness/part_policy.rs diff --git a/ddi/nix/src/dev.rs b/ddi/nix/src/dev.rs index 5777affe4..24fed14cc 100644 --- a/ddi/nix/src/dev.rs +++ b/ddi/nix/src/dev.rs @@ -26,6 +26,7 @@ use azihsm_ddi_mbor_types::MborError; use azihsm_ddi_mbor_types::SessionControlKind; use azihsm_ddi_tbor_types::TborOpReq; use azihsm_ddi_tbor_types::TborResp; +use azihsm_ddi_tbor_types::TborStatus; use bitfield_struct::bitfield; use nix::ioctl_readwrite; use parking_lot::RwLock; @@ -698,6 +699,40 @@ impl DdiNixDev { Ok(ioctl_status) } + + /// Same as [`Self::map_ioctl_status`] but for callers executing a + /// **TBOR** command. Driver-layer session-scoping rejections + /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — no live + /// session, id mismatch, limit reached) are surfaced as + /// `DdiError::TborStatus(TborStatus::FileHandle*)` instead of + /// `DdiError::DdiStatus(DdiStatus::FileHandle*)`, so callers see a + /// TBOR status for a TBOR command regardless of which layer of + /// the stack (driver vs FW) produced the rejection. The + /// `FileHandle*` variants exist in both `DdiStatus` and + /// `TborStatus` with identical numeric encodings, so this is a + /// pure type-level remap. All non-session errors fall through to + /// the generic mapping. + fn map_ioctl_status_tbor(&self, ioctl_status: u32) -> Result { + match McrCpGenericIoctlErrorKind::try_from(ioctl_status) { + Ok(McrCpGenericIoctlErrorKind::SessionLimitReached) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleSessionLimitReached, + )); + } + Ok(McrCpGenericIoctlErrorKind::NoExistingSession) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleNoExistingSession, + )); + } + Ok(McrCpGenericIoctlErrorKind::SessionIdMismatch) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleSessionIdDoesNotMatch, + )); + } + _ => {} + } + self.map_ioctl_status(ioctl_status) + } } /// Align AAD in place according to the following rules: @@ -932,7 +967,7 @@ impl DdiDev for DdiNixDev { // of the ioctl call; the buffers outlive `cmd`. let res = unsafe { mcr_ctrl_cmd_generic_ioctl(self.file.read().as_raw_fd(), &mut cmd) }; if res.is_err() { - self.map_ioctl_status(cmd.out_data.ioctl_status)?; + self.map_ioctl_status_tbor(cmd.out_data.ioctl_status)?; res.map_err(DdiError::NixError)?; } if cmd.out_data.status != 0 { diff --git a/ddi/tbor/types/tests/commands/default_psk_gate.rs b/ddi/tbor/types/tests/commands/default_psk_gate.rs index 9013dc8d8..4992bc30d 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -29,13 +29,10 @@ use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; use azihsm_ddi_tbor_types::PSK_LEN; +use crate::commands::part_init::known_good_part_policy; +use crate::commands::part_init::mach_seed; +use crate::commands::part_init::pota_thumbprint; use crate::harness::assertions::assert_fw_rejects; -use crate::harness::known_good_part_policy; -use crate::harness::mach_seed; -use crate::harness::open_dev_with_path; -use crate::harness::open_session_on_dev; -use crate::harness::pota_thumbprint; -use crate::harness::session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TestCtx; @@ -102,12 +99,16 @@ fn default_psk_gate_session_open_init_bypass() { fn default_psk_gate_session_close_bypass() { let ctx = TestCtx::new(); - let session_co = ctx.open_session(CO, SessionType::Authenticated); + let session_co = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); session_co .close() .expect("SessionClose must bypass gate while CO PSK is default"); - let session_cu = ctx.open_session(CU, SessionType::PlainText); + let session_cu = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); session_cu .close() .expect("SessionClose must bypass gate while CU PSK is default"); @@ -122,7 +123,9 @@ fn default_psk_gate_session_close_bypass() { #[test] fn default_psk_gate_psk_change_bypass() { let ctx = TestCtx::new(); - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); ctx.psk_change(session.handshake(), &GATE_ROTATED_PSK) .expect("PskChange must bypass gate while CO PSK is default"); } @@ -134,7 +137,9 @@ fn default_psk_gate_psk_change_bypass() { #[test] fn default_psk_gate_part_init_rejected() { let ctx = TestCtx::new(); - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); let err = ctx .part_init( @@ -159,23 +164,25 @@ fn default_psk_gate_part_init_rejected() { /// asserted, then are closed on their owning fds. #[test] fn default_psk_gate_co_and_cu_parallel_on_separate_devs_bypass() { - let ctx = TestCtx::new(); + let ctx_a = TestCtx::new(); - // Session A: CO + Authenticated on the ctx's fd, under default CO PSK. - let session_co = ctx.open_session(CO, SessionType::Authenticated); + // Session A: CO + Authenticated on ctx_a's fd, under default CO PSK. + let session_co = ctx_a + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); // Session B: CU + PlainText on a second fd bound to the same // device, under default CU PSK. Held concurrently with A. - let dev_b = open_dev_with_path(ctx.path()); - let session_cu = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + let ctx_b = TestCtx::new_with_path(ctx_a.path()); + let session_cu = ctx_b + .open_session(CU, SessionType::PlainText) .expect("CU open on second fd under default CU PSK must succeed while CO session is live"); assert_ne!( session_co.session_id(), - session_cu.session_id, + session_cu.session_id(), "parallel CO and CU sessions must have distinct ids", ); - // Close B on its owning fd; A closes on drop via SessionGuard. - session_close_on_dev(&dev_b, session_cu.session_id).expect("close CU session on second fd"); + // Both sessions close on drop via their SessionGuards. } diff --git a/ddi/tbor/types/tests/commands/key_report.rs b/ddi/tbor/types/tests/commands/key_report.rs index cd6645aca..1875e3caa 100644 --- a/ddi/tbor/types/tests/commands/key_report.rs +++ b/ddi/tbor/types/tests/commands/key_report.rs @@ -257,7 +257,9 @@ fn key_report_rejected_on_cu_session_emu() { // Rotate the CU PSK out of the default so the dispatcher's default-PSK // gate does not fire first; then reopen a CU session under it. CU // sessions are pinned to `SessionType::PlainText`. - let bootstrap = ctx.open_session(CU, SessionType::PlainText); + let bootstrap = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); ctx.psk_change(bootstrap.handshake(), &ROTATED_CU_PSK) .expect("rotate CU PSK"); bootstrap.close().expect("close bootstrap CU session"); @@ -286,7 +288,9 @@ fn key_report_rejected_on_default_psk_emu() { // Open a CO session WITHOUT rotating the PSK (still the public // default) — the dispatcher's default-PSK gate must reject the command // before the handler runs. - let session = ctx.open_session(CO_DEFAULT, SessionType::Authenticated); + let session = ctx + .open_session(CO_DEFAULT, SessionType::Authenticated) + .expect("open_session must succeed"); let req = TborKeyReportReq { session_id: session.session_id(), diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 3bdb28239..64bb96863 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -21,6 +21,7 @@ use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; use crate::harness::open_dev_with_path; use crate::harness::open_session_on_dev; +#[cfg(not(feature = "emu"))] use crate::harness::session_close_on_dev; #[cfg(not(feature = "emu"))] use crate::harness::session_open_finish_on_dev; @@ -38,7 +39,9 @@ const CU: u8 = 1; #[test] fn open_session_co_authenticated_happy() { let ctx = TestCtx::new(); - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); let h = session.handshake(); assert_eq!(h.psk_id, CO); assert!(h.session_type.is_authenticated()); @@ -58,7 +61,9 @@ fn open_session_co_authenticated_happy() { #[test] fn open_session_cu_plaintext_happy() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); let h = session.handshake(); assert_eq!(h.psk_id, CU); assert!(!h.session_type.is_authenticated()); @@ -168,11 +173,7 @@ fn session_open_finish_mac_tampered() { #[test] fn session_open_finish_unknown_session_id() { // Pick a session id that cannot possibly correspond to a live - // pending slot. On emu the FW pre-check fails to load the blob - // (`DdiError::DdiError`). On hw the Linux kernel driver enforces - // per-fd session scoping and rejects the ioctl with - // `FileHandleNoExistingSession` (`DdiError::DdiStatus`) before - // the FW sees it — either surface is a valid rejection. + // pending slot. The FW pre-check fails to load the blob. let ctx = TestCtx::new(); let req = TborSessionOpenFinishReq { session_id: 0xFFFF, @@ -183,19 +184,17 @@ fn session_open_finish_unknown_session_id() { .tbor(&req) .expect_err("finish against unknown session_id must fail"); assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), - "expected FW or driver rejection, got {err:?}", + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), + "expected FW-side rejection, got {err:?}", ); } #[test] fn open_session_double_finish() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); // Replay the finish: pending slot is gone, FW must refuse. let req = TborSessionOpenFinishReq { session_id: session.session_id(), @@ -247,24 +246,60 @@ fn session_open_finish_seed_envelope_tampered() { #[test] fn open_session_multiple_concurrent() { - let ctx = TestCtx::new(); - let a = ctx.open_session(CU, SessionType::PlainText); + let ctx_a = TestCtx::new(); + let a = ctx_a + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); // Second session lives on its own Dev — on hw the kernel driver // enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so overlapping // sessions must sit on separate fds bound to the same underlying - // device (`ctx.path()`). - let dev_b = open_dev_with_path(ctx.path()); - let b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + // device (`ctx_a.path()`). + let ctx_b = TestCtx::new_with_path(ctx_a.path()); + let b = ctx_b + .open_session(CU, SessionType::PlainText) .expect("open second session on extra dev"); assert_ne!( a.session_id(), - b.session_id, + b.session_id(), "concurrent sessions must have distinct ids", ); - session_close_on_dev(&dev_b, b.session_id).expect("close session B on extra dev"); + // Both sessions close on drop via their SessionGuards. +} + +// --------------------------------------------------------------------------- +// Per-fd session limit (hw-only) +// --------------------------------------------------------------------------- + +/// Guards against a regression to the old "two `open_session` on the +/// same ctx succeed" behavior. On hw the kernel driver enforces +/// `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so a second open on the fd that +/// already owns a live session must be rejected before FW is +/// dispatched. `exec_op_tbor`'s `map_ioctl_status_tbor` remaps that +/// driver-layer rejection to a `TborStatus`, so the caller sees a +/// TBOR-typed error for a TBOR command. +/// +/// Not applicable on emu: emu's `exec_op_tbor` does not run the +/// per-fd `validate_session_request` shim, so overlapping opens on a +/// single Dev succeed there — this is precisely the discrepancy that +/// motivated the multi-fd rewrite of the other overlap tests. +#[cfg(not(feature = "emu"))] +#[test] +fn open_session_second_on_same_fd_rejected() { + let ctx = TestCtx::new(); + let _a = ctx + .open_session(CU, SessionType::PlainText) + .expect("first open on a fresh fd must succeed"); + let err = ctx + .open_session(CU, SessionType::PlainText) + .err() + .expect("second open on the same fd must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), + "expected TborStatus (kernel per-fd limit remapped in exec_op_tbor), got {err:?}", + ); } // --------------------------------------------------------------------------- @@ -302,12 +337,8 @@ fn open_session_fills_table_then_recovers() { Ok(h) => open_slots.push((dev, h.session_id)), Err(e) => { assert!( - matches!( - e, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), - "expected FW/driver rejection, got {e:?}", + matches!(e, azihsm_ddi_interface::DdiError::TborStatus(_)), + "expected FW-side rejection, got {e:?}", ); rejection_seen = true; break; @@ -670,11 +701,7 @@ fn open_session_multi_threaded_all_should_open() { ); for err in &rejections { assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "concurrent open_session rejections must be FW/driver rejections, got {err:?}", ); } @@ -776,11 +803,7 @@ fn open_session_multi_threaded_single_winner() { ); for err in &rejections { assert!( - matches!( - err, - azihsm_ddi_interface::DdiError::TborStatus(_) - | azihsm_ddi_interface::DdiError::DdiStatus(_) - ), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "single-winner losers must surface FW/driver rejections, got {err:?}", ); } diff --git a/ddi/tbor/types/tests/commands/part_init/crypto_rejects.rs b/ddi/tbor/types/tests/commands/part_init/crypto_rejects.rs index c451e7f53..3de6a80d0 100644 --- a/ddi/tbor/types/tests/commands/part_init/crypto_rejects.rs +++ b/ddi/tbor/types/tests/commands/part_init/crypto_rejects.rs @@ -27,7 +27,7 @@ use crate::harness::TestCtx; /// tag verification must fail before any plaintext is exposed, and /// the handler surfaces [`TborStatus::AeadEnvelopeAuthFailed`]. #[test] -fn part_init_envelope_tampered_emu() { +fn part_init_envelope_tampered() { use crate::harness::encrypt_mach_seed_envelope; let ctx = TestCtx::new(); @@ -62,7 +62,7 @@ fn part_init_envelope_tampered_emu() { /// [`TborStatus::AeadEnvelopeAuthFailed`]. Mirrors the equivalent /// `psk_change_envelope_from_other_session_emu` test. #[test] -fn part_init_envelope_from_other_session_emu() { +fn part_init_envelope_from_other_session() { let ctx = TestCtx::new(); // Session A: rotated CO (clears default-PSK gate). Close it @@ -108,7 +108,7 @@ fn part_init_envelope_from_other_session_emu() { /// check rejects it at decode with /// [`TborStatus::TborInvalidFixedLength`] before any AEAD work. #[test] -fn part_init_wrong_aad_length_emu() { +fn part_init_wrong_aad_length() { let ctx = TestCtx::new(); let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); let long_aad = vec![0u8; 64]; @@ -135,7 +135,7 @@ fn part_init_wrong_aad_length_emu() { /// of the canonical length. Mirrors /// `psk_change_wrong_plaintext_length_emu`. #[test] -fn part_init_wrong_mach_seed_length_emu() { +fn part_init_wrong_mach_seed_length() { let ctx = TestCtx::new(); // Hoist bootstrap out of the loop: PartInit's length check rejects // before any partition-state mutation, so the same rotated-CO @@ -171,7 +171,7 @@ fn part_init_wrong_mach_seed_length_emu() { /// `build_part_init_mach_seed_aad(req.session_id)` and rejects with /// [`TborStatus::AeadEnvelopeAuthFailed`]. #[test] -fn part_init_wrong_session_id_in_aad_emu() { +fn part_init_wrong_session_id_in_aad() { let ctx = TestCtx::new(); let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); diff --git a/ddi/tbor/types/tests/commands/part_init/fw_rejects.rs b/ddi/tbor/types/tests/commands/part_init/fw_rejects.rs index e34109742..c889401bf 100644 --- a/ddi/tbor/types/tests/commands/part_init/fw_rejects.rs +++ b/ddi/tbor/types/tests/commands/part_init/fw_rejects.rs @@ -55,10 +55,12 @@ fn open_role_with( /// runs. Independent of partition state: the rejection lives in the /// dispatcher gate, not in any setter. #[test] -fn part_init_reject_default_psk_co_emu() { +fn part_init_reject_default_psk_co() { let ctx = TestCtx::new(); - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); let policy = known_good_part_policy(); let seed = mach_seed(); let thumb = pota_thumbprint(); @@ -74,13 +76,15 @@ fn part_init_reject_default_psk_co_emu() { /// rotated up-front so the dispatcher's default-PSK gate does not /// fire first. #[test] -fn part_init_reject_cu_session_emu() { +fn part_init_reject_cu_session() { let ctx = TestCtx::new(); // Rotate CU PSK out of the default so we exercise the role gate, // not the default-PSK gate. CU sessions are pinned to // `SessionType::PlainText` (CO-only is `Authenticated`). - let bootstrap = ctx.open_session(CU, SessionType::PlainText); + let bootstrap = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); ctx.psk_change(bootstrap.handshake(), &ROTATED_CU_PSK) .expect("rotate CU PSK"); bootstrap.close().expect("close bootstrap CU session"); @@ -102,7 +106,7 @@ fn part_init_reject_cu_session_emu() { /// [`TborStatus::InvalidArg`] **before** any setter runs, leaving /// partition state untouched. #[test] -fn part_init_reject_bad_policy_emu() { +fn part_init_reject_bad_policy() { let ctx = TestCtx::new(); let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index 22d613ab7..2301856b7 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -31,7 +31,7 @@ use crate::harness::assertions::assert_fw_rejects; use crate::harness::TestCtx; #[test] -fn part_init_smoke_roundtrip_emu() { +fn part_init_smoke_roundtrip() { let ctx = TestCtx::new(); // 1. Bootstrap: rotate CO PSK so PartInit clears the @@ -101,11 +101,9 @@ fn part_init_smoke_roundtrip_emu() { ); // Full COSE_Sign1 verification of the PTAReport under the PID - // pubkey. The PID pubkey is the SubjectPublicKeyInfo of the - // slot-0 cert-chain leaf (idx = num_certs - 1; signed by the - // Alias CA in the std PAL emu cert store). Cross-binds the - // report by also asserting its embedded COSE_Key `pk_x`/`pk_y` - // matches the PTA pubkey we just extracted from the CSR. + // pubkey. Emu-only: `KeyAttester::verify` lives in the sim + // (`azihsm_ddi_mbor_sim`) which is not linked into the hw build. + #[cfg(feature = "emu")] verify_pta_report(&ctx, &resp.pta_report, &pta_spki); // 2. Second PartInit on a freshly-opened session must be rejected @@ -124,6 +122,9 @@ fn part_init_smoke_roundtrip_emu() { /// embedded COSE_Key payload to the PTA pubkey carried in /// `pta_spki_der`. /// +/// Emu-only: pulls in `azihsm_ddi_mbor_sim`'s attestation/COSE +/// verifier stack which is not linked into the hw build. +/// /// Steps: /// /// 1. Fetch the partition's slot-0 cert chain via the existing MBOR @@ -144,6 +145,7 @@ fn part_init_smoke_roundtrip_emu() { /// the CSR's SubjectPublicKeyInfo — proving the report /// actually attests the same key the CSR is requesting a cert /// for. +#[cfg(feature = "emu")] fn verify_pta_report(ctx: &TestCtx, pta_report: &[u8], pta_spki_der: &[u8]) { use azihsm_crypto::DerEccPublicKey; use azihsm_ddi_mbor_sim::attestation::KeyAttester; @@ -281,7 +283,7 @@ fn run_part_init_capture_pta_pub( /// non-deterministic ECDSA nonces, but the PTA public key is the /// canonical determinism invariant under test. #[test] -fn part_init_determinism_emu() { +fn part_init_determinism() { let ctx = TestCtx::new(); // `TestCtx::new` already left the partition factory-reset; this diff --git a/ddi/tbor/types/tests/commands/part_init/mod.rs b/ddi/tbor/types/tests/commands/part_init/mod.rs index 073e66e51..f30d773a8 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -3,11 +3,18 @@ //! Integration tests for the TBOR `PartInit` command. //! -//! Every test runs against the `emu` backend. Cross-test isolation -//! comes from [`TestCtx::new`] (factory-reset + process-global lock -//! held for the ctx's lifetime, see [`crate::harness::fixture`]) so -//! each test starts from a pristine `Enabled` partition with the -//! canonical default PSKs. +//! `PartInit` is mainline-supported on both `emu` and hardware. Tests +//! run on both backends by default; only per-test cases that depend on +//! FW-schema variants not yet landed on silicon (`InvalidArg` bad +//! policy, `TborInvalidFixedLength` short AAD/mach_seed) stay +//! individually `#[cfg(feature = "emu")]`. The sim-only +//! `verify_pta_report` helper (which pokes the emu backend for +//! deterministic PTA outputs) is likewise emu-gated. +//! +//! Cross-test isolation comes from [`TestCtx::new`] (factory-reset + +//! process-global lock held for the ctx's lifetime, see +//! [`crate::harness::fixture`]) so each test starts from a pristine +//! `Enabled` partition with the canonical default PSKs. //! //! Submodules group tests by what is being exercised: //! * [`happy_path`] — the full `OpenSession → PskChange → PartInit` @@ -19,14 +26,18 @@ //! AAD-binding rejects. //! //! Shared bootstrap helpers (`open_co_with`, `bootstrap_rotated_co`, -//! the wire-correct `known_good_part_policy`/`mach_seed`/ -//! `pota_thumbprint` fixtures, and the rotated CO PSK constant) -//! live in this module and are `pub(super)` so each submodule can -//! reach them via `super::*`. - -#![cfg(feature = "emu")] +//! the wire-correct `known_good_part_policy` / `mach_seed` / +//! `pota_thumbprint` / `part_policy_with_pota` fixtures, and the +//! rotated CO PSK constant) live in this module and are `pub(crate)` +//! so both same-module submodules and sibling command modules +//! (`default_psk_gate`, `part_final`, `sd_sealing_key_gen`, +//! `sd_create_remote_backup`) can reach them. +use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; +use azihsm_ddi_tbor_types::MACH_SEED_LEN; +use azihsm_ddi_tbor_types::PART_POLICY_LEN; +use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::PSK_LEN; use azihsm_ddi_tbor_types::SATA_THUMBPRINT_LEN; @@ -41,15 +52,80 @@ mod sd_config; pub(crate) const CO: u8 = 0; -// Wire-format `PartInit` fixtures live in `crate::harness::part_policy` -// so the hw-eligible `default_psk_gate` tests can reuse them without -// ungating this emu-only module. Re-exported at the same paths the -// submodules already import through (`super::known_good_part_policy`, -// `super::mach_seed`, etc.). -pub(crate) use crate::harness::known_good_part_policy; -pub(crate) use crate::harness::mach_seed; -pub(crate) use crate::harness::part_policy_with_pota; -pub(crate) use crate::harness::pota_thumbprint; +// --------------------------------------------------------------------------- +// Wire-format PartInit fixtures — pure byte-array builders (no backend, no +// session state). Layout mirrors the canonical wire format defined in +// `fw/core/ddi/tbor/types/src/policy.rs`. +// --------------------------------------------------------------------------- + +/// Build a 484-byte unified `PartPolicy` blob that passes +/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. POTA + SATA +/// trust anchors are populated Ecc384 keys; SAPOTA + backing-partition +/// keys are left absent (zero `len`); flags are clear; `info` is filled. +pub(crate) fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { + const OFF_POTA: usize = 2; + const OFF_SATA: usize = 102; + const OFF_FLAGS: usize = 418; + const OFF_INFO: usize = 419; + + // Write an Ecc384 (kind 0) raw X‖Y pubkey at `off` (no SEC1 prefix). + fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { + bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); + bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); + for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { + *b = (fill.wrapping_add(i as u8)) | 0x80; + } + } + + let mut bytes = [0u8; PART_POLICY_LEN]; + bytes[0] = 1; // version major + bytes[1] = 0; // version minor + write_pubkey(&mut bytes, OFF_POTA, 0x10); + write_pubkey(&mut bytes, OFF_SATA, 0x20); + // SAPOTA + backup-part pubkeys left absent (len 0). + bytes[OFF_FLAGS] = 0; + for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { + *b = 0xAB; + } + bytes +} + +/// Like [`known_good_part_policy`] but with a caller-supplied **real** +/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can +/// validate a PTA certificate chain anchored to it. +/// +/// Emu-gated because every current consumer (`part_final`, +/// `sd_sealing_key_gen`, `sd_create_remote_backup`) is an emu-only +/// module. If a hw-eligible test ever needs the POTA-anchored variant, +/// drop the gate. +#[cfg(feature = "emu")] +pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { + const OFF_POTA: usize = 2; + let mut bytes = known_good_part_policy(); + // POTA slot layout: kind(2) ‖ len(2) ‖ data(96); overwrite the data. + bytes[OFF_POTA + 4..OFF_POTA + 4 + 96].copy_from_slice(pota_raw); + bytes +} + +/// Canonical `mach_seed` for `PartInit`: deterministic ramp so failing +/// fixtures are trivially identifiable in a hex dump. +pub(crate) fn mach_seed() -> [u8; MACH_SEED_LEN] { + let mut v = [0u8; MACH_SEED_LEN]; + for (i, b) in v.iter_mut().enumerate() { + *b = 0x40 + i as u8; + } + v +} + +/// Canonical `pota_thumbprint` for `PartInit`: deterministic pattern +/// distinct from [`mach_seed`] so a mis-plumbed field is obvious. +pub(crate) fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { + let mut v = [0u8; POTA_THUMBPRINT_LEN]; + for (i, b) in v.iter_mut().enumerate() { + *b = 0x80 ^ i as u8; + } + v +} /// Non-default 32-byte CO PSK used so PartInit clears the /// default-PSK-gate. Pinned to a fixed value so the smoke test is @@ -114,7 +190,9 @@ pub(super) fn open_co_with(ctx: &TestCtx, psk: &[u8; PSK_LEN]) -> SessionHandsha /// under the rotated PSK — ready for the in-session command under /// test. pub(crate) fn bootstrap_rotated_co(ctx: &TestCtx, target_psk: &[u8; PSK_LEN]) -> SessionHandshake { - let bootstrap = ctx.open_session(CO, SessionType::Authenticated); + let bootstrap = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); ctx.psk_change(bootstrap.handshake(), target_psk) .expect("rotate CO PSK"); bootstrap.close().expect("close bootstrap CO session"); diff --git a/ddi/tbor/types/tests/commands/part_init/sd_config.rs b/ddi/tbor/types/tests/commands/part_init/sd_config.rs index 15db55042..f751e8ce7 100644 --- a/ddi/tbor/types/tests/commands/part_init/sd_config.rs +++ b/ddi/tbor/types/tests/commands/part_init/sd_config.rs @@ -24,7 +24,7 @@ use super::ROTATED_CO_PSK; use crate::harness::TestCtx; #[test] -fn part_init_with_sata_emu() { +fn part_init_with_sata() { let ctx = TestCtx::new(); let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); @@ -44,7 +44,7 @@ fn part_init_with_sata_emu() { } #[test] -fn part_init_with_sapota_emu() { +fn part_init_with_sapota() { let ctx = TestCtx::new(); let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 5b3284853..87c942c32 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -32,7 +32,6 @@ use azihsm_crypto::aead_envelope; use azihsm_crypto::aead_envelope::AeadAlg; use azihsm_crypto::AesKey; use azihsm_crypto::Rng; -use azihsm_ddi_interface::DdiDev; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; #[cfg(not(feature = "emu"))] @@ -44,9 +43,6 @@ use azihsm_ddi_tbor_types::PSK_LEN; use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_psk_change_aad; use crate::harness::encrypt_psk_envelope; -use crate::harness::open_dev_with_path; -use crate::harness::open_session_on_dev; -use crate::harness::session_close_on_dev; use crate::harness::SessionOpenInitOptions; use crate::harness::TborPskChangeReq; use crate::harness::TestCtx; @@ -92,7 +88,9 @@ fn build_envelope(param_key: &AesKey, aad: &[u8], plaintext: &[u8]) -> Vec { /// bytes. fn run_psk_change_happy(role: u8, sty: SessionType) { let ctx = TestCtx::new(); - let session = ctx.open_session(role, sty); + let session = ctx + .open_session(role, sty) + .expect("open_session must succeed"); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate to ROTATED_PSK"); session.close().expect("close rotating session"); @@ -125,7 +123,9 @@ fn psk_change_happy_co() { #[test] fn psk_change_reopen_with_old_psk_fails() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("rotate"); session.close().expect("close rotating session"); @@ -148,7 +148,9 @@ fn psk_change_reopen_with_old_psk_fails() { #[test] fn psk_change_second_attempt_same_session_fails() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); ctx.psk_change(session.handshake(), &ROTATED_PSK) .expect("first rotate"); @@ -189,7 +191,9 @@ fn psk_change_envelope_tampered() { }) as fn(&mut Vec), ), ] { - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); let mut envelope = encrypt_psk_envelope(session.handshake(), &ROTATED_PSK).expect("encrypt envelope"); mutate(&mut envelope); @@ -211,7 +215,9 @@ fn psk_change_envelope_tampered() { #[test] fn psk_change_empty_envelope() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); let req = TborPskChangeReq { session_id: session.session_id(), psk_envelope: Vec::new(), @@ -229,7 +235,9 @@ fn psk_change_empty_envelope() { #[test] fn psk_change_wrong_session_id_in_aad() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); // Build an envelope whose AAD encodes a different (bogus) // session id. AEAD-GCM tag verifies (the FW recomputes the tag // over *these* bytes), but the FW then constant-compares the AAD @@ -256,26 +264,28 @@ fn psk_change_wrong_session_id_in_aad() { #[test] fn psk_change_envelope_from_other_session() { - let ctx = TestCtx::new(); - let session_a = ctx.open_session(CU, SessionType::PlainText); - - let dev_b = open_dev_with_path(ctx.path()); - let session_b = open_session_on_dev(&dev_b, CU, SessionType::PlainText) + let ctx_a = TestCtx::new(); + let session_a = ctx_a + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); + + let ctx_b = TestCtx::new_with_path(ctx_a.path()); + let session_b = ctx_b + .open_session(CU, SessionType::PlainText) .expect("open session B on extra dev"); - let aad_for_b = build_psk_change_aad(session_b.session_id); + let aad_for_b = build_psk_change_aad(session_b.session_id()); let envelope = build_envelope(&session_a.handshake().param_key, &aad_for_b, &ROTATED_PSK); let req = TborPskChangeReq { - session_id: session_b.session_id, + session_id: session_b.session_id(), psk_envelope: envelope, }; - let mut cookie = None; - let err = dev_b - .exec_op_tbor(&req, None, &mut cookie) + let err = ctx_b + .tbor(&req) .expect_err("PskChange envelope from session A must be rejected on session B"); crate::harness::assertions::assert_fw_rejects(&err, TborStatus::AeadEnvelopeAuthFailed); - session_close_on_dev(&dev_b, session_b.session_id).expect("close session B on extra dev"); + // Both sessions close on drop via their SessionGuards. } // =========================================================================== @@ -291,7 +301,9 @@ fn psk_change_wrong_plaintext_length() { // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both with // `TborInvalidFixedLength` in the derive-generated decoder. for len in [PSK_LEN - 1, PSK_LEN + 1] { - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); let bogus_psk = vec![0xCDu8; len]; let aad = build_psk_change_aad(session.session_id()); let envelope = build_envelope(&session.handshake().param_key, &aad, &bogus_psk); @@ -314,7 +326,9 @@ fn psk_change_wrong_plaintext_length() { #[test] fn psk_change_wrong_aad_length() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); // 64 bytes of arbitrary AAD (valid AEAD granularity) inflates the // envelope past PSK_CHANGE_ENVELOPE_LEN (100 B); the derive- // generated decoder rejects with `TborInvalidFixedLength`. @@ -344,7 +358,9 @@ fn psk_change_wrong_aad_length() { #[cfg(not(feature = "emu"))] fn run_psk_change_to_default_rejected(role: u8, sty: SessionType, new_psk: &[u8; PSK_LEN]) { let ctx = TestCtx::new(); - let session = ctx.open_session(role, sty); + let session = ctx + .open_session(role, sty) + .expect("open_session must succeed"); let err = ctx .psk_change(session.handshake(), new_psk) .expect_err("psk_change to a default PSK must be rejected"); diff --git a/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs b/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs index 641266473..456556c87 100644 --- a/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs +++ b/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs @@ -167,7 +167,9 @@ fn sd_sealing_key_gen_rejected_on_cu_session_emu() { // gate does not fire first; then reopen a CU session under the rotated // PSK. CU sessions are pinned to `SessionType::PlainText` (CO-only is // `Authenticated`). - let bootstrap = ctx.open_session(CU, SessionType::PlainText); + let bootstrap = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); ctx.psk_change(bootstrap.handshake(), &ROTATED_CU_PSK) .expect("rotate CU PSK"); bootstrap.close().expect("close bootstrap CU session"); @@ -195,7 +197,9 @@ fn sd_sealing_key_gen_rejected_on_default_psk_emu() { // Open a CO session WITHOUT rotating the PSK (still the public // default) — the dispatcher's default-PSK gate must reject the command // before the handler runs. - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); let req = TborSdSealingKeyGenReq { session_id: session.session_id(), diff --git a/ddi/tbor/types/tests/commands/session_close.rs b/ddi/tbor/types/tests/commands/session_close.rs index efbce5b50..cdf23c69a 100644 --- a/ddi/tbor/types/tests/commands/session_close.rs +++ b/ddi/tbor/types/tests/commands/session_close.rs @@ -36,14 +36,18 @@ const CU: u8 = 1; #[test] fn session_close_cu_plaintext_active_emu() { let ctx = TestCtx::new(); - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); session.close().expect("close active CU session"); } #[test] fn session_close_co_authenticated_active_emu() { let ctx = TestCtx::new(); - let session = ctx.open_session(CO, SessionType::Authenticated); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); session.close().expect("close active CO session"); } @@ -85,7 +89,9 @@ fn session_close_double_close_emu() { // Take the `SessionHandshake` out of the guard via `.close()` so // we own the lifecycle for the second (failing) call. The first // close therefore must succeed — the test asserts the second. - let session = ctx.open_session(CU, SessionType::PlainText); + let session = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); let session_id = session.session_id(); session.close().expect("first close succeeds"); let err = ctx @@ -104,9 +110,13 @@ fn session_close_double_close_emu() { #[test] fn session_close_then_reopen_emu() { let ctx = TestCtx::new(); - let first = ctx.open_session(CU, SessionType::PlainText); + let first = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); first.close().expect("close first"); // FW is free to reuse the freed slot id; we only assert the // second handshake completes end-to-end (guard drops it). - let _second = ctx.open_session(CU, SessionType::PlainText); + let _second = ctx + .open_session(CU, SessionType::PlainText) + .expect("open_session must succeed"); } diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 015683872..9aa866b7b 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -47,6 +47,7 @@ use crate::harness::api_rev::helper_api_rev_tbor; use crate::harness::assertions::assert_fw_rejects; use crate::harness::assertions::assert_tbor_decode_error; use crate::harness::fixture::open_dev; +use crate::harness::fixture::open_dev_secondary; use crate::harness::fixture::TestDev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; @@ -79,6 +80,22 @@ impl TestCtx { Self { dev: open_dev() } } + /// Open a secondary `TestCtx` on the same backend `path` as the + /// primary `TestCtx` already alive in this test — see + /// [`open_dev_secondary`] for the no-lock / no-erase semantics + /// and lifetime constraints. + /// + /// Multi-fd tests need distinct `Dev` handles (hw enforces + /// `AZIHSM_MAX_SESSIONS_PER_FD = 1`); each secondary `TestCtx` + /// owns one such handle and otherwise behaves identically to a + /// primary — `open_session`, `tbor`, `mbor`, etc. all work the + /// same. + pub fn new_with_path(path: &str) -> Self { + Self { + dev: open_dev_secondary(path), + } + } + /// The backend path this ctx's [`TestDev`] was opened on. Multi-fd /// tests thread it into [`crate::harness::open_dev_with_path`] so every /// extra `Dev` binds to the same underlying device as the primary. @@ -86,10 +103,11 @@ impl TestCtx { self.dev.path() } - /// Factory-reset the partition. Available only on `emu`; the - /// determinism tests in `commands::part_init` call this between - /// cold-restart iterations. - #[cfg(feature = "emu")] + /// Factory-reset the partition. Used by the + /// `commands::part_init` determinism test for cold-restart + /// iterations. Available on every backend the harness compiles + /// under (emu + hw); `mock`/`sock` gate the whole harness out at + /// the crate root. pub fn erase(&self) -> DdiResult<()> { self.dev.erase() } diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index 789fc8be7..b888a852d 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -55,8 +55,10 @@ pub struct TestDev { dev: ::Dev, // Lifetime parameter is `'static` because `TEST_LOCK` is // `static`. Underscore-prefixed to mark it as "held purely for - // the side-effect of locking". - _guard: MutexGuard<'static, ()>, + // the side-effect of locking". `None` on secondary handles opened + // on the same path as a primary that already holds the lock — + // taking it a second time would deadlock the test. + _guard: Option>, /// Backend `DevInfo::path` this handle was opened on. Cached so /// multi-fd tests can bind extra `Dev`s to the *same* underlying /// device via [`open_dev_with_path`]. @@ -96,11 +98,37 @@ pub fn open_dev() -> TestDev { .expect("open_dev: factory-reset backend before test"); TestDev { dev, - _guard: guard, + _guard: Some(guard), path, } } +/// Open a secondary [`TestDev`] on the same backend path as the +/// primary [`TestDev`] already alive in this test. +/// +/// Does not acquire `TEST_LOCK` — the primary already holds it, and +/// `parking_lot::Mutex` is not reentrant, so a second acquisition on +/// this thread would deadlock. Also skips `erase`: the primary owns +/// the device state, and wiping it mid-test would break both handles. +/// +/// Used by multi-fd tests whose semantics require overlapping +/// sessions on distinct fds — on hw the driver enforces +/// `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so the second concurrent session +/// must live on its own handle. +/// +/// Caller must ensure the primary [`TestDev`] outlives every +/// secondary, otherwise the lock guard drops mid-test. +pub fn open_dev_secondary(path: &str) -> TestDev { + let dev = AzihsmDdi::default() + .open_dev(path) + .expect("open secondary backend device on the same path as the primary TestDev"); + TestDev { + dev, + _guard: None, + path: path.to_string(), + } +} + /// Open an additional `Dev` bound to the same backend path as an /// already-open [`TestDev`]. /// diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 9d4403f21..7cb0536b8 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -37,7 +37,6 @@ pub mod api_rev; pub mod assertions; pub mod ctx; pub mod fixture; -pub mod part_policy; pub mod session; pub mod session_guard; #[cfg(feature = "emu")] @@ -56,11 +55,8 @@ pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; pub use fixture::open_dev; +pub use fixture::open_dev_secondary; pub use fixture::open_dev_with_path; -pub use part_policy::known_good_part_policy; -pub use part_policy::mach_seed; -pub use part_policy::part_policy_with_pota; -pub use part_policy::pota_thumbprint; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; diff --git a/ddi/tbor/types/tests/harness/part_policy.rs b/ddi/tbor/types/tests/harness/part_policy.rs deleted file mode 100644 index fc9792335..000000000 --- a/ddi/tbor/types/tests/harness/part_policy.rs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Wire-format fixtures for the TBOR `PartInit` command. -//! -//! Pure byte-array builders — no backend / no session state — so they -//! are safe to share between the emu-only per-command tests in -//! [`crate::commands::part_init`] and the hw-eligible dispatcher-gate -//! tests in [`crate::commands::default_psk_gate`]. The layout mirrors -//! the canonical wire format in -//! `fw/core/ddi/tbor/types/src/policy.rs`. - -use azihsm_ddi_tbor_types::PolicyKeyKind; -use azihsm_ddi_tbor_types::MACH_SEED_LEN; -use azihsm_ddi_tbor_types::PART_POLICY_LEN; -use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; - -/// Build a 484-byte unified `PartPolicy` blob that passes -/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. Layout mirrors -/// the canonical wire format defined in -/// `fw/core/ddi/tbor/types/src/policy.rs`: POTA + SATA trust anchors -/// are populated Ecc384 keys; SAPOTA + backing-partition keys are left -/// absent (zero `len`); flags are clear; `info` is filled. -pub fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - const OFF_SATA: usize = 102; - const OFF_FLAGS: usize = 418; - const OFF_INFO: usize = 419; - - // Write an Ecc384 (kind 0) raw X‖Y pubkey at `off` (no SEC1 prefix). - fn write_pubkey(bytes: &mut [u8], off: usize, fill: u8) { - bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); - bytes[off + 2..off + 4].copy_from_slice(&96u16.to_le_bytes()); - for (i, b) in bytes[off + 4..off + 4 + 96].iter_mut().enumerate() { - *b = (fill.wrapping_add(i as u8)) | 0x80; - } - } - - let mut bytes = [0u8; PART_POLICY_LEN]; - bytes[0] = 1; // version major - bytes[1] = 0; // version minor - write_pubkey(&mut bytes, OFF_POTA, 0x10); - write_pubkey(&mut bytes, OFF_SATA, 0x20); - // SAPOTA + backup-part pubkeys left absent (len 0). - bytes[OFF_FLAGS] = 0; - for b in bytes[OFF_INFO..OFF_INFO + 64].iter_mut() { - *b = 0xAB; - } - bytes -} - -/// Like [`known_good_part_policy`] but with a caller-supplied **real** -/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can -/// validate a PTA certificate chain anchored to it. -pub fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { - const OFF_POTA: usize = 2; - let mut bytes = known_good_part_policy(); - // POTA slot layout: kind(2) ‖ len(2) ‖ data(96); overwrite the data. - bytes[OFF_POTA + 4..OFF_POTA + 4 + 96].copy_from_slice(pota_raw); - bytes -} - -/// Canonical `mach_seed` for `PartInit`: deterministic ramp so failing -/// fixtures are trivially identifiable in a hex dump. -pub fn mach_seed() -> [u8; MACH_SEED_LEN] { - let mut v = [0u8; MACH_SEED_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x40 + i as u8; - } - v -} - -/// Canonical `pota_thumbprint` for `PartInit`: deterministic pattern -/// distinct from [`mach_seed`] so a mis-plumbed field is obvious. -pub fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { - let mut v = [0u8; POTA_THUMBPRINT_LEN]; - for (i, b) in v.iter_mut().enumerate() { - *b = 0x80 ^ i as u8; - } - v -} diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index 6e201649a..84fe1fb35 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -95,13 +95,16 @@ impl TestCtx { /// Open a session via the happy-path two-phase handshake and /// return a [`SessionGuard`] that will close it on `Drop`. /// - /// Panics on any FW or transport error; negative-path tests must - /// call [`TestCtx::session_open_init`] (etc.) directly so they - /// can inspect the failure mode. - pub fn open_session(&self, psk_id: u8, session_type: SessionType) -> SessionGuard<'_> { - let handshake = self - .open_session_raw(psk_id, session_type) - .expect("TestCtx::open_session: handshake must succeed on the happy path"); - SessionGuard::new(self, handshake) + /// Fallible: propagates any FW or transport error from the + /// underlying [`Self::open_session_raw`]. Happy-path callers + /// typically `.expect(...)` the returned `Result`; negative-path + /// tests inspect the `Err` directly. + pub fn open_session( + &self, + psk_id: u8, + session_type: SessionType, + ) -> DdiResult> { + let handshake = self.open_session_raw(psk_id, session_type)?; + Ok(SessionGuard::new(self, handshake)) } } diff --git a/ddi/win/src/dev.rs b/ddi/win/src/dev.rs index a10aac12e..47355d222 100644 --- a/ddi/win/src/dev.rs +++ b/ddi/win/src/dev.rs @@ -28,6 +28,7 @@ use azihsm_ddi_mbor_types::MborError; use azihsm_ddi_mbor_types::SessionControlKind; use azihsm_ddi_tbor_types::TborOpReq; use azihsm_ddi_tbor_types::TborResp; +use azihsm_ddi_tbor_types::TborStatus; use bitfield_struct::bitfield; use parking_lot::RwLock; use winapi::ctypes::c_void; @@ -641,6 +642,53 @@ impl DdiWinDev { Ok(ioctl_status) } + + /// Same as [`Self::map_ioctl_status`] but for callers executing a + /// **TBOR** command. Driver-layer session-scoping rejections + /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — no live + /// session, id mismatch, limit reached) are surfaced as + /// `DdiError::TborStatus(TborStatus::FileHandle*)` instead of + /// `DdiError::DdiStatus(DdiStatus::FileHandle*)`, so callers see a + /// TBOR status for a TBOR command regardless of which layer of + /// the stack (driver vs FW) produced the rejection. The + /// `FileHandle*` variants exist in both `DdiStatus` and + /// `TborStatus` with identical numeric encodings, so this is a + /// pure type-level remap. All non-session errors fall through to + /// the generic mapping. + fn map_ioctl_status_tbor(&self, ioctl_status: u32) -> Result { + match McrCpGenericIoctlErrorKind::try_from(ioctl_status) { + Ok(McrCpGenericIoctlErrorKind::SessionLimitReached) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleSessionLimitReached, + )); + } + Ok(McrCpGenericIoctlErrorKind::NoExistingSession) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleNoExistingSession, + )); + } + Ok(McrCpGenericIoctlErrorKind::SessionIdDoesNotMatch) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleSessionIdDoesNotMatch, + )); + } + _ => {} + } + match McrFpIoctlErrorKind::try_from(ioctl_status) { + Ok(McrFpIoctlErrorKind::InvalidSessionId) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleSessionIdDoesNotMatch, + )); + } + Ok(McrFpIoctlErrorKind::NoValidSessionId) => { + return Err(DdiError::TborStatus( + TborStatus::FileHandleNoExistingSession, + )); + } + _ => {} + } + self.map_ioctl_status(ioctl_status) + } } /// Align AAD in place according to the following rules: @@ -1011,7 +1059,7 @@ impl DdiDev for DdiWinDev { Err(DdiError::IoError(last_error))?; } - self.map_ioctl_status(ioctl_out_buffer.ioctl_status)?; + self.map_ioctl_status_tbor(ioctl_out_buffer.ioctl_status)?; if ioctl_out_buffer.ioctl_status != 0 { Err(DdiError::WinError(ioctl_out_buffer.ioctl_status))?; From 6ae0efeb71a3ca3b391cdb5242d01cf6d85f2157 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Mon, 27 Jul 2026 23:11:15 -0700 Subject: [PATCH 18/27] cleanup --- .../tests/commands/part_init/happy_path.rs | 7 ++- .../types/tests/commands/part_init/mod.rs | 61 +++++++------------ 2 files changed, 28 insertions(+), 40 deletions(-) diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index 2301856b7..67a8916ee 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -101,8 +101,11 @@ fn part_init_smoke_roundtrip() { ); // Full COSE_Sign1 verification of the PTAReport under the PID - // pubkey. Emu-only: `KeyAttester::verify` lives in the sim - // (`azihsm_ddi_mbor_sim`) which is not linked into the hw build. + // pubkey. The PID pubkey is the SubjectPublicKeyInfo of the + // slot-0 cert-chain leaf (idx = num_certs - 1; signed by the + // Alias CA in the std PAL emu cert store). Cross-binds the + // report by also asserting its embedded COSE_Key `pk_x`/`pk_y` + // matches the PTA pubkey we just extracted from the CSR. #[cfg(feature = "emu")] verify_pta_report(&ctx, &resp.pta_report, &pta_spki); diff --git a/ddi/tbor/types/tests/commands/part_init/mod.rs b/ddi/tbor/types/tests/commands/part_init/mod.rs index f30d773a8..80bef0816 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -4,12 +4,9 @@ //! Integration tests for the TBOR `PartInit` command. //! //! `PartInit` is mainline-supported on both `emu` and hardware. Tests -//! run on both backends by default; only per-test cases that depend on -//! FW-schema variants not yet landed on silicon (`InvalidArg` bad -//! policy, `TborInvalidFixedLength` short AAD/mach_seed) stay -//! individually `#[cfg(feature = "emu")]`. The sim-only -//! `verify_pta_report` helper (which pokes the emu backend for -//! deterministic PTA outputs) is likewise emu-gated. +//! run on both backends by default; only the sim-only +//! `verify_pta_report` helper in [`happy_path`] (which uses the +//! `azihsm_ddi_mbor_sim` COSE verifier) stays `#[cfg(feature = "emu")]`. //! //! Cross-test isolation comes from [`TestCtx::new`] (factory-reset + //! process-global lock held for the ctx's lifetime, see @@ -26,12 +23,10 @@ //! AAD-binding rejects. //! //! Shared bootstrap helpers (`open_co_with`, `bootstrap_rotated_co`, -//! the wire-correct `known_good_part_policy` / `mach_seed` / -//! `pota_thumbprint` / `part_policy_with_pota` fixtures, and the -//! rotated CO PSK constant) live in this module and are `pub(crate)` -//! so both same-module submodules and sibling command modules -//! (`default_psk_gate`, `part_final`, `sd_sealing_key_gen`, -//! `sd_create_remote_backup`) can reach them. +//! the wire-correct `known_good_part_policy`/`mach_seed`/ +//! `pota_thumbprint` fixtures, and the rotated CO PSK constant) +//! live in this module and are `pub(super)` so each submodule can +//! reach them via `super::*`. use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; @@ -52,16 +47,20 @@ mod sd_config; pub(crate) const CO: u8 = 0; -// --------------------------------------------------------------------------- -// Wire-format PartInit fixtures — pure byte-array builders (no backend, no -// session state). Layout mirrors the canonical wire format defined in -// `fw/core/ddi/tbor/types/src/policy.rs`. -// --------------------------------------------------------------------------- +/// Non-default 32-byte CO PSK used so PartInit clears the +/// default-PSK-gate. Pinned to a fixed value so the smoke test is +/// fully deterministic. +pub(crate) const ROTATED_CO_PSK: [u8; PSK_LEN] = [ + 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, + 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, +]; /// Build a 484-byte unified `PartPolicy` blob that passes -/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. POTA + SATA -/// trust anchors are populated Ecc384 keys; SAPOTA + backing-partition -/// keys are left absent (zero `len`); flags are clear; `info` is filled. +/// `azihsm_fw_hsm_core::ddi::tbor::policy::from_bytes`. Layout mirrors +/// the canonical wire format defined in +/// `fw/core/ddi/tbor/types/src/policy.rs`: POTA + SATA trust anchors are +/// populated Ecc384 keys; SAPOTA + backing-partition keys are left +/// absent (zero `len`); flags are clear; `info` is filled. pub(crate) fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { const OFF_POTA: usize = 2; const OFF_SATA: usize = 102; @@ -91,13 +90,11 @@ pub(crate) fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { } /// Like [`known_good_part_policy`] but with a caller-supplied **real** -/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can -/// validate a PTA certificate chain anchored to it. +/// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can validate +/// a PTA certificate chain anchored to it. /// -/// Emu-gated because every current consumer (`part_final`, -/// `sd_sealing_key_gen`, `sd_create_remote_backup`) is an emu-only -/// module. If a hw-eligible test ever needs the POTA-anchored variant, -/// drop the gate. +/// Emu-only: the only consumers (`part_final`, `sd_sealing_key_gen`, +/// `sd_create_remote_backup`) are gated behind `feature = "emu"`. #[cfg(feature = "emu")] pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { const OFF_POTA: usize = 2; @@ -107,8 +104,6 @@ pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN bytes } -/// Canonical `mach_seed` for `PartInit`: deterministic ramp so failing -/// fixtures are trivially identifiable in a hex dump. pub(crate) fn mach_seed() -> [u8; MACH_SEED_LEN] { let mut v = [0u8; MACH_SEED_LEN]; for (i, b) in v.iter_mut().enumerate() { @@ -117,8 +112,6 @@ pub(crate) fn mach_seed() -> [u8; MACH_SEED_LEN] { v } -/// Canonical `pota_thumbprint` for `PartInit`: deterministic pattern -/// distinct from [`mach_seed`] so a mis-plumbed field is obvious. pub(crate) fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { let mut v = [0u8; POTA_THUMBPRINT_LEN]; for (i, b) in v.iter_mut().enumerate() { @@ -127,14 +120,6 @@ pub(crate) fn pota_thumbprint() -> [u8; POTA_THUMBPRINT_LEN] { v } -/// Non-default 32-byte CO PSK used so PartInit clears the -/// default-PSK-gate. Pinned to a fixed value so the smoke test is -/// fully deterministic. -pub(crate) const ROTATED_CO_PSK: [u8; PSK_LEN] = [ - 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, - 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, -]; - pub(super) fn sata_thumbprint() -> [u8; SATA_THUMBPRINT_LEN] { let mut v = [0u8; SATA_THUMBPRINT_LEN]; for (i, b) in v.iter_mut().enumerate() { From 48287f50e27ef9cea57dd035045f0fa769469109 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 00:20:38 -0700 Subject: [PATCH 19/27] Bring in more tests --- ddi/tbor/types/tests/commands/open_session.rs | 50 +++++++++---------- .../tests/commands/part_init/happy_path.rs | 41 +++++++++++++++ .../types/tests/commands/part_init/mod.rs | 11 ++++ 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 64bb96863..d80825f09 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -20,7 +20,6 @@ use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; use crate::harness::open_dev_with_path; -use crate::harness::open_session_on_dev; #[cfg(not(feature = "emu"))] use crate::harness::session_close_on_dev; #[cfg(not(feature = "emu"))] @@ -311,30 +310,31 @@ fn open_session_second_on_same_fd_rejected() { // // The table size differs by backend (emu FW has a small hardcoded // table; hw silicon a larger one), so the loop is bounded generously -// at `MULTI_THREADED_TOTAL` and we assert a rejection was observed +// at `SESSION_STRESS_TOTAL` and we assert a rejection was observed // within that ceiling. // --------------------------------------------------------------------------- -const MULTI_THREADED_TOTAL: usize = 12; +const SESSION_STRESS_TOTAL: usize = 12; #[test] fn open_session_fills_table_then_recovers() { let ctx = TestCtx::new(); - // Each concurrent session lives on its own extra `Dev` bound to - // the same underlying device — required on hw where - // `AZIHSM_MAX_SESSIONS_PER_FD = 1`. The Dev handles are kept - // alive in the vec so their fds stay open while we probe capacity; - // dropping the vec later closes every fd in one shot, which the - // kernel driver must translate into per-session slot reclaim on - // the FW side (that reclaim path is exactly what the recovery - // assertion below exercises). + // Each concurrent session lives on its own extra `TestCtx` bound + // to the same underlying device via `TestCtx::new_with_path` — + // required on hw where `AZIHSM_MAX_SESSIONS_PER_FD = 1`. The + // secondary `TestCtx`s are kept alive in the vec so their fds + // stay open while we probe capacity; dropping the vec later + // closes every fd in one shot, which the kernel driver must + // translate into per-session slot reclaim on the FW side (that + // reclaim path is exactly what the recovery assertion below + // exercises). let mut open_slots = Vec::new(); let mut rejection_seen = false; - for _ in 0..MULTI_THREADED_TOTAL { - let dev = open_dev_with_path(ctx.path()); - match open_session_on_dev(&dev, CU, SessionType::PlainText) { - Ok(h) => open_slots.push((dev, h.session_id)), + for _ in 0..SESSION_STRESS_TOTAL { + let ctx_extra = TestCtx::new_with_path(ctx.path()); + match ctx_extra.open_session_raw(CU, SessionType::PlainText) { + Ok(h) => open_slots.push((ctx_extra, h.session_id)), Err(e) => { assert!( matches!(e, azihsm_ddi_interface::DdiError::TborStatus(_)), @@ -656,8 +656,8 @@ fn open_session_multi_threaded_all_should_open() { // Each entry holds the owning Dev + the session id so we can // close on the correct fd once the race resolves. let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); - for _ in 0..MULTI_THREADED_TOTAL { + let mut handles = Vec::with_capacity(SESSION_STRESS_TOTAL); + for _ in 0..SESSION_STRESS_TOTAL { handles.push( s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { let dev = open_dev_with_path(path); @@ -707,7 +707,7 @@ fn open_session_multi_threaded_all_should_open() { } if rejections.is_empty() { eprintln!( - "open_session_multi_threaded_all_should_open: FW accepted all {MULTI_THREADED_TOTAL} \ + "open_session_multi_threaded_all_should_open: FW accepted all {SESSION_STRESS_TOTAL} \ concurrent sessions without emitting any table-full rejection; race between success \ and rejection branches not exercised at this thread count", ); @@ -731,7 +731,7 @@ fn open_session_multi_threaded_single_winner() { // fills_table_then_recovers so we don't loop forever on a // pathological build. Each filler session lives on its own Dev. let mut fillers = Vec::new(); - for _ in 0..MULTI_THREADED_TOTAL { + for _ in 0..SESSION_STRESS_TOTAL { let dev = open_dev_with_path(path); match session_open_init_on_dev(&dev, CU, SessionType::PlainText) .and_then(|pending| session_open_finish_on_dev(&dev, pending)) @@ -742,13 +742,13 @@ fn open_session_multi_threaded_single_winner() { } // Capacity exceeds ceiling: cannot set up a single-slot race — clean up + skip. - if fillers.len() >= MULTI_THREADED_TOTAL { + if fillers.len() >= SESSION_STRESS_TOTAL { for (dev, sid) in &fillers { let _ = session_close_on_dev(dev, *sid); } eprintln!( "open_session_multi_threaded_single_winner: FW capacity exceeds probe ceiling of \ - {MULTI_THREADED_TOTAL}; skipping single-slot race", + {SESSION_STRESS_TOTAL}; skipping single-slot race", ); return; } @@ -758,10 +758,10 @@ fn open_session_multi_threaded_single_winner() { session_close_on_dev(&freed_dev, freed_id).expect("close of freed filler slot must succeed"); drop(freed_dev); - // Phase 3: race MULTI_THREADED_TOTAL threads for the one slot. + // Phase 3: race SESSION_STRESS_TOTAL threads for the one slot. let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(MULTI_THREADED_TOTAL); - for _ in 0..MULTI_THREADED_TOTAL { + let mut handles = Vec::with_capacity(SESSION_STRESS_TOTAL); + for _ in 0..SESSION_STRESS_TOTAL { handles.push( s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { let dev = open_dev_with_path(path); @@ -798,7 +798,7 @@ fn open_session_multi_threaded_single_winner() { ); assert_eq!( rejection_count, - MULTI_THREADED_TOTAL - 1, + SESSION_STRESS_TOTAL - 1, "all non-winning racers must fail cleanly", ); for err in &rejections { diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index 67a8916ee..9cf6d05af 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -12,6 +12,7 @@ //! `ctx.erase()`), the derived PTA pubkey is byte-identical given //! the same `(UDS, MachineSeed, Policy, POTA thumb)` inputs. +use azihsm_ddi_tbor_types::PolicyFlags; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::MACH_SEED_LEN; @@ -24,6 +25,7 @@ use super::bootstrap_rotated_co; use super::known_good_part_policy; use super::mach_seed; use super::open_co_with; +use super::part_policy_with_flags; use super::pota_thumbprint; use super::CO; use super::ROTATED_CO_PSK; @@ -312,3 +314,42 @@ fn part_init_determinism() { same (UDS, MachineSeed, Policy, POTA thumb) inputs", ); } + +/// Toggling `PolicyFlags::INCLUDE_FMC_CDI` must change the PTA +/// pubkey. Two runs with byte-identical `(UDS, MachineSeed, POTA +/// thumbprint)` are performed against the same `TestCtx` (with an +/// `erase()` between them to reset partition state), differing only +/// in the `flags` byte of the `PartPolicy` blob. Because the PTA +/// keypair is derived from `KBKDF(PartRoot, ctx)` and +/// `include_fmc_cdi` gates whether `fmc_cdi` is mixed into that +/// context — and, independently, the flag byte is part of the +/// `PartPolicy` blob whose hash is bound into the derivation — the +/// two PTA pubkeys must not collide. A byte-identical result would +/// mean either the flag is a stealth no-op in the KBKDF chain or +/// the `PartPolicy` hash isn't being bound at all. +#[test] +fn part_init_pta_pub_differs_when_include_fmc_cdi_flag_toggled() { + let ctx = TestCtx::new(); + ctx.erase().expect("erase before run 1"); + + let seed = mach_seed(); + let thumb = pota_thumbprint(); + + let default_policy = part_policy_with_flags(0); + let fmc_cdi_policy = part_policy_with_flags(PolicyFlags::INCLUDE_FMC_CDI); + + let pta_pub_default = run_part_init_capture_pta_pub(&ctx, &seed, &default_policy, &thumb); + + // Cold restart between runs so PartInit derives fresh PTA key + // material against the second policy under an otherwise + // pristine partition (same deterministic UDS). + ctx.erase().expect("erase between runs"); + + let pta_pub_fmc_cdi = run_part_init_capture_pta_pub(&ctx, &seed, &fmc_cdi_policy, &thumb); + + assert_ne!( + pta_pub_default, pta_pub_fmc_cdi, + "PTA pubkey must differ when `PolicyFlags::INCLUDE_FMC_CDI` \ + is the only input that changes between the two runs", + ); +} diff --git a/ddi/tbor/types/tests/commands/part_init/mod.rs b/ddi/tbor/types/tests/commands/part_init/mod.rs index 80bef0816..5e4c8527c 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -104,6 +104,17 @@ pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN bytes } +/// Like [`known_good_part_policy`] but with a caller-supplied `flags` +/// byte, so tests can toggle individual `PolicyFlags` bits (e.g. +/// `PolicyFlags::INCLUDE_FMC_CDI`) while keeping every other field +/// byte-identical to the canonical fixture. +pub(crate) fn part_policy_with_flags(flags: u8) -> [u8; PART_POLICY_LEN] { + const OFF_FLAGS: usize = 418; + let mut bytes = known_good_part_policy(); + bytes[OFF_FLAGS] = flags; + bytes +} + pub(crate) fn mach_seed() -> [u8; MACH_SEED_LEN] { let mut v = [0u8; MACH_SEED_LEN]; for (i, b) in v.iter_mut().enumerate() { From c97b7593b9c867a09fd678dc434fd13ec3e49e15 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 13:44:52 -0700 Subject: [PATCH 20/27] Resolve comments --- ddi/tbor/types/Cargo.toml | 2 +- ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs | 16 +- ddi/tbor/types/tests/commands/open_session.rs | 369 +++++++++--------- .../tests/commands/part_init/happy_path.rs | 11 +- .../types/tests/commands/part_init/mod.rs | 6 +- ddi/tbor/types/tests/commands/psk_change.rs | 10 +- ddi/tbor/types/tests/harness/ctx.rs | 23 +- ddi/tbor/types/tests/harness/fixture.rs | 24 +- ddi/tbor/types/tests/harness/mod.rs | 17 +- ddi/tbor/types/tests/harness/session_guard.rs | 13 +- 10 files changed, 242 insertions(+), 249 deletions(-) diff --git a/ddi/tbor/types/Cargo.toml b/ddi/tbor/types/Cargo.toml index f647e4391..660664bec 100644 --- a/ddi/tbor/types/Cargo.toml +++ b/ddi/tbor/types/Cargo.toml @@ -22,7 +22,7 @@ azihsm_ddi_mbor_test_helpers.workspace = true azihsm_ddi_mbor_types.workspace = true azihsm_session_ex_crypto.workspace = true minicbor = { features = ["derive"], workspace = true } -parking_lot.workspace = true +parking_lot = { features = ["send_guard"], workspace = true } x509.workspace = true [lib] diff --git a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs index 3f97b6051..ac332a473 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -4,18 +4,18 @@ //! Integration test binary for `azihsm_ddi_tbor_types`. //! //! Backend selection is feature-gated; the same tests run across every -//! transport. Run with `--features emu` (in-process firmware) or with +//! transport. Run with `--features emu` (in-process firmware), +//! `--features sock` (firmware behind a socket server), or with //! **no** feature (native OS backend — `nix` on Linux / `win` on //! Windows — for on-silicon test runs). //! -//! `mock` and `sock` disable the whole harness + `commands` tree: -//! mock rejects TBOR at the transport layer, and sock's `erase` is a -//! stub, so command-level integration tests are meaningless there. -//! Under `mock` or `sock` this binary compiles the crate only and -//! runs zero tests. +//! `mock` disables the whole harness + `commands` tree: mock rejects +//! TBOR at the transport layer, so command-level integration tests +//! are meaningless there. Under `--features mock` this binary +//! compiles the crate only and runs zero tests. -#[cfg(not(any(feature = "mock", feature = "sock")))] +#[cfg(not(feature = "mock"))] pub mod harness; -#[cfg(not(any(feature = "mock", feature = "sock")))] +#[cfg(not(feature = "mock"))] pub mod commands; diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index d80825f09..d19aae852 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -19,13 +19,6 @@ use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; use crate::harness::build_mac_fin; -use crate::harness::open_dev_with_path; -#[cfg(not(feature = "emu"))] -use crate::harness::session_close_on_dev; -#[cfg(not(feature = "emu"))] -use crate::harness::session_open_finish_on_dev; -#[cfg(not(feature = "emu"))] -use crate::harness::session_open_init_on_dev; use crate::harness::TestCtx; const CO: u8 = 0; @@ -302,94 +295,135 @@ fn open_session_second_on_same_fd_rejected() { } // --------------------------------------------------------------------------- -// Session-table exhaustion + recovery +// FW session-table capacity // -// Iteratively opens sessions until the FW reports the table is full, -// then drops every extra fd and confirms one more open succeeds — the -// pending/active slot cleanup path must fully reclaim capacity. +// FW allocates a single 8-slot session table: slot 0 is reserved for +// CryptoOfficer (only 1 concurrent CO session), slots 1..=7 for +// CryptoUser (`CU_SESSION_LIMIT = 7` concurrent CU sessions). Limits +// are identical on emu (fw/plat/std) and hw (fw/plat/uno) — see +// `MAX_SESSIONS = 8` in +// `fw/plat/uno/fw/drivers/session_store/src/session_store.rs` and +// `fw/plat/std/pal/src/drivers/session.rs`. +// --------------------------------------------------------------------------- + +const CU_SESSION_LIMIT: usize = 7; + +// --------------------------------------------------------------------------- +// CU session-table exhaustion + recovery // -// The table size differs by backend (emu FW has a small hardcoded -// table; hw silicon a larger one), so the loop is bounded generously -// at `SESSION_STRESS_TOTAL` and we assert a rejection was observed -// within that ceiling. +// Opens exactly `CU_SESSION_LIMIT` CU sessions (all must succeed), then +// probes one more (must be rejected with `TborStatus`), then drops every +// extra fd and confirms one fresh open on the primary ctx succeeds — +// the pending/active slot cleanup path must fully reclaim capacity. +// Relying on fd-drop (rather than an explicit close loop) for the +// recovery is deliberate: the invariant we care about is +// `fd-drop == slot-reclaim`; if that isn't true, it's a product bug +// worth surfacing here. // --------------------------------------------------------------------------- -const SESSION_STRESS_TOTAL: usize = 12; +#[test] +fn open_session_fills_cu_table_then_recovers() { + let ctx = TestCtx::new(); + // Two-phase construction: allocate all secondary `TestCtx`s + // first (each opens its own fd — required on hw where + // `AZIHSM_MAX_SESSIONS_PER_FD = 1`), then borrow them to open + // sessions and stash the `SessionGuard`s. The guards borrow + // their ctx by reference, so ctxs must live at least as long as + // guards; declaring ctxs first ensures rustc drops `guards` + // before `ctxs`. + let ctxs: Vec<_> = (0..CU_SESSION_LIMIT) + .map(|_| TestCtx::new_with_path(ctx.path())) + .collect(); + let guards: Vec<_> = ctxs + .iter() + .enumerate() + .map(|(i, c)| { + c.open_session(CU, SessionType::PlainText) + .unwrap_or_else(|e| { + panic!("CU session {i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") + }) + }) + .collect(); + + // One more CU open must be rejected — table is full. + let overflow_ctx = TestCtx::new_with_path(ctx.path()); + let err = overflow_ctx + .open_session(CU, SessionType::PlainText) + .expect_err("open_session past CU limit must be rejected"); + assert!( + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), + "expected FW-side TborStatus rejection, got {err:?}", + ); + drop(overflow_ctx); + + // Recovery: drop every guard (each guard's `Drop` sends an + // explicit SessionClose), then drop the extra fds. A fresh open + // on the primary ctx must succeed. + drop(guards); + drop(ctxs); + + let _recovered = ctx + .open_session(CU, SessionType::PlainText) + .expect("session table must recover after all extra sessions are closed"); + // `_recovered` closes on drop at end of scope. +} + +// --------------------------------------------------------------------------- +// CO session-slot exhaustion + recovery +// +// CO has a single slot. Opening the first CO session must succeed, +// a second concurrent CO open must be rejected, and dropping the +// first fd must restore capacity. +// --------------------------------------------------------------------------- #[test] -fn open_session_fills_table_then_recovers() { +fn open_session_fills_co_slot_then_recovers() { let ctx = TestCtx::new(); - // Each concurrent session lives on its own extra `TestCtx` bound - // to the same underlying device via `TestCtx::new_with_path` — - // required on hw where `AZIHSM_MAX_SESSIONS_PER_FD = 1`. The - // secondary `TestCtx`s are kept alive in the vec so their fds - // stay open while we probe capacity; dropping the vec later - // closes every fd in one shot, which the kernel driver must - // translate into per-session slot reclaim on the FW side (that - // reclaim path is exactly what the recovery assertion below - // exercises). - let mut open_slots = Vec::new(); - let mut rejection_seen = false; - - for _ in 0..SESSION_STRESS_TOTAL { - let ctx_extra = TestCtx::new_with_path(ctx.path()); - match ctx_extra.open_session_raw(CU, SessionType::PlainText) { - Ok(h) => open_slots.push((ctx_extra, h.session_id)), - Err(e) => { - assert!( - matches!(e, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection, got {e:?}", - ); - rejection_seen = true; - break; - } - } - } - let slot_count = open_slots.len(); + let ctx_first = TestCtx::new_with_path(ctx.path()); + let first = ctx_first + .open_session(CO, SessionType::Authenticated) + .expect("first CO session must succeed"); + + let ctx_second = TestCtx::new_with_path(ctx.path()); + let err = ctx_second + .open_session(CO, SessionType::Authenticated) + .expect_err("second concurrent CO open must be rejected"); assert!( - rejection_seen, - "capacity limit not observed within {slot_count} probes — either the backend supports \ - more concurrent CU sessions than the probe ceiling (bump the loop bound), or the \ - session table exhaustion path regressed", + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), + "expected FW-side TborStatus rejection, got {err:?}", ); + drop(ctx_second); - // Recovery: drop every extra fd in one shot so the driver reclaims - // their FW-side session slots, then one fresh open on the primary - // ctx dev must succeed. Relying on fd-drop (rather than an - // explicit close loop) is deliberate — the invariant we care - // about is that fd-drop == slot-reclaim; if that isn't true, it's - // a product bug worth surfacing. - drop(open_slots); - - let recovered = ctx - .open_session_raw(CU, SessionType::PlainText) - .expect("session table must recover after all extra fds are dropped"); - ctx.session_close(recovered.session_id) - .expect("SessionClose after recovery must succeed"); + // Recovery: drop the first guard + fd; a fresh CO open must succeed. + drop(first); + drop(ctx_first); + + let _recovered = ctx + .open_session(CO, SessionType::Authenticated) + .expect("CO slot must recover after the first fd is dropped"); + // `_recovered` closes on drop at end of scope. } // --------------------------------------------------------------------------- -// Open / close / reopen +// Open, close, open new session // --------------------------------------------------------------------------- -/// Open a session, close it, then open again. The second open must -/// succeed — guards against a stale slot or leaked FSM state that -/// would otherwise wedge the second attempt. +/// Open a session, close it, then open a new one. The second open +/// must succeed — guards against a stale slot or leaked FSM state +/// that would otherwise wedge the second attempt. #[test] -fn open_close_reopen_same_slot() { +fn open_close_then_open_new_session() { let ctx = TestCtx::new(); let first = ctx - .open_session_raw(CU, SessionType::PlainText) + .open_session(CU, SessionType::PlainText) .expect("first handshake must succeed"); - ctx.session_close(first.session_id) - .expect("first SessionClose must succeed"); + first.close().expect("first SessionClose must succeed"); let second = ctx - .open_session_raw(CU, SessionType::PlainText) - .expect("reopen after close must succeed"); - ctx.session_close(second.session_id) - .expect("second SessionClose must succeed"); + .open_session(CU, SessionType::PlainText) + .expect("open new session after close must succeed"); + second.close().expect("second SessionClose must succeed"); } // --------------------------------------------------------------------------- @@ -407,25 +441,21 @@ fn co_authenticated_derives_unique_keys_per_session() { let ctx = TestCtx::new(); let a = ctx - .open_session_raw(CO, SessionType::Authenticated) + .open_session(CO, SessionType::Authenticated) .expect("first CO+Authenticated handshake must succeed"); - let a_id = a.session_id; - let a_tx = a.derive_mac_tx_key().expect("derive a tx"); - let a_rx = a.derive_mac_rx_key().expect("derive a rx"); - let a_exported = a.exported.clone(); - ctx.session_close(a_id) - .expect("close first session must succeed"); + let a_tx = a.handshake().derive_mac_tx_key().expect("derive a tx"); + let a_rx = a.handshake().derive_mac_rx_key().expect("derive a rx"); + let a_exported = a.handshake().exported.clone(); + a.close().expect("close first session must succeed"); let b = ctx - .open_session_raw(CO, SessionType::Authenticated) + .open_session(CO, SessionType::Authenticated) .expect("second CO+Authenticated handshake must succeed"); - let b_id = b.session_id; - let b_tx = b.derive_mac_tx_key().expect("derive b tx"); - let b_rx = b.derive_mac_rx_key().expect("derive b rx"); - let b_exported = b.exported.clone(); + let b_tx = b.handshake().derive_mac_tx_key().expect("derive b tx"); + let b_rx = b.handshake().derive_mac_rx_key().expect("derive b rx"); + let b_exported = b.handshake().exported.clone(); // Close before asserting so a failing assertion never leaks. - ctx.session_close(b_id) - .expect("close second session must succeed"); + b.close().expect("close second session must succeed"); assert_ne!( a_exported, b_exported, @@ -640,32 +670,29 @@ fn pk_init_single_byte_tampered_rejected() { // The property under test only holds on the native OS backend. // --------------------------------------------------------------------------- -/// Race N concurrent opens; winners must have distinct session ids, -/// and any losers must surface as clean FW/driver rejections. Each -/// racing thread owns its own [`open_dev_with_path`] fd — hw requires -/// one fd per concurrent session (`AZIHSM_MAX_SESSIONS_PER_FD = 1`), -/// so `TestCtx` itself is not touched by the racers. +/// Race exactly `CU_SESSION_LIMIT` concurrent CU opens; all must +/// succeed with distinct session ids and no rejections. Each racing +/// thread owns its own [`TestCtx`] fd — hw requires one fd per +/// concurrent session (`AZIHSM_MAX_SESSIONS_PER_FD = 1`), so the +/// primary `ctx` itself is not touched by the racers. #[cfg(not(feature = "emu"))] #[test] fn open_session_multi_threaded_all_should_open() { let ctx = TestCtx::new(); - let path: &str = ctx.path(); - - type Dev = ::Dev; + let path = ctx.path(); - // Each entry holds the owning Dev + the session id so we can + // Each entry holds the owning ctx + the session id so we can // close on the correct fd once the race resolves. let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(SESSION_STRESS_TOTAL); - for _ in 0..SESSION_STRESS_TOTAL { - handles.push( - s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { - let dev = open_dev_with_path(path); - let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; - let handshake = session_open_finish_on_dev(&dev, pending)?; - Ok((dev, handshake.session_id)) - }), - ); + let mut handles = Vec::with_capacity(CU_SESSION_LIMIT); + for _ in 0..CU_SESSION_LIMIT { + handles.push(s.spawn( + || -> Result<(TestCtx, u16), azihsm_ddi_interface::DdiError> { + let ctx_extra = TestCtx::new_with_path(path); + let handshake = ctx_extra.open_session_raw(CU, SessionType::PlainText)?; + Ok((ctx_extra, handshake.session_id)) + }, + )); } let mut winners = Vec::new(); let mut rejections = Vec::new(); @@ -678,98 +705,76 @@ fn open_session_multi_threaded_all_should_open() { (winners, rejections) }); - let mut sorted_ids: Vec = winners.iter().map(|(_, sid)| *sid).collect(); + let mut sorted_ids: Vec<_> = winners.iter().map(|(_, sid)| *sid).collect(); let winner_ids = sorted_ids.clone(); sorted_ids.sort_unstable(); sorted_ids.dedup(); let unique_wins = sorted_ids.len(); - // Close winners on their owning fds before asserting so a failing - // assert never leaves the session table dirty. - for (dev, sid) in &winners { - let _ = session_close_on_dev(dev, *sid); - } + // Drop winners' ctxs before asserting so a failing assert never + // leaves the session table dirty — each ctx's fd close reclaims + // its slot in FW. + drop(winners); assert!( - !winner_ids.is_empty(), - "at least one concurrent open_session must succeed; rejections = {rejections:?}", + rejections.is_empty(), + "all {CU_SESSION_LIMIT} concurrent CU opens must succeed; observed rejections: \ + {rejections:?}", ); assert_eq!( - unique_wins, winner_ids.len(), + CU_SESSION_LIMIT, + "expected {CU_SESSION_LIMIT} winning session ids, got {winner_ids:?}", + ); + assert_eq!( + unique_wins, CU_SESSION_LIMIT, "concurrent winners must have distinct session ids: {winner_ids:?}", ); - for err in &rejections { - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "concurrent open_session rejections must be FW/driver rejections, got {err:?}", - ); - } - if rejections.is_empty() { - eprintln!( - "open_session_multi_threaded_all_should_open: FW accepted all {SESSION_STRESS_TOTAL} \ - concurrent sessions without emitting any table-full rejection; race between success \ - and rejection branches not exercised at this thread count", - ); - } } -/// Fill to one free slot then race N threads for it. Regression for -/// FW's undo-on-loser path: every losing racer must see a clean -/// rejection and leave the session table intact for retry. Each -/// filler + racer holds its own `open_dev_with_path` fd — hw requires -/// one fd per concurrent session. +/// Fill CU capacity to one free slot then race `CU_SESSION_LIMIT` +/// threads for it. Regression for FW's undo-on-loser path: every +/// losing racer must see a clean rejection and leave the session +/// table intact for retry. Each filler + racer holds its own +/// [`TestCtx`] fd — hw requires one fd per concurrent session. #[cfg(not(feature = "emu"))] #[test] fn open_session_multi_threaded_single_winner() { let ctx = TestCtx::new(); - let path: &str = ctx.path(); - - type Dev = ::Dev; - - // Phase 1: probe capacity sequentially; ceiling matches - // fills_table_then_recovers so we don't loop forever on a - // pathological build. Each filler session lives on its own Dev. - let mut fillers = Vec::new(); - for _ in 0..SESSION_STRESS_TOTAL { - let dev = open_dev_with_path(path); - match session_open_init_on_dev(&dev, CU, SessionType::PlainText) - .and_then(|pending| session_open_finish_on_dev(&dev, pending)) - { - Ok(handshake) => fillers.push((dev, handshake.session_id)), - Err(_) => break, // `dev` drops here — fd released cleanly. - } + let path = ctx.path(); + + // Phase 1: fill CU capacity sequentially. Each filler owns its + // own ctx because hw enforces one session per fd. + let mut fillers: Vec<(TestCtx, u16)> = Vec::new(); + for i in 0..CU_SESSION_LIMIT { + let ctx_extra = TestCtx::new_with_path(path); + let handshake = ctx_extra + .open_session_raw(CU, SessionType::PlainText) + .unwrap_or_else(|e| { + panic!("filler CU session {i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") + }); + fillers.push((ctx_extra, handshake.session_id)); } - // Capacity exceeds ceiling: cannot set up a single-slot race — clean up + skip. - if fillers.len() >= SESSION_STRESS_TOTAL { - for (dev, sid) in &fillers { - let _ = session_close_on_dev(dev, *sid); - } - eprintln!( - "open_session_multi_threaded_single_winner: FW capacity exceeds probe ceiling of \ - {SESSION_STRESS_TOTAL}; skipping single-slot race", - ); - return; - } + // Phase 2: free exactly one slot (close and drop the tail filler's ctx). + let (freed_ctx, freed_id) = fillers.pop().expect("at least one filler must exist"); + freed_ctx + .session_close(freed_id) + .expect("close of freed filler slot must succeed"); + drop(freed_ctx); - // Phase 2: free exactly one slot (close and drop the tail filler's Dev). - let (freed_dev, freed_id) = fillers.pop().expect("at least one filler must exist"); - session_close_on_dev(&freed_dev, freed_id).expect("close of freed filler slot must succeed"); - drop(freed_dev); - - // Phase 3: race SESSION_STRESS_TOTAL threads for the one slot. + // Phase 3: race `CU_SESSION_LIMIT` threads for the one free slot. + let racer_count = CU_SESSION_LIMIT; let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(SESSION_STRESS_TOTAL); - for _ in 0..SESSION_STRESS_TOTAL { - handles.push( - s.spawn(|| -> Result<(Dev, u16), azihsm_ddi_interface::DdiError> { - let dev = open_dev_with_path(path); - let pending = session_open_init_on_dev(&dev, CU, SessionType::PlainText)?; - let handshake = session_open_finish_on_dev(&dev, pending)?; - Ok((dev, handshake.session_id)) - }), - ); + let mut handles = Vec::with_capacity(racer_count); + for _ in 0..racer_count { + handles.push(s.spawn( + || -> Result<(TestCtx, u16), azihsm_ddi_interface::DdiError> { + let ctx_extra = TestCtx::new_with_path(path); + let handshake = ctx_extra.open_session_raw(CU, SessionType::PlainText)?; + Ok((ctx_extra, handshake.session_id)) + }, + )); } let mut winners = Vec::new(); let mut rejections = Vec::new(); @@ -784,12 +789,10 @@ fn open_session_multi_threaded_single_winner() { let winner_count = winners.len(); let rejection_count = rejections.len(); - for (dev, sid) in &winners { - let _ = session_close_on_dev(dev, *sid); - } - for (dev, sid) in &fillers { - let _ = session_close_on_dev(dev, *sid); - } + // Drop everything before asserting — each ctx's fd close reclaims + // its slot in FW. + drop(winners); + drop(fillers); assert_eq!( winner_count, 1, @@ -798,7 +801,7 @@ fn open_session_multi_threaded_single_winner() { ); assert_eq!( rejection_count, - SESSION_STRESS_TOTAL - 1, + racer_count - 1, "all non-winning racers must fail cleanly", ); for err in &rejections { diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index 9cf6d05af..cb1a7b0d9 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -108,7 +108,6 @@ fn part_init_smoke_roundtrip() { // Alias CA in the std PAL emu cert store). Cross-binds the // report by also asserting its embedded COSE_Key `pk_x`/`pk_y` // matches the PTA pubkey we just extracted from the CSR. - #[cfg(feature = "emu")] verify_pta_report(&ctx, &resp.pta_report, &pta_spki); // 2. Second PartInit on a freshly-opened session must be rejected @@ -127,9 +126,6 @@ fn part_init_smoke_roundtrip() { /// embedded COSE_Key payload to the PTA pubkey carried in /// `pta_spki_der`. /// -/// Emu-only: pulls in `azihsm_ddi_mbor_sim`'s attestation/COSE -/// verifier stack which is not linked into the hw build. -/// /// Steps: /// /// 1. Fetch the partition's slot-0 cert chain via the existing MBOR @@ -150,7 +146,6 @@ fn part_init_smoke_roundtrip() { /// the CSR's SubjectPublicKeyInfo — proving the report /// actually attests the same key the CSR is requesting a cert /// for. -#[cfg(feature = "emu")] fn verify_pta_report(ctx: &TestCtx, pta_report: &[u8], pta_spki_der: &[u8]) { use azihsm_crypto::DerEccPublicKey; use azihsm_ddi_mbor_sim::attestation::KeyAttester; @@ -252,11 +247,11 @@ fn run_part_init_capture_pta_pub( use x509::X509CsrOp; let bootstrap = ctx - .open_session_raw(CO, SessionType::Authenticated) + .open_session(CO, SessionType::Authenticated) .expect("open CO default"); - ctx.psk_change(&bootstrap, &ROTATED_CO_PSK) + ctx.psk_change(bootstrap.handshake(), &ROTATED_CO_PSK) .expect("rotate CO PSK"); - let _ = ctx.session_close(bootstrap.session_id); + let _ = bootstrap.close(); let session = open_co_with(ctx, &ROTATED_CO_PSK); let resp = ctx diff --git a/ddi/tbor/types/tests/commands/part_init/mod.rs b/ddi/tbor/types/tests/commands/part_init/mod.rs index 5e4c8527c..f5264f495 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -93,8 +93,10 @@ pub(crate) fn known_good_part_policy() -> [u8; PART_POLICY_LEN] { /// `POTAPubKey` (raw P-384 `X ‖ Y`, 96 bytes), so `PartFinal` can validate /// a PTA certificate chain anchored to it. /// -/// Emu-only: the only consumers (`part_final`, `sd_sealing_key_gen`, -/// `sd_create_remote_backup`) are gated behind `feature = "emu"`. +/// TODO(hw): gated to `feature = "emu"` because the only consumers +/// (`part_final`, `sd_sealing_key_gen`, `sd_create_remote_backup`) +/// are not yet supported on hw. Remove this gate once those commands +/// land on hw. #[cfg(feature = "emu")] pub(crate) fn part_policy_with_pota(pota_raw: &[u8; 96]) -> [u8; PART_POLICY_LEN] { const OFF_POTA: usize = 2; diff --git a/ddi/tbor/types/tests/commands/psk_change.rs b/ddi/tbor/types/tests/commands/psk_change.rs index 87c942c32..bd37a068e 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -134,7 +134,7 @@ fn psk_change_reopen_with_old_psk_fails() { // `exported` diverges from FW's, so Phase-1 MAC verification // (or HPKE auth) fails. Either a host-side or FW-side // rejection is acceptable; we only need "must err". - let result = ctx.open_session_raw(CU, SessionType::PlainText); + let result = ctx.open_session(CU, SessionType::PlainText); assert!( result.is_err(), "reopen with old default PSK must fail after rotation", @@ -256,10 +256,10 @@ fn psk_change_wrong_session_id_in_aad() { // // Encrypt under session A's param_key but ship the request through // session B (with B's id in both header and AAD). FW verifies with -// B's key and the tag fails. Session B is opened on a **second Dev** -// (via `open_dev_with_path(ctx.path())`) so on hw the crafted request -// lands on B's fd — `AZIHSM_MAX_SESSIONS_PER_FD = 1` requires the -// second concurrent session to live on its own fd. +// B's key and the tag fails. Session B is opened on a **second ctx** +// (via `TestCtx::new_with_path(ctx.path())`) so on hw the crafted +// request lands on B's fd — `AZIHSM_MAX_SESSIONS_PER_FD = 1` requires +// the second concurrent session to live on its own fd. // =========================================================================== #[test] diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 9aa866b7b..b190dc763 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -81,15 +81,19 @@ impl TestCtx { } /// Open a secondary `TestCtx` on the same backend `path` as the - /// primary `TestCtx` already alive in this test — see - /// [`open_dev_secondary`] for the no-lock / no-erase semantics - /// and lifetime constraints. + /// primary `TestCtx` already alive in this test. + /// + /// Does not acquire `TEST_LOCK` — the primary already holds it, + /// and `parking_lot::Mutex` is not reentrant, so a second + /// acquisition on this thread would deadlock. Also skips `erase`: + /// the primary owns the device state. /// /// Multi-fd tests need distinct `Dev` handles (hw enforces /// `AZIHSM_MAX_SESSIONS_PER_FD = 1`); each secondary `TestCtx` /// owns one such handle and otherwise behaves identically to a /// primary — `open_session`, `tbor`, `mbor`, etc. all work the - /// same. + /// same. Caller must ensure the primary `TestCtx` outlives every + /// secondary, otherwise the lock guard drops mid-test. pub fn new_with_path(path: &str) -> Self { Self { dev: open_dev_secondary(path), @@ -97,8 +101,8 @@ impl TestCtx { } /// The backend path this ctx's [`TestDev`] was opened on. Multi-fd - /// tests thread it into [`crate::harness::open_dev_with_path`] so every - /// extra `Dev` binds to the same underlying device as the primary. + /// tests thread it into [`TestCtx::new_with_path`] so every extra + /// ctx binds to the same underlying device as the primary. pub fn path(&self) -> &str { self.dev.path() } @@ -257,9 +261,8 @@ impl TestCtx { /// One-shot happy-path handshake that returns the raw /// [`SessionHandshake`] *without* a `SessionGuard`. Callers are /// responsible for the matching [`Self::session_close`]. Used - /// when the test needs to compare two open sessions opened under - /// a non-default PSK, or to inspect the handshake before closing - /// it explicitly. + /// when the test needs to inspect the handshake before closing + /// it explicitly or move the handshake into a container. pub fn open_session_raw( &self, psk_id: u8, @@ -356,13 +359,11 @@ impl TestCtx { // ------------------------------------------------------------------- /// MBOR `GetCertChainInfo(slot_id=0)`. - #[cfg(feature = "emu")] pub fn cert_chain_info(&self) -> DdiResult { azihsm_ddi_mbor_test_helpers::helper_get_cert_chain_info(&self.dev) } /// MBOR `GetCertificate(slot_id=0, cert_id)`. - #[cfg(feature = "emu")] pub fn get_certificate( &self, cert_id: u8, diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index b888a852d..a0205228c 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -60,8 +60,8 @@ pub struct TestDev { // taking it a second time would deadlock the test. _guard: Option>, /// Backend `DevInfo::path` this handle was opened on. Cached so - /// multi-fd tests can bind extra `Dev`s to the *same* underlying - /// device via [`open_dev_with_path`]. + /// multi-fd tests can bind extra `TestDev`s to the *same* + /// underlying device via [`TestCtx::new_with_path`](crate::harness::TestCtx::new_with_path). path: String, } @@ -118,7 +118,7 @@ pub fn open_dev() -> TestDev { /// /// Caller must ensure the primary [`TestDev`] outlives every /// secondary, otherwise the lock guard drops mid-test. -pub fn open_dev_secondary(path: &str) -> TestDev { +pub(crate) fn open_dev_secondary(path: &str) -> TestDev { let dev = AzihsmDdi::default() .open_dev(path) .expect("open secondary backend device on the same path as the primary TestDev"); @@ -128,21 +128,3 @@ pub fn open_dev_secondary(path: &str) -> TestDev { path: path.to_string(), } } - -/// Open an additional `Dev` bound to the same backend path as an -/// already-open [`TestDev`]. -/// -/// Used by multi-fd tests (e.g. `open_session_multiple_concurrent`) -/// whose semantics require overlapping sessions on distinct fds — on -/// hw the driver enforces `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so the -/// second concurrent session must live on its own handle. No lock is -/// acquired: the primary `TestDev` already holds `TEST_LOCK`, and -/// extras are conceptually part of the same test's device set. -/// -/// Caller must ensure the primary `TestDev` outlives every extra -/// `Dev`, otherwise the lock guard drops mid-test. -pub fn open_dev_with_path(path: &str) -> ::Dev { - AzihsmDdi::default() - .open_dev(path) - .expect("open extra backend device on the same path as the primary TestDev") -} diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index 7cb0536b8..c2e1a980c 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -16,22 +16,23 @@ //! //! # Backend feature regimes //! -//! The test binary supports two active build modes: +//! The test binary supports three active build modes: //! //! * `--features emu` — the canonical configuration; runs the full //! suite against the in-process std-PAL firmware. +//! * `--features sock` — drives the same TBOR round-trips against +//! firmware behind a socket server. //! * **No backend feature** — targets the native OS backend (`nix` on //! Linux / `win` on Windows) so the hw-eligible tests in //! [`crate::commands`] run against real silicon. Destructive //! emu-only tests remain gated `#[cfg(feature = "emu")]` at the //! test-item level. //! -//! `--features mock` and `--features sock` are compilable but disable -//! both this harness and the `commands` tree at the crate root -//! (`tests/azihsm_ddi_tbor_tests.rs`). Under either feature the test -//! binary compiles the crate only and runs zero tests: mock rejects -//! TBOR at the transport layer, and sock's `erase` is a stub — neither -//! can drive command-level integration tests. +//! `--features mock` is compilable but disables both this harness +//! and the `commands` tree at the crate root +//! (`tests/azihsm_ddi_tbor_tests.rs`) — mock rejects TBOR at the +//! transport layer, so command-level integration tests are +//! meaningless there. pub mod api_rev; pub mod assertions; @@ -55,8 +56,6 @@ pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; pub use ctx::TestCtx; pub use fixture::open_dev; -pub use fixture::open_dev_secondary; -pub use fixture::open_dev_with_path; pub use session::build_mac_fin; pub use session::build_part_init_mach_seed_aad; pub use session::encrypt_mach_seed_envelope; diff --git a/ddi/tbor/types/tests/harness/session_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index 84fe1fb35..5cd0fe023 100644 --- a/ddi/tbor/types/tests/harness/session_guard.rs +++ b/ddi/tbor/types/tests/harness/session_guard.rs @@ -4,7 +4,7 @@ //! RAII guard for a live TBOR session. //! //! A [`SessionGuard`] owns the handshake carrier produced by -//! [`TestCtx::open_session_raw`](crate::harness::TestCtx::open_session_raw) +//! [`TestCtx::open_session`](crate::harness::TestCtx::open_session) //! and closes the session when dropped — including when the test is //! unwinding from a failed assertion. The emulator's session table //! is process-global and the per-test serialisation provided by @@ -22,6 +22,8 @@ //! directly. The guard exists for the well-behaved 90% case, not for //! those intentional misuses. +use core::fmt; + use azihsm_ddi_interface::DdiResult; use azihsm_ddi_tbor_types::SessionType; @@ -73,6 +75,15 @@ impl<'ctx> SessionGuard<'ctx> { } } +impl fmt::Debug for SessionGuard<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SessionGuard") + .field("session_id", &self.handshake.session_id) + .field("closed", &self.closed) + .finish() + } +} + impl Drop for SessionGuard<'_> { fn drop(&mut self) { if self.closed { From 75fc22b173ef5d3c9da613e008fe49585ae6b00b Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 15:22:22 -0700 Subject: [PATCH 21/27] Fix clippy --- ddi/tbor/types/tests/commands/open_session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index d19aae852..091a76fca 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -337,7 +337,7 @@ fn open_session_fills_cu_table_then_recovers() { let guards: Vec<_> = ctxs .iter() .enumerate() - .map(|(i, c)| { + .map(|(_, c)| { c.open_session(CU, SessionType::PlainText) .unwrap_or_else(|e| { panic!("CU session {i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") From 0c7cd04445d05682f9e8d0415b173204d761f654 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 15:40:19 -0700 Subject: [PATCH 22/27] Fix --- ddi/tbor/types/tests/commands/open_session.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 091a76fca..ab0fcedd6 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -337,10 +337,10 @@ fn open_session_fills_cu_table_then_recovers() { let guards: Vec<_> = ctxs .iter() .enumerate() - .map(|(_, c)| { + .map(|(_i, c)| { c.open_session(CU, SessionType::PlainText) .unwrap_or_else(|e| { - panic!("CU session {i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") + panic!("CU session {_i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") }) }) .collect(); From 35b0d57dce5b5ff152166f6ef763bf92d4d79c40 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 16:11:42 -0700 Subject: [PATCH 23/27] Fix the mock tests --- ddi/tbor/types/Cargo.toml | 2 +- ddi/tbor/types/tests/harness/fixture.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ddi/tbor/types/Cargo.toml b/ddi/tbor/types/Cargo.toml index 660664bec..f647e4391 100644 --- a/ddi/tbor/types/Cargo.toml +++ b/ddi/tbor/types/Cargo.toml @@ -22,7 +22,7 @@ azihsm_ddi_mbor_test_helpers.workspace = true azihsm_ddi_mbor_types.workspace = true azihsm_session_ex_crypto.workspace = true minicbor = { features = ["derive"], workspace = true } -parking_lot = { features = ["send_guard"], workspace = true } +parking_lot.workspace = true x509.workspace = true [lib] diff --git a/ddi/tbor/types/tests/harness/fixture.rs b/ddi/tbor/types/tests/harness/fixture.rs index a0205228c..1c992b379 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -72,6 +72,19 @@ impl TestDev { } } +// SAFETY: The only `!Send` field on `TestDev` is +// `_guard: Option>`. The lock protects a +// unit `()` and is never dereferenced — the guard exists purely to +// serialise tests. Secondary `TestDev` handles (returned by +// `open_dev_secondary`) always have `_guard: None`. Sending the +// primary is safe as long as `TestDev` is not moved off the thread +// that acquired the lock until the guard drops; in practice only +// secondaries (with `_guard: None`) are sent into `thread::scope` +// worker threads, and rustc cannot prove that at compile time +// because both variants share the same type. +#[allow(unsafe_code)] +unsafe impl Send for TestDev {} + impl Deref for TestDev { type Target = ::Dev; fn deref(&self) -> &Self::Target { From 26498c0da2ffd74acf4f3aebb0ad09a45caf2af5 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Tue, 28 Jul 2026 16:23:31 -0700 Subject: [PATCH 24/27] Fix clippy --- ddi/tbor/types/tests/commands/open_session.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index ab0fcedd6..57474dffc 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -286,8 +286,7 @@ fn open_session_second_on_same_fd_rejected() { .expect("first open on a fresh fd must succeed"); let err = ctx .open_session(CU, SessionType::PlainText) - .err() - .expect("second open on the same fd must be rejected"); + .expect_err("second open on the same fd must be rejected"); assert!( matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected TborStatus (kernel per-fd limit remapped in exec_op_tbor), got {err:?}", From 3a41e779fd02194e7c2e1d2554912b40b68cef67 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Wed, 29 Jul 2026 22:39:21 -0700 Subject: [PATCH 25/27] Resolve comments --- ddi/nix/src/dev.rs | 11 +- ddi/tbor/types/tests/commands/open_session.rs | 189 ++++-------------- .../tests/commands/part_init/happy_path.rs | 11 +- ddi/tbor/types/tests/harness/ctx.rs | 18 +- ddi/tbor/types/tests/harness/mod.rs | 38 ++-- ddi/tbor/types/tests/harness/session.rs | 72 +++---- .../types/tests/harness/session/finish.rs | 17 +- ddi/tbor/types/tests/harness/session/init.rs | 12 +- .../types/tests/harness/session/open_close.rs | 55 +++++ .../types/tests/harness/session/part_final.rs | 2 +- .../types/tests/harness/session/part_init.rs | 6 +- .../types/tests/harness/session/psk_change.rs | 4 +- .../tests/harness/session/session_close.rs | 31 --- ddi/win/src/dev.rs | 15 +- 14 files changed, 170 insertions(+), 311 deletions(-) create mode 100644 ddi/tbor/types/tests/harness/session/open_close.rs delete mode 100644 ddi/tbor/types/tests/harness/session/session_close.rs diff --git a/ddi/nix/src/dev.rs b/ddi/nix/src/dev.rs index 24fed14cc..e99b32023 100644 --- a/ddi/nix/src/dev.rs +++ b/ddi/nix/src/dev.rs @@ -702,16 +702,15 @@ impl DdiNixDev { /// Same as [`Self::map_ioctl_status`] but for callers executing a /// **TBOR** command. Driver-layer session-scoping rejections - /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — no live - /// session, id mismatch, limit reached) are surfaced as + /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — id mismatch, + /// limit reached) are surfaced as /// `DdiError::TborStatus(TborStatus::FileHandle*)` instead of /// `DdiError::DdiStatus(DdiStatus::FileHandle*)`, so callers see a /// TBOR status for a TBOR command regardless of which layer of /// the stack (driver vs FW) produced the rejection. The /// `FileHandle*` variants exist in both `DdiStatus` and /// `TborStatus` with identical numeric encodings, so this is a - /// pure type-level remap. All non-session errors fall through to - /// the generic mapping. + /// pure type-level remap. fn map_ioctl_status_tbor(&self, ioctl_status: u32) -> Result { match McrCpGenericIoctlErrorKind::try_from(ioctl_status) { Ok(McrCpGenericIoctlErrorKind::SessionLimitReached) => { @@ -720,9 +719,7 @@ impl DdiNixDev { )); } Ok(McrCpGenericIoctlErrorKind::NoExistingSession) => { - return Err(DdiError::TborStatus( - TborStatus::FileHandleNoExistingSession, - )); + return Err(DdiError::TborStatus(TborStatus::SessionNotFound)); } Ok(McrCpGenericIoctlErrorKind::SessionIdMismatch) => { return Err(DdiError::TborStatus( diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 57474dffc..1a7440889 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -18,6 +18,7 @@ use azihsm_ddi_tbor_types::PK_INIT_LEN; use azihsm_ddi_tbor_types::SEED_ENVELOPE_LEN; use azihsm_ddi_tbor_types::SESSION_SUITE_P384_HKDF_SHA384_AES_GCM_256; +use crate::harness::assertions::assert_fw_rejects; use crate::harness::build_mac_fin; use crate::harness::TestCtx; @@ -71,7 +72,7 @@ fn open_session_co_plaintext_rejected() { let err = ctx .session_open_init(CO, SessionType::PlainText) .expect_err("CO + PlainText is not a permitted pairing"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::InvalidSessionType); + assert_fw_rejects(&err, TborStatus::InvalidSessionType); } #[test] @@ -80,7 +81,7 @@ fn open_session_cu_authenticated_rejected() { let err = ctx .session_open_init(CU, SessionType::Authenticated) .expect_err("CU + Authenticated is not a permitted pairing"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::InvalidSessionType); + assert_fw_rejects(&err, TborStatus::InvalidSessionType); } // --------------------------------------------------------------------------- @@ -99,7 +100,7 @@ fn open_session_invalid_psk_id() { let err = ctx .session_open_init(bad, SessionType::PlainText) .expect_err(&format!("psk_id {bad} must be rejected")); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::InvalidPskId); + assert_fw_rejects(&err, TborStatus::InvalidPskId); } } @@ -154,7 +155,7 @@ fn session_open_finish_mac_tampered() { let err = ctx .session_open_finish_with_mac(pending, mac_fin) .expect_err("tampered mac_fin must be rejected by the FW"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::SessionAuthFailure); + assert_fw_rejects(&err, TborStatus::SessionAuthFailure); // FW destroys the pending slot on MAC mismatch. } @@ -175,10 +176,7 @@ fn session_open_finish_unknown_session_id() { let err = ctx .tbor(&req) .expect_err("finish against unknown session_id must fail"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection, got {err:?}", - ); + assert_fw_rejects(&err, TborStatus::SessionNotFound); } #[test] @@ -196,10 +194,7 @@ fn open_session_double_finish() { let err = ctx .tbor(&req) .expect_err("second finish against the same slot must fail"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side rejection, got {err:?}", - ); + assert_fw_rejects(&err, TborStatus::SessionNotPending); } // --------------------------------------------------------------------------- @@ -287,10 +282,7 @@ fn open_session_second_on_same_fd_rejected() { let err = ctx .open_session(CU, SessionType::PlainText) .expect_err("second open on the same fd must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected TborStatus (kernel per-fd limit remapped in exec_op_tbor), got {err:?}", - ); + assert_fw_rejects(&err, TborStatus::FileHandleSessionLimitReached); } // --------------------------------------------------------------------------- @@ -333,7 +325,7 @@ fn open_session_fills_cu_table_then_recovers() { let ctxs: Vec<_> = (0..CU_SESSION_LIMIT) .map(|_| TestCtx::new_with_path(ctx.path())) .collect(); - let guards: Vec<_> = ctxs + let mut guards: Vec<_> = ctxs .iter() .enumerate() .map(|(_i, c)| { @@ -349,21 +341,18 @@ fn open_session_fills_cu_table_then_recovers() { let err = overflow_ctx .open_session(CU, SessionType::PlainText) .expect_err("open_session past CU limit must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side TborStatus rejection, got {err:?}", - ); + assert_fw_rejects(&err, TborStatus::VaultSessionLimitReached); drop(overflow_ctx); - // Recovery: drop every guard (each guard's `Drop` sends an - // explicit SessionClose), then drop the extra fds. A fresh open - // on the primary ctx must succeed. - drop(guards); - drop(ctxs); + // Recovery: drop the last guard only (its `Drop` sends + // SessionClose). That frees exactly one CU slot — enough for a + // fresh open on the primary ctx to succeed. The remaining guards + // stay alive and close at end of scope. + guards.pop().expect("guards must not be empty"); let _recovered = ctx .open_session(CU, SessionType::PlainText) - .expect("session table must recover after all extra sessions are closed"); + .expect("session table must recover after freeing one slot"); // `_recovered` closes on drop at end of scope. } @@ -388,11 +377,7 @@ fn open_session_fills_co_slot_then_recovers() { let err = ctx_second .open_session(CO, SessionType::Authenticated) .expect_err("second concurrent CO open must be rejected"); - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "expected FW-side TborStatus rejection, got {err:?}", - ); - drop(ctx_second); + assert_fw_rejects(&err, TborStatus::VaultSessionLimitReached); // Recovery: drop the first guard + fd; a fresh CO open must succeed. drop(first); @@ -533,7 +518,7 @@ fn pk_init_all_zero_rejected() { let err = ctx .tbor(&req) .expect_err("all-zero pk_init must be rejected"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::InvalidArg); + assert_fw_rejects(&err, TborStatus::InvalidArg); } /// `pk_init` with the SEC1 uncompressed prefix (`0x04`) but garbage @@ -553,7 +538,7 @@ fn pk_init_not_on_curve_rejected() { let err = ctx .tbor(&req) .expect_err("off-curve pk_init must be rejected"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); + assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } // --------------------------------------------------------------------------- @@ -610,7 +595,7 @@ fn pk_init_x_as_prime_rejected() { let err = ctx .tbor(&req) .expect_err("pk_init with X = P-384 prime must be rejected"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); + assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } /// Symmetric to `pk_init_x_as_prime_rejected` — guards against @@ -628,7 +613,7 @@ fn pk_init_y_as_prime_rejected() { let err = ctx .tbor(&req) .expect_err("pk_init with Y = P-384 prime must be rejected"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); + assert_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); } // --------------------------------------------------------------------------- @@ -655,7 +640,7 @@ fn pk_init_single_byte_tampered_rejected() { let err = ctx .tbor(&req) .expect_err("single-bit-tampered pk_init must be rejected"); - crate::harness::assertions::assert_fw_rejects(&err, TborStatus::EccPointValidationFailed); + assert_fw_rejects(&err, TborStatus::EccPointValidationFailed); } // --------------------------------------------------------------------------- @@ -680,41 +665,35 @@ fn open_session_multi_threaded_all_should_open() { let ctx = TestCtx::new(); let path = ctx.path(); - // Each entry holds the owning ctx + the session id so we can - // close on the correct fd once the race resolves. - let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(CU_SESSION_LIMIT); - for _ in 0..CU_SESSION_LIMIT { - handles.push(s.spawn( - || -> Result<(TestCtx, u16), azihsm_ddi_interface::DdiError> { - let ctx_extra = TestCtx::new_with_path(path); - let handshake = ctx_extra.open_session_raw(CU, SessionType::PlainText)?; - Ok((ctx_extra, handshake.session_id)) - }, - )); - } - let mut winners = Vec::new(); - let mut rejections = Vec::new(); - for h in handles { - match h.join().expect("worker thread must not panic") { - Ok(w) => winners.push(w), - Err(e) => rejections.push(e), - } - } - (winners, rejections) + // Pre-allocate one ctx per racer on the main thread so the + // spawned workers can borrow them and return `SessionGuard<'_>` + // values whose borrow outlives `thread::scope`. + let ctxs: Vec<_> = (0..CU_SESSION_LIMIT) + .map(|_| TestCtx::new_with_path(path)) + .collect(); + + let results: Vec<_> = std::thread::scope(|s| { + let handles: Vec<_> = ctxs + .iter() + .map(|c| s.spawn(move || c.open_session(CU, SessionType::PlainText))) + .collect(); + handles + .into_iter() + .map(|h| h.join().expect("worker thread must not panic")) + .collect() }); - let mut sorted_ids: Vec<_> = winners.iter().map(|(_, sid)| *sid).collect(); + let (winner_results, rejections): (Vec<_>, Vec<_>) = + results.into_iter().partition(Result::is_ok); + let winners: Vec<_> = winner_results.into_iter().map(Result::unwrap).collect(); + let rejections: Vec<_> = rejections.into_iter().map(Result::unwrap_err).collect(); + + let mut sorted_ids: Vec<_> = winners.iter().map(|g| g.session_id()).collect(); let winner_ids = sorted_ids.clone(); sorted_ids.sort_unstable(); sorted_ids.dedup(); let unique_wins = sorted_ids.len(); - // Drop winners' ctxs before asserting so a failing assert never - // leaves the session table dirty — each ctx's fd close reclaims - // its slot in FW. - drop(winners); - assert!( rejections.is_empty(), "all {CU_SESSION_LIMIT} concurrent CU opens must succeed; observed rejections: \ @@ -730,83 +709,3 @@ fn open_session_multi_threaded_all_should_open() { "concurrent winners must have distinct session ids: {winner_ids:?}", ); } - -/// Fill CU capacity to one free slot then race `CU_SESSION_LIMIT` -/// threads for it. Regression for FW's undo-on-loser path: every -/// losing racer must see a clean rejection and leave the session -/// table intact for retry. Each filler + racer holds its own -/// [`TestCtx`] fd — hw requires one fd per concurrent session. -#[cfg(not(feature = "emu"))] -#[test] -fn open_session_multi_threaded_single_winner() { - let ctx = TestCtx::new(); - let path = ctx.path(); - - // Phase 1: fill CU capacity sequentially. Each filler owns its - // own ctx because hw enforces one session per fd. - let mut fillers: Vec<(TestCtx, u16)> = Vec::new(); - for i in 0..CU_SESSION_LIMIT { - let ctx_extra = TestCtx::new_with_path(path); - let handshake = ctx_extra - .open_session_raw(CU, SessionType::PlainText) - .unwrap_or_else(|e| { - panic!("filler CU session {i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") - }); - fillers.push((ctx_extra, handshake.session_id)); - } - - // Phase 2: free exactly one slot (close and drop the tail filler's ctx). - let (freed_ctx, freed_id) = fillers.pop().expect("at least one filler must exist"); - freed_ctx - .session_close(freed_id) - .expect("close of freed filler slot must succeed"); - drop(freed_ctx); - - // Phase 3: race `CU_SESSION_LIMIT` threads for the one free slot. - let racer_count = CU_SESSION_LIMIT; - let (winners, rejections) = std::thread::scope(|s| { - let mut handles = Vec::with_capacity(racer_count); - for _ in 0..racer_count { - handles.push(s.spawn( - || -> Result<(TestCtx, u16), azihsm_ddi_interface::DdiError> { - let ctx_extra = TestCtx::new_with_path(path); - let handshake = ctx_extra.open_session_raw(CU, SessionType::PlainText)?; - Ok((ctx_extra, handshake.session_id)) - }, - )); - } - let mut winners = Vec::new(); - let mut rejections = Vec::new(); - for h in handles { - match h.join().expect("racer thread must not panic") { - Ok(w) => winners.push(w), - Err(e) => rejections.push(e), - } - } - (winners, rejections) - }); - - let winner_count = winners.len(); - let rejection_count = rejections.len(); - // Drop everything before asserting — each ctx's fd close reclaims - // its slot in FW. - drop(winners); - drop(fillers); - - assert_eq!( - winner_count, 1, - "exactly one racer must win the single free slot (got {winner_count} winners, \ - {rejection_count} rejections: {rejections:?})", - ); - assert_eq!( - rejection_count, - racer_count - 1, - "all non-winning racers must fail cleanly", - ); - for err in &rejections { - assert!( - matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), - "single-winner losers must surface FW/driver rejections, got {err:?}", - ); - } -} diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index cb1a7b0d9..abbd89823 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -13,7 +13,6 @@ //! the same `(UDS, MachineSeed, Policy, POTA thumb)` inputs. use azihsm_ddi_tbor_types::PolicyFlags; -use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::MACH_SEED_LEN; use azihsm_ddi_tbor_types::PART_POLICY_LEN; @@ -27,7 +26,6 @@ use super::mach_seed; use super::open_co_with; use super::part_policy_with_flags; use super::pota_thumbprint; -use super::CO; use super::ROTATED_CO_PSK; use crate::harness::assertions::assert_fw_rejects; use crate::harness::TestCtx; @@ -246,14 +244,7 @@ fn run_part_init_capture_pta_pub( use x509::X509Csr; use x509::X509CsrOp; - let bootstrap = ctx - .open_session(CO, SessionType::Authenticated) - .expect("open CO default"); - ctx.psk_change(bootstrap.handshake(), &ROTATED_CO_PSK) - .expect("rotate CO PSK"); - let _ = bootstrap.close(); - - let session = open_co_with(ctx, &ROTATED_CO_PSK); + let session = bootstrap_rotated_co(ctx, &ROTATED_CO_PSK); let resp = ctx .part_init(&session, seed, policy, thumb) .expect("PartInit roundtrip"); diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index b190dc763..4fd4942fd 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -52,10 +52,11 @@ use crate::harness::fixture::TestDev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; -use crate::harness::session::session_close_on_dev; -use crate::harness::session::session_open_finish_on_dev; +use crate::harness::session::session_close; +use crate::harness::session::session_open as session_open_helper; +use crate::harness::session::session_open_finish; use crate::harness::session::session_open_finish_with_mac; -use crate::harness::session::session_open_init_on_dev; +use crate::harness::session::session_open_init; use crate::harness::session::session_open_init_with_options; use crate::harness::session::PendingHandshake; use crate::harness::session::SessionHandshake; @@ -229,7 +230,7 @@ impl TestCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - session_open_init_on_dev(&self.dev, psk_id, session_type) + session_open_init(&self.dev, psk_id, session_type) } /// Full-control Phase 1 entry point: honours every override in @@ -245,7 +246,7 @@ impl TestCtx { /// confirm MAC. Consumes `pending` so callers cannot reuse stale /// state. pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - session_open_finish_on_dev(&self.dev, pending) + session_open_finish(&self.dev, pending) } /// Phase 2 entry point that ships a caller-supplied `mac_fin`, @@ -263,20 +264,19 @@ impl TestCtx { /// responsible for the matching [`Self::session_close`]. Used /// when the test needs to inspect the handshake before closing /// it explicitly or move the handshake into a container. - pub fn open_session_raw( + pub(crate) fn open_session_raw( &self, psk_id: u8, session_type: SessionType, ) -> DdiResult { - let pending = self.session_open_init(psk_id, session_type)?; - self.session_open_finish(pending) + session_open_helper(&self.dev, psk_id, session_type) } /// Issue `SessionClose(session_id)`. Used by negative-path /// tests (double-close, unknown id) and by callers that hold a /// raw [`SessionHandshake`] outside of a [`SessionGuard`]. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - session_close_on_dev(&self.dev, session_id) + session_close(&self.dev, session_id) } /// Issue `PskChange` on `session` with `new_psk` as the diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index c2e1a980c..65dd35621 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -47,28 +47,16 @@ pub mod x509_fixture; // import them from `azihsm_ddi_tbor_types` directly when driving // negative-path tests through raw `TborSessionOpen*Req` / // `TborPskChangeReq` requests. -// Flat re-exports so test files write `use crate::harness::open_session` -// instead of `crate::harness::session::open_session`. -pub use api_rev::helper_api_rev_tbor; -pub use azihsm_ddi_tbor_types::build_psk_change_aad; -pub use azihsm_ddi_tbor_types::TborPskChangeReq; -pub use azihsm_ddi_tbor_types::PSK_CHANGE_AAD_LEN; -pub use azihsm_ddi_tbor_types::PSK_CHANGE_ENVELOPE_MAX_LEN; -pub use ctx::TestCtx; -pub use fixture::open_dev; -pub use session::build_mac_fin; -pub use session::build_part_init_mach_seed_aad; -pub use session::encrypt_mach_seed_envelope; -pub use session::encrypt_psk_envelope; -pub use session::open_session_on_dev; -pub use session::part_init; -pub use session::psk_change; -pub use session::session_close_on_dev; -pub use session::session_open_finish_on_dev; -pub use session::session_open_finish_with_mac; -pub use session::session_open_init_on_dev; -pub use session::session_open_init_with_options; -pub use session::PendingHandshake; -pub use session::SessionHandshake; -pub use session::SessionOpenInitOptions; -pub use session_guard::SessionGuard; +// Flat re-exports so test files write `use crate::harness::TestCtx` +// instead of `crate::harness::ctx::TestCtx`. Only items actually used +// from test files are re-exported here; helpers only reached through +// `TestCtx` methods stay behind their submodule. +pub(crate) use azihsm_ddi_tbor_types::build_psk_change_aad; +pub(crate) use azihsm_ddi_tbor_types::TborPskChangeReq; +pub(crate) use ctx::TestCtx; +pub(crate) use session::build_mac_fin; +pub(crate) use session::build_part_init_mach_seed_aad; +pub(crate) use session::encrypt_mach_seed_envelope; +pub(crate) use session::encrypt_psk_envelope; +pub(crate) use session::SessionHandshake; +pub(crate) use session::SessionOpenInitOptions; diff --git a/ddi/tbor/types/tests/harness/session.rs b/ddi/tbor/types/tests/harness/session.rs index b9f4ddb11..80382f621 100644 --- a/ddi/tbor/types/tests/harness/session.rs +++ b/ddi/tbor/types/tests/harness/session.rs @@ -1,61 +1,35 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! TBOR session-establishment helpers. +//! TBOR session helpers. //! -//! [`open_session`] runs the full happy-path two-phase handshake -//! (`SessionOpenInit` + `SessionOpenFinish`) against a [`DdiDev`] and -//! returns a [`SessionHandshake`] carrier whose fields are everything -//! a per-command test needs to drive subsequent in-session commands -//! (param_key for the AEAD-GCM envelope, session_id, session_type, -//! bmk_session for later resume tests). -//! -//! The lower-level [`session_open_init`] and [`session_open_finish`] -//! helpers are also exposed so negative-path tests can intercept the -//! handshake — e.g., tamper with `mac_fin` to drive the Phase-2 MAC -//! mismatch arm in the FW. +//! The [`open_close`] submodule holds the paired [`session_open`] and +//! [`session_close`] lifecycle helpers. Lower-level [`session_open_init`] +//! and [`session_open_finish`] primitives live in [`init`] and [`finish`] +//! so negative-path tests can intercept the handshake — e.g., tamper with +//! `mac_fin` to drive the Phase-2 MAC mismatch arm in the FW. mod crypto; pub mod finish; pub mod init; +pub mod open_close; pub mod part_final; pub mod part_init; pub mod psk_change; -pub mod session_close; - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use azihsm_ddi_interface::DdiError; -use azihsm_ddi_tbor_types::SessionType; -pub use finish::build_mac_fin; -pub use finish::session_open_finish_on_dev; -pub use finish::session_open_finish_with_mac; -pub use finish::SessionHandshake; -pub use init::session_open_init_on_dev; -pub use init::session_open_init_with_options; -pub use init::PendingHandshake; -pub use init::SessionOpenInitOptions; -pub use part_final::part_final; -pub use part_init::build_part_init_mach_seed_aad; -pub use part_init::encrypt_mach_seed_envelope; -pub use part_init::part_init; -pub use psk_change::encrypt_psk_envelope; -pub use psk_change::psk_change; -pub use session_close::session_close_on_dev; -/// One-shot helper: run both phases of the session handshake against -/// a specific `dev`. Equivalent to -/// `session_open_init_on_dev(...)? → session_open_finish_on_dev(...)`. -/// -/// Named with an `_on_dev` suffix to disambiguate from the -/// [`TestCtx::open_session`](crate::harness::TestCtx::open_session) -/// method which acts on the ctx's implicit fd and returns a -/// [`SessionGuard`](crate::harness::SessionGuard). -pub fn open_session_on_dev( - dev: &::Dev, - psk_id: u8, - session_type: SessionType, -) -> Result { - let pending = session_open_init_on_dev(dev, psk_id, session_type)?; - session_open_finish_on_dev(dev, pending) -} +pub(crate) use finish::build_mac_fin; +pub(crate) use finish::session_open_finish; +pub(crate) use finish::session_open_finish_with_mac; +pub(crate) use finish::SessionHandshake; +pub(crate) use init::session_open_init; +pub(crate) use init::session_open_init_with_options; +pub(crate) use init::PendingHandshake; +pub(crate) use init::SessionOpenInitOptions; +pub(crate) use open_close::session_close; +pub(crate) use open_close::session_open; +pub(crate) use part_final::part_final; +pub(crate) use part_init::build_part_init_mach_seed_aad; +pub(crate) use part_init::encrypt_mach_seed_envelope; +pub(crate) use part_init::part_init; +pub(crate) use psk_change::encrypt_psk_envelope; +pub(crate) use psk_change::psk_change; diff --git a/ddi/tbor/types/tests/harness/session/finish.rs b/ddi/tbor/types/tests/harness/session/finish.rs index 021541077..4c218d9f8 100644 --- a/ddi/tbor/types/tests/harness/session/finish.rs +++ b/ddi/tbor/types/tests/harness/session/finish.rs @@ -96,7 +96,7 @@ impl core::fmt::Debug for SessionHandshake { /// `mac_fin`. Exposed so negative-path tests can compute the canonical /// MAC, tamper with it, and ship the result via /// [`session_open_finish_with_mac`]. -pub fn build_mac_fin(pending: &PendingHandshake) -> Result<[u8; 48], DdiError> { +pub(crate) fn build_mac_fin(pending: &PendingHandshake) -> Result<[u8; 48], DdiError> { build_phase2_mac( &pending.exported, pending.session_id, @@ -114,15 +114,10 @@ fn fresh_seed() -> Result<[u8; SESSION_SEED_LEN], DdiError> { Ok(seed) } -/// Run Phase 2 of the handshake on a specific `dev`. Consumes the -/// [`PendingHandshake`] so callers cannot accidentally reuse stale -/// state for a second `SessionOpenFinish` against the same Pending -/// slot. -/// -/// Named with an `_on_dev` suffix to disambiguate from the -/// [`TestCtx::session_open_finish`](crate::harness::TestCtx::session_open_finish) -/// method which acts on the ctx's implicit fd. -pub fn session_open_finish_on_dev( +/// Run Phase 2 of the handshake. Consumes the [`PendingHandshake`] +/// so callers cannot accidentally reuse stale state for a second +/// `SessionOpenFinish` against the same Pending slot. +pub(crate) fn session_open_finish( dev: &::Dev, pending: PendingHandshake, ) -> Result { @@ -135,7 +130,7 @@ pub fn session_open_finish_on_dev( /// /// On Phase-2 MAC mismatch the FW returns an error that surfaces here /// as a [`DdiError`] from `exec_op_tbor`. -pub fn session_open_finish_with_mac( +pub(crate) fn session_open_finish_with_mac( dev: &::Dev, pending: PendingHandshake, mac_fin: [u8; 48], diff --git a/ddi/tbor/types/tests/harness/session/init.rs b/ddi/tbor/types/tests/harness/session/init.rs index adf1a85c7..005ff4df3 100644 --- a/ddi/tbor/types/tests/harness/session/init.rs +++ b/ddi/tbor/types/tests/harness/session/init.rs @@ -120,16 +120,12 @@ impl<'a> SessionOpenInitOptions<'a> { } } -/// Convenience wrapper: happy-path `SessionOpenInit` on a specific -/// `dev` with fresh ephemeral and partition default PSK. +/// Convenience wrapper: happy-path `SessionOpenInit` with fresh +/// ephemeral and partition default PSK. /// /// Equivalent to /// `session_open_init_with_options(dev, SessionOpenInitOptions::new(psk_id, session_type))`. -/// -/// Named with an `_on_dev` suffix to disambiguate from the -/// [`TestCtx::session_open_init`](crate::harness::TestCtx::session_open_init) -/// method which acts on the ctx's implicit fd. -pub fn session_open_init_on_dev( +pub(crate) fn session_open_init( dev: &::Dev, psk_id: u8, session_type: SessionType, @@ -139,7 +135,7 @@ pub fn session_open_init_on_dev( /// Full-control entry point. Honours every override in `opts`; /// fills in happy-path defaults for the rest. -pub fn session_open_init_with_options( +pub(crate) fn session_open_init_with_options( dev: &::Dev, opts: SessionOpenInitOptions<'_>, ) -> Result { diff --git a/ddi/tbor/types/tests/harness/session/open_close.rs b/ddi/tbor/types/tests/harness/session/open_close.rs new file mode 100644 index 000000000..08e91a104 --- /dev/null +++ b/ddi/tbor/types/tests/harness/session/open_close.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Session lifecycle helpers — combined open + close. +//! +//! [`session_open`] runs the full happy-path two-phase handshake +//! (`SessionOpenInit` + `SessionOpenFinish`) against a [`DdiDev`] and +//! returns a [`SessionHandshake`] carrier whose fields are everything +//! a per-command test needs to drive subsequent in-session commands +//! (param_key for the AEAD-GCM envelope, session_id, session_type, +//! bmk_session for later resume tests). +//! +//! [`session_close`] is the matching teardown: a thin wrapper around +//! [`TborSessionCloseReq`] that closes the session identified by +//! `session_id`. The FW response is an empty ack ([`TborSessionCloseResp`]); +//! callers only care whether it succeeded. +//! +//! The lower-level [`session_open_init`] and [`session_open_finish`] +//! primitives live in their own modules ([`super::init`], [`super::finish`]) +//! so negative-path tests can intercept the handshake — e.g., tamper with +//! `mac_fin` to drive the Phase-2 MAC mismatch arm in the FW. + +use azihsm_ddi::AzihsmDdi; +use azihsm_ddi_interface::Ddi; +use azihsm_ddi_interface::DdiDev; +use azihsm_ddi_interface::DdiError; +use azihsm_ddi_tbor_types::SessionType; +use azihsm_ddi_tbor_types::TborSessionCloseReq; +use azihsm_ddi_tbor_types::TborSessionCloseResp; + +use super::finish::session_open_finish; +use super::finish::SessionHandshake; +use super::init::session_open_init; + +/// One-shot helper: run both phases of the session handshake against +/// `dev`. Equivalent to `session_open_init(...)? → session_open_finish(...)`. +pub(crate) fn session_open( + dev: &::Dev, + psk_id: u8, + session_type: SessionType, +) -> Result { + let pending = session_open_init(dev, psk_id, session_type)?; + session_open_finish(dev, pending) +} + +/// Issue `SessionClose(session_id)` and return on success. +pub(crate) fn session_close( + dev: &::Dev, + session_id: u16, +) -> Result<(), DdiError> { + let req = TborSessionCloseReq { session_id }; + let mut cookie = None; + let _resp: TborSessionCloseResp = dev.exec_op_tbor(&req, None, &mut cookie)?; + Ok(()) +} diff --git a/ddi/tbor/types/tests/harness/session/part_final.rs b/ddi/tbor/types/tests/harness/session/part_final.rs index 120fb8845..d51ea4f48 100644 --- a/ddi/tbor/types/tests/harness/session/part_final.rs +++ b/ddi/tbor/types/tests/harness/session/part_final.rs @@ -32,7 +32,7 @@ use super::finish::SessionHandshake; /// (the schema still needs ≥1 descriptor, so a single placeholder with no /// OOB region is emitted). `prev_local_mk_backup` is the optional prior /// backup to restore (empty = first instantiation). -pub fn part_final( +pub(crate) fn part_final( dev: &::Dev, session: &SessionHandshake, part_policy: &[u8], diff --git a/ddi/tbor/types/tests/harness/session/part_init.rs b/ddi/tbor/types/tests/harness/session/part_init.rs index f31b8d0d0..0abead6ab 100644 --- a/ddi/tbor/types/tests/harness/session/part_init.rs +++ b/ddi/tbor/types/tests/harness/session/part_init.rs @@ -40,7 +40,7 @@ use super::finish::SessionHandshake; /// `SATA_THUMBPRINT_LEN`. Any size violation surfaces /// [`DdiError::InvalidParameter`] before the request reaches the /// device. -pub fn part_init( +pub(crate) fn part_init( dev: &::Dev, session: &SessionHandshake, mach_seed: &[u8], @@ -85,7 +85,7 @@ pub fn part_init( /// Exposed so negative-path tests can mutate the envelope (flip a /// ciphertext byte, swap AAD session id, …) before shipping it via a /// raw [`TborPartInitReq`]. -pub fn encrypt_mach_seed_envelope( +pub(crate) fn encrypt_mach_seed_envelope( session: &SessionHandshake, mach_seed: &[u8], ) -> Result, DdiError> { @@ -127,7 +127,7 @@ pub fn encrypt_mach_seed_envelope( /// negative-path tests; the FW handler reconstructs the same bytes /// from the wire-pinned constants and rejects any mismatch. #[must_use] -pub fn build_part_init_mach_seed_aad(session_id: u16) -> [u8; PART_INIT_MACH_SEED_AAD_LEN] { +pub(crate) fn build_part_init_mach_seed_aad(session_id: u16) -> [u8; PART_INIT_MACH_SEED_AAD_LEN] { let mut aad = [0u8; PART_INIT_MACH_SEED_AAD_LEN]; aad[..PART_INIT_MACH_SEED_AAD_LABEL.len()].copy_from_slice(PART_INIT_MACH_SEED_AAD_LABEL); aad[PART_INIT_MACH_SEED_AAD_LABEL.len()..PART_INIT_MACH_SEED_AAD_LABEL.len() + 2] diff --git a/ddi/tbor/types/tests/harness/session/psk_change.rs b/ddi/tbor/types/tests/harness/session/psk_change.rs index ba43f2f42..2e6629f1c 100644 --- a/ddi/tbor/types/tests/harness/session/psk_change.rs +++ b/ddi/tbor/types/tests/harness/session/psk_change.rs @@ -32,7 +32,7 @@ use super::finish::SessionHandshake; /// `new_psk` must be exactly [`PSK_LEN`] (32) bytes — anything else /// is an immediate [`DdiError::InvalidParameter`] without touching /// the device. -pub fn psk_change( +pub(crate) fn psk_change( dev: &::Dev, session: &SessionHandshake, new_psk: &[u8], @@ -56,7 +56,7 @@ pub fn psk_change( /// Exposed so negative-path tests can mutate the envelope (e.g. /// flip a ciphertext byte) before shipping it via a raw /// [`TborPskChangeReq`]. -pub fn encrypt_psk_envelope( +pub(crate) fn encrypt_psk_envelope( session: &SessionHandshake, new_psk: &[u8], ) -> Result, DdiError> { diff --git a/ddi/tbor/types/tests/harness/session/session_close.rs b/ddi/tbor/types/tests/harness/session/session_close.rs deleted file mode 100644 index 878a7325a..000000000 --- a/ddi/tbor/types/tests/harness/session/session_close.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Helper for the TBOR `SessionClose` command. -//! -//! Thin wrapper around [`TborSessionCloseReq`] — closes the session -//! identified by `session_id` against `dev`. The FW response is an -//! empty ack ([`TborSessionCloseResp`]); callers only care whether -//! it succeeded. - -use azihsm_ddi::AzihsmDdi; -use azihsm_ddi_interface::Ddi; -use azihsm_ddi_interface::DdiDev; -use azihsm_ddi_interface::DdiError; -use azihsm_ddi_tbor_types::TborSessionCloseReq; -use azihsm_ddi_tbor_types::TborSessionCloseResp; - -/// Issue `SessionClose(session_id)` against a specific `dev`. -/// -/// Named with an `_on_dev` suffix to disambiguate from the -/// [`TestCtx::session_close`](crate::harness::TestCtx::session_close) -/// method which acts on the ctx's implicit fd. -pub fn session_close_on_dev( - dev: &::Dev, - session_id: u16, -) -> Result<(), DdiError> { - let req = TborSessionCloseReq { session_id }; - let mut cookie = None; - let _resp: TborSessionCloseResp = dev.exec_op_tbor(&req, None, &mut cookie)?; - Ok(()) -} diff --git a/ddi/win/src/dev.rs b/ddi/win/src/dev.rs index 47355d222..da44ce9c5 100644 --- a/ddi/win/src/dev.rs +++ b/ddi/win/src/dev.rs @@ -645,16 +645,15 @@ impl DdiWinDev { /// Same as [`Self::map_ioctl_status`] but for callers executing a /// **TBOR** command. Driver-layer session-scoping rejections - /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — no live - /// session, id mismatch, limit reached) are surfaced as + /// (per-fd `AZIHSM_MAX_SESSIONS_PER_FD` bookkeeping — id mismatch, + /// limit reached) are surfaced as /// `DdiError::TborStatus(TborStatus::FileHandle*)` instead of /// `DdiError::DdiStatus(DdiStatus::FileHandle*)`, so callers see a /// TBOR status for a TBOR command regardless of which layer of /// the stack (driver vs FW) produced the rejection. The /// `FileHandle*` variants exist in both `DdiStatus` and /// `TborStatus` with identical numeric encodings, so this is a - /// pure type-level remap. All non-session errors fall through to - /// the generic mapping. + /// pure type-level remap. fn map_ioctl_status_tbor(&self, ioctl_status: u32) -> Result { match McrCpGenericIoctlErrorKind::try_from(ioctl_status) { Ok(McrCpGenericIoctlErrorKind::SessionLimitReached) => { @@ -663,9 +662,7 @@ impl DdiWinDev { )); } Ok(McrCpGenericIoctlErrorKind::NoExistingSession) => { - return Err(DdiError::TborStatus( - TborStatus::FileHandleNoExistingSession, - )); + return Err(DdiError::TborStatus(TborStatus::SessionNotFound)); } Ok(McrCpGenericIoctlErrorKind::SessionIdDoesNotMatch) => { return Err(DdiError::TborStatus( @@ -681,9 +678,7 @@ impl DdiWinDev { )); } Ok(McrFpIoctlErrorKind::NoValidSessionId) => { - return Err(DdiError::TborStatus( - TborStatus::FileHandleNoExistingSession, - )); + return Err(DdiError::TborStatus(TborStatus::SessionNotFound)); } _ => {} } From 5273a04dd90914b6d2591a1ae61c08f086c98eb4 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Thu, 30 Jul 2026 11:11:47 -0700 Subject: [PATCH 26/27] Resolve comments --- ddi/tbor/types/tests/commands/open_session.rs | 83 +++++++++++++++++++ .../tests/commands/part_init/happy_path.rs | 5 -- ddi/tbor/types/tests/harness/ctx.rs | 20 ++--- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 1a7440889..6d0afabb2 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -389,6 +389,89 @@ fn open_session_fills_co_slot_then_recovers() { // `_recovered` closes on drop at end of scope. } +// --------------------------------------------------------------------------- +// Full session-table exhaustion (CO + CU together) + recovery +// +// The FW session table is a single 8-slot table shared across roles +// (1 CO slot + 7 CU slots). CO and CU exhaustion are exercised +// separately above, but this test drives both roles together to +// confirm the whole table can be filled in one go and that neither +// role's rejection path interferes with the other's. +// +// Sequence: +// 1. Open 1 CO + `CU_SESSION_LIMIT` CU sessions — every slot must +// succeed. +// 2. One more CO open is rejected (CO slot occupied). +// 3. One more CU open is rejected (all CU slots occupied). +// 4. Drop a single CU guard; a fresh CU open must succeed while +// the CO slot stays full — proves per-role slot accounting. +// 5. Drop the CO guard + fd; a fresh CO open must succeed — +// proves the CO slot reclaims independently. +// --------------------------------------------------------------------------- + +#[test] +fn open_session_fills_full_table_then_recovers() { + let ctx = TestCtx::new(); + + // CO slot: one guard + its own fd (hw needs one fd per session). + let ctx_co = TestCtx::new_with_path(ctx.path()); + let co_guard = ctx_co + .open_session(CO, SessionType::Authenticated) + .expect("CO session must succeed"); + + // CU slots: `CU_SESSION_LIMIT` guards, each on its own fd. + let cu_ctxs: Vec<_> = (0..CU_SESSION_LIMIT) + .map(|_| TestCtx::new_with_path(ctx.path())) + .collect(); + let mut cu_guards: Vec<_> = cu_ctxs + .iter() + .enumerate() + .map(|(_i, c)| { + c.open_session(CU, SessionType::PlainText) + .unwrap_or_else(|e| { + panic!("CU session {_i} of {CU_SESSION_LIMIT} must succeed, got {e:?}") + }) + }) + .collect(); + + // Table is full — one more CO open must be rejected. + let overflow_co_ctx = TestCtx::new_with_path(ctx.path()); + let err_co = overflow_co_ctx + .open_session(CO, SessionType::Authenticated) + .expect_err("second concurrent CO open must be rejected"); + assert_fw_rejects(&err_co, TborStatus::VaultSessionLimitReached); + drop(overflow_co_ctx); + + // One more CU open must be rejected too. + let overflow_cu_ctx = TestCtx::new_with_path(ctx.path()); + let err_cu = overflow_cu_ctx + .open_session(CU, SessionType::PlainText) + .expect_err("open_session past CU limit must be rejected"); + assert_fw_rejects(&err_cu, TborStatus::VaultSessionLimitReached); + drop(overflow_cu_ctx); + + // Recovery-1: drop one CU guard. That frees exactly one CU slot; + // a fresh CU open on a new fd must succeed. CO stays full. + cu_guards.pop().expect("cu_guards must not be empty"); + let ctx_recover_cu = TestCtx::new_with_path(ctx.path()); + let _recovered_cu = ctx_recover_cu + .open_session(CU, SessionType::PlainText) + .expect("CU slot must recover after freeing one CU guard"); + + // Recovery-2: drop the CO guard + its fd. A fresh CO open on a + // new fd must succeed. (A new fd is required because on hw + // `AZIHSM_MAX_SESSIONS_PER_FD = 1`, so we cannot reuse + // `ctx_recover_cu` — its fd already holds the CU session opened + // in Recovery-1.) + drop(co_guard); + drop(ctx_co); + let ctx_recover_co = TestCtx::new_with_path(ctx.path()); + let _recovered_co = ctx_recover_co + .open_session(CO, SessionType::Authenticated) + .expect("CO slot must recover after CO fd is dropped"); + // Remaining guards close on drop at end of scope. +} + // --------------------------------------------------------------------------- // Open, close, open new session // --------------------------------------------------------------------------- diff --git a/ddi/tbor/types/tests/commands/part_init/happy_path.rs b/ddi/tbor/types/tests/commands/part_init/happy_path.rs index abbd89823..78ff49c6e 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -277,10 +277,6 @@ fn run_part_init_capture_pta_pub( fn part_init_determinism() { let ctx = TestCtx::new(); - // `TestCtx::new` already left the partition factory-reset; this - // first `erase` is redundant but documents the run-1 precondition. - ctx.erase().expect("erase to pristine Enabled before run 1"); - let seed = mach_seed(); let policy = known_good_part_policy(); let thumb = pota_thumbprint(); @@ -316,7 +312,6 @@ fn part_init_determinism() { #[test] fn part_init_pta_pub_differs_when_include_fmc_cdi_flag_toggled() { let ctx = TestCtx::new(); - ctx.erase().expect("erase before run 1"); let seed = mach_seed(); let thumb = pota_thumbprint(); diff --git a/ddi/tbor/types/tests/harness/ctx.rs b/ddi/tbor/types/tests/harness/ctx.rs index 4fd4942fd..2ed21246f 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -52,12 +52,12 @@ use crate::harness::fixture::TestDev; use crate::harness::session::part_final as part_final_helper; use crate::harness::session::part_init as part_init_helper; use crate::harness::session::psk_change as psk_change_helper; -use crate::harness::session::session_close; +use crate::harness::session::session_close as session_close_helper; use crate::harness::session::session_open as session_open_helper; -use crate::harness::session::session_open_finish; -use crate::harness::session::session_open_finish_with_mac; -use crate::harness::session::session_open_init; -use crate::harness::session::session_open_init_with_options; +use crate::harness::session::session_open_finish as session_open_finish_helper; +use crate::harness::session::session_open_finish_with_mac as session_open_finish_with_mac_helper; +use crate::harness::session::session_open_init as session_open_init_helper; +use crate::harness::session::session_open_init_with_options as session_open_init_with_options_helper; use crate::harness::session::PendingHandshake; use crate::harness::session::SessionHandshake; use crate::harness::session::SessionOpenInitOptions; @@ -230,7 +230,7 @@ impl TestCtx { psk_id: u8, session_type: SessionType, ) -> DdiResult { - session_open_init(&self.dev, psk_id, session_type) + session_open_init_helper(&self.dev, psk_id, session_type) } /// Full-control Phase 1 entry point: honours every override in @@ -239,14 +239,14 @@ impl TestCtx { &self, opts: SessionOpenInitOptions<'_>, ) -> DdiResult { - session_open_init_with_options(&self.dev, opts) + session_open_init_with_options_helper(&self.dev, opts) } /// Run Phase 2 of the TBOR session handshake with the canonical /// confirm MAC. Consumes `pending` so callers cannot reuse stale /// state. pub fn session_open_finish(&self, pending: PendingHandshake) -> DdiResult { - session_open_finish(&self.dev, pending) + session_open_finish_helper(&self.dev, pending) } /// Phase 2 entry point that ships a caller-supplied `mac_fin`, @@ -256,7 +256,7 @@ impl TestCtx { pending: PendingHandshake, mac_fin: [u8; 48], ) -> DdiResult { - session_open_finish_with_mac(&self.dev, pending, mac_fin) + session_open_finish_with_mac_helper(&self.dev, pending, mac_fin) } /// One-shot happy-path handshake that returns the raw @@ -276,7 +276,7 @@ impl TestCtx { /// tests (double-close, unknown id) and by callers that hold a /// raw [`SessionHandshake`] outside of a [`SessionGuard`]. pub fn session_close(&self, session_id: u16) -> DdiResult<()> { - session_close(&self.dev, session_id) + session_close_helper(&self.dev, session_id) } /// Issue `PskChange` on `session` with `new_psk` as the From 8b9482e0503c13a9501d126d93cae6b63bdb27e6 Mon Sep 17 00:00:00 2001 From: bobo-91 Date: Fri, 31 Jul 2026 10:39:16 -0700 Subject: [PATCH 27/27] Resolve comments --- ddi/tbor/types/tests/harness/{session.rs => session/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ddi/tbor/types/tests/harness/{session.rs => session/mod.rs} (100%) diff --git a/ddi/tbor/types/tests/harness/session.rs b/ddi/tbor/types/tests/harness/session/mod.rs similarity index 100% rename from ddi/tbor/types/tests/harness/session.rs rename to ddi/tbor/types/tests/harness/session/mod.rs