diff --git a/ddi/nix/src/dev.rs b/ddi/nix/src/dev.rs index 5777affe4..e99b32023 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,37 @@ 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 — 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. + 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::SessionNotFound)); + } + 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 +964,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/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 3b948dade..ac332a473 100644 --- a/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs +++ b/ddi/tbor/types/tests/azihsm_ddi_tbor_tests.rs @@ -5,17 +5,17 @@ //! //! 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 sock` (firmware behind a socket server), or with +//! **no** feature (native OS backend — `nix` on Linux / `win` on +//! Windows — for on-silicon test runs). //! -//! 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`]. +//! `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(any(feature = "emu", feature = "mock", feature = "sock"))] +#[cfg(not(feature = "mock"))] pub mod harness; +#[cfg(not(feature = "mock"))] pub mod commands; - -#[cfg(feature = "hw-tests")] -pub mod hw; diff --git a/ddi/tbor/types/tests/commands/api_rev.rs b/ddi/tbor/types/tests/commands/api_rev.rs index ce74b68ad..f8d400166 100644 --- a/ddi/tbor/types/tests/commands/api_rev.rs +++ b/ddi/tbor/types/tests/commands/api_rev.rs @@ -3,30 +3,23 @@ //! 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. -//! `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. //! -//! 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. - -#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]. use azihsm_ddi_tbor_types::TborApiRevReq; use crate::harness::TestCtx; -#[cfg(any(feature = "emu", feature = "sock"))] const EXPECTED: azihsm_ddi_tbor_types::TborApiRevResp = azihsm_ddi_tbor_types::TborApiRevResp { min_ver: 1, max_ver: 1, }; -#[cfg(any(feature = "emu", feature = "sock"))] #[test] fn round_trip() { let ctx = TestCtx::new(); @@ -39,14 +32,13 @@ 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(feature = "emu")] #[test] -fn api_rev_repeated_stable_emu() { +fn api_rev_repeated_stable() { let ctx = TestCtx::new(); let baseline = ctx.tbor(&TborApiRevReq::new()).expect("baseline ApiRev"); assert_eq!(baseline, EXPECTED, "baseline must match expected"); @@ -56,15 +48,14 @@ fn api_rev_repeated_stable_emu() { } } -/// 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(feature = "emu")] +/// `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_emu() { +fn api_rev_independent_of_session_state() { use azihsm_ddi_tbor_types::SessionType; let ctx = TestCtx::new(); @@ -99,15 +90,3 @@ fn api_rev_independent_of_session_state_emu() { 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 fbcbcedbd..4992bc30d 100644 --- a/ddi/tbor/types/tests/commands/default_psk_gate.rs +++ b/ddi/tbor/types/tests/commands/default_psk_gate.rs @@ -5,36 +5,34 @@ //! //! 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 from -//! the plan): +//! 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 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. - -#![cfg(feature = "emu")] +//! 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::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::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::SessionOpenInitOptions; use crate::harness::TestCtx; @@ -49,26 +47,28 @@ 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_emu() { +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_emu() { +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); @@ -78,6 +78,8 @@ fn default_psk_gate_session_open_init_bypass_emu() { 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); @@ -87,41 +89,100 @@ fn default_psk_gate_session_open_init_bypass_emu() { 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"); } -/// 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_emu() { +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"); } -/// 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_emu() { +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"); } + +/// 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() { + let ctx = TestCtx::new(); + let session = ctx + .open_session(CO, SessionType::Authenticated) + .expect("open_session must succeed"); + + 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); +} + +/// `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_a = TestCtx::new(); + + // 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 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(), + "parallel CO and CU sessions must have distinct ids", + ); + + // 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 93a531823..6d0afabb2 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -4,25 +4,11 @@ //! 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`]. -//! -//! 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")] +//! 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. use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborSessionOpenFinishReq; @@ -32,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; @@ -43,9 +30,11 @@ const CU: u8 = 1; // --------------------------------------------------------------------------- #[test] -fn open_session_co_authenticated_happy_emu() { +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()); @@ -63,9 +52,11 @@ fn open_session_co_authenticated_happy_emu() { } #[test] -fn open_session_cu_plaintext_happy_emu() { +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()); @@ -76,21 +67,21 @@ fn open_session_cu_plaintext_happy_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_co_plaintext_rejected_emu() { +fn open_session_co_plaintext_rejected() { let ctx = TestCtx::new(); 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] -fn open_session_cu_authenticated_rejected_emu() { +fn open_session_cu_authenticated_rejected() { let ctx = TestCtx::new(); 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); } // --------------------------------------------------------------------------- @@ -98,7 +89,7 @@ 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 @@ -109,12 +100,12 @@ fn open_session_invalid_psk_id_emu() { 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); } } #[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(); @@ -132,9 +123,9 @@ 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(); @@ -154,7 +145,7 @@ fn open_session_unsupported_suite_id_emu() { // --------------------------------------------------------------------------- #[test] -fn session_open_finish_mac_tampered_emu() { +fn session_open_finish_mac_tampered() { let ctx = TestCtx::new(); let pending = ctx .session_open_init(CU, SessionType::PlainText) @@ -164,7 +155,7 @@ fn session_open_finish_mac_tampered_emu() { 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. } @@ -173,7 +164,7 @@ 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(); @@ -185,16 +176,15 @@ fn session_open_finish_unknown_session_id_emu() { 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] -fn open_session_double_finish_emu() { +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(), @@ -204,10 +194,7 @@ fn open_session_double_finish_emu() { 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); } // --------------------------------------------------------------------------- @@ -215,7 +202,7 @@ 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(); @@ -245,39 +232,563 @@ fn session_open_finish_seed_envelope_tampered_emu() { // --------------------------------------------------------------------------- #[test] -fn open_session_multiple_concurrent_emu() { - let ctx = TestCtx::new(); - let a = ctx.open_session(CU, SessionType::PlainText); - let b = ctx.open_session(CU, SessionType::PlainText); +fn open_session_multiple_concurrent() { + 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_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(), "concurrent sessions must have distinct ids", ); + + // 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_cu_range_exhausted_rejected_emu() { +fn open_session_second_on_same_fd_rejected() { 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)); - } + let _a = ctx + .open_session(CU, SessionType::PlainText) + .expect("first open on a fresh fd must succeed"); let err = 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; + .open_session(CU, SessionType::PlainText) + .expect_err("second open on the same fd must be rejected"); + assert_fw_rejects(&err, TborStatus::FileHandleSessionLimitReached); +} + +// --------------------------------------------------------------------------- +// FW session-table 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 +// +// 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. +// --------------------------------------------------------------------------- + +#[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 mut 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_fw_rejects(&err, TborStatus::VaultSessionLimitReached); + drop(overflow_ctx); + + // 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 freeing one slot"); + // `_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_co_slot_then_recovers() { + let ctx = TestCtx::new(); + + 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_fw_rejects(&err, TborStatus::VaultSessionLimitReached); + + // 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. +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +/// 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_then_open_new_session() { + let ctx = TestCtx::new(); + let first = ctx + .open_session(CU, SessionType::PlainText) + .expect("first handshake must succeed"); + first.close().expect("first SessionClose must succeed"); + + let second = ctx + .open_session(CU, SessionType::PlainText) + .expect("open new session after close must succeed"); + second.close().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 = TestCtx::new(); + + let a = ctx + .open_session(CO, SessionType::Authenticated) + .expect("first CO+Authenticated handshake 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(CO, SessionType::Authenticated) + .expect("second CO+Authenticated handshake must succeed"); + 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. + b.close().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); +} + +// --------------------------------------------------------------------------- +// 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 = TestCtx::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", + ); +} + +// --------------------------------------------------------------------------- +// Point-validation negatives +// +// FW `EccPublicKeyValidation` checks that `pk_init` is a valid, +// 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 +// 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 ctx = TestCtx::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_fw_rejects(&err, TborStatus::InvalidArg); +} + +/// `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 ctx = TestCtx::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_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); +} + +// --------------------------------------------------------------------------- +// 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 = 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, + 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_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); +} + +/// Symmetric to `pk_init_x_as_prime_rejected` — guards against +/// X-only validation bugs. +#[test] +fn pk_init_y_as_prime_rejected() { + 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, + 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_fw_rejects(&err, TborStatus::EccPublicKeyValidationFailed); +} + +// --------------------------------------------------------------------------- +// 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 = 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; + 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_fw_rejects(&err, TborStatus::EccPointValidationFailed); +} + +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- + +/// 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 = ctx.path(); + + // 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 (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(); + + assert!( + rejections.is_empty(), + "all {CU_SESSION_LIMIT} concurrent CU opens must succeed; observed rejections: \ + {rejections:?}", + ); + assert_eq!( + 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:?}", + ); } diff --git a/ddi/tbor/types/tests/commands/part_info.rs b/ddi/tbor/types/tests/commands/part_info.rs index 4aefe04b0..710ee445d 100644 --- a/ddi/tbor/types/tests/commands/part_info.rs +++ b/ddi/tbor/types/tests/commands/part_info.rs @@ -3,27 +3,27 @@ //! 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 -//! 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. - -#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +//! `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). +//! +//! Backend is selected at compile time by +//! [`azihsm_ddi::AzihsmDdi::default`]. use azihsm_ddi_tbor_types::TborPartInfoReq; use crate::harness::TestCtx; /// `DdiDeviceKind::Physical` discriminant — uno is a physical device. -#[cfg(any(feature = "emu", feature = "sock"))] 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"))] const PART_STATE_ENABLED: u8 = 2; /// `PartState::Initializing` discriminant — the state a partition enters @@ -34,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(any(feature = "emu", feature = "sock"))] fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { assert_eq!( resp.device_kind, DEVICE_KIND_PHYSICAL, @@ -50,7 +49,6 @@ fn assert_default_part_info(resp: &azihsm_ddi_tbor_types::TborPartInfoResp) { ); } -#[cfg(any(feature = "emu", feature = "sock"))] #[test] fn round_trip() { let ctx = TestCtx::new(); @@ -65,9 +63,8 @@ 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")] #[test] -fn part_info_repeated_stable_emu() { +fn part_info_repeated_stable() { let ctx = TestCtx::new(); let baseline = ctx .tbor(&TborPartInfoReq::new()) @@ -84,11 +81,10 @@ fn part_info_repeated_stable_emu() { /// `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(feature = "emu")] #[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(); @@ -194,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/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..78ff49c6e 100644 --- a/ddi/tbor/types/tests/commands/part_init/happy_path.rs +++ b/ddi/tbor/types/tests/commands/part_init/happy_path.rs @@ -12,7 +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::SessionType; +use azihsm_ddi_tbor_types::PolicyFlags; use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::MACH_SEED_LEN; use azihsm_ddi_tbor_types::PART_POLICY_LEN; @@ -24,14 +24,14 @@ 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; 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 @@ -244,14 +244,7 @@ fn run_part_init_capture_pta_pub( use x509::X509Csr; use x509::X509CsrOp; - let bootstrap = ctx - .open_session_raw(CO, SessionType::Authenticated) - .expect("open CO default"); - ctx.psk_change(&bootstrap, &ROTATED_CO_PSK) - .expect("rotate CO PSK"); - let _ = ctx.session_close(bootstrap.session_id); - - 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"); @@ -281,13 +274,9 @@ 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 - // 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(); @@ -307,3 +296,41 @@ fn part_init_determinism_emu() { 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(); + + 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 b35182969..f5264f495 100644 --- a/ddi/tbor/types/tests/commands/part_init/mod.rs +++ b/ddi/tbor/types/tests/commands/part_init/mod.rs @@ -3,11 +3,15 @@ //! 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 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 +//! [`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` @@ -24,8 +28,6 @@ //! live in this module and are `pub(super)` so each submodule can //! reach them via `super::*`. -#![cfg(feature = "emu")] - use azihsm_ddi_tbor_types::PolicyKeyKind; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::MACH_SEED_LEN; @@ -90,6 +92,12 @@ 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. +/// +/// 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; let mut bytes = known_good_part_policy(); @@ -98,6 +106,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() { @@ -169,7 +188,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 25ce32831..bd37a068e 100644 --- a/ddi/tbor/types/tests/commands/psk_change.rs +++ b/ddi/tbor/types/tests/commands/psk_change.rs @@ -3,30 +3,30 @@ //! 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. +//! 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 → `TborStatus::TborInvalidFixedLength` -//! (the schema pins `psk_envelope` to `PSK_CHANGE_ENVELOPE_LEN`). -//! * 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`. - -#![cfg(feature = "emu")] +//! * 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) → `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`. use azihsm_crypto::aead_envelope; use azihsm_crypto::aead_envelope::AeadAlg; @@ -34,9 +34,13 @@ use azihsm_crypto::AesKey; use azihsm_crypto::Rng; use azihsm_ddi_tbor_types::SessionType; use azihsm_ddi_tbor_types::TborStatus; +#[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(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::SessionOpenInitOptions; @@ -84,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"); @@ -101,12 +107,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,9 +121,11 @@ fn psk_change_happy_co_emu() { // =========================================================================== #[test] -fn psk_change_reopen_with_old_psk_fails_emu() { +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"); @@ -126,7 +134,7 @@ fn psk_change_reopen_with_old_psk_fails_emu() { // `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", @@ -138,9 +146,11 @@ fn psk_change_reopen_with_old_psk_fails_emu() { // =========================================================================== #[test] -fn psk_change_second_attempt_same_session_fails_emu() { +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"); @@ -163,7 +173,7 @@ fn psk_change_second_attempt_same_session_fails_emu() { // =========================================================================== #[test] -fn psk_change_envelope_tampered_emu() { +fn psk_change_envelope_tampered() { let ctx = TestCtx::new(); for (label, mutate) in [ @@ -181,7 +191,9 @@ fn psk_change_envelope_tampered_emu() { }) 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); @@ -201,15 +213,18 @@ fn psk_change_envelope_tampered_emu() { // =========================================================================== #[test] -fn psk_change_empty_envelope_emu() { +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(), }; - // The FW schema pins `psk_envelope` to PSK_CHANGE_ENVELOPE_LEN - // (100 B), so an empty envelope is rejected at decode. + // FW schema pins `psk_envelope` to PSK_CHANGE_ENVELOPE_LEN (100 B); + // the derive-generated decoder rejects a wrong length with + // `TborInvalidFixedLength` before the handler runs. ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); } @@ -218,9 +233,11 @@ fn psk_change_empty_envelope_emu() { // =========================================================================== #[test] -fn psk_change_wrong_session_id_in_aad_emu() { +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 @@ -235,24 +252,40 @@ fn psk_change_wrong_session_id_in_aad_emu() { } // =========================================================================== -// Envelope built under a *different* session's param_key +// Envelope built under a different session's param_key +// +// 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 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] -fn psk_change_envelope_from_other_session_emu() { - let ctx = TestCtx::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. +fn psk_change_envelope_from_other_session() { + 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 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); + 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); + + // Both sessions close on drop via their SessionGuards. } // =========================================================================== @@ -260,14 +293,17 @@ fn psk_change_envelope_from_other_session_emu() { // =========================================================================== #[test] -fn psk_change_wrong_plaintext_length_emu() { +fn psk_change_wrong_plaintext_length() { 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 - // PSK_CHANGE_ENVELOPE_LEN (100 B) rejects both at decode. + // 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); @@ -288,12 +324,14 @@ fn psk_change_wrong_plaintext_length_emu() { // =========================================================================== #[test] -fn psk_change_wrong_aad_length_emu() { +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), so the FW schema's - // fixed-length check rejects it at decode. + // 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 { @@ -302,3 +340,53 @@ fn psk_change_wrong_aad_length_emu() { }; ctx.expect_fw_reject(&req, TborStatus::TborInvalidFixedLength); } + +// =========================================================================== +// Hardware-only: rotation to a default PSK must be rejected +// +// 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 reject lives in the silicon firmware, not in the +// in-tree Rust FW that emu runs. Emu accepts the rotation. +// =========================================================================== + +#[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) + .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"); + assert_fw_rejects(&err, TborStatus::InvalidArg); +} + +#[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(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(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(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/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 bdba7df84..2ed21246f 100644 --- a/ddi/tbor/types/tests/harness/ctx.rs +++ b/ddi/tbor/types/tests/harness/ctx.rs @@ -47,11 +47,13 @@ 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; 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 as session_open_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; @@ -79,10 +81,38 @@ impl TestCtx { Self { dev: open_dev() } } - /// Factory-reset the partition. Available only on `emu`; the - /// determinism tests in `commands::part_init` call this between - /// cold-restart iterations. - #[cfg(feature = "emu")] + /// Open a secondary `TestCtx` on the same backend `path` as the + /// 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. 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), + } + } + + /// The backend path this ctx's [`TestDev`] was opened on. Multi-fd + /// 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() + } + + /// 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() } @@ -232,16 +262,14 @@ 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. - pub fn open_session_raw( + /// when the test needs to inspect the handshake before closing + /// it explicitly or move the handshake into a container. + 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 @@ -331,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 81905e633..1c992b379 100644 --- a/ddi/tbor/types/tests/harness/fixture.rs +++ b/ddi/tbor/types/tests/harness/fixture.rs @@ -12,10 +12,11 @@ //! 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. +//! 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 @@ -28,7 +29,6 @@ use std::ops::Deref; use azihsm_ddi::AzihsmDdi; use azihsm_ddi_interface::Ddi; -#[cfg(feature = "emu")] use azihsm_ddi_interface::DdiDev; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CO; pub use azihsm_ddi_tbor_types::DEFAULT_PSK_CU; @@ -55,10 +55,36 @@ 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 `TestDev`s to the *same* + /// underlying device via [`TestCtx::new_with_path`](crate::harness::TestCtx::new_with_path). + path: String, } +impl TestDev { + /// The backend path this device was opened on. + pub fn path(&self) -> &str { + &self.path + } +} + +// 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 { @@ -67,7 +93,9 @@ impl Deref for TestDev { } /// Acquire the test lock, open the configured backend device, and -/// (under `feature = "emu"`) factory-reset it. 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 @@ -77,9 +105,39 @@ pub fn open_dev() -> TestDev { 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")] + let path = info.path.clone(); + let dev = ddi.open_dev(&path).expect("open test backend device"); dev.erase() - .expect("open_dev: factory-reset emu backend before test"); - TestDev { dev, _guard: guard } + .expect("open_dev: factory-reset backend before test"); + TestDev { + dev, + _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(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"); + TestDev { + dev, + _guard: None, + path: path.to_string(), + } } diff --git a/ddi/tbor/types/tests/harness/mod.rs b/ddi/tbor/types/tests/harness/mod.rs index f6afeaea5..65dd35621 100644 --- a/ddi/tbor/types/tests/harness/mod.rs +++ b/ddi/tbor/types/tests/harness/mod.rs @@ -16,33 +16,23 @@ //! //! # 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 three 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. +//! * `--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. //! -//! 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`. -//! -//! 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"))] +//! `--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; @@ -57,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; -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_open_finish_with_mac; -pub use session::session_open_init; -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 deleted file mode 100644 index ee273b5a7..000000000 --- a/ddi/tbor/types/tests/harness/session.rs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! TBOR session-establishment 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. - -mod crypto; -pub mod finish; -pub mod init; -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; -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_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; - -/// One-shot helper: run both phases of the session handshake against -/// `dev`. Equivalent to `session_open_init(...)? → session_open_finish(...)`. -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) -} diff --git a/ddi/tbor/types/tests/harness/session/finish.rs b/ddi/tbor/types/tests/harness/session/finish.rs index 20077831d..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, @@ -117,7 +117,7 @@ fn fresh_seed() -> Result<[u8; SESSION_SEED_LEN], DdiError> { /// 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( +pub(crate) fn session_open_finish( dev: &::Dev, pending: PendingHandshake, ) -> Result { @@ -130,7 +130,7 @@ pub fn session_open_finish( /// /// 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 a2fc3972e..005ff4df3 100644 --- a/ddi/tbor/types/tests/harness/session/init.rs +++ b/ddi/tbor/types/tests/harness/session/init.rs @@ -125,7 +125,7 @@ impl<'a> SessionOpenInitOptions<'a> { /// /// Equivalent to /// `session_open_init_with_options(dev, SessionOpenInitOptions::new(psk_id, session_type))`. -pub fn session_open_init( +pub(crate) fn session_open_init( dev: &::Dev, psk_id: u8, session_type: SessionType, @@ -135,7 +135,7 @@ pub fn session_open_init( /// 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/mod.rs b/ddi/tbor/types/tests/harness/session/mod.rs new file mode 100644 index 000000000..80382f621 --- /dev/null +++ b/ddi/tbor/types/tests/harness/session/mod.rs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR session helpers. +//! +//! 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(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/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 1c30a9999..000000000 --- a/ddi/tbor/types/tests/harness/session/session_close.rs +++ /dev/null @@ -1,24 +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)` and return on success. -pub 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_guard.rs b/ddi/tbor/types/tests/harness/session_guard.rs index 6e201649a..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 { @@ -95,13 +106,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/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/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 90dc25590..000000000 --- a/ddi/tbor/types/tests/hw/mod.rs +++ /dev/null @@ -1,120 +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 api_rev; -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) -} diff --git a/ddi/win/src/dev.rs b/ddi/win/src/dev.rs index a10aac12e..da44ce9c5 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,48 @@ 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 — 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. + 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::SessionNotFound)); + } + 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::SessionNotFound)); + } + _ => {} + } + self.map_ioctl_status(ioctl_status) + } } /// Align AAD in place according to the following rules: @@ -1011,7 +1054,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))?;