diff --git a/fw/core/lib/src/ddi/mbor/open_session.rs b/fw/core/lib/src/ddi/mbor/open_session.rs index fbcee61a7..207fb393f 100644 --- a/fw/core/lib/src/ddi/mbor/open_session.rs +++ b/fw/core/lib/src/ddi/mbor/open_session.rs @@ -101,23 +101,38 @@ pub(crate) async fn open_session<'p, P: HsmPal>( let mk_session = pal.dma_alloc(io, BK_LEN)?; pal.rng_fill_bytes(io, mk_session)?; + // Resolve the SVNs *before* allocating the slot so the only fallible step + // left after `session_create` is the response encode, which we roll back. + let mfgr_svn = crate::part_state::part_mfgr_svn(pal); + let owner_svn = + u16::try_from(crate::part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?; + let api_rev_bytes = pack_api_rev(api_rev); let sess_id = pal .session_create(io, &api_rev_bytes, mk_session, None) .await?; // ── Step 9: Encode response + envelope MK_SESSION under BK_SESSION - let resp = encode_response( - pal, - io, - hdr, - sess_id, - bk_session, - mk_session, - crate::part_state::part_mfgr_svn(pal), - u16::try_from(crate::part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?, + // + // The slot is now allocated. If the encode (crypto + DMA + MBOR) fails, + // roll the slot back — otherwise the logical slot and its session-vault + // key stay allocated forever: `sess_id` is never surfaced to the CQE, so + // the host never registers the session and can never `CloseSession` it, + // leaking a slot for the rest of the boot. This mirrors the reference + // firmware's FSM `rollback_open_session` backstop. + let resp = match encode_response( + pal, io, hdr, sess_id, bk_session, mk_session, mfgr_svn, owner_svn, ) - .await?; + .await + { + Ok(resp) => resp, + Err(err) => { + // Best-effort teardown of the just-allocated slot; surface the + // original failure to the host regardless of teardown result. + let _ = pal.session_destroy(io, sess_id).await; + return Err(err); + } + }; // Surface the new session id to the IO layer for the CQE (only on the // success path, since the `?` above returns early on failure), letting the diff --git a/fw/core/lib/src/ddi/mbor/reopen_session.rs b/fw/core/lib/src/ddi/mbor/reopen_session.rs index e68e4c863..aef3582a4 100644 --- a/fw/core/lib/src/ddi/mbor/reopen_session.rs +++ b/fw/core/lib/src/ddi/mbor/reopen_session.rs @@ -96,26 +96,40 @@ pub(crate) async fn reopen_session<'p, P: HsmPal>( &body.bmk_session[layout.plaintext_offset..layout.plaintext_offset + BK_LEN], ); + // Re-envelope MK_SESSION under BK_SESSION for the host to persist + // (the SVN etc. recorded in the metadata may have advanced), mirroring + // OpenSession's response. Resolve the SVNs *before* re-keying the slot + // so the only fallible step left after `session_create` is the response + // encode, which the rollback arm below covers — otherwise a `try_from` + // failure here would return early and leak the just-recreated slot. + let mfgr_svn = crate::part_state::part_mfgr_svn(pal); + let owner_svn = + u16::try_from(crate::part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?; + // Recreate the migrated session's own slot with the recovered key. let api_rev_bytes = pack_api_rev(api_rev); let sess_id = pal .session_create(io, &api_rev_bytes, mk_session, Some(reopen_id)) .await?; - // Re-envelope MK_SESSION under BK_SESSION for the host to persist - // (the SVN etc. recorded in the metadata may have advanced), mirroring - // OpenSession's response. - let resp = encode_reopen_response( - pal, - io, - hdr, - sess_id, - bk_session, - mk_session, - crate::part_state::part_mfgr_svn(pal), - u16::try_from(crate::part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?, + let resp = match encode_reopen_response( + pal, io, hdr, sess_id, bk_session, mk_session, mfgr_svn, owner_svn, ) - .await?; + .await + { + Ok(resp) => resp, + // The slot was already re-keyed by `session_create` above (flipped + // from NeedsRenegotiation to Active with a fresh masking key). If + // the response cannot be encoded, the host never learns the reopen + // succeeded and will retry it — but a now-Active slot would reject + // that retry and the fresh vault key would orphan. Restore the + // slot to NeedsRenegotiation and drop the fresh key, mirroring the + // reference firmware's `rollback_open_session` reopen branch. + Err(err) => { + let _ = pal.session_rollback_reopen(io, sess_id).await; + return Err(err); + } + }; // `ReopenSession` is an in-session command: it reuses the caller's // existing session id (echoed in the response header), so unlike diff --git a/fw/pal/traits/src/session.rs b/fw/pal/traits/src/session.rs index 56cf75db9..eed7ccd0d 100644 --- a/fw/pal/traits/src/session.rs +++ b/fw/pal/traits/src/session.rs @@ -301,6 +301,42 @@ pub trait HsmSessionManager { /// session in the caller's partition. async fn session_destroy(&self, io: &impl HsmIo, id: HsmSessId) -> HsmResult<()>; + /// Rolls back an in-place re-key performed by + /// [`session_create`](Self::session_create) with `id == Some(_)` + /// (the post-migration `ReopenSession` path). + /// + /// A `ReopenSession` re-keys a + /// [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation) slot: + /// it installs a fresh masking-key vault entry and clears the slot's + /// renegotiation flag, flipping it to [`Active`](HsmSessionState::Active). + /// If a later step of the same command fails (e.g. the response + /// cannot be encoded), the host never learns the session was + /// re-keyed and will retry the reopen — but the now-`Active` slot + /// would reject that retry. This call undoes the re-key so the slot + /// returns to a freshly-migrated `NeedsRenegotiation` state: + /// + /// 1. Deletes the fresh masking-key vault entry that + /// [`session_create`](Self::session_create) installed. + /// 2. Restores the slot's renegotiation flag and clears its physical + /// vault mapping. + /// + /// The slot itself is **not** freed (it still represents a migrated + /// session awaiting renegotiation), mirroring the reference + /// firmware's `rollback_open_session` reopen branch + /// (`session_table().rollback_recreation(id)`). + /// + /// # Parameters + /// + /// - `io` — caller's I/O context (partition scope). + /// - `id` — session that was just re-keyed and must be restored. + /// + /// # Returns + /// + /// - `Ok(())` on success. + /// - `Err(HsmError::SessionNotFound)` — `id` does not refer to a + /// live slot in the caller's partition. + async fn session_rollback_reopen(&self, io: &impl HsmIo, id: HsmSessId) -> HsmResult<()>; + /// Queries the lifecycle state of a session slot. /// /// This is an infallible probe: an unknown or freed slot is diff --git a/fw/plat/std/pal/src/drivers/session.rs b/fw/plat/std/pal/src/drivers/session.rs index 317a5f627..ac416f70d 100644 --- a/fw/plat/std/pal/src/drivers/session.rs +++ b/fw/plat/std/pal/src/drivers/session.rs @@ -268,6 +268,24 @@ impl SessionTable { Ok(id) } + /// Roll back a [`recreate`](Self::recreate): restore the + /// renegotiation bit and clear the physical mapping. + /// + /// The caller is responsible for deleting the orphaned vault entry + /// (via [`physical_id`](Self::physical_id)) before invoking this, so + /// the physical mapping is read while still intact. + /// + /// Leaves the slot as a freshly-migrated + /// [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation) slot + /// so the host can retry a failed `ReopenSession` (mirrors the + /// reference firmware's `session_table().rollback_recreation(id)`). + pub fn rollback_recreation(&mut self, id: HsmSessId) -> HsmResult<()> { + let slot = self.active_slot(id)?; + self.renego_mask |= 1 << slot; + self.phys_ids[slot] = 0; + Ok(()) + } + /// Query the current state of a session slot. pub fn state(&self, id: HsmSessId) -> HsmSessionState { let Ok(slot) = self.active_slot(id) else { diff --git a/fw/plat/std/pal/src/session.rs b/fw/plat/std/pal/src/session.rs index a92310570..dfc056ad2 100644 --- a/fw/plat/std/pal/src/session.rs +++ b/fw/plat/std/pal/src/session.rs @@ -75,20 +75,21 @@ impl HsmSessionManager for StdHsmPal { let pid = io.pid(); let entry = self.active_part_mut(pid)?; - // On re-key: clean up the old session-scoped keys and the old - // session key before creating the replacement. A slot awaiting - // renegotiation after a live-migration disable has already had - // its vault key material cleared, so there is nothing to tear - // down — `recreate` below simply installs the fresh mapping - // (mirroring the reference firmware's `recreate_session`). + // Validate the reopen target. The only valid target is a slot + // awaiting renegotiation after a live-migration disable; `restore()` + // already zeroed its physical vault mapping, so there is nothing to + // tear down and `recreate` below simply installs the fresh mapping + // (mirroring the reference firmware's `recreate_session`). Every + // other state (Active, Pending, Invalid) is rejected up front with no + // side effects: tearing down the old keys here destroyed a live + // session and leaked its slot, because `recreate` then rejects the + // non-renegotiation slot anyway. if let Some(reopen_id) = id { if !matches!( entry.session_table.state(reopen_id), HsmSessionState::NeedsRenegotiation ) { - let old_phys = entry.session_table.physical_id(reopen_id)?; - entry.vault.delete_by_session_key(old_phys)?; - entry.vault.delete(old_phys)?; + return Err(HsmError::InvalidArg); } } @@ -157,6 +158,26 @@ impl HsmSessionManager for StdHsmPal { Ok(()) } + /// Roll back a failed post-migration re-key (`ReopenSession`). + /// + /// Restores the slot to [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation) + /// and deletes the fresh masking-key vault entry (plus any + /// session-scoped keys bound to it) that + /// [`session_create`](Self::session_create) installed, so neither the + /// slot state nor the vault leaks and the host can retry the reopen. + async fn session_rollback_reopen(&self, io: &impl HsmIo, id: HsmSessId) -> HsmResult<()> { + let entry = self.active_part_mut(io.pid())?; + + // Delete the fresh masking-key vault entry that the failed reopen + // installed, then restore the slot to NeedsRenegotiation (clearing + // its physical mapping) so neither the vault nor the slot leaks and + // the host can retry the reopen. + let physical_id = entry.session_table.physical_id(id)?; + entry.vault.delete(physical_id)?; + entry.session_table.rollback_recreation(id)?; + Ok(()) + } + /// Query the lifecycle state of a session. fn session_state(&self, io: &impl HsmIo, id: HsmSessId) -> HsmSessionState { let Ok(entry) = self.active_part(io.pid()) else { diff --git a/fw/plat/uno/fw/drivers/iic/src/api.rs b/fw/plat/uno/fw/drivers/iic/src/api.rs index 864ec64f3..9115583da 100644 --- a/fw/plat/uno/fw/drivers/iic/src/api.rs +++ b/fw/plat/uno/fw/drivers/iic/src/api.rs @@ -26,8 +26,22 @@ struct ChannelState { /// ICQ consumer index — FW advances after reading completions. icq_head: u16, + /// Free-running count of ICQ entries consumed (both completed and + /// failed-recycled). Unlike [`icq_head`](Self::icq_head) this never + /// wraps at the ring boundary, so a consumer can latch a target value + /// and wait for the head to advance past a fixed point. Used by the + /// live-migration SQ-delete drain to account for entries the hardware + /// has already accepted into the ICQ but that `recv` has not yet pulled + /// into the in-flight IO accounting. + icq_consumed: u32, + /// Waker for async recv — woken by IRQ or polling. waker: WakerRegistration, + + /// Waker for a task tracking ICQ-consumption progress (the LM SQ-delete + /// drain). Woken on every consumed entry so the drain re-polls as the IO + /// task empties the shared ICQ, independent of the `recv` waker. + drain_waker: WakerRegistration, } /// Async IIC (Inbound IO Controller) driver. @@ -128,7 +142,9 @@ impl IicDriver { io_meta: config.io_meta_base as *mut IoMetaEntry, state: SingleCell::new(ChannelState { icq_head: 0, + icq_consumed: 0, waker: WakerRegistration::new(), + drain_waker: WakerRegistration::new(), }), regs, config, @@ -284,6 +300,13 @@ impl IicDriver { .head .write(ICQ_CHANNEL_HEAD::HEAD.val(s.icq_head.into())); + // Advance the free-running consumer count and wake any SQ-delete + // drain that is waiting for the ICQ to quiesce. This covers both + // the success path below and the failed-recycle path, so a drain + // makes progress even when the only remaining entries are errors. + s.icq_consumed = s.icq_consumed.wrapping_add(1); + s.drain_waker.wake(); + // Skip failed entries — recycle buffer, return credit, and keep polling if !success { let _cause = self.regs.interrupt_cause.get(); @@ -315,6 +338,31 @@ impl IicDriver { }) } + /// Free-running count of ICQ entries consumed since boot. + /// + /// Monotonic (modulo `u32` wrap) across the ring boundary, so a waiter + /// can latch a value and detect when the consumer has advanced past it. + pub fn icq_consumed(&self) -> u32 { + self.state.with(|s| s.icq_consumed) + } + + /// Number of ICQ entries the hardware has accepted but `recv` has not yet + /// consumed (ring distance between the shadow tail and the FW head). + pub fn icq_pending(&self) -> u16 { + self.state.with(|s| { + let tail = unsafe { self.icq_tail_shadow.read_volatile() } as u16; + tail.wrapping_sub(s.icq_head) & Self::MASK + }) + } + + /// Register `waker` to be notified on every ICQ consumption. + /// + /// Used by the live-migration SQ-delete drain to re-poll as the IO task + /// empties the shared ICQ, independent of the `recv` waker. + pub fn register_drain_waker(&self, waker: &core::task::Waker) { + self.state.with(|s| s.drain_waker.register(waker)); + } + /// Wake the driver if the ICQ has pending entries. pub fn wake(&self, irq: u16) { self.state.with(|s| { diff --git a/fw/plat/uno/fw/drivers/part_store/src/part_store.rs b/fw/plat/uno/fw/drivers/part_store/src/part_store.rs index 95d4728a1..98bd93e91 100644 --- a/fw/plat/uno/fw/drivers/part_store/src/part_store.rs +++ b/fw/plat/uno/fw/drivers/part_store/src/part_store.rs @@ -679,19 +679,15 @@ impl Partition { unsafe { DmaBuf::from_raw_mut(&mut self.slot_mut().vm_launch_guid) } } - /// Sets the VM-launch GUID. + /// Captures the host-provided VM-launch GUID. /// - /// # Errors - /// - /// - [`HsmError::InvalidArg`] — `v` is not exactly `GUID_LEN` bytes. + /// The GUID arrives as plain bytes in the `SetResource` IPC payload + /// (not DMA-sourced), so this takes a fixed-size array rather than a + /// [`DmaBuf`]. Mirrors the reference firmware's capture in + /// `part_init::handle_set_res_cnt`. #[inline(never)] - pub fn set_vm_launch_guid(mut self, v: &DmaBuf) -> HsmResult<()> { - let src: &[u8] = v; - if src.len() != GUID_LEN { - return Err(HsmError::InvalidArg); - } - self.slot_mut().vm_launch_guid.copy_from_slice(src); - Ok(()) + pub fn set_vm_launch_guid(mut self, v: &[u8; GUID_LEN]) { + self.slot_mut().vm_launch_guid.copy_from_slice(v); } // ── masked boot key (variable length) ──────────────────────────────── @@ -752,9 +748,6 @@ impl Partition { return Err(HsmError::InvalidArg); } let slot = self.slot_mut(); - if slot.sealed_bk3.len != 0 { - return Err(HsmError::SealedBk3AlreadySet); - } slot.sealed_bk3.len = src.len() as u32; slot.sealed_bk3.data[..src.len()].copy_from_slice(src); // Clear the unused tail of the storage slot. diff --git a/fw/plat/uno/fw/drivers/part_store/src/part_store/tests.rs b/fw/plat/uno/fw/drivers/part_store/src/part_store/tests.rs index aecd121de..9bd3f05ca 100644 --- a/fw/plat/uno/fw/drivers/part_store/src/part_store/tests.rs +++ b/fw/plat/uno/fw/drivers/part_store/src/part_store/tests.rs @@ -46,7 +46,7 @@ fn preserves_provisioning_and_clears_tenant_state() { let p = Partition(pid); p.set_psk_co(buf(&psk_co)).unwrap(); p.set_psk_cu(buf(&psk_cu)).unwrap(); - p.set_vm_launch_guid(buf(&guid)).unwrap(); + p.set_vm_launch_guid(&guid); p.set_pta_pub_key(buf(&pta)).unwrap(); p.set_bk3_initialized(true); @@ -198,7 +198,7 @@ fn clear_identity_zeros_id_handle_and_pub_key() { } #[test] -fn sealed_bk3_is_write_once_until_cleared() { +fn sealed_bk3_raw_setter_overwrites_and_bounds_length() { let p = Partition(10); let sealed = [0xA5u8; SEALED_BK3_DATA_LEN]; let replacement = [0x5Au8; 16]; @@ -206,38 +206,42 @@ fn sealed_bk3_is_write_once_until_cleared() { p.set_sealed_bk3(buf(&[])).unwrap(); p.set_sealed_bk3(buf(&sealed)).unwrap(); - assert_eq!( - p.set_sealed_bk3(buf(&replacement)), - Err(HsmError::SealedBk3AlreadySet) - ); - assert_eq!( - p.set_sealed_bk3(buf(&[])), - Err(HsmError::SealedBk3AlreadySet) - ); - assert_eq!(p.set_sealed_bk3(buf(&oversized)), Err(HsmError::InvalidArg)); assert_eq!(&p.sealed_bk3()[..], &sealed); - p.clear_sealed_bk3(); + // The low-level setter is a raw store: write-once enforcement now lives in + // the PAL property layer, so repeated writes overwrite rather than + // returning `SealedBk3AlreadySet`. p.set_sealed_bk3(buf(&replacement)).unwrap(); assert_eq!(&p.sealed_bk3()[..], &replacement); + + // Oversized blobs are still rejected, leaving the prior value intact. + assert_eq!(p.set_sealed_bk3(buf(&oversized)), Err(HsmError::InvalidArg)); + assert_eq!(&p.sealed_bk3()[..], &replacement); + + p.clear_sealed_bk3(); + assert!(p.sealed_bk3().is_empty()); + p.set_sealed_bk3(buf(&sealed)).unwrap(); + assert_eq!(&p.sealed_bk3()[..], &sealed); } #[test] -fn sealed_bk3_stays_write_once_after_migrate() { +fn sealed_bk3_survives_migrate_and_clears_on_disable() { let p = Partition(11); let sealed = [0xA5u8; 64]; let replacement = [0x5Au8; 16]; p.set_sealed_bk3(buf(&sealed)).unwrap(); + + // Sealed BK3 is write-once provisioning material: preserved across a + // migrate (NSSR) alongside the rest of the provisioning state. p.clear_state(PartResetKind::Migrate); assert_eq!(&p.sealed_bk3()[..], &sealed); - assert_eq!( - p.set_sealed_bk3(buf(&replacement)), - Err(HsmError::SealedBk3AlreadySet) - ); - assert_eq!(&p.sealed_bk3()[..], &sealed); - p.clear_state(PartResetKind::Disable); + // The driver setter overwrites (write-once enforcement is in the PAL). p.set_sealed_bk3(buf(&replacement)).unwrap(); assert_eq!(&p.sealed_bk3()[..], &replacement); + + // A `Disable` reset deallocates the slot, wiping the sealed blob. + p.clear_state(PartResetKind::Disable); + assert!(p.sealed_bk3().is_empty()); } diff --git a/fw/plat/uno/fw/drivers/session_store/src/session_store.rs b/fw/plat/uno/fw/drivers/session_store/src/session_store.rs index b0dd309d4..b9870d076 100644 --- a/fw/plat/uno/fw/drivers/session_store/src/session_store.rs +++ b/fw/plat/uno/fw/drivers/session_store/src/session_store.rs @@ -179,6 +179,14 @@ impl SessionTable { /// Looks up the physical vault key ID for a logical session. /// + /// This is a raw slot→key lookup and does **not** enforce renegotiation + /// state. Renegotiation is gated centrally in the IO dispatch path + /// (`classify_session_state`): crypto/in-session ops on a migrated slot are + /// rejected with [`HsmError::SessionNeedsRenegotiation`] before they reach + /// the vault, while `CloseSession` is allowed through so the slot can be + /// torn down (post-migration cleanup). A migrated slot carries a zeroed + /// physical id, which `session_destroy` never dereferences. + /// /// # Errors /// /// - [`HsmError::SessionNotFound`] — `id` is not an allocated slot. @@ -207,6 +215,31 @@ impl SessionTable { Ok(id) } + /// Rolls back a [`recreate`](Self::recreate): restores the + /// renegotiation flag and clears the physical vault mapping. + /// + /// The caller must delete the orphaned vault entry (resolved via + /// [`physical_id`](Self::physical_id)) before calling this, so the + /// physical mapping is read while still intact. + /// + /// This is the slot-table half of the reference firmware's + /// `rollback_open_session` reopen branch + /// (`session_table().rollback_recreation(id)`): a `ReopenSession` + /// whose later steps fail must leave the slot exactly as a + /// freshly-migrated [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation) + /// slot — zeroed physical id — so the host can retry the reopen. + /// + /// # Errors + /// + /// - [`HsmError::SessionNotFound`] — `id` is not an allocated slot. + #[inline(never)] + pub fn rollback_recreation(&mut self, id: HsmSessId) -> HsmResult<()> { + let slot = self.active_slot(id)?; + self.region_mut()[RENEGO_MASK] |= 1 << slot; + self.set_phys_id(slot, 0); + Ok(()) + } + /// Returns the persistent state of a session slot. /// /// A slot whose handshake is still in flight reports @@ -243,6 +276,46 @@ impl SessionTable { } } + /// Snapshot the set of live session slots for a live-migration / NSSR + /// reset. + /// + /// Returns a bitmask of every currently-occupied slot that carries + /// reopenable credential state — i.e. `Active` and already + /// `NeedsRenegotiation` slots — excluding in-flight `Pending` + /// handshakes, which have no reopenable state. The result is handed to + /// [`restore`](Self::restore) *after* the partition's vault and + /// per-tenant state have been cleared, so a migrated tenant's sessions + /// survive as `NeedsRenegotiation` and can be re-keyed by a subsequent + /// `ReopenSession`. Mirrors the reference firmware's `backup`/`restore` + /// pair (`state.migrate()` / `state.disable()` do + /// `session_table().restore(session_table().backup())`). + #[inline(never)] + pub fn backup(&self) -> u8 { + self.alloc_mask() & !self.pending_mask() + } + + /// Re-establish the session slots preserved across a reset, marking + /// every survivor as + /// [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation). + /// + /// `mask` is a snapshot taken by [`backup`](Self::backup) *before* the + /// partition's vault and session table were cleared. Every bit set in + /// `mask` becomes an allocated slot needing renegotiation; its physical + /// vault mapping is dropped (the key material did not survive the + /// reset) and a subsequent `ReopenSession` re-keys it via + /// [`recreate`](Self::recreate). Any leftover `Pending` / psk-change + /// state is discarded. Mirrors the reference firmware's `restore`. + #[inline(never)] + pub fn restore(&mut self, mask: u8) { + self.region_mut()[ALLOC_MASK] = mask; + self.region_mut()[RENEGO_MASK] = mask; + for slot in 0..MAX_SESSIONS { + self.set_phys_id(slot, 0); + } + self.set_pending_mask(0); + self.set_psk_change_mask(0); + } + /// Returns the physical vault key id of every occupied slot (Active, /// NeedsRenegotiation, or Pending), so a caller tearing the partition /// down can delete the backing session-blob vault keys before the diff --git a/fw/plat/uno/fw/pal/src/io.rs b/fw/plat/uno/fw/pal/src/io.rs index 245707c44..d537aa0ea 100644 --- a/fw/plat/uno/fw/pal/src/io.rs +++ b/fw/plat/uno/fw/pal/src/io.rs @@ -176,7 +176,11 @@ impl HsmIoController for UnoHsmPal { async fn poll_io(&self) -> HsmResult { let index = self.iic.recv().await; reset_io_alloc(self, index); - Ok(UnoHsmIo { index }) + let io = UnoHsmIo { index }; + // Count this IO against its submission queue for the live-migration + // SQ-delete drain; `drop_io` performs the paired decrement. + self.sq_inflight_inc(io.queue_id()); + Ok(io) } /// Posts the completion (CQE) to the host via OIC. @@ -203,6 +207,10 @@ impl HsmIoController for UnoHsmPal { // next IO that reuses the slot. self.scrub_io_slot(io.index).await; self.iic.free_io(io.index, queue_id); + // Paired with the increment in `poll_io`: release this IO's hold on + // its submission queue so a pending live-migration SQ-delete drain + // can make progress once the queue is fully quiesced. + self.sq_inflight_dec(queue_id); Ok(()) } } diff --git a/fw/plat/uno/fw/pal/src/ipc.rs b/fw/plat/uno/fw/pal/src/ipc.rs index 51b4a7ebe..11b5b36ac 100644 --- a/fw/plat/uno/fw/pal/src/ipc.rs +++ b/fw/plat/uno/fw/pal/src/ipc.rs @@ -526,8 +526,10 @@ pub struct SetResInfo { /// `u16` -- a wider field shifts `vm_launch_guid` and corrupts `pfn`. pub pfn: u8, - /// VM launch GUID (unused by the emulator; retained for wire - /// compatibility with the reference firmware). + /// VM launch GUID supplied by the host. Captured into the partition + /// persistent store on a non-zero (allocate) `SetResource`, so a later + /// `InitBk3` reports the correct VM identity and migration preserves it + /// (mirrors the reference firmware's `part_init::handle_set_res_cnt`). pub vm_launch_guid: [u8; 16], } @@ -694,6 +696,131 @@ pub fn encode_pfn_enable_disable_ack( out } +// --------------------------------------------------------------------------- +// IpcMessageCreateDeleteSq (opcode 0x3) — Admin → HSM submission-queue mgmt +// --------------------------------------------------------------------------- + +/// Submission-queue action carried by [`SqCreateDeleteInfo::action`]. +/// +/// Mirrors the reference firmware's `SqAction` (`serde/ipc` +/// `sq_create_delete.rs`). +#[repr(u8)] +#[open_enum] +#[derive(Clone, Copy, Debug, PartialEq, Eq, IntoBytes, Immutable, FromBytes)] +pub enum SqAction { + /// Tear down an existing submission queue (the live-migration drain + /// path: quiesce in-flight IO on this queue before replying). + Delete = 0, + + /// Create a new submission queue. + Create = 1, +} + +/// `CreateDeleteSq` payload: targets one partition's submission queue. +/// +/// Mirrors the reference firmware's `SqCreateDeleteInfo`. Each field is a +/// single byte on the wire, in order: `pfn`, `device_sq_id`, +/// `device_cq_id`, `action`. The reference uses `PcieFunction` / `DevSqId` +/// / `DevCqId` newtypes; uno carries the raw bytes (matching +/// [`PfnEnableDisableInfo`]) and validates ranges in [`IpcMessageType`]. +#[repr(C)] +#[derive(Debug, Clone, Copy, IntoBytes, Immutable, FromBytes)] +pub struct SqCreateDeleteInfo { + /// Target partition (PCIe function); 1-byte `PcieFunction` on the wire. + pub pfn: u8, + + /// Device-side submission queue id. + pub device_sq_id: u8, + + /// Device-side completion queue id. + pub device_cq_id: u8, + + /// Action to perform (`Delete` / `Create`). + pub action: u8, +} + +const _: () = assert!(core::mem::size_of::() == 4); + +/// `CreateDeleteSq` IPC message body (opcode `CreateDeleteSq`, 0x3). +#[repr(C)] +#[derive(Debug, IntoBytes, Immutable, FromBytes)] +pub struct IpcMessageCreateDeleteSq { + /// IPC header fields. + pub header: IpcMessageHeader, + + /// Create / delete submission-queue payload. + pub info: SqCreateDeleteInfo, + + /// Reserved padding so the body fills the 60-byte payload area. + pub _rsvd: [u8; IPC_MESSAGE_PAYLOAD_LEN - IpcMessageCreateDeleteSq::LEN], +} + +const _: () = + assert!(core::mem::size_of::() == core::mem::size_of::()); + +impl IpcMessageType for IpcMessageCreateDeleteSq { + const OP: IpcMessageOpCode = IpcMessageOpCode::CreateDeleteSq; + const LEN: usize = core::mem::size_of::(); + + fn validate(&self) -> IpcResult<()> { + if usize::from(self.info.pfn) >= crate::part::NUM_PARTITIONS { + return Err(IpcMessageErr::InvalidPartitionId.into()); + } + // `open_enum` makes every byte representable, so reject any action + // outside the known set rather than silently treating it as a + // create/delete. + match SqAction(self.info.action) { + SqAction::Delete | SqAction::Create => {} + _ => return Err(IpcMessageErr::InvalidInputMessageForDecode.into()), + } + Ok(()) + } +} + +/// Decode a raw IPC buffer into an [`IpcMessageCreateDeleteSq`]. +/// +/// Returns `None` if the opcode does not match or the body fails to +/// decode/validate. +pub fn decode_create_delete_sq( + buf: &[u32; IPC_MESSAGE_LENGTH], +) -> Option { + let msg = IpcMessage { data: *buf }; + IpcMessageDecoder::decode::(msg).ok() +} + +/// Returns `true` when `buf`'s header opcode is `CreateDeleteSq`. +/// +/// Lets a handler distinguish a genuinely unrelated message (which should +/// fall through to the next handler / echo path) from a `CreateDeleteSq` +/// whose body fails layout/range/action validation (which must be rejected +/// with an error ACK rather than echoed as `Success`). `decode_create_delete_sq` +/// alone cannot make this distinction — it collapses an opcode mismatch and a +/// malformed body into the same `None`. +pub fn is_create_delete_sq(buf: &[u32; IPC_MESSAGE_LENGTH]) -> bool { + IpcMessageHeader::read_from_bytes(buf[0].as_bytes()) + .map(|h| h.msg_op() == IpcMessageOpCode::CreateDeleteSq as u32) + .unwrap_or(false) +} + +/// Encode an ACK reply for a `CreateDeleteSq` message. +/// +/// Copies the original header, sets the response bit and `status`. +pub fn encode_create_delete_sq_ack( + original_buf: &[u32; IPC_MESSAGE_LENGTH], + status: IpcMessageStatusCode, +) -> [u32; IPC_MESSAGE_LENGTH] { + let original_header = IpcMessageHeader::read_from_bytes(original_buf[0].as_bytes()) + .unwrap_or(IpcMessageHeader::new()); + + let ack_header = original_header + .with_response(true) + .with_status(status as u32); + + let mut out = *original_buf; + out[0] = ack_header.into_bits(); + out +} + // --------------------------------------------------------------------------- // IpcMessageKeyUpdate (opcode 0x7) — HSM → FP bulk AES key push // --------------------------------------------------------------------------- @@ -856,3 +983,83 @@ pub struct AesBulk256KeyId { #[bits(6)] _rsvd: u8, } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a raw `CreateDeleteSq` slot the way the Admin core would. + fn make_sq_buf( + pfn: u8, + device_sq_id: u8, + device_cq_id: u8, + action: SqAction, + ) -> [u32; IPC_MESSAGE_LENGTH] { + let msg = IpcMessageCreateDeleteSq { + header: IpcMessageHeader::new() + .with_msg_op(IpcMessageOpCode::CreateDeleteSq as u32) + .with_length(IpcMessageCreateDeleteSq::LEN as u32), + info: SqCreateDeleteInfo { + pfn, + device_sq_id, + device_cq_id, + action: action.0, + }, + _rsvd: [0; IPC_MESSAGE_PAYLOAD_LEN - IpcMessageCreateDeleteSq::LEN], + }; + IpcMessageEncoder::encode(msg).data + } + + #[test] + fn create_delete_sq_byte_layout() { + // Mirrors the reference encode test: the info word packs pfn, sq, cq, + // action as consecutive little-endian bytes. + let buf = make_sq_buf(13, 10, 11, SqAction::Create); + assert_eq!(buf[1], 0x010B_0A0D, "pfn|sq<<8|cq<<16|action<<24"); + let header = IpcMessageHeader::read_from_bytes(buf[0].as_bytes()).unwrap(); + assert_eq!(header.msg_op(), IpcMessageOpCode::CreateDeleteSq as u32); + assert_eq!(header.length(), IpcMessageCreateDeleteSq::LEN as u32); + } + + #[test] + fn create_delete_sq_roundtrip() { + for action in [SqAction::Create, SqAction::Delete] { + let buf = make_sq_buf(2, 7, 8, action); + let msg = decode_create_delete_sq(&buf).expect("decode"); + assert_eq!(msg.info.pfn, 2); + assert_eq!(msg.info.device_sq_id, 7); + assert_eq!(msg.info.device_cq_id, 8); + assert_eq!(SqAction(msg.info.action), action); + } + } + + #[test] + fn create_delete_sq_rejects_bad_pfn() { + let buf = make_sq_buf(crate::part::NUM_PARTITIONS as u8, 1, 1, SqAction::Create); + assert!(decode_create_delete_sq(&buf).is_none()); + } + + #[test] + fn create_delete_sq_rejects_bad_action() { + let mut buf = make_sq_buf(1, 1, 1, SqAction::Create); + // Stomp the action byte (byte 3 of the info word) with an unknown + // value; decode must reject it. + buf[1] = (buf[1] & 0x00FF_FFFF) | (0x7F << 24); + assert!(decode_create_delete_sq(&buf).is_none()); + } + + #[test] + fn create_delete_sq_ack_sets_response_and_status() { + let buf = make_sq_buf(1, 2, 3, SqAction::Delete); + let ack = encode_create_delete_sq_ack(&buf, IpcMessageStatusCode::Success); + let header = IpcMessageHeader::read_from_bytes(ack[0].as_bytes()).unwrap(); + assert!(header.response()); + assert_eq!(header.status(), IpcMessageStatusCode::Success as u32); + // Payload is preserved so the Admin can correlate the reply. + assert_eq!(ack[1], buf[1]); + } +} diff --git a/fw/plat/uno/fw/pal/src/pal.rs b/fw/plat/uno/fw/pal/src/pal.rs index 8c5c52cee..a94ce7529 100644 --- a/fw/plat/uno/fw/pal/src/pal.rs +++ b/fw/plat/uno/fw/pal/src/pal.rs @@ -29,6 +29,8 @@ //! without a copy. use core::cell::Cell; +use core::future::poll_fn; +use core::task::Poll; use azihsm_fw_hsm_pal_traits::DmaBuf; use azihsm_fw_hsm_pal_traits::HsmCustomDispatch; @@ -37,6 +39,7 @@ use azihsm_fw_hsm_pal_traits::HsmIo; use azihsm_fw_hsm_pal_traits::HsmPal; use azihsm_fw_hsm_pal_traits::HsmPartId; use azihsm_fw_hsm_pal_traits::HsmResult; +use azihsm_fw_single_cell::SingleCell; use azihsm_fw_static_init::static_init; use azihsm_fw_uno_drivers_aes::AesDriver; use azihsm_fw_uno_drivers_boot_status as boot_status; @@ -94,6 +97,7 @@ use embassy_futures::select::Either; use embassy_futures::select::select; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::mutex::Mutex; +use embassy_sync::waitqueue::WakerRegistration; use crate::alloc::IO_ALLOC_INIT; use crate::alloc::IoAllocTable; @@ -261,6 +265,28 @@ const WAKE_TABLE: [WakeFn; MAX_IRQ] = { t }; +/// Number of distinct hardware submission-queue ids the live-migration +/// drain tracks. `queue_id` is the IIC 8-bit "source queue identifier" +/// (identical to the Admin `device_sq_id`), so 256 slots cover its full +/// range and indexing by the raw id can never go out of bounds. +const SQ_DRAIN_SLOTS: usize = 256; + +/// Per-submission-queue in-flight host-IO accounting for the live-migration +/// SQ-delete drain. +/// +/// `queue_id` — the hardware source queue id the IIC tags onto each inbound +/// IO — equals the Admin `device_sq_id`, so a single counter keyed by +/// `queue_id` lets [`try_handle_create_delete_sq`](UnoHsmPal::try_handle_create_delete_sq)'s +/// `Delete` arm wait until every IO already accepted from the queue being +/// torn down has finished. +pub(crate) struct SqDrainState { + /// In-flight host-IO count per `queue_id`. + inflight: [u16; SQ_DRAIN_SLOTS], + + /// Waker for a task awaiting a queue's in-flight count to reach zero. + waker: WakerRegistration, +} + /// The Uno HSM platform abstraction layer. /// /// Holds static references to the IIC, OIC, GDMA, and IPC drivers. @@ -316,6 +342,10 @@ pub struct UnoHsmPal { /// being torn down can't be registered with the engine after the /// session's engine-side delete (see `vault.rs`). pub(crate) fp_bulk_lock: Mutex, + + /// In-flight host-IO accounting for the live-migration SQ-delete drain + /// (see [`try_handle_create_delete_sq`](Self::try_handle_create_delete_sq)). + pub(crate) sq_drain: SingleCell, } // SAFETY: UnoHsmPal is only accessed from a single-threaded Embassy @@ -421,6 +451,10 @@ impl Default for UnoHsmPal { io_alloc: IO_ALLOC_INIT, io_peak: IO_ALLOC_INIT, fp_bulk_lock: Mutex::new(()), + sq_drain: SingleCell::new(SqDrainState { + inflight: [0; SQ_DRAIN_SLOTS], + waker: WakerRegistration::new(), + }), } } } @@ -627,6 +661,9 @@ impl UnoHsmPal { if self.try_handle_pfn_enable(channel, buf).await { return; } + if self.try_handle_create_delete_sq(channel, buf).await { + return; + } buf[0] |= 0x80; self.ipc.reply(channel as u8, buf); } @@ -687,6 +724,198 @@ impl UnoHsmPal { true } + /// Records that a host IO accepted from `queue_id` has entered the + /// pipeline, for the live-migration SQ-delete drain. Paired with + /// [`sq_inflight_dec`](Self::sq_inflight_dec) in + /// [`drop_io`](azihsm_fw_hsm_pal_traits::HsmIoController::drop_io), the + /// universal IO teardown point. A `queue_id` outside the tracked range + /// is ignored — it can never be the target of an LM SQ-delete, whose + /// `device_sq_id` is an 8-bit id. + pub(crate) fn sq_inflight_inc(&self, queue_id: u16) { + self.sq_drain.with(|s| { + if let Some(c) = s.inflight.get_mut(queue_id as usize) { + *c = c.saturating_add(1); + } + }); + } + + /// Records that a host IO accepted from `queue_id` has finished + /// teardown. Wakes a pending SQ-delete drain once the queue's in-flight + /// count reaches zero. + pub(crate) fn sq_inflight_dec(&self, queue_id: u16) { + self.sq_drain.with(|s| { + if let Some(c) = s.inflight.get_mut(queue_id as usize) { + *c = c.saturating_sub(1); + if *c == 0 { + s.waker.wake(); + } + } + }); + } + + /// Clears the in-flight count for `queue_id` back to zero. + /// + /// Called when the Admin (re)creates a submission queue. A `queue_id` + /// reused across a live-migration restore must not inherit a stale, + /// non-zero count from the queue's previous life — every IO from that + /// prior life already completed through `drop_io` before the SQ was + /// deleted, so the correct baseline for a freshly created queue is zero. + pub(crate) fn sq_inflight_reset(&self, queue_id: u16) { + self.sq_drain.with(|s| { + if let Some(c) = s.inflight.get_mut(queue_id as usize) { + *c = 0; + } + }); + } + + /// Awaits until no host IO accepted from submission queue `queue_id` + /// remains in flight. + /// + /// The Admin disables the hardware submission queue before sending the + /// `CreateDeleteSq{Delete}` that drives this wait + /// (`admin/src/fsm/vf_stop.rs::disable_submission_queues`), so no new IO + /// for `queue_id` can be pushed into the shared ICQ after this point. + /// + /// Two conditions must both hold before the queue is quiesced: + /// + /// 1. **ICQ drained to the finish line.** Entries the hardware already + /// accepted into the shared ICQ but that `recv` has not yet pulled out + /// are not yet reflected in `inflight` (the counter is bumped in + /// `poll_io` *after* `recv` returns). On the first poll we latch + /// `target = icq_consumed + icq_pending` — the consumer count the IO + /// task must reach for every currently-accepted entry (including any + /// for this queue) to have been dequeued. `icq_consumed` advances + /// monotonically past `target`; other partitions' later IO only pushes + /// it higher, so the finish line is always crossed (no stall waiting on + /// an unrelated idle queue, no premature completion). + /// 2. **In-flight count drained.** Once dequeued, each IO for `queue_id` + /// is tracked in `inflight` until `drop_io`; the drain also waits for + /// that count to reach zero. + /// + /// Progress is driven by two wakers: the IIC `drain_waker` (woken on every + /// ICQ consumption, advancing condition 1) and `sq_drain.waker` (woken when + /// `inflight` hits zero, condition 2). A `queue_id` outside the tracked + /// range is already drained. Only one drain runs at a time: the Admin FSM + /// waits for each SQ-delete ACK before issuing the next, and `poll_ipc` + /// handles one IPC message to completion before the next. + async fn drain_sq(&self, queue_id: u16) { + let mut target: Option = None; + poll_fn(|cx| { + // Re-poll whenever the IO task consumes an ICQ entry. + self.iic.register_drain_waker(cx.waker()); + + // Latch the finish line on the first poll: every ICQ entry the + // hardware has already accepted must be consumed before the queue + // can be considered quiesced. + let goal = *target.get_or_insert_with(|| { + self.iic + .icq_consumed() + .wrapping_add(self.iic.icq_pending() as u32) + }); + + let icq_drained = self.iic.icq_consumed().wrapping_sub(goal) as i32 >= 0; + + self.sq_drain.with(|s| { + let inflight = s.inflight.get(queue_id as usize).copied().unwrap_or(0); + if icq_drained && inflight == 0 { + Poll::Ready(()) + } else { + s.waker.register(cx.waker()); + Poll::Pending + } + }) + }) + .await + } + + /// Handle a `CreateDeleteSq` IPC in the running state. + /// + /// Decodes the message; if it is a `CreateDeleteSq`, acknowledges the + /// submission-queue create/delete for the target partition. Returns + /// `true` when the message was handled here, `false` otherwise so the + /// caller falls back to the default echo path. + /// + /// This is the HSM-core half of the Admin's live-migration queue + /// teardown (see `admin/src/fsm/vf_stop.rs::disable_submission_queues`). + /// The Admin disables the hardware submission queue first, then sends + /// `Delete` here so the HSM core can quiesce IO already accepted from + /// that queue before VfSave snapshots GSRAM. `Create` is the symmetric + /// bring-up on restore. + /// + /// - `Create`: the Admin has already programmed the IIC hardware queue; + /// there is no software SQ registry to populate in uno (unlike the + /// reference's `enable_io_queue`), so the handler resets the drain + /// counter for this `device_sq_id` to zero (defensive against reuse + /// across a restore) and ACKs `Success`. + /// - `Delete`: drains host IO already accepted from this submission + /// queue, then ACKs `Success`. uno indexes in-flight IO by the + /// hardware `queue_id`, which the IIC tags onto each inbound IO and + /// which is identical to the Admin `device_sq_id` (the reference's + /// `DevSqId`); so the drain keys directly on `device_sq_id` with no + /// mapping table. Because the Admin disables the hardware queue before + /// sending `Delete`, no new IO for this queue enters the ICQ, so the + /// drain's finish line is fixed and always reached (see + /// [`drain_sq`](Self::drain_sq)). Unlike the reference, uno can `.await` + /// the drain inline on the IPC task and reply `Success` directly, rather + /// than returning `Pending` with a deferred completion. + async fn try_handle_create_delete_sq(&self, channel: IpcChannel, buf: &[u32; 16]) -> bool { + // Only claim the message when the opcode is `CreateDeleteSq`; anything + // else belongs to a later handler or the echo path. + if !is_create_delete_sq(buf) { + return false; + } + + // The opcode matched but the body failed layout, pfn-range, or action + // validation. Reject it with an error ACK rather than returning `false` + // and letting the echo path reply `Success` for a malformed message. + let Some(msg) = decode_create_delete_sq(buf) else { + let reply = encode_create_delete_sq_ack(buf, IpcMessageStatusCode::InvalidField); + self.ipc.reply(channel as u8, &reply); + return true; + }; + + // `decode_create_delete_sq` already validated the pfn range and the + // action, so `pid` is in range and `action` is Create/Delete here. + let pid = HsmPartId::from(msg.info.pfn); + + // A submission queue can only exist on an enabled function. Reject + // create/delete for a function that is not enabled, mirroring the + // reference firmware's `part.enabled()` guard. + if !self.part_is_enabled(pid) { + let reply = encode_create_delete_sq_ack(buf, IpcMessageStatusCode::FunctionNotEnabled); + self.ipc.reply(channel as u8, &reply); + return true; + } + + match SqAction(msg.info.action) { + SqAction::Create => { + // The Admin has already programmed the IIC hardware queue, + // so there is no software SQ registry to populate. Reset the + // in-flight drain counter to a clean zero baseline in case + // this `device_sq_id` is being reused across a restore, then + // acknowledge so the Admin FSM proceeds. + self.sq_inflight_reset(u16::from(msg.info.device_sq_id)); + } + SqAction::Delete => { + // Quiesce IO already accepted from this submission queue + // before acknowledging, so VfSave snapshots a consistent + // GSRAM. The Admin has already disabled the hardware queue + // (vf_stop.rs::disable_submission_queues), so no new IO can + // arrive for `device_sq_id` and this wait always terminates. + // uno's hardware `queue_id` *is* the Admin `device_sq_id` + // (identity — the IIC tags each inbound IO with its source + // queue id), so the drain keys directly on it. + self.drain_sq(u16::from(msg.info.device_sq_id)).await; + } + // Unreachable: `validate` rejects any other action during decode. + _ => {} + } + + let reply = encode_create_delete_sq_ack(buf, IpcMessageStatusCode::Success); + self.ipc.reply(channel as u8, &reply); + true + } + /// Handle a `SetResource` IPC in the running state. /// /// Decodes the message; if it is a `SetResource`, applies the @@ -735,7 +964,8 @@ impl UnoHsmPal { let result = if mask == 0 { self.part_free(pid).await } else { - self.part_alloc(pid, mask, is_pf).await + self.part_alloc(pid, mask, &msg.info.vm_launch_guid, is_pf) + .await }; let (status, count) = match result { Ok(()) => (IpcMessageStatusCode::Success, mask.count_ones() as u8), diff --git a/fw/plat/uno/fw/pal/src/part.rs b/fw/plat/uno/fw/pal/src/part.rs index 72af14536..fe31ffd8d 100644 --- a/fw/plat/uno/fw/pal/src/part.rs +++ b/fw/plat/uno/fw/pal/src/part.rs @@ -77,6 +77,7 @@ impl UnoHsmPal { &self, pid: HsmPartId, mask: u128, + vm_launch_guid: &[u8; 16], is_pf: bool, ) -> HsmResult<()> { let part = PartStore::partition(pid)?; @@ -94,6 +95,15 @@ impl UnoHsmPal { } part.set_res_mask(mask); + // Capture the host-provided VM-launch GUID carried by this + // `SetResource` into the persistent store, so a later `InitBk3` + // reports the correct VM identity to the host (and migration-restore + // preserves it). Mirrors the reference firmware's + // `part_init::handle_set_res_cnt`, which records the GUID alongside + // the resource mask. `part_free` above cleared any prior value, so + // reallocation always rebinds to the current launch's GUID. + part.set_vm_launch_guid(vm_launch_guid); + // Arm Gate 1 so the SP may stage an unwrapping key for this partition; // gate on `mask != 0` to never arm an idle PFN. The SP polls // `unwrapping_key_required` and, once armed, publishes the RSA-2048 key @@ -516,6 +526,19 @@ impl UnoHsmPal { } } + /// Returns `true` when partition `pid` is currently in + /// [`PartState::Enabled`]. + /// + /// Used by the `CreateDeleteSq` IPC handler to reject submission-queue + /// create/delete for a function that is not enabled (mirroring the + /// reference firmware's `part.enabled()` check). + pub(crate) fn part_is_enabled(&self, pid: HsmPartId) -> bool { + PartStore::partition(pid) + .and_then(|part| part.state()) + .map(|state| state == PartState::Enabled) + .unwrap_or(false) + } + /// Resets partition `pid`'s per-tenant state for an NSSR `Migrate`, /// mirroring the reference firmware's `state.migrate()`. /// @@ -593,6 +616,16 @@ impl UnoHsmPal { // `VaultSessionLimitReached` across the NSSR that `erase()` drives, // because the session slots were never freed. + // Snapshot the live session slots *before* the vault and session + // table are cleared, so a subsequent `ReopenSession` can re-key + // them. Mirrors the reference firmware's `state.migrate()`, which + // does `session_table().restore(session_table().backup())` across + // the reset. Without this, the migrated partition's slots are wiped + // and an in-session op on the old session id returns `SessionNotFound` + // instead of `SessionNeedsRenegotiation`, breaking live-migration + // reopen. + let session_backup = SessionStore::partition(pid)?.backup(); + // Wipe every vault key (app + session + internal) so no prior // tenant key material survives the reset, and release the partition's // fast-path engine bulk-key slots. Runs inside a `with_admin_io` @@ -606,6 +639,11 @@ impl UnoHsmPal { // Clear the per-tenant persistent state (including the session // table), preserving the partition's provisioning material. part.clear_state(PartResetKind::Migrate); + // Re-establish the snapshotted sessions as `NeedsRenegotiation` so a + // host that migrated mid-session can `ReopenSession` the same slot; + // their physical vault mappings were dropped by the wipe above and + // are re-keyed on reopen. Mirrors the reference `restore(backup())`. + SessionStore::partition(pid)?.restore(session_backup); // The `vault.clear()` above also deleted the identity private key. // Zero the identity fields (id, `id_key_id`, cached public key) // *before* awaiting so no concurrent reader can observe `id_key_id` @@ -876,7 +914,19 @@ impl HsmPartitionManager for UnoHsmPal { PartPropId::PSK_CO => p.set_psk_co(data), PartPropId::PSK_CU => p.set_psk_cu(data), PartPropId::CREDENTIAL => p.set_credential(data), - PartPropId::SEALED_BK3 => p.set_sealed_bk3(data), + // Write-once per power cycle: a second `SetSealedBk3` without an + // intervening clear (partition free / NSSR) returns + // `SealedBk3AlreadySet`, matching the std PAL (`part.rs` setter) + // and the `part_set_sealed_bk3` contract. The blob is preserved + // across `Migrate` (NSSR), so this one-shot gate must survive a + // live migration. The low-level store accessor keeps plain + // overwrite semantics; the policy lives here. + PartPropId::SEALED_BK3 => { + if !p.sealed_bk3().is_empty() { + return Err(HsmError::SealedBk3AlreadySet); + } + p.set_sealed_bk3(data) + } PartPropId::MASKED_BK_BOOT => p.set_masked_bk_boot(data), PartPropId::BK3_SESSION => p.set_bk3_session(data), PartPropId::POTA_THUMBPRINT => p.set_pota_thumbprint(data), diff --git a/fw/plat/uno/fw/pal/src/session.rs b/fw/plat/uno/fw/pal/src/session.rs index 5cc52eda9..1680ed390 100644 --- a/fw/plat/uno/fw/pal/src/session.rs +++ b/fw/plat/uno/fw/pal/src/session.rs @@ -79,19 +79,25 @@ impl HsmSessionManager for UnoHsmPal { if api_rev.len() != SESSION_API_REV_SIZE || masking_key.len() != SESSION_MASKING_KEY_SIZE { return Err(HsmError::InvalidArg); } - // Held until the slot is (re)created, so on re-key no bulk key can be - // registered for the old session between its key teardown and the - // recreation. + // Serialize slot (re)creation so concurrent ReopenSession / + // OpenSession calls cannot race on the same session table. let _guard = self.fp_bulk_lock.lock().await; let table = SessionStore::partition(io.pid())?; - // On re-key: tear down the old session-scoped keys and the old - // session key before creating the replacement. - if let Some(reopen_id) = id { - let old_phys = table.physical_id(reopen_id)?; - crate::vault::delete_session_keys(self, io, reopen_id).await?; - crate::vault::vault(io).delete(self, io, old_phys).await?; + // Validate the reopen target. The only valid target is a slot + // awaiting renegotiation after a live-migration disable; `restore()` + // already zeroed its physical vault mapping, so there is nothing to + // tear down and `recreate` below simply installs the fresh mapping + // (mirroring the reference firmware's `recreate_session`, and matching + // the `std` PAL). Every other state (Active, Pending, Invalid) is + // rejected up front with no side effects: tearing down the old keys + // here destroyed a live session and leaked its slot, because + // `recreate` then rejects the non-renegotiation slot anyway. + if let Some(reopen_id) = id + && !matches!(table.state(reopen_id), HsmSessionState::NeedsRenegotiation) + { + return Err(HsmError::InvalidArg); } // Build the 88-byte session blob in a DMA buffer: @@ -148,8 +154,36 @@ impl HsmSessionManager for UnoHsmPal { let mut table = SessionStore::partition(io.pid())?; - // Resolve the physical vault key id before the vault awaits (drops the - // session-store borrow before the awaits). + match table.state(id) { + // A slot awaiting renegotiation survived a live migration / NSSR, + // which already cleared the partition vault (`vault_clear`) and + // zeroed the physical key mapping, so there is no vault state to + // tear down — just free the logical slot. This is the + // post-migration cleanup path the IO gate routes `CloseSession` + // (and driver flush) through — see `classify_session_state`. + HsmSessionState::NeedsRenegotiation => { + table.delete(id)?; + return Ok(()); + } + // A Pending slot holds an in-flight TBOR handshake blob as a + // session-scoped `SessionExPending` vault key (see + // `session_create_pending`). Delete that key before freeing the + // slot so it does not leak. Resolve the physical id and drop the + // session-store borrow before the vault await. + HsmSessionState::Pending => { + let pending_phys = table.pending_phys(id)?; + crate::vault::vault(io) + .delete(self, io, pending_phys) + .await?; + table.delete(id)?; + return Ok(()); + } + // Active sessions fall through to full vault teardown below. + _ => {} + } + + // Active session: resolve the physical vault key id before the vault + // awaits (drops the session-store borrow before the awaits). let physical_id = table.physical_id(id)?; // Delete every session-scoped key bound to this logical session @@ -165,6 +199,31 @@ impl HsmSessionManager for UnoHsmPal { Ok(()) } + /// Roll back a failed post-migration re-key (`ReopenSession`). + /// + /// Deletes the fresh masking-key vault entry that + /// [`session_create`](Self::session_create) installed, then restores + /// the slot to [`NeedsRenegotiation`](HsmSessionState::NeedsRenegotiation) + /// (clearing its physical mapping), so neither the vault nor the slot + /// leaks and the host can retry the reopen. Mirrors the reference + /// firmware's `rollback_open_session` reopen branch. + async fn session_rollback_reopen(&self, io: &impl HsmIo, id: HsmSessId) -> HsmResult<()> { + // Hold the bulk-key lock for the same reason as `session_destroy`. + let _guard = self.fp_bulk_lock.lock().await; + + let mut table = SessionStore::partition(io.pid())?; + + // Resolve the physical vault key id the failed reopen installed + // before the vault await; delete it, then restore the slot's + // renegotiation state (zeroing its physical mapping). + let physical_id = table.physical_id(id)?; + crate::vault::vault(io) + .delete(self, io, physical_id) + .await?; + table.rollback_recreation(id)?; + Ok(()) + } + fn session_state(&self, io: &impl HsmIo, id: HsmSessId) -> HsmSessionState { let Ok(table) = SessionStore::partition(io.pid()) else { return HsmSessionState::Invalid; diff --git a/fw/plat/uno/xtask/src/main.rs b/fw/plat/uno/xtask/src/main.rs index 38acfa7ab..cc986cfff 100644 --- a/fw/plat/uno/xtask/src/main.rs +++ b/fw/plat/uno/xtask/src/main.rs @@ -31,6 +31,7 @@ mod reggen; mod rustup_component_add; mod setup; mod size; +mod test; /// Common context passed into every xtask. #[derive(Clone)] @@ -70,6 +71,7 @@ enum Commands { Audit(audit::Audit), Setup(setup::Setup), RustupComponentAdd(rustup_component_add::RustupComponentAdd), + Test(test::Test), } fn main() { @@ -109,5 +111,6 @@ fn try_main() -> anyhow::Result<()> { Commands::Audit(task) => task.run(ctx), Commands::Setup(task) => task.run(ctx), Commands::RustupComponentAdd(task) => task.run(ctx), + Commands::Test(task) => task.run(ctx), } } diff --git a/fw/plat/uno/xtask/src/precheck.rs b/fw/plat/uno/xtask/src/precheck.rs index 082c2adb9..8c51cb3d7 100644 --- a/fw/plat/uno/xtask/src/precheck.rs +++ b/fw/plat/uno/xtask/src/precheck.rs @@ -38,6 +38,9 @@ struct Stage { /// Run release build (firmware). #[clap(long)] build: bool, + /// Run unit tests (firmware, host target). + #[clap(long)] + test: bool, } impl Stage { @@ -49,6 +52,7 @@ impl Stage { || self.copyright || self.audit || self.build + || self.test } fn all() -> Self { @@ -60,13 +64,16 @@ impl Stage { copyright: true, audit: true, build: true, + test: true, } } } /// Run pre-commit checks across the uno firmware workspace. #[derive(Parser)] -#[clap(about = "Run all pre-commit checks (setup, fmt, clippy, copyright, audit, build, reggen)")] +#[clap( + about = "Run all pre-commit checks (setup, fmt, clippy, copyright, audit, build, reggen, test)" +)] pub struct Precheck { #[clap(flatten)] stage: Stage, @@ -175,6 +182,21 @@ impl Xtask for Precheck { } } + if stage.test { + log::info!("=== test (firmware) ==="); + if let Err(e) = (crate::test::Test { + package: Vec::new(), + release: false, + features: None, + no_default_features: false, + args: Vec::new(), + }) + .run(ctx.clone()) + { + errors.push(("test", e)); + } + } + if errors.is_empty() { log::info!("All prechecks passed!"); Ok(()) diff --git a/fw/plat/uno/xtask/src/test.rs b/fw/plat/uno/xtask/src/test.rs new file mode 100644 index 000000000..260b99950 --- /dev/null +++ b/fw/plat/uno/xtask/src/test.rs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +//! Firmware test command. + +use clap::Parser; +use xshell::cmd; +use xshell::Shell; + +use crate::fw_util::FwPaths; +use crate::Xtask; +use crate::XtaskCtx; + +/// Host target used to run firmware unit tests. +/// +/// The firmware workspace builds for `thumbv7em-none-eabi` by default (see +/// `fw/.cargo/config.toml`), which cannot host a test harness. Unit tests are +/// therefore compiled and executed for the host target instead. +const HOST_TARGET: &str = "x86_64-unknown-linux-gnu"; + +/// Run firmware unit tests on the host target. +#[derive(Parser)] +#[clap(about = "Run firmware unit tests (host target)")] +pub struct Test { + /// Only test the specified package(s). Defaults to the whole workspace. + #[clap(short, long)] + pub package: Vec, + + /// Build and test in release mode. + #[clap(long)] + pub release: bool, + + /// Test with the given features enabled. + #[clap(long)] + pub features: Option, + + /// Test without the crate's default features. + #[clap(long)] + pub no_default_features: bool, + + /// Arguments passed through to the test harness (e.g. a test name filter). + #[clap(trailing_var_arg = true)] + pub args: Vec, +} + +impl Xtask for Test { + fn run(self, ctx: XtaskCtx) -> anyhow::Result<()> { + let sh = Shell::new()?; + let fw = FwPaths::new(&ctx)?; + let _dir = sh.push_dir(&fw.fw_dir); + + let mut args = vec!["test", "--target", HOST_TARGET]; + + if self.package.is_empty() { + args.push("--workspace"); + } else { + for package in &self.package { + args.push("--package"); + args.push(package); + } + } + + if self.release { + args.push("--release"); + } + + if self.no_default_features { + args.push("--no-default-features"); + } + + if let Some(features) = self + .features + .as_ref() + .filter(|features| !features.trim().is_empty()) + { + args.push("--features"); + args.push(features); + } + + if !self.args.is_empty() { + args.push("--"); + for arg in &self.args { + args.push(arg); + } + } + + log::info!("Running firmware tests on {HOST_TARGET}..."); + cmd!(sh, "cargo {args...}").run()?; + log::info!("Firmware tests complete."); + Ok(()) + } +}