From 4e6f75abcc53806a7b9b31eb95b2e62a272fd490 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 17:58:11 +0000 Subject: [PATCH 1/8] refactor(fw): make TBOR CU/CO session masking key 32 B AES-256-GCM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TBOR SessionEx (CU/CO) per-session `masking_key` was carried as an 80 B `aes32 ‖ hmac48` key shaped for the legacy `key_masking::cbc` MBOR masked-key system, even though TBOR masked keys are (and will be) wrapped with `key_masking::aead` (AES-256-GCM), which needs only a 32 B key. The extra 48 B were dead weight in every CU/CO session blob and blurred the key's purpose. Reshape the SessionEx masking key to a native 32 B AES-256-GCM key: * `SESSION_MASKING_KEY_LEN` 80 → 32 (CU/CO only). The legacy MBOR `Session` blob keeps its 80 B cbc key via the std/uno-PAL-local `SESSION_MASKING_KEY_SIZE`, so MBOR's cbc masked-key system is unaffected. * std + uno PAL: CU/CO blob shrinks (CU 120→72 B, CO 216→168 B); the std `session_masking_key` reader now sizes the returned key by blob kind — 32 B for SessionEx blobs, 80 B for the legacy Session blob — so MBOR still reads its full 80 B key. `session_promote` validation follows the const. * `session_open_finish` derives the 32 B key (auto via the const); the `bmk_session` envelope is variable-length (`BMK_SESSION_MAX_LEN`), so the shorter payload needs no schema change. Foundational for the upcoming TBOR HMAC/crypto masked-key commands, which resolve a scope's 32 B AEAD masking key uniformly (Session → this key). Validation: TBOR emu 108/108 (CU + CO sessions, close, reopen, SD roundtrips); MBOR emu 225/225 (masked_key, unmask_key, aes_gen, live-migration/reopen — legacy 80 B path intact); std + uno fmt + clippy clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09e50a-a9be-4bae-b347-d42dc775a258 --- .../lib/src/ddi/tbor/session_open_finish.rs | 4 +-- fw/pal/traits/src/lib.rs | 15 ++++++---- fw/plat/std/pal/src/session.rs | 29 +++++++++++-------- fw/plat/uno/fw/pal/src/session.rs | 19 +++++++----- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/fw/core/lib/src/ddi/tbor/session_open_finish.rs b/fw/core/lib/src/ddi/tbor/session_open_finish.rs index db57b909c..3566a8fd6 100644 --- a/fw/core/lib/src/ddi/tbor/session_open_finish.rs +++ b/fw/core/lib/src/ddi/tbor/session_open_finish.rs @@ -85,8 +85,8 @@ struct ParsedRequest<'a> { /// * `param_key` — 32 B AES-256 key used to AEAD-open `seed_envelope` /// (this handler) and to authenticate per-parameter envelopes in /// in-session commands like `PskChange`. Always populated. -/// * `masking_key` — 80 B `aes32 ‖ hmac48` used by the `cbc::mask` -/// masked-key system. Always populated. +/// * `masking_key` — 32 B AES-256-GCM key for the `key_masking::aead` +/// TBOR masked-key system. Always populated. /// * `mac_tx_key` — 48 B HMAC-SHA-384 key for outbound (HSM → host) /// message MACs. `Some` iff `session_type` is `Authenticated`. /// * `mac_rx_key` — 48 B HMAC-SHA-384 key for inbound (host → HSM) diff --git a/fw/pal/traits/src/lib.rs b/fw/pal/traits/src/lib.rs index 75444b9b0..5c720eef1 100644 --- a/fw/pal/traits/src/lib.rs +++ b/fw/pal/traits/src/lib.rs @@ -429,13 +429,16 @@ pub const SESSION_BMK_KEY_LABEL: &[u8] = b"SMK"; /// [`azihsm_fw_core_crypto_aead_envelope`]. pub const SESSION_PARAM_KEY_LEN: usize = 32; -/// Length in bytes of the per-session `masking_key`. +/// Length in bytes of the per-session `masking_key` for TBOR +/// **SessionEx (CU/CO)** sessions. /// -/// 80 B = AES-CBC-256 key (32 B) ‖ HMAC-SHA-384 key (48 B). Consumed -/// by the `key_masking::cbc`-based MBOR masked-key system; unrelated to -/// [`SESSION_PARAM_KEY_LEN`] which now refers to the AEAD-GCM -/// per-session wrap key. Present for both CO and CU sessions. -pub const SESSION_MASKING_KEY_LEN: usize = 80; +/// 32 B AES-256-GCM key used by the `key_masking::aead`-based TBOR +/// masked-key system (masked keys are scoped and wrapped under AES-GCM). +/// Present for both CO and CU SessionEx blobs. Distinct from the legacy +/// MBOR `Session`-blob masking key (an 80 B `aes32 ‖ hmac48` cbc key, +/// sized by the std-PAL-local `SESSION_MASKING_KEY_SIZE`), which the +/// `key_masking::cbc`-based MBOR masked-key system continues to use. +pub const SESSION_MASKING_KEY_LEN: usize = 32; /// Length in bytes of each directional message-MAC key (HMAC-SHA-384). /// diff --git a/fw/plat/std/pal/src/session.rs b/fw/plat/std/pal/src/session.rs index 1efc7f588..1f39561ce 100644 --- a/fw/plat/std/pal/src/session.rs +++ b/fw/plat/std/pal/src/session.rs @@ -34,12 +34,14 @@ const SESSION_MASKING_KEY_SIZE: usize = 80; const SESSION_BLOB_SIZE: usize = SESSION_API_REV_SIZE + SESSION_MASKING_KEY_SIZE; /// `SessionEx`-kind blob size for **PlainText (CU)** sessions: -/// `api_rev(8) || param_key(32) || masking_key(80)` = 120 B. +/// `api_rev(8) || param_key(32) || masking_key(32)` = 72 B. The +/// SessionEx masking key is the 32 B AES-256-GCM `key_masking::aead` +/// key ([`SESSION_MASKING_KEY_LEN`]), not the legacy 80 B cbc key. const SESSION_CU_BLOB_SIZE: usize = SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN + SESSION_MASKING_KEY_LEN; /// `SessionEx`-kind blob size for **Authenticated (CO)** sessions: -/// PlainText blob ‖ `mac_tx(48) ‖ mac_rx(48)` = 216 B. +/// PlainText blob ‖ `mac_tx(48) ‖ mac_rx(48)` = 168 B. const SESSION_CU_AUTH_BLOB_SIZE: usize = SESSION_CU_BLOB_SIZE + 2 * SESSION_MAC_DIR_KEY_LEN; impl HsmSessionManager for StdHsmPal { @@ -228,8 +230,8 @@ impl HsmSessionManager for StdHsmPal { let attrs = HsmVaultKeyAttrs::new().with_internal(true); // Length-discriminated blob: - // - PlainText: api_rev(8) + param_key(32) + masking_key(80) = 120 B - // - Authenticated: above ‖ mac_tx(48) ‖ mac_rx(48) = 216 B + // - PlainText: api_rev(8) + param_key(32) + masking_key(32) = 72 B + // - Authenticated: above ‖ mac_tx(48) ‖ mac_rx(48) = 168 B let mut blob = [0u8; SESSION_CU_AUTH_BLOB_SIZE]; blob[..SESSION_API_REV_SIZE].copy_from_slice(api_rev); blob[SESSION_API_REV_SIZE..SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN] @@ -280,16 +282,19 @@ impl HsmSessionManager for StdHsmPal { let kid = entry.session_table.physical_id(id)?; let blob = entry.vault.key(kid)?; // The masking key follows `api_rev` in a legacy MBOR `Session` - // blob, or `api_rev ‖ param_key` in a `SessionEx` (CU/CO) blob; - // pick the offset from the blob length so both schedules work. - let offset = match blob.len() { - SESSION_BLOB_SIZE => SESSION_API_REV_SIZE, - SESSION_CU_BLOB_SIZE | SESSION_CU_AUTH_BLOB_SIZE => { - SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN - } + // blob (80 B `aes32 ‖ hmac48` cbc key), or `api_rev ‖ param_key` + // in a `SessionEx` (CU/CO) blob (32 B AES-256-GCM aead key); pick + // the offset *and length* from the blob length so both schedules + // work. + let (offset, size) = match blob.len() { + SESSION_BLOB_SIZE => (SESSION_API_REV_SIZE, SESSION_MASKING_KEY_SIZE), + SESSION_CU_BLOB_SIZE | SESSION_CU_AUTH_BLOB_SIZE => ( + SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN, + SESSION_MASKING_KEY_LEN, + ), _ => return Err(HsmError::InternalError), }; - let key_bytes = &blob[offset..offset + SESSION_MASKING_KEY_SIZE]; + let key_bytes = &blob[offset..offset + size]; // SAFETY: same justification as `session_param_key` — on the // host, any heap byte is reachable; branding the sub-slice as // `DmaBuf` only satisfies the type system. diff --git a/fw/plat/uno/fw/pal/src/session.rs b/fw/plat/uno/fw/pal/src/session.rs index bfd7ec6ba..84d180e63 100644 --- a/fw/plat/uno/fw/pal/src/session.rs +++ b/fw/plat/uno/fw/pal/src/session.rs @@ -32,6 +32,7 @@ use azihsm_fw_hsm_pal_traits::HsmVault; use azihsm_fw_hsm_pal_traits::HsmVaultKeyAttrs; use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; use azihsm_fw_hsm_pal_traits::SESSION_MAC_DIR_KEY_LEN; +use azihsm_fw_hsm_pal_traits::SESSION_MASKING_KEY_LEN; use azihsm_fw_hsm_pal_traits::SESSION_PARAM_KEY_LEN; use azihsm_fw_hsm_pal_traits::SESSION_PENDING_BLOB_MAX; use azihsm_fw_hsm_pal_traits::SessionRole; @@ -42,20 +43,22 @@ use crate::UnoHsmPal; /// API-revision portion of the session blob (bytes). const SESSION_API_REV_SIZE: usize = 8; -/// Masking-key portion of the session blob: AES-CBC-256 (32) + HMAC-SHA-384 -/// (48) = 80 bytes. +/// Masking-key portion of a **legacy MBOR `Session`** blob: AES-CBC-256 +/// (32) + HMAC-SHA-384 (48) = 80 bytes (the `key_masking::cbc` key). const SESSION_MASKING_KEY_SIZE: usize = 80; /// `Session`-kind blob: `[api_rev(8) || masking_key(80)]` = 88 bytes. const SESSION_BLOB_SIZE: usize = SESSION_API_REV_SIZE + SESSION_MASKING_KEY_SIZE; /// `SessionEx` plaintext (CU) blob: -/// `[api_rev(8) || param_key(32) || masking_key(80)]` = 120 bytes. +/// `[api_rev(8) || param_key(32) || masking_key(32)]` = 72 bytes. The +/// SessionEx masking key is the 32 B AES-256-GCM `key_masking::aead` key +/// ([`SESSION_MASKING_KEY_LEN`]), not the legacy 80 B cbc key. const SESSION_CU_BLOB_SIZE: usize = - SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN + SESSION_MASKING_KEY_SIZE; + SESSION_API_REV_SIZE + SESSION_PARAM_KEY_LEN + SESSION_MASKING_KEY_LEN; /// `SessionEx` authenticated (CO) blob: the plaintext blob followed by -/// `mac_tx(48) || mac_rx(48)` = 216 bytes. +/// `mac_tx(48) || mac_rx(48)` = 168 bytes. const SESSION_CU_AUTH_BLOB_SIZE: usize = SESSION_CU_BLOB_SIZE + 2 * SESSION_MAC_DIR_KEY_LEN; impl HsmSessionManager for UnoHsmPal { @@ -237,7 +240,7 @@ impl HsmSessionManager for UnoHsmPal { ) -> HsmResult<()> { if api_rev.len() != SESSION_API_REV_SIZE || param_key.len() != SESSION_PARAM_KEY_LEN - || masking_key.len() != SESSION_MASKING_KEY_SIZE + || masking_key.len() != SESSION_MASKING_KEY_LEN { return Err(HsmError::InvalidArg); } @@ -261,8 +264,8 @@ impl HsmSessionManager for UnoHsmPal { } // Build the length-discriminated SessionEx blob: - // PlainText: api_rev(8) ‖ param_key(32) ‖ masking_key(80) = 120 B - // Authenticated: above ‖ mac_tx(48) ‖ mac_rx(48) = 216 B + // PlainText: api_rev(8) ‖ param_key(32) ‖ masking_key(32) = 72 B + // Authenticated: above ‖ mac_tx(48) ‖ mac_rx(48) = 168 B let blob_len = match mac_pair { None => SESSION_CU_BLOB_SIZE, Some(_) => SESSION_CU_AUTH_BLOB_SIZE, From a6b885daec09528dcf1a09c2ed029508198b47ac Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 18:09:09 +0000 Subject: [PATCH 2/8] refactor(fw): move hmac_hash to the shared ddi layer Relocate the `HsmVaultKeyKind -> HsmHashAlgo` HMAC mapping out of the MBOR-only `mbor::from_pal` module up to the codec-neutral `ddi` module (alongside `recover_bk_boot`), so the upcoming TBOR HMAC commands can reuse it without depending on `mbor`. Pure refactor: MBOR's `Hmac` handler now calls `crate::ddi::hmac_hash`; behavior is unchanged. Validation: MBOR HMAC emu 36/36 (fixed + var-len HMAC, all SHA variants, sign-permission, KBKDF-derived, masked-key HMAC); fmt + clippy clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09e50a-a9be-4bae-b347-d42dc775a258 --- fw/core/lib/src/ddi/mbor/from_pal.rs | 17 ----------------- fw/core/lib/src/ddi/mbor/hmac.rs | 2 +- fw/core/lib/src/ddi/mod.rs | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/fw/core/lib/src/ddi/mbor/from_pal.rs b/fw/core/lib/src/ddi/mbor/from_pal.rs index 11e257140..f7127f7a3 100644 --- a/fw/core/lib/src/ddi/mbor/from_pal.rs +++ b/fw/core/lib/src/ddi/mbor/from_pal.rs @@ -21,29 +21,12 @@ use azihsm_fw_ddi_mbor_types::DdiKeyType; use azihsm_fw_hsm_pal_traits::HsmEccCurve; use azihsm_fw_hsm_pal_traits::HsmError; -use azihsm_fw_hsm_pal_traits::HsmHashAlgo; use azihsm_fw_hsm_pal_traits::HsmResult; use azihsm_fw_hsm_pal_traits::HsmRsaKey; use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; // ── HsmVaultKeyKind → … ─────────────────────────────────────────── -/// Map an HMAC vault kind to the hash algorithm whose digest length -/// is the MAC tag size. -/// -/// Accepts both the fixed-length (`_HmacSha*`) and variable-length -/// (`VarLenHmacSha*`) HMAC kinds — mirroring the reference firmware's -/// `Hmac` handler. Any non-HMAC kind returns -/// [`HsmError::InvalidKeyType`]. -pub(crate) fn hmac_hash(kind: HsmVaultKeyKind) -> HsmResult { - match kind { - HsmVaultKeyKind::_HmacSha256 | HsmVaultKeyKind::VarLenHmacSha256 => Ok(HsmHashAlgo::Sha256), - HsmVaultKeyKind::_HmacSha384 | HsmVaultKeyKind::VarLenHmacSha384 => Ok(HsmHashAlgo::Sha384), - HsmVaultKeyKind::_HmacSha512 | HsmVaultKeyKind::VarLenHmacSha512 => Ok(HsmHashAlgo::Sha512), - _ => Err(HsmError::InvalidKeyType), - } -} - /// Map an ECC private vault kind to its [`HsmEccCurve`]. /// Non-ECC kinds return [`HsmError::InvalidKeyType`]. pub(crate) fn ecc_curve(kind: HsmVaultKeyKind) -> HsmResult { diff --git a/fw/core/lib/src/ddi/mbor/hmac.rs b/fw/core/lib/src/ddi/mbor/hmac.rs index adb571d1f..1cf736b84 100644 --- a/fw/core/lib/src/ddi/mbor/hmac.rs +++ b/fw/core/lib/src/ddi/mbor/hmac.rs @@ -36,7 +36,7 @@ pub(crate) async fn hmac<'p, P: HsmPal>( // Resolve the key's hash variant — an unknown id surfaces as // `KeyNotFound`, a non-HMAC kind as `InvalidKeyType`. - let algo = super::from_pal::hmac_hash(pal.vault_key_kind(io, key_id)?)?; + let algo = crate::ddi::hmac_hash(pal.vault_key_kind(io, key_id)?)?; let tag_len = algo.digest_len(); // Generating a MAC is a PKCS#11 `C_Sign` operation, so the key diff --git a/fw/core/lib/src/ddi/mod.rs b/fw/core/lib/src/ddi/mod.rs index af10b7609..bcfd08a7a 100644 --- a/fw/core/lib/src/ddi/mod.rs +++ b/fw/core/lib/src/ddi/mod.rs @@ -52,3 +52,22 @@ pub(crate) async fn recover_bk_boot( } azihsm_fw_core_crypto_key_derive::unmask_bk_boot(pal, io, masked_buf, out).await } + +/// Map an HMAC vault kind to the hash algorithm whose digest length is +/// the MAC tag size. +/// +/// Accepts both the fixed-length (`_HmacSha*`) and variable-length +/// (`VarLenHmacSha*`) HMAC kinds. Any non-HMAC kind returns +/// [`HsmError::InvalidKeyType`]. +/// +/// Shared across both wire codecs (`Hmac` in [`mbor`], and the HMAC +/// crypto commands in [`tbor`]), so it lives at the command level rather +/// than in either codec — keeping `tbor` independent of `mbor`. +pub(crate) fn hmac_hash(kind: HsmVaultKeyKind) -> HsmResult { + match kind { + HsmVaultKeyKind::_HmacSha256 | HsmVaultKeyKind::VarLenHmacSha256 => Ok(HsmHashAlgo::Sha256), + HsmVaultKeyKind::_HmacSha384 | HsmVaultKeyKind::VarLenHmacSha384 => Ok(HsmHashAlgo::Sha384), + HsmVaultKeyKind::_HmacSha512 | HsmVaultKeyKind::VarLenHmacSha512 => Ok(HsmHashAlgo::Sha512), + _ => Err(HsmError::InvalidKeyType), + } +} From c4bd905ada07ee3429a69cd1442c7b4ff9fc2415 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 20:01:32 +0000 Subject: [PATCH 3/8] feat(tbor): implement RSA-AES key unwrap (GetUnwrappingKey + UnwrapKey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the TBOR key-import pair that lets a host RSA-AES-wrap a key to a partition and re-import it as a masked (unmask-on-use) blob — the TBOR analogue of MBOR `RsaUnwrap`, but re-masking the recovered key under a scope's masking key instead of vaulting it under a key handle. GetUnwrappingKey (opcode 0x13): return the partition's RSA-2048 unwrapping public key (`n_le(256) ‖ e_le(4)`). The private half never leaves the device; `UnwrapKey` resolves it internally by the partition's `RSA_UNWRAPPING_KEY_ID` property. The key is materialised lazily (std PAL) or in the background from partition init (hardware); an absent key surfaces as `PendingKeyGeneration` so the host retries. UnwrapKey (opcode 0x14): within an open CO/CU session, unwrap a host-supplied `RSA-OAEP(KEK) ‖ AES-KWP(key)` blob with the unwrapping key and return the recovered key masked (AEAD-GCM-256) under the requested scope's masking key. Supports the AES, RSA (plain/CRT), ECC, and HMAC key classes via the shared `azihsm_fw_hsm_key_unwrap` / `azihsm_fw_hsm_key_decode` crates; the decode crate gains an HMAC path (32/48/64 B -> _HmacShaN). The re-derived wire public key is returned for the asymmetric classes. As the first general crypto command in the TBOR stack, this introduces the shared session/masking helpers `validate_active_session` and `resolve_masking_key(scope, sess_id)`, and moves the `HashAlgo` wire enum into the shared `key_props` module. Encoder "reserve + fill" support: the TBOR derive now emits a `_reserve(len)` setter for `#[tbor(mutable)]` buffer / sealed_key fields, which reserves the slot without copying so the response can be built with reserved slots and then filled in place via `decode_mut` (zero-copy, mirroring MBOR's `reserve` / `from_layout`). GetUnwrappingKey uses it to have the PAL derive the wire public key straight into the response — no scratch buffer, no copy. Wires both opcodes through the fw dispatcher, the `is_known_opcode` / `is_in_session` / `needs_session_id_cross_check` classifiers, and `op::SessionCtrl::from_tbor_opcode`. Adds fw + host wire schemas, emu round-trip tests (GetUnwrappingKey; UnwrapKey AES + HMAC key-class), and command docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09e50a-a9be-4bae-b347-d42dc775a258 Mask RSA-4096 keys within the fixed per-IO DMA budget: the recovered key is vaulted transiently (created inside an allocation scope that frees the multi-KB unwrap/decode scratch, masked from vault storage — which lives outside the per-IO DMA arena — then deleted, registered in the undo log for abort-safe cleanup), mirroring MBOR RsaUnwrap. The masked blob and wire public key are derived straight into reserved response slots (no scratch buffers), and aead::mask no longer stages a redundant plaintext copy. This keeps RSA-3072/4096 (CRT and non-CRT) UnwrapKey within 8 KB; new emu tests cover RSA-4096 CRT and non-CRT. --- ddi/tbor/types/src/get_unwrapping_key.rs | 61 ++++ ddi/tbor/types/src/lib.rs | 4 + ddi/tbor/types/src/unwrap_key.rs | 103 ++++++ .../tests/commands/get_unwrapping_key.rs | 66 ++++ ddi/tbor/types/tests/commands/mod.rs | 2 + ddi/tbor/types/tests/commands/unwrap_key.rs | 183 ++++++++++ docs/tbor-ddi/README.md | 2 + docs/tbor-ddi/commands/get_unwrapping_key.md | 66 ++++ docs/tbor-ddi/commands/unwrap_key.md | 116 ++++++ fw/core/crypto/key-masking/src/aead/encode.rs | 32 +- fw/core/ddi/tbor/derive/src/codegen_enc.rs | 115 +++++- fw/core/ddi/tbor/derive/src/schema.rs | 7 + .../ddi/tbor/types/src/get_unwrapping_key.rs | 97 +++++ fw/core/ddi/tbor/types/src/key_props.rs | 27 +- fw/core/ddi/tbor/types/src/lib.rs | 4 + fw/core/ddi/tbor/types/src/unwrap_key.rs | 188 ++++++++++ fw/core/key-decode/src/hmac.rs | 34 ++ fw/core/key-decode/src/lib.rs | 4 + .../lib/src/ddi/tbor/get_unwrapping_key.rs | 89 +++++ fw/core/lib/src/ddi/tbor/mod.rs | 77 +++- fw/core/lib/src/ddi/tbor/unwrap_key.rs | 341 ++++++++++++++++++ fw/core/lib/src/op.rs | 4 +- fw/pal/traits/src/session.rs | 20 +- 23 files changed, 1616 insertions(+), 26 deletions(-) create mode 100644 ddi/tbor/types/src/get_unwrapping_key.rs create mode 100644 ddi/tbor/types/src/unwrap_key.rs create mode 100644 ddi/tbor/types/tests/commands/get_unwrapping_key.rs create mode 100644 ddi/tbor/types/tests/commands/unwrap_key.rs create mode 100644 docs/tbor-ddi/commands/get_unwrapping_key.md create mode 100644 docs/tbor-ddi/commands/unwrap_key.md create mode 100644 fw/core/ddi/tbor/types/src/get_unwrapping_key.rs create mode 100644 fw/core/ddi/tbor/types/src/unwrap_key.rs create mode 100644 fw/core/key-decode/src/hmac.rs create mode 100644 fw/core/lib/src/ddi/tbor/get_unwrapping_key.rs create mode 100644 fw/core/lib/src/ddi/tbor/unwrap_key.rs diff --git a/ddi/tbor/types/src/get_unwrapping_key.rs b/ddi/tbor/types/src/get_unwrapping_key.rs new file mode 100644 index 000000000..52640ddd5 --- /dev/null +++ b/ddi/tbor/types/src/get_unwrapping_key.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `GetUnwrappingKey` command. +//! +//! `GetUnwrappingKey` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that returns the partition's RSA-2048 **unwrapping** +//! public key, which the host uses to RSA-AES key-wrap a payload for a +//! future `UnwrapKey` import. The unwrapping key is a device-provisioned +//! partition-internal key; only its public half is returned. +//! +//! RSA key generation is expensive, so the key is materialised lazily; an +//! absent key surfaces as `PendingKeyGeneration` (the host retries). + +use crate::tbor; + +/// TBOR opcode for `GetUnwrappingKey`. +pub const TBOR_OP_GET_UNWRAPPING_KEY: u8 = 0x13; + +/// Wire length of the RSA-2048 unwrapping public key in HSM format: +/// `n_le(256) ‖ e_le(4)`. +pub const UNWRAPPING_PUB_KEY_LEN: usize = 256 + 4; + +/// Host-facing TBOR `GetUnwrappingKey` request. +#[tbor(opcode = TBOR_OP_GET_UNWRAPPING_KEY, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TborGetUnwrappingKeyReq { + /// Session id this request is bound to. Cross-checked against the + /// SQE-carried session id by the dispatcher. + #[tbor(session_id)] + pub session_id: u16, +} + +/// Host-facing TBOR `GetUnwrappingKey` response. +#[tbor(response)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TborGetUnwrappingKeyResp { + /// The RSA-2048 unwrapping public key in HSM wire format + /// (`n_le(256) ‖ e_le(4)`), exactly [`UNWRAPPING_PUB_KEY_LEN`] (260) + /// bytes. + pub pub_key: [u8; UNWRAPPING_PUB_KEY_LEN], +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_session_id() { + let req = TborGetUnwrappingKeyReq { session_id: 11 }; + let mut buf = [0u8; 128]; + let frame = req.encode_request(&mut buf).expect("encode"); + // The session id (11) must appear in the encoded frame. + assert!( + frame.contains(&11), + "encoded frame must carry the session id" + ); + } +} diff --git a/ddi/tbor/types/src/lib.rs b/ddi/tbor/types/src/lib.rs index 3398c075c..2b3bd2ad9 100644 --- a/ddi/tbor/types/src/lib.rs +++ b/ddi/tbor/types/src/lib.rs @@ -80,6 +80,7 @@ impl From for u8 { mod api_rev; mod evidence; +mod get_unwrapping_key; mod key_report; mod part_final; mod part_info; @@ -97,9 +98,11 @@ mod session_close; mod session_open_finish; mod session_open_init; mod status; +mod unwrap_key; pub use api_rev::*; pub use evidence::*; +pub use get_unwrapping_key::*; pub use key_report::*; pub use part_final::*; pub use part_info::*; @@ -117,6 +120,7 @@ pub use session_close::*; pub use session_open_finish::*; pub use session_open_init::*; pub use status::*; +pub use unwrap_key::*; /// Trait implemented by host-side TBOR request value types. /// diff --git a/ddi/tbor/types/src/unwrap_key.rs b/ddi/tbor/types/src/unwrap_key.rs new file mode 100644 index 000000000..679a71a8f --- /dev/null +++ b/ddi/tbor/types/src/unwrap_key.rs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `UnwrapKey` command. +//! +//! `UnwrapKey` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that RSA-AES-unwraps a host-supplied wrapped key +//! (AES / RSA / ECC / HMAC) with the partition's unwrapping key (see +//! [`GetUnwrappingKey`](crate::get_unwrapping_key)) and returns it as a +//! **masked** blob under the requested scope's masking key — plus the +//! re-derived wire public key for RSA / ECC. +//! +//! The `scope`, `key_class`, and `oaep_hash` are raw 1-byte discriminants +//! (the firmware types them as the `KeyScope` / `KeyClass` / `HashAlgo` +//! open-enums; this host crate is firewalled from the firmware PAL types). + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `UnwrapKey`. +pub const TBOR_OP_UNWRAP_KEY: u8 = 0x14; + +/// Max wrapped-blob length (`RSA-OAEP(KEK) ‖ AES-KWP(key)`). +pub const UNWRAP_WRAPPED_BLOB_MAX_LEN: usize = 3072; +/// Max masked recovered-key envelope length. +pub const UNWRAP_MASKED_KEY_MAX_LEN: usize = 3072; +/// Max recovered public-key length. +pub const UNWRAP_PUB_KEY_MAX_LEN: usize = 520; + +/// `KeyClass` discriminant for a raw AES key. +pub const KEY_CLASS_AES: u8 = 0; +/// `KeyClass` discriminant for a DER RSA private key (non-CRT). +pub const KEY_CLASS_RSA: u8 = 1; +/// `KeyClass` discriminant for a DER RSA private key (CRT). +pub const KEY_CLASS_RSA_CRT: u8 = 2; +/// `KeyClass` discriminant for a PKCS#8 DER ECC private key. +pub const KEY_CLASS_ECC: u8 = 3; +/// `KeyClass` discriminant for a raw HMAC key. +pub const KEY_CLASS_HMAC: u8 = 4; + +/// Host-facing TBOR `UnwrapKey` request. +#[tbor(opcode = TBOR_OP_UNWRAP_KEY, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborUnwrapKeyReq { + /// Session id this request is bound to. + #[tbor(session_id)] + pub session_id: u16, + + /// Requested key scope (masks the recovered key), 1-byte `KeyScope`. + pub scope: u8, + + /// Class of the wrapped key, 1-byte `KeyClass` (see `KEY_CLASS_*`). + pub key_class: u8, + + /// OAEP hash used to wrap the KEK, 1-byte `HashAlgo`. + pub oaep_hash: u8, + + /// The RSA-AES-wrapped key (`RSA-OAEP(KEK) ‖ AES-KWP(key)`). + #[tbor(max_len = 3072)] + pub wrapped_blob: Vec, +} + +/// Host-facing TBOR `UnwrapKey` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborUnwrapKeyResp { + /// The recovered key's vault-kind discriminant. + pub key_kind: u8, + + /// The recovered key, masked under the scope's masking key. + #[tbor(max_len = 3072)] + pub masked_key: Vec, + + /// The recovered key's wire public key for RSA / ECC; empty for + /// symmetric (AES / HMAC) keys. + #[tbor(max_len = 520)] + pub pub_key: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_fields() { + let req = TborUnwrapKeyReq { + session_id: 7, + scope: 0b011, + key_class: KEY_CLASS_HMAC, + oaep_hash: 1, + wrapped_blob: alloc::vec![0x5Au8; 300], + }; + let mut buf = [0u8; 4096]; + let frame = req.encode_request(&mut buf).expect("encode"); + assert!( + frame.contains(&KEY_CLASS_HMAC), + "encoded frame must carry the key-class discriminant", + ); + } +} diff --git a/ddi/tbor/types/tests/commands/get_unwrapping_key.rs b/ddi/tbor/types/tests/commands/get_unwrapping_key.rs new file mode 100644 index 000000000..26df9938a --- /dev/null +++ b/ddi/tbor/types/tests/commands/get_unwrapping_key.rs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `GetUnwrappingKey` command. +//! +//! The command returns the partition's RSA-2048 unwrapping public key +//! (HSM wire format `n_le(256) ‖ e_le(4)` = 260 bytes), which the host +//! uses to RSA-AES key-wrap a payload for a future `UnwrapKey` import. +//! The std (emulator) PAL materialises the key lazily on first read. +//! +//! Coverage: +//! * Happy path — returns a full, non-zero 260-byte public key. +//! * Stability — a second call returns the same (stable) key. + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::TborGetUnwrappingKeyReq; +use azihsm_ddi_tbor_types::UNWRAPPING_PUB_KEY_LEN; + +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::harness::TestCtx; + +#[test] +fn get_unwrapping_key_returns_rsa_pub_key_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let req = TborGetUnwrappingKeyReq { + session_id: session.session_id, + }; + let resp = ctx.tbor(&req).expect("GetUnwrappingKey"); + + assert_eq!( + resp.pub_key.len(), + UNWRAPPING_PUB_KEY_LEN, + "unwrapping pub key must be the pinned length", + ); + // The RSA modulus (first 256 bytes) must be a full, non-zero value. + assert!( + resp.pub_key[..256].iter().any(|&b| b != 0), + "RSA modulus must not be all-zero", + ); + // The public exponent (last 4 bytes) must be non-zero. + assert!( + resp.pub_key[256..].iter().any(|&b| b != 0), + "RSA public exponent must not be all-zero", + ); +} + +#[test] +fn get_unwrapping_key_is_stable_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let req = TborGetUnwrappingKeyReq { + session_id: session.session_id, + }; + let first = ctx.tbor(&req).expect("first GetUnwrappingKey"); + let second = ctx.tbor(&req).expect("second GetUnwrappingKey"); + + // The unwrapping key is a stable partition key — not regenerated. + assert_eq!( + first.pub_key, second.pub_key, + "the unwrapping key must be stable across calls", + ); +} diff --git a/ddi/tbor/types/tests/commands/mod.rs b/ddi/tbor/types/tests/commands/mod.rs index b14278c44..705b61c12 100644 --- a/ddi/tbor/types/tests/commands/mod.rs +++ b/ddi/tbor/types/tests/commands/mod.rs @@ -9,6 +9,7 @@ pub mod api_rev; pub mod default_psk_gate; pub mod forward_compat; pub mod fw_error_decode; +pub mod get_unwrapping_key; pub mod key_report; pub mod open_session; pub mod part_final; @@ -24,3 +25,4 @@ pub mod sd_restore_remote_backup; pub mod sd_sealing_key_gen; pub mod session_close; pub mod unexpected_toc_type; +pub mod unwrap_key; diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs new file mode 100644 index 000000000..09f8401d9 --- /dev/null +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `UnwrapKey` command. +//! +//! `UnwrapKey` RSA-AES-unwraps a host-supplied wrapped key with the +//! partition's unwrapping key (from `GetUnwrappingKey`) and returns it +//! masked under the requested scope. These tests wrap a known key on the +//! host (RSA-OAEP a KEK against the unwrapping public key, AES-KWP the +//! key), unwrap it on-device, and check the result. +//! +//! Coverage: +//! * AES key — the masked blob is non-zero and there is no public key. +//! * HMAC key — the recovered key's kind is `_HmacSha256` and it carries +//! no public key. (Using the recovered key via `Hmac` to compute a MAC +//! is exercised by the HMAC command's own emu tests, which build on +//! this command.) +//! * RSA-4096 (CRT and non-CRT) — the largest supported key and the +//! tightest on the per-IO DMA budget; exercises the transient-vault +//! unwrap path that keeps it within 8 KB. + +#![cfg(feature = "emu")] + +use azihsm_crypto::AesKey; +use azihsm_crypto::AesKeyWrapPadAlgo; +use azihsm_crypto::Encrypter; +use azihsm_crypto::ExportableKey; +use azihsm_crypto::HashAlgo; +use azihsm_crypto::ImportableKey; +use azihsm_crypto::KeyGenerationOp; +use azihsm_crypto::RsaEncryptAlgo; +use azihsm_crypto::RsaPrivateKey; +use azihsm_crypto::RsaPublicKey; +use azihsm_ddi_tbor_types::TborGetUnwrappingKeyReq; +use azihsm_ddi_tbor_types::TborUnwrapKeyReq; +use azihsm_ddi_tbor_types::TborUnwrapKeyResp; +use azihsm_ddi_tbor_types::KEY_CLASS_AES; +use azihsm_ddi_tbor_types::KEY_CLASS_HMAC; +use azihsm_ddi_tbor_types::KEY_CLASS_RSA; +use azihsm_ddi_tbor_types::KEY_CLASS_RSA_CRT; + +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::harness::TestCtx; + +/// OAEP hash discriminant (SHA-256) used for wrapping. +const OAEP_SHA256: u8 = 1; +/// `KeyScope::Local` discriminant — masks the recovered key under the +/// partition-local masking key. +const SCOPE_LOCAL: u8 = 0b011; +/// `HsmVaultKeyKind::_HmacSha256` discriminant. +const KIND_HMAC_SHA256: u8 = 28; + +/// RSA-AES-wrap `data` against the HSM-format unwrapping public key +/// (`n_le ‖ e_le`): RSA-OAEP(SHA-256) an ephemeral 32-byte KEK, then +/// AES-KWP the data under it, and concatenate. +fn rsa_aes_wrap(hsm_pub: &[u8], data: &[u8]) -> Vec { + // `GetUnwrappingKey` returns the modulus / exponent little-endian + // (`n_le(256) ‖ e_le(4)`), but `RsaPublicKey::from_hsm_bytes` parses + // each component big-endian — reverse them per-component. + assert_eq!(hsm_pub.len(), 260, "RSA-2048 HSM pubkey is 260 bytes"); + let mut be = Vec::with_capacity(260); + be.extend(hsm_pub[..256].iter().rev()); + be.extend(hsm_pub[256..260].iter().rev()); + + let ephemeral_kek = [0xA7u8; 32]; + let pub_key = RsaPublicKey::from_hsm_bytes(&be).expect("from_hsm_bytes"); + let mut enc_kek = Encrypter::encrypt_vec( + &mut RsaEncryptAlgo::with_oaep_padding(HashAlgo::sha256(), None), + &pub_key, + &ephemeral_kek, + ) + .expect("RSA-OAEP wrap KEK"); + // The device expects the OAEP ciphertext in wire-LE (it flips it to + // big-endian internally for OpenSSL); OpenSSL emits big-endian, so + // reverse the modulus-sized RSA ciphertext. + enc_kek.reverse(); + + let kek = AesKey::from_bytes(&ephemeral_kek).expect("AES KEK"); + let mut enc_data = Encrypter::encrypt_vec(&mut AesKeyWrapPadAlgo::default(), &kek, data) + .expect("AES-KWP wrap data"); + + let mut wrapped = Vec::with_capacity(enc_kek.len() + enc_data.len()); + wrapped.append(&mut enc_kek); + wrapped.append(&mut enc_data); + wrapped +} + +/// Fetch the unwrapping public key, wrap `key` for `class`, and unwrap it +/// on-device under the `Local` scope. +fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKeyResp { + let hsm_pub = ctx + .tbor(&TborGetUnwrappingKeyReq { session_id }) + .expect("GetUnwrappingKey") + .pub_key; + let wrapped = rsa_aes_wrap(&hsm_pub, key); + ctx.tbor(&TborUnwrapKeyReq { + session_id, + scope: SCOPE_LOCAL, + key_class: class, + oaep_hash: OAEP_SHA256, + wrapped_blob: wrapped, + }) + .expect("UnwrapKey") +} + +/// Import a host-generated RSA-4096 key via `UnwrapKey` and assert the +/// recovered blob is well-formed. RSA-4096 is the largest supported key +/// and the tightest on the per-IO DMA budget — this exercises the +/// transient-vault unwrap path that keeps it within 8 KB (both the CRT and +/// non-CRT vault forms). +fn rsa_4k_unwrap_roundtrip(crt: bool) { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + // `generate` takes the modulus size in bytes: 512 = RSA-4096. + let key = RsaPrivateKey::generate(512).expect("generate RSA-4096 key"); + let der = key.to_vec().expect("RSA private DER export"); + let class = if crt { + KEY_CLASS_RSA_CRT + } else { + KEY_CLASS_RSA + }; + let resp = unwrap(&ctx, session.session_id, class, &der); + + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked RSA key must not be all-zero", + ); + // RSA-4096 wire public key is `n_le(512) ‖ e_le(4)`. + assert_eq!( + resp.pub_key.len(), + 516, + "RSA-4096 recovered public key is n_le(512) ‖ e_le(4)", + ); +} + +#[test] +fn unwrap_key_rsa_4k_emu() { + rsa_4k_unwrap_roundtrip(false); +} + +#[test] +fn unwrap_key_rsa_4k_crt_emu() { + rsa_4k_unwrap_roundtrip(true); +} + +#[test] +fn unwrap_key_aes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let aes_key = [0x42u8; 32]; + let resp = unwrap(&ctx, session.session_id, KEY_CLASS_AES, &aes_key); + + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked AES key must not be all-zero", + ); + assert!(resp.pub_key.is_empty(), "a symmetric key has no public key"); +} + +#[test] +fn unwrap_key_hmac_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let hmac_key = [0x37u8; 32]; + let resp = unwrap(&ctx, session.session_id, KEY_CLASS_HMAC, &hmac_key); + + // A 32-byte HMAC key decodes to HMAC-SHA-256; symmetric keys carry no + // public key. Exercising the recovered key via `Hmac` to compute a + // MAC is covered by the HMAC command's own emu tests, which build on + // this command. + assert_eq!( + resp.key_kind, KIND_HMAC_SHA256, + "recovered kind = HMAC-SHA256" + ); + assert!(resp.pub_key.is_empty()); + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked HMAC key must not be all-zero", + ); +} diff --git a/docs/tbor-ddi/README.md b/docs/tbor-ddi/README.md index 9c1b1e3b7..4298d6b1a 100644 --- a/docs/tbor-ddi/README.md +++ b/docs/tbor-ddi/README.md @@ -65,6 +65,8 @@ single `none` TOC placeholder and no typed body fields. | `0x0E` | `SdCreatePeerBackup` | InSession | [`commands/sd_create_peer_backup.md`](./commands/sd_create_peer_backup.md) | | `0x0F` | `SdRestorePeerBackup` | InSession | [`commands/sd_restore_peer_backup.md`](./commands/sd_restore_peer_backup.md) | | `0x10` | `KeyReport` | InSession | [`commands/key_report.md`](./commands/key_report.md) | +| `0x13` | `GetUnwrappingKey` | InSession | [`commands/get_unwrapping_key.md`](./commands/get_unwrapping_key.md) | +| `0x14` | `UnwrapKey` | InSession | [`commands/unwrap_key.md`](./commands/unwrap_key.md) | ## Default-PSK gate diff --git a/docs/tbor-ddi/commands/get_unwrapping_key.md b/docs/tbor-ddi/commands/get_unwrapping_key.md new file mode 100644 index 000000000..606d00f74 --- /dev/null +++ b/docs/tbor-ddi/commands/get_unwrapping_key.md @@ -0,0 +1,66 @@ + + +# GetUnwrappingKey (Opcode 0x13) + +**Handler:** `fw/core/lib/src/ddi/tbor/get_unwrapping_key.rs` +**Session:** InSession + +## Description + +Returns the partition's RSA-2048 **unwrapping** public key, which the +host uses to RSA-AES key-wrap a payload for a future `UnwrapKey` import. + +The unwrapping key is a device-provisioned partition-internal key. Only +its **public** half is returned: the private half never leaves the device +and `UnwrapKey` resolves it internally by the partition's +`RSA_UNWRAPPING_KEY_ID` property, so no host-supplied key reference is +needed. + +RSA key generation is expensive, so each PAL materialises the key behind +the property read: the std (emulator) PAL generates it lazily on first +read, while hardware PALs generate it in the background from partition +init and leave the property unset until ready. An absent key surfaces as +`PendingKeyGeneration` so the host retries. + +Available to **both Crypto-Officer and Crypto-User** sessions. + +## Request + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 4 | `session_id` | `session_id` (inline) | Session this request is bound to; cross-checked against the SQE-carried session id. | + +### Data section + +_Empty._ + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `pub_key` | `buffer` (260 B) | The RSA-2048 unwrapping public key in HSM wire format: `n_le(256) ‖ e_le(4)`. | + +### Data section + +Carries the 260-byte public key. + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | +| `PendingKeyGeneration` | The unwrapping key is still being generated; retry | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/get_unwrapping_key.rs` diff --git a/docs/tbor-ddi/commands/unwrap_key.md b/docs/tbor-ddi/commands/unwrap_key.md new file mode 100644 index 000000000..eb1af829f --- /dev/null +++ b/docs/tbor-ddi/commands/unwrap_key.md @@ -0,0 +1,116 @@ + + +# UnwrapKey (Opcode 0x14) + +**Handler:** `fw/core/lib/src/ddi/tbor/unwrap_key.rs` +**Session:** InSession + +## Description + +Implements `CKM_RSA_AES_KEY_WRAP` for the TBOR transport: within an open +session, unwrap a host-supplied wrapped-key blob with the partition's +RSA-2048 **unwrapping** key and return the recovered key as a **masked** +blob under the requested scope's masking key. This is the TBOR analogue +of MBOR `RsaUnwrap`, but it re-masks the recovered key (unmask-on-use) +instead of vaulting it under a key handle. Nothing is persisted on the +device on success; the handler uses the undo log only to guarantee the +**transient** vault key (staged while re-masking) is deleted if a later +step fails. + +The host first calls [`GetUnwrappingKey`](./get_unwrapping_key.md) to +obtain the partition's RSA-2048 unwrapping public key, then wraps the +key to import as `RSA-OAEP(KEK) ‖ AES-KWP(key)`: a random KEK is +RSA-OAEP-encrypted to the unwrapping key, and the key material is +AES-KWP-wrapped under that KEK. The device resolves the unwrapping +**private** key internally by the partition's `RSA_UNWRAPPING_KEY_ID` +property (no host key reference), OAEP-decrypts the KEK, AES-KWP-unwraps +the payload, decodes it into vault form, and masks it. + +`key_class` selects the decode path and the recovered key's usage +attributes: + +- `Aes` → raw 16 / 24 / 32-byte AES key; `encrypt` + `decrypt`. +- `Rsa` → DER RSA private key (non-CRT vault kind); `sign` + `decrypt`. +- `RsaCrt` → DER RSA private key (CRT vault kind); `sign` + `decrypt`. +- `Ecc` → PKCS#8 DER ECC private key; `sign` + `derive`. +- `Hmac` → raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512); + `sign` + `verify`. + +Imported keys are never `local`. For the asymmetric classes (`Rsa`, +`RsaCrt`, `Ecc`) the recovered key's wire public key is re-derived and +returned in `pub_key`; symmetric classes (`Aes`, `Hmac`) return an empty +`pub_key`. + +Scope → masking key (resolved on-device): + +- `Session` → the per-session masking key (works for any Active session, + including before `PartFinal`). **Platform note:** provisioned only on + the std/emu PAL today; the Uno (hardware) PAL returns `UnsupportedCmd` + from `session_masking_key`, so `scope = Session` fails on hardware until + session-key masking is implemented — target a persisted scope + (`Local` / `SecurityDomain`) on Uno. +- `Ephemeral` → the partition `PartitionEphemeralMaskingKey`. +- `Local` → the partition `PartitionLocalMaskingKey`. +- `SecurityDomain` → the security-domain masking key (`SDMK`). + +The `Ephemeral` / `Local` / `SecurityDomain` masking keys are provisioned +by `PartFinal` / `CreateSD`, so a non-`Session` scope before the partition +is `Initialized` is rejected with `InvalidArg`, and `SecurityDomain` +before `CreateSD` with `UnsupportedKeyScope`. + +Available to **both Crypto-Officer and Crypto-User** sessions. + +## Request + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 4 | `session_id` | `session_id` (inline) | Session this request is bound to; cross-checked against the SQE-carried session id. | +| 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain. | +| 12 | `key_class` | `uint8` (inline) | Class of the wrapped key (`KeyClass` discriminant): `0` = Aes, `1` = Rsa, `2` = RsaCrt, `3` = Ecc, `4` = Hmac. | +| 16 | `oaep_hash` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | +| 20 | `wrapped_blob` | `buffer` (≤ 3072 B) | The RSA-AES-wrapped key: `RSA-OAEP(KEK) ‖ AES-KWP(key)`. The leading modulus-sized (256 B for RSA-2048) OAEP ciphertext is wire little-endian. | + +### Data section + +Carries the wrapped-key blob. + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `key_kind` | `uint8` (inline) | The recovered key's `HsmVaultKeyKind` discriminant. | +| 12 | `masked_key` | `buffer` (≤ 3072 B) | The recovered key, masked (AEAD-GCM-256) under the scope's masking key: `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(key) ‖ tag(16)`. Not stored on-device. | +| 16 | `pub_key` | `buffer` (≤ 520 B) | The recovered key's wire public key for RSA (`n_le ‖ e_le`) / ECC (`x ‖ y`); empty for symmetric (AES / HMAC) keys. | + +### Data section + +Carries the masked key followed by the public key (empty for symmetric +keys). + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | +| `InvalidArg` | A non-`Session` scope was requested before the partition is `Initialized`, or an unknown `oaep_hash` | +| `UnsupportedKeyScope` | The requested scope has no masking key yet (e.g. `SecurityDomain` before `CreateSD`) | +| `UnsupportedCmd` | An unknown `key_class` discriminant | +| `PendingKeyGeneration` | The partition's unwrapping key is still being generated; call `GetUnwrappingKey` and retry | +| `RsaUnwrapInvalidRequest` | The wrapped blob is shorter than the modulus-sized OAEP segment | +| `RsaUnwrapInvalidKek` | The recovered KEK has an invalid length | +| `RsaDecryptFailed` | OAEP-decrypt of the KEK failed (wrong unwrapping key, corrupt ciphertext, or `oaep_hash` mismatch) | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- [`GetUnwrappingKey`](./get_unwrapping_key.md) — fetch the RSA-2048 unwrapping public key +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/unwrap_key.rs` diff --git a/fw/core/crypto/key-masking/src/aead/encode.rs b/fw/core/crypto/key-masking/src/aead/encode.rs index 19aee57f6..e0fb99f15 100644 --- a/fw/core/crypto/key-masking/src/aead/encode.rs +++ b/fw/core/crypto/key-masking/src/aead/encode.rs @@ -11,9 +11,8 @@ //! //! Mirrors the call shape of `build_bmk_session` in the TBOR session //! handler — the canonical AEAD-seal pattern in this firmware — by -//! taking a scoped allocator for the small IV / AAD / target-key-copy -//! `DmaBuf`s that [`aead_envelope::seal`] requires as separate -//! inputs. +//! taking a scoped allocator for the small IV / AAD `DmaBuf`s that +//! [`aead_envelope::seal`] requires as separate inputs. use azihsm_fw_core_crypto_aead_envelope::seal as aead_seal; use azihsm_fw_core_crypto_aead_envelope::AeadAlg; @@ -80,10 +79,8 @@ pub struct MaskParams<'a> { /// * `crypto` — PAL providing AES and RNG (any [`HsmCrypto`]). /// * `io` — caller's I/O context. /// * `alloc` — scoped allocator. Used internally to stage the -/// IV (12 B), AAD copy (96 B), and a target-key copy -/// (`target_key.len()` B). Mirrors the staging pattern used by -/// every other AEAD seal call site in the firmware (e.g. -/// `build_bmk_session`). All three buffers are freed when the +/// IV (12 B) and AAD copy (96 B); the plaintext is sealed **directly** +/// from `target_key` (no copy). Both buffers are freed when the /// enclosing scope exits. /// * `alg` — AEAD algorithm for this blob (e.g. /// [`AeadAlg::AesGcm256`]). Determines the required `key` length @@ -145,21 +142,24 @@ pub async fn mask( params.key_label, )?; - // Stage IV, AAD, and a target-key copy in scoped DMA buffers, - // mirroring `build_bmk_session` in the TBOR session code path. - // IV size is alg-dependent (alg.iv_len()). + // Stage IV and AAD in scoped DMA buffers (the plaintext is sealed + // directly from `target_key`, no copy). IV size is alg-dependent + // (alg.iv_len()). let iv = alloc.dma_alloc(alg.iv_len())?; crypto.rng_fill_bytes(io, &mut iv[..])?; let aad = alloc.dma_alloc(META_LEN)?; aad.copy_from_slice(metadata.as_bytes()); - let pt = alloc.dma_alloc(target_key.len())?; - pt.copy_from_slice(target_key); - - // Seal into `out`. `aead_envelope::seal` writes exactly `total` - // bytes and returns that count. - let result = aead_seal(crypto, io, alg, key, iv, aad, pt, Some(out)).await; + // Seal directly from `target_key` into `out`. `aead_seal` / `seal_gcm` + // only *reads* `pt` (it copies the plaintext into `out`'s data region + // and encrypts in place there), so staging a separate `pt` copy of + // `target_key` would be a redundant second copy — wasteful of the + // scarce per-IO DMA budget for large keys (e.g. an RSA-4096 private + // key). `target_key` and `out` are disjoint buffers, so passing it + // through directly is sound. Mirrors `cbc::mask`, which likewise + // writes the plaintext straight into `out`. + let result = aead_seal(crypto, io, alg, key, iv, aad, target_key, Some(out)).await; match result { Ok(n) => { debug_assert_eq!(n, total); diff --git a/fw/core/ddi/tbor/derive/src/codegen_enc.rs b/fw/core/ddi/tbor/derive/src/codegen_enc.rs index 6a8a3dd9c..8df9a0047 100644 --- a/fw/core/ddi/tbor/derive/src/codegen_enc.rs +++ b/fw/core/ddi/tbor/derive/src/codegen_enc.rs @@ -440,7 +440,97 @@ fn gen_field_write(i: usize, schema: &Schema, layout: &TocLayout) -> TokenStream } } -// ── Helper: state impl blocks ───────────────────────────────────────── +// ── Helper: field reserve code (fill-later) ─────────────────────────── + +/// Generate the token stream that reserves a buffer / sealed_key field's +/// `len` bytes **without** copying data — the TOC entry and data-section +/// offset are advanced, but the region is left for the caller to fill in +/// place later (via `decode_mut`). Mirrors [`gen_field_write`]'s buffer +/// arm minus the `copy_from_slice`. Only emitted for `#[tbor(mutable)]` +/// fields, which the schema parser guarantees are non-optional buffer / +/// sealed_key fields. +fn gen_field_reserve(i: usize, schema: &Schema, layout: &TocLayout) -> TokenStream { + let f = &schema.fields[i]; + let toc_type_id = f.toc_type_id; + let field_toc_idx = effective_toc_idx(i, layout, &schema.fields); + let has_padding = f.has_padding(); + let align = f.align_expr().unwrap_or_else(|| quote! { 1 }); + + let pad_toc_idx = if has_padding { + let local_pad = layout + .padding_positions + .iter() + .find(|&&(_, fi)| fi == i) + .map(|&(ti, _)| ti) + .unwrap(); + let ga: Vec<_> = schema.fields[..i] + .iter() + .filter_map(|pf| { + pf.include_group + .as_ref() + .map(|pg| quote! { + #pg::TOC_COUNT }) + }) + .collect(); + quote! { (#local_pad #(#ga)*) } + } else { + quote! { 0 } + }; + + let pad_write = if has_padding { + quote! { + let pad_len = (#align - (self.data_offset % #align)) % #align; + let pad_end = DATA_START + self.data_offset + pad_len; + if pad_end > self.buf.len() { + return Err(azihsm_fw_hsm_pal_traits::HsmError::TborBufferTooSmall); + } + for j in 0..pad_len { + self.buf[DATA_START + self.data_offset + j] = 0; + } + let pad_word = azihsm_fw_ddi_tbor::toc::build_toc_offset_len(9, pad_len, self.data_offset); + azihsm_fw_ddi_tbor::toc::write_toc_word(self.buf, HEADER_LEN, #pad_toc_idx, pad_word); + self.data_offset += pad_len; + } + } else { + quote! {} + }; + + let min_l = f.min_len; + let max_l = f.max_len; + let len_check = if f.fixed_len.is_some() || min_l > 0 || max_l < 8191 { + let effective_min = f.fixed_len.unwrap_or(min_l); + let effective_max = f.fixed_len.unwrap_or(max_l); + quote! { + if !(#effective_min..=#effective_max).contains(&len) { + return Err(azihsm_fw_hsm_pal_traits::HsmError::TborDataTooLarge); + } + } + } else { + quote! {} + }; + + quote! { + #pad_write + let off = self.data_offset; + #len_check + let end = DATA_START + off + len; + if end > self.buf.len() { + return Err(azihsm_fw_hsm_pal_traits::HsmError::TborBufferTooSmall); + } + if self.data_offset + len > azihsm_fw_ddi_tbor::MAX_DATA_SIZE { + return Err(azihsm_fw_hsm_pal_traits::HsmError::TborDataTooLarge); + } + // Zero the reserved region: a `_reserve(len)` caller fills it + // separately (e.g. `decode_mut`), so a partially-filled slot — or an + // early return on an error path after reserving — must not leak prior + // DMA-arena contents into the response. + for __b in &mut self.buf[DATA_START + off..end] { + *__b = 0; + } + self.data_offset += len; + let word = azihsm_fw_ddi_tbor::toc::build_toc_offset_len(#toc_type_id, len, off); + azihsm_fw_ddi_tbor::toc::write_toc_word(self.buf, HEADER_LEN, #field_toc_idx, word); + } +} /// Generate the `impl` blocks for each typestate, containing field setter /// methods and `finish()`. @@ -521,6 +611,29 @@ fn gen_state_impls( Ok(#enc_name { buf: self.buf, data_offset: self.data_offset, #resp_extra_pass _state: core::marker::PhantomData }) } }); + + // For a `#[tbor(mutable)]` buffer / sealed_key field, also + // emit a `_reserve(len)` setter that reserves the + // slot without copying — the caller fills it in place later + // via `decode_mut` (zero-copy "reserve + fill"). + if f.mutable { + let reserve_name = format_ident!("{}_reserve", field_name); + let reserve_code = gen_field_reserve(j, schema, layout); + methods.push(quote! { + /// Reserve this field's `len` data bytes without + /// copying; fill the region in place afterwards via + /// [`decode_mut`]. Advances the encoder to the next + /// field exactly like the value setter. + pub fn #reserve_name(mut self, len: usize) -> Result<#enc_name<'a, #target_state>, azihsm_fw_hsm_pal_traits::HsmError> { + const HEADER_LEN: usize = #header_len_tokens; + const TOC_COUNT: usize = #toc_count_expr; + const DATA_START: usize = HEADER_LEN + TOC_COUNT * 4; + #skip_nones + #reserve_code + Ok(#enc_name { buf: self.buf, data_offset: self.data_offset, #resp_extra_pass _state: core::marker::PhantomData }) + } + }); + } } } diff --git a/fw/core/ddi/tbor/derive/src/schema.rs b/fw/core/ddi/tbor/derive/src/schema.rs index 38b2e29d2..5e7dd15a1 100644 --- a/fw/core/ddi/tbor/derive/src/schema.rs +++ b/fw/core/ddi/tbor/derive/src/schema.rs @@ -39,6 +39,13 @@ pub struct SchemaField { /// any field in the schema is `mutable`, the codegen emits a /// parallel `decode_mut` entry point and a `ViewMut` accessor /// type whose mut-marked fields hand out `&mut DmaBuf`. + /// + /// A `mutable` field additionally gets a `_reserve(len)` + /// encoder setter (alongside the value setter) that reserves the + /// slot without copying data, so a response can be built with + /// reserved slots and then filled in place via `decode_mut` + /// (zero-copy "reserve + fill"; see + /// [`codegen_enc::gen_field_reserve`](crate::codegen_enc)). pub mutable: bool, /// For a typed-slice `Buffer` field declared as `&[T]` (T != u8), /// the element type `T`. `None` for a plain `&[u8]` buffer. The diff --git a/fw/core/ddi/tbor/types/src/get_unwrapping_key.rs b/fw/core/ddi/tbor/types/src/get_unwrapping_key.rs new file mode 100644 index 000000000..1e0de2780 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/get_unwrapping_key.rs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `GetUnwrappingKey` wire schema. +//! +//! `GetUnwrappingKey` is an in-session command that returns the +//! partition's RSA-2048 **unwrapping** public key, which the host uses to +//! RSA-AES key-wrap a payload for [`UnwrapKey`](crate::unwrap_key). The +//! unwrapping key is a device-provisioned partition-internal key; only its +//! public half is returned (the private half never leaves the device and +//! is resolved internally by `UnwrapKey`), so — unlike the other +//! key-producing commands — there is no masked blob. +//! +//! RSA key generation is expensive, so the key is materialised lazily; an +//! absent key surfaces as `PendingKeyGeneration` so the host retries. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked against the +//! SQE-carried session id by the dispatcher. +//! +//! Outputs: +//! +//! * `pub_key` — the RSA-2048 unwrapping public key in HSM wire format: +//! `n_le(256) ‖ e_le(4)` = [`UNWRAPPING_PUB_KEY_LEN`] (260) bytes. + +use azihsm_fw_ddi_tbor_api::tbor; + +/// TBOR opcode for `GetUnwrappingKey`. +pub const TBOR_OP_GET_UNWRAPPING_KEY: u8 = 0x13; + +/// Wire length of the RSA-2048 unwrapping public key in HSM format: +/// `n_le(256) ‖ e_le(4)`. Pinned into the `#[tbor(buffer, len = 260)]` +/// literal on [`TborGetUnwrappingKeyResp::pub_key`]. +pub const UNWRAPPING_PUB_KEY_LEN: usize = 256 + 4; + +/// `GetUnwrappingKey` request schema. +#[tbor(opcode = 0x13)] +pub struct TborGetUnwrappingKeyReq { + /// CO/CU session id this request is bound to. The dispatcher + /// cross-checks it against the SQE-carried session id. + #[tbor(session_id)] + pub session_id: SessionId, +} + +/// `GetUnwrappingKey` response schema. +/// +/// `pub_key` is `#[tbor(mutable)]` so the handler can build the response +/// with the slot **reserved** (via `pub_key_reserve`) and have the PAL +/// write the wire public key straight into it (`decode_mut`) — no scratch +/// buffer and no copy. +#[tbor(response)] +pub struct TborGetUnwrappingKeyResp<'a> { + /// The RSA-2048 unwrapping public key in HSM wire format + /// (`n_le(256) ‖ e_le(4)`), exactly [`UNWRAPPING_PUB_KEY_LEN`] (260) + /// bytes. + #[tbor(buffer, len = 260, mutable)] + pub pub_key: &'a [u8], +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use azihsm_fw_ddi_tbor_api::SessionId; + + use super::*; + + #[test] + fn request_round_trips_session_id() { + let mut buf = [0u8; 128]; + let frame = TborGetUnwrappingKeyReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(11)) + .unwrap() + .finish(); + assert_eq!(u16::from(frame.session_id()), 11); + } + + #[test] + fn response_round_trips_pub_key() { + let mut buf = [0u8; 512]; + let pub_key = [0xA5u8; UNWRAPPING_PUB_KEY_LEN]; + let frame = TborGetUnwrappingKeyResp::encode(&mut buf, 0, true) + .unwrap() + .pub_key(&pub_key) + .unwrap() + .finish(); + assert_eq!(frame.pub_key(), &pub_key[..]); + } + + #[test] + fn pub_key_len_matches_pinned_value() { + const _: () = assert!(260 == UNWRAPPING_PUB_KEY_LEN); + assert_eq!(UNWRAPPING_PUB_KEY_LEN, 260); + } +} diff --git a/fw/core/ddi/tbor/types/src/key_props.rs b/fw/core/ddi/tbor/types/src/key_props.rs index 9f2219f69..bad5d34a4 100644 --- a/fw/core/ddi/tbor/types/src/key_props.rs +++ b/fw/core/ddi/tbor/types/src/key_props.rs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Key-scope wire mirror for TBOR key-property schemas. +//! Key-scope and hash-algorithm wire mirrors for TBOR key-property +//! schemas. use open_enum::open_enum; @@ -38,3 +39,27 @@ pub enum KeyScope { /// Firmware-internal key. Internal = 0b101, } + +/// Hash algorithm selector on the TBOR wire. +/// +/// The 1-byte discriminants mirror the firmware +/// [`HsmHashAlgo`](azihsm_fw_hsm_pal_traits::HsmHashAlgo) values +/// (`Sha256 = 1`, `Sha384 = 2`, `Sha512 = 3`) so the two convert +/// losslessly. Shared across the key-property schemas: it selects both +/// the HMAC SHA variant (`HmacGenerateKey`) and the OAEP hash +/// (`UnwrapKey`). Kept as an [`open_enum`] so an unrecognized +/// discriminant round-trips as `HashAlgo(x)` and is rejected on-device +/// rather than failing to decode. SHA-1 is intentionally absent. +#[repr(u8)] +#[open_enum] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgo { + /// SHA-256 (32-byte digest; 32-byte HMAC key / tag). + Sha256 = 1, + + /// SHA-384 (48-byte digest; 48-byte HMAC key / tag). + Sha384 = 2, + + /// SHA-512 (64-byte digest; 64-byte HMAC key / tag). + Sha512 = 3, +} diff --git a/fw/core/ddi/tbor/types/src/lib.rs b/fw/core/ddi/tbor/types/src/lib.rs index eda70e3fc..907846571 100644 --- a/fw/core/ddi/tbor/types/src/lib.rs +++ b/fw/core/ddi/tbor/types/src/lib.rs @@ -36,6 +36,7 @@ pub mod tbor_int { pub mod api_rev; pub mod evidence; +pub mod get_unwrapping_key; pub mod key_props; pub mod key_report; pub mod part_final; @@ -53,9 +54,11 @@ pub mod sd_sealing_key_gen; pub mod session_close; pub mod session_open_finish; pub mod session_open_init; +pub mod unwrap_key; pub use api_rev::*; pub use evidence::*; +pub use get_unwrapping_key::*; pub use key_props::*; pub use key_report::*; pub use part_final::*; @@ -73,3 +76,4 @@ pub use sd_sealing_key_gen::*; pub use session_close::*; pub use session_open_finish::*; pub use session_open_init::*; +pub use unwrap_key::*; diff --git a/fw/core/ddi/tbor/types/src/unwrap_key.rs b/fw/core/ddi/tbor/types/src/unwrap_key.rs new file mode 100644 index 000000000..ed70262ec --- /dev/null +++ b/fw/core/ddi/tbor/types/src/unwrap_key.rs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `UnwrapKey` wire schema. +//! +//! `UnwrapKey` implements the firmware side of `CKM_RSA_AES_KEY_WRAP` for +//! the TBOR transport: within an open session, unwrap a host-supplied +//! wrapped-key blob with the partition's RSA-2048 unwrapping key (see +//! [`GetUnwrappingKey`](crate::get_unwrapping_key)) and return the +//! recovered key as a **masked** blob under the requested scope's masking +//! key — the TBOR analogue of MBOR `RsaUnwrap`, but re-masking the +//! recovered key instead of vaulting it. +//! +//! The AES, RSA (plain / CRT), ECC, and HMAC key classes are supported. +//! The recovered key is re-derived to wire public form for the asymmetric +//! (RSA / ECC) classes and returned alongside the masked blob. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. +//! * `scope` — the [`KeyScope`] whose masking key wraps the recovered key. +//! * `key_class` — the [`KeyClass`] of the wrapped key. +//! * `oaep_hash` — the OAEP [`HashAlgo`] used to wrap the KEK. +//! * `wrapped_blob` — the RSA-AES-wrapped key +//! (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), up to [`UNWRAP_WRAPPED_BLOB_MAX_LEN`]. +//! +//! Outputs: +//! +//! * `key_kind` — the recovered key's [`HsmVaultKeyKind`](azihsm_fw_hsm_pal_traits::HsmVaultKeyKind) +//! discriminant. +//! * `masked_key` — the recovered key, masked (AEAD-GCM-256) under the +//! scope's masking key. +//! * `pub_key` — the recovered key's wire public key for RSA / ECC +//! (`n_le ‖ e_le` / `x ‖ y`); empty for symmetric (AES / HMAC) keys. + +use azihsm_fw_ddi_tbor_api::tbor; +use open_enum::open_enum; + +use crate::key_props::HashAlgo; +use crate::key_props::KeyScope; + +/// TBOR opcode for `UnwrapKey`. +pub const TBOR_OP_UNWRAP_KEY: u8 = 0x14; + +/// Max wrapped-blob length (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), sized for the +/// largest supported key (RSA-4096-CRT). Pinned into the `#[tbor(buffer, +/// max_len = 3072)]` literal on [`TborUnwrapKeyReq::wrapped_blob`]. +pub const UNWRAP_WRAPPED_BLOB_MAX_LEN: usize = 3072; + +/// Max masked recovered-key envelope length. Pinned into the +/// `#[tbor(buffer, max_len = 3072)]` literal on +/// [`TborUnwrapKeyResp::masked_key`]. +pub const UNWRAP_MASKED_KEY_MAX_LEN: usize = 3072; + +/// Max recovered public-key length (RSA-4096 `n_le(512) ‖ e_le(4)`). +/// Pinned into the `#[tbor(buffer, max_len = 520)]` literal on +/// [`TborUnwrapKeyResp::pub_key`]. +pub const UNWRAP_PUB_KEY_MAX_LEN: usize = 520; + +// Tripwires: keep the public length constants in lock-step with the +// `#[tbor(buffer, max_len = ...)]` literals below (the derive requires +// integer literals, so the two cannot share a symbol). +const _: () = assert!(UNWRAP_WRAPPED_BLOB_MAX_LEN == 3072); +const _: () = assert!(UNWRAP_MASKED_KEY_MAX_LEN == 3072); +const _: () = assert!(UNWRAP_PUB_KEY_MAX_LEN == 520); + +/// Class of the wrapped key on the TBOR wire — selects the decode path. +/// +/// Kept as an [`open_enum`] so an unrecognized discriminant round-trips +/// and is rejected on-device rather than failing to decode. +#[repr(u8)] +#[open_enum] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyClass { + /// A raw 16 / 24 / 32-byte AES key. + Aes = 0, + /// A DER-encoded RSA private key (non-CRT vault kind). + Rsa = 1, + /// A DER-encoded RSA private key (CRT vault kind). + RsaCrt = 2, + /// A PKCS#8 DER-encoded ECC private key. + Ecc = 3, + /// A raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512). + Hmac = 4, +} + +/// `UnwrapKey` request schema. +#[tbor(opcode = 0x14)] +pub struct TborUnwrapKeyReq<'a> { + /// CO/CU session id this request is bound to. + #[tbor(session_id)] + pub session_id: SessionId, + + /// Requested key scope (masks the recovered key), 1-byte [`KeyScope`]. + #[tbor(U8)] + pub scope: KeyScope, + + /// Class of the wrapped key, 1-byte [`KeyClass`]. + #[tbor(U8)] + pub key_class: KeyClass, + + /// OAEP hash used to wrap the KEK, 1-byte [`HashAlgo`]. + #[tbor(U8)] + pub oaep_hash: HashAlgo, + + /// The RSA-AES-wrapped key (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), up to + /// [`UNWRAP_WRAPPED_BLOB_MAX_LEN`] bytes. + #[tbor(buffer, max_len = 3072)] + pub wrapped_blob: &'a [u8], +} + +/// `UnwrapKey` response schema. +#[tbor(response)] +pub struct TborUnwrapKeyResp<'a> { + /// The recovered key's vault-kind discriminant (`HsmVaultKeyKind`). + #[tbor(U8)] + pub key_kind: u8, + + /// The recovered key, masked (AEAD-GCM-256) under the scope's masking + /// key. + /// + /// `#[tbor(mutable)]` so the handler can reserve the slot and mask the + /// recovered key straight into it (`decode_mut`) — avoiding a separate + /// MAX-sized scratch buffer that would otherwise coexist with the + /// (multi-KB, for RSA) unwrap material and blow the per-IO DMA budget. + #[tbor(buffer, max_len = 3072, mutable)] + pub masked_key: &'a [u8], + + /// The recovered key's wire public key for RSA / ECC; empty for + /// symmetric (AES / HMAC) keys. + /// + /// `#[tbor(mutable)]` so the handler can reserve the slot and derive the + /// wire public key straight into it (`decode_mut`) — no scratch buffer, + /// no copy. + #[tbor(buffer, max_len = 520, mutable)] + pub pub_key: &'a [u8], +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use azihsm_fw_ddi_tbor_api::SessionId; + + use super::*; + + #[test] + fn request_round_trips_fields() { + let mut buf = [0u8; 4096]; + let wrapped = [0x5Au8; 300]; + let frame = TborUnwrapKeyReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(7)) + .unwrap() + .scope(KeyScope::Local) + .unwrap() + .key_class(KeyClass::Hmac) + .unwrap() + .oaep_hash(HashAlgo::Sha256) + .unwrap() + .wrapped_blob(&wrapped) + .unwrap() + .finish(); + + assert_eq!(frame.key_class(), KeyClass::Hmac); + assert_eq!(frame.oaep_hash(), HashAlgo::Sha256); + assert_eq!(frame.wrapped_blob(), &wrapped[..]); + } + + #[test] + fn response_round_trips_fields() { + let mut buf = [0u8; 4096]; + let masked = [0xABu8; 196]; + let pub_key = [0xCDu8; 96]; + let frame = TborUnwrapKeyResp::encode(&mut buf, 0, true) + .unwrap() + .key_kind(28) + .unwrap() + .masked_key(&masked) + .unwrap() + .pub_key(&pub_key) + .unwrap() + .finish(); + assert_eq!(frame.key_kind(), 28); + assert_eq!(frame.masked_key(), &masked[..]); + assert_eq!(frame.pub_key(), &pub_key[..]); + } +} diff --git a/fw/core/key-decode/src/hmac.rs b/fw/core/key-decode/src/hmac.rs new file mode 100644 index 000000000..650a64c45 --- /dev/null +++ b/fw/core/key-decode/src/hmac.rs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! HMAC-key decode path. +//! +//! Classifies a raw 32 / 48 / 64-byte HMAC key into its fixed-length HMAC +//! vault kind (SHA-256 / 384 / 512) and returns the +//! [`DecodedKey`](super::DecodedKey) the caller persists / masks. Any +//! other byte length is a host contract violation. The raw key is itself +//! the vault material. + +use azihsm_fw_hsm_pal_traits::DmaBuf; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmResult; +use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; + +use super::DecodedKey; + +/// Classify a raw HMAC key by length: 32 / 48 / 64 B → SHA-256 / 384 / 512. +pub(super) fn decode(material: &DmaBuf) -> HsmResult> { + let kind = match material.len() { + 32 => HsmVaultKeyKind::_HmacSha256, + 48 => HsmVaultKeyKind::_HmacSha384, + 64 => HsmVaultKeyKind::_HmacSha512, + _ => return Err(HsmError::InvalidArg), + }; + + // Symmetric — no public key. + Ok(DecodedKey { + kind, + material, + pub_key: None, + }) +} diff --git a/fw/core/key-decode/src/lib.rs b/fw/core/key-decode/src/lib.rs index 14da5c28a..fe8f78b62 100644 --- a/fw/core/key-decode/src/lib.rs +++ b/fw/core/key-decode/src/lib.rs @@ -26,6 +26,7 @@ use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; mod aes; mod ecc; +mod hmac; mod rsa; /// Class of a recovered key — selects the decode path. @@ -42,6 +43,8 @@ pub enum KeyClass { RsaCrt, /// A PKCS#8 DER-encoded ECC private key. Ecc, + /// A raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512). + Hmac, } /// A decoded key in vault-ready form, for the caller to persist. @@ -88,5 +91,6 @@ pub async fn decode<'p, P: HsmPal>( KeyClass::Rsa => rsa::decode(pal, io, material, false).await, KeyClass::RsaCrt => rsa::decode(pal, io, material, true).await, KeyClass::Ecc => ecc::decode(pal, io, material).await, + KeyClass::Hmac => hmac::decode(material), } } diff --git a/fw/core/lib/src/ddi/tbor/get_unwrapping_key.rs b/fw/core/lib/src/ddi/tbor/get_unwrapping_key.rs new file mode 100644 index 000000000..e96fb04af --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/get_unwrapping_key.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `GetUnwrappingKey` command handler. +//! +//! Within an open session, return the partition's RSA-2048 **unwrapping** +//! public key (raw wire `n_le ‖ e_le`), which the host uses to RSA-AES +//! key-wrap a payload for `UnwrapKey`. The unwrapping key is a +//! device-provisioned partition-internal key; only its public half is +//! returned — the private half never leaves the device and `UnwrapKey` +//! resolves it internally by the partition's `RSA_UNWRAPPING_KEY_ID` +//! property. +//! +//! RSA key generation is expensive, so each PAL materialises the key +//! behind the property read: the std (emulator) PAL generates it lazily on +//! first read, while hardware PALs generate it in the background from +//! partition init and leave the property unset until ready. An absent id +//! therefore means generation is still pending, surfaced as +//! `PendingKeyGeneration` so the host retries. Available to both +//! Crypto-Officer and Crypto-User sessions. + +use azihsm_fw_ddi_tbor_types::TborGetUnwrappingKeyReq; +use azihsm_fw_ddi_tbor_types::TborGetUnwrappingKeyResp; +use azihsm_fw_ddi_tbor_types::UNWRAPPING_PUB_KEY_LEN; +use azihsm_fw_hsm_pal_traits::DmaBuf; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmIo; +use azihsm_fw_hsm_pal_traits::HsmPal; +use azihsm_fw_hsm_pal_traits::HsmResult; +use azihsm_fw_hsm_pal_traits::HsmSessId; + +use super::validate_active_session; +use crate::part_state; + +/// Handle a TBOR `GetUnwrappingKey` request. +/// +/// No partition lock or undo log is required: the command only reads the +/// unwrapping key property and derives its public key — it makes no +/// observable state change. +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &DmaBuf, +) -> HsmResult<&'p DmaBuf> { + let req = TborGetUnwrappingKeyReq::decode(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id())); + + validate_active_session(pal, io, sess_id)?; + + // Resolve the partition's RSA-2048 unwrapping key id. The PAL + // materialises the key behind this read; an absent id means generation + // is still pending, surfaced so the host retries. + let key_id = match part_state::part_unwrapping_key_id(pal, io) { + Ok(id) => id, + Err(HsmError::PartPropNotFound) => return Err(HsmError::PendingKeyGeneration), + Err(e) => return Err(e), + }; + + // Derive the wire public key from the vault-stored private key. Its + // length is a fixed invariant for the RSA-2048 unwrapping key; a + // mismatch signals an internal sizing bug. + let priv_key = pal.vault_key(io, key_id)?; + let pub_len = pal.rsa_priv_pub_key(io, priv_key, None)?; + if pub_len != UNWRAPPING_PUB_KEY_LEN { + return Err(HsmError::InternalError); + } + + // Build the response with the `pub_key` slot reserved (sized but + // unwritten), then have the PAL derive the wire public key straight + // into the reserved slot — no scratch buffer and no copy. + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborGetUnwrappingKeyResp::encode(buf, 0, false)? + .pub_key_reserve(UNWRAPPING_PUB_KEY_LEN)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + + // `decode_mut` hands out a `&mut` view into the reserved slot; the view + // is scoped so its borrow of `resp` ends before `resp` is returned. + { + let out = TborGetUnwrappingKeyResp::decode_mut(resp)?; + let actual = pal.rsa_priv_pub_key(io, priv_key, Some(out.pub_key))?; + if actual != pub_len { + return Err(HsmError::InternalError); + } + } + + Ok(resp) +} diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index b6626cb38..72f77ea73 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod api_rev; pub(crate) mod from_pal; +pub(crate) mod get_unwrapping_key; pub(crate) mod key_report; pub(crate) mod part_final; pub mod part_info; @@ -39,6 +40,7 @@ pub(crate) mod sd_sealing_key_gen; pub(crate) mod session_close; pub(crate) mod session_open_finish; pub(crate) mod session_open_init; +pub(crate) mod unwrap_key; use azihsm_fw_ddi_tbor::RequestView; use azihsm_fw_ddi_tbor::ResponseEncoder; @@ -167,6 +169,17 @@ pub(crate) mod opcode { /// `0x0A..=0x0F` are reserved by the Security-Domain backup schema /// family, so `KeyReport` takes the next free opcode, `0x10`. pub(crate) const KEY_REPORT: u8 = 0x10; + + /// `GetUnwrappingKey` — return the partition's RSA-2048 unwrapping + /// public key, which the host uses to RSA-AES key-wrap a payload for a + /// future `UnwrapKey` import. See [`super::get_unwrapping_key`]. + pub(crate) const GET_UNWRAPPING_KEY: u8 = 0x13; + + /// `UnwrapKey` — RSA-AES-unwrap a host-supplied wrapped key + /// (AES / RSA / ECC / HMAC) with the partition unwrapping key and + /// return it masked under the requested scope's masking key (plus the + /// re-derived public key for RSA / ECC). See [`super::unwrap_key`]. + pub(crate) const UNWRAP_KEY: u8 = 0x14; } /// Validate that `sess_id` belongs to an active Crypto-Officer session. @@ -186,6 +199,24 @@ fn validate_crypto_officer_active_session( Ok(()) } +/// Validate that `sess_id` belongs to an active session of any role +/// (Crypto-Officer or Crypto-User). +/// +/// Used by the general crypto commands (`HmacGenerateKey`, `Hmac`) which +/// — unlike the security-domain administrative commands — are available +/// to both CO and CU sessions. The default-PSK gate (applied by the +/// dispatcher) still requires the calling role's PSK to be rotated. +fn validate_active_session( + pal: &P, + io: &impl HsmIo, + sess_id: HsmSessId, +) -> HsmResult<()> { + if !matches!(pal.session_state(io, sess_id), HsmSessionState::Active) { + return Err(HsmError::SessionNotFound); + } + Ok(()) +} + /// Resolve the vault id of the masking key associated with `scope`. fn masking_key_id_for_scope( pal: &P, @@ -210,6 +241,40 @@ fn masking_key_id_for_scope( } } +/// Resolve the 32-byte AES-256-GCM masking key material for `scope`, +/// returning a borrow of the on-device key. +/// +/// `Session` scope resolves to the per-session masking key +/// ([`HsmSessionManager::session_masking_key`](azihsm_fw_hsm_pal_traits::HsmSessionManager::session_masking_key)); +/// every other scope resolves via [`masking_key_id_for_scope`] and reads +/// the vault key. All scopes yield a 32-byte key suitable for the +/// `key_masking::aead` (AES-GCM-256) masked-key system. +/// +/// # Platform note +/// +/// `Session`-scope masking is currently available only on the std/emu PAL. +/// The Uno (hardware) PAL does not yet provision the per-session masking +/// key and returns [`HsmError::UnsupportedCmd`] from +/// `session_masking_key`, so `scope = Session` on masked-key commands +/// (`UnwrapKey`, `Ecc*`, KDF, …) fails on Uno until session-key masking is +/// implemented. Callers targeting hardware should use a persisted scope +/// (`Local` / `SecurityDomain`) provisioned by `PartFinal` / the SD +/// lifecycle. +fn resolve_masking_key<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + scope: HsmKeyScope, + sess_id: HsmSessId, +) -> HsmResult<&'p DmaBuf> { + match scope { + HsmKeyScope::Session => pal.session_masking_key(io, sess_id), + _ => { + let mk_key_id = masking_key_id_for_scope(pal, io, scope)?; + pal.vault_key(io, mk_key_id) + } + } +} + /// Dispatch a parsed TBOR request to its handler. /// /// On success returns a `&DmaBuf` view of the encoded response (lifetime @@ -309,6 +374,8 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( sd_restore_peer_backup::handle(pal, io, req_buf, oob, undo).await } opcode::KEY_REPORT => key_report::handle(pal, io, req_buf).await, + opcode::GET_UNWRAPPING_KEY => get_unwrapping_key::handle(pal, io, req_buf).await, + opcode::UNWRAP_KEY => unwrap_key::handle(pal, io, req_buf, undo).await, _ => Err(HsmError::UnsupportedCmd), } } @@ -336,6 +403,8 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP | opcode::KEY_REPORT + | opcode::GET_UNWRAPPING_KEY + | opcode::UNWRAP_KEY ) } @@ -369,7 +438,9 @@ fn is_in_session(opcode: u8) -> bool { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP - | opcode::KEY_REPORT => true, + | opcode::KEY_REPORT + | opcode::GET_UNWRAPPING_KEY + | opcode::UNWRAP_KEY => true, // Default-deny: any future opcode is treated as in-session // until classified, so the default-PSK gate applies to it. _ => true, @@ -411,7 +482,9 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP - | opcode::KEY_REPORT => true, + | opcode::KEY_REPORT + | opcode::GET_UNWRAPPING_KEY + | opcode::UNWRAP_KEY => true, _ => true, } } diff --git a/fw/core/lib/src/ddi/tbor/unwrap_key.rs b/fw/core/lib/src/ddi/tbor/unwrap_key.rs new file mode 100644 index 000000000..021d3911f --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/unwrap_key.rs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `UnwrapKey` command handler. +//! +//! Implements `CKM_RSA_AES_KEY_WRAP` for the TBOR transport: within an open +//! session, unwrap a host-supplied wrapped-key blob with the partition's +//! RSA-2048 unwrapping key and return the recovered key as a **masked** +//! blob under the requested scope's masking key — the TBOR analogue of +//! MBOR `RsaUnwrap`, but re-masking the recovered key instead of vaulting +//! it. +//! +//! The unwrap + key-classification mechanics live in the protocol-neutral +//! [`azihsm_fw_hsm_key_unwrap`] / [`azihsm_fw_hsm_key_decode`] crates +//! (shared with MBOR `RsaUnwrap`). This handler owns the TBOR concerns: +//! resolve the internal unwrapping key, decode the recovered material, mask +//! it under the scope, and — for the RSA / ECC classes — return the +//! re-derived wire public key. Available to both Crypto-Officer and +//! Crypto-User sessions. + +use azihsm_fw_core_crypto_key_masking::aead::mask; +use azihsm_fw_core_crypto_key_masking::aead::masked_blob_len; +use azihsm_fw_core_crypto_key_masking::aead::AeadAlg; +use azihsm_fw_core_crypto_key_masking::aead::MaskParams; +use azihsm_fw_ddi_tbor_types::HashAlgo; +use azihsm_fw_ddi_tbor_types::KeyClass; +use azihsm_fw_ddi_tbor_types::TborUnwrapKeyReq; +use azihsm_fw_ddi_tbor_types::TborUnwrapKeyResp; +use azihsm_fw_ddi_tbor_types::UNWRAP_MASKED_KEY_MAX_LEN; +use azihsm_fw_ddi_tbor_types::UNWRAP_PUB_KEY_MAX_LEN; +use azihsm_fw_hsm_key_decode::decode; +use azihsm_fw_hsm_key_decode::KeyClass as DecodeKeyClass; +use azihsm_fw_hsm_key_unwrap::unwrap_key; +use azihsm_fw_hsm_key_unwrap::UnwrapParams; +use azihsm_fw_hsm_pal_traits::DmaBuf; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmHashAlgo; +use azihsm_fw_hsm_pal_traits::HsmIo; +use azihsm_fw_hsm_pal_traits::HsmKeyId; +use azihsm_fw_hsm_pal_traits::HsmKeyScope; +use azihsm_fw_hsm_pal_traits::HsmPal; +use azihsm_fw_hsm_pal_traits::HsmResult; +use azihsm_fw_hsm_pal_traits::HsmScopedAlloc; +use azihsm_fw_hsm_pal_traits::HsmSessId; +use azihsm_fw_hsm_pal_traits::HsmVaultKeyAttrs; +use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; +use azihsm_fw_hsm_undo::UndoLog; + +use super::resolve_masking_key; +use super::validate_active_session; +use crate::part_state; + +/// Envelope key-label recorded in the masked blob's `MaskedKeyMetadata`. +const UNWRAP_KEY_LABEL: &[u8] = b"UnwrappedKey"; + +/// Map the wire OAEP [`HashAlgo`] onto the firmware hash algorithm. +fn oaep_hsm_hash(algo: HashAlgo) -> HsmResult { + match algo { + HashAlgo::Sha256 => Ok(HsmHashAlgo::Sha256), + HashAlgo::Sha384 => Ok(HsmHashAlgo::Sha384), + HashAlgo::Sha512 => Ok(HsmHashAlgo::Sha512), + _ => Err(HsmError::InvalidArg), + } +} + +/// Map the wire [`KeyClass`] onto the decode crate's key class. +fn decode_class(class: KeyClass) -> HsmResult { + match class { + KeyClass::Aes => Ok(DecodeKeyClass::Aes), + KeyClass::Rsa => Ok(DecodeKeyClass::Rsa), + KeyClass::RsaCrt => Ok(DecodeKeyClass::RsaCrt), + KeyClass::Ecc => Ok(DecodeKeyClass::Ecc), + KeyClass::Hmac => Ok(DecodeKeyClass::Hmac), + _ => Err(HsmError::UnsupportedCmd), + } +} + +/// Standard usage attributes for an imported key of `class`, plus the +/// requested `scope`. Imported keys are never `local`. +fn attrs_for_class(class: KeyClass, scope: HsmKeyScope) -> HsmVaultKeyAttrs { + let base = HsmVaultKeyAttrs::new().with_scope(scope); + match class { + // Symmetric cipher key: encrypt / decrypt. + KeyClass::Aes => base.with_encrypt(true).with_decrypt(true), + // RSA private key: sign / decrypt. + KeyClass::Rsa | KeyClass::RsaCrt => base.with_sign(true).with_decrypt(true), + // ECC private key: sign / derive. + KeyClass::Ecc => base.with_sign(true).with_derive(true), + // HMAC key: sign / verify. + KeyClass::Hmac => base.with_sign(true).with_verify(true), + _ => base, + } +} + +/// Which wire public key (if any) an imported private key kind carries, +/// and how to re-derive it from the vault-resident private key. +enum PubDeriv { + /// RSA — wire public key is `n_le ‖ e_le`, via `rsa_priv_pub_key`. + Rsa, + /// ECC — wire public key is `x ‖ y`, via `ecc_priv_pub_key`. + Ecc, +} + +/// Classify an imported vault key kind for public-key re-derivation. +/// Symmetric kinds (AES / HMAC) carry no public key → `None`. +fn pub_deriv(kind: HsmVaultKeyKind) -> Option { + match kind { + HsmVaultKeyKind::Rsa2kPrivate + | HsmVaultKeyKind::Rsa3kPrivate + | HsmVaultKeyKind::Rsa4kPrivate + | HsmVaultKeyKind::Rsa2kPrivateCrt + | HsmVaultKeyKind::Rsa3kPrivateCrt + | HsmVaultKeyKind::Rsa4kPrivateCrt => Some(PubDeriv::Rsa), + HsmVaultKeyKind::Ecc256Private + | HsmVaultKeyKind::Ecc384Private + | HsmVaultKeyKind::Ecc521Private => Some(PubDeriv::Ecc), + _ => None, + } +} + +/// Masking context for the recovered key — everything `mask` needs beyond +/// the key material itself, resolved once in [`handle`] and threaded into +/// [`encode_and_mask`]. +struct MaskCtx { + /// Scope whose masking key wraps the recovered key. + scope: HsmKeyScope, + /// Session the request is bound to (resolves the `Session`-scope key). + sess_id: HsmSessId, + /// Partition SVN bound into the masked blob (anti-rollback). + svn: u64, + /// Owner-seed lineage id bound into the masked blob. + owner: u16, + /// Usage attributes recorded in the masked blob's metadata. + attrs: HsmVaultKeyAttrs, +} + +/// Handle a TBOR `UnwrapKey` request. +/// +/// **DMA budget:** the recovered key is vaulted **transiently** — created +/// inside an allocation scope (so the multi-KB unwrap / decode scratch is +/// freed before the response is built), masked from vault storage (which +/// lives outside the per-IO DMA arena), then deleted. This mirrors MBOR +/// `RsaUnwrap`, whose vault step is what keeps the largest RSA-4096 keys +/// within the fixed per-IO DMA budget; masking the material straight from +/// the arena instead would require the material and the masked output to +/// coexist, overrunning the budget for RSA-3072 / 4096. +/// +/// **Cleanup:** the transient key is registered in the undo log +/// (`push_vault_create`) immediately after creation, so any failure before +/// the explicit delete is rolled back (undo → delete). On success the key +/// is deleted explicitly and the undo log discarded — TBOR persists +/// nothing. +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &DmaBuf, + undo: &mut UndoLog<'p>, +) -> HsmResult<&'p DmaBuf> { + let req = TborUnwrapKeyReq::decode(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id())); + let scope = HsmKeyScope(req.scope().0); + validate_active_session(pal, io, sess_id)?; + + let oaep_hash = oaep_hsm_hash(req.oaep_hash())?; + let class = req.key_class(); + let dclass = decode_class(class)?; + let wrapped_blob = req.wrapped_blob(); + + // Resolve the partition's RSA-2048 unwrapping key id; an absent id means + // generation is still pending, surfaced so the host retries (call + // `GetUnwrappingKey` first). + let unwrap_key_id = match part_state::part_unwrapping_key_id(pal, io) { + Ok(id) => id, + Err(HsmError::PartPropNotFound) => return Err(HsmError::PendingKeyGeneration), + Err(e) => return Err(e), + }; + + // Platform identity bound into the masked blob (anti-rollback on + // re-import). + let svn = part_state::part_mfgr_svn(pal); + let owner = u16::try_from(part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?; + let attrs = attrs_for_class(class, scope); + + // Phase 1 — unwrap, decode, and vault the recovered key inside an + // allocation scope. The (multi-KB, for RSA) unwrap / decode scratch is + // freed when the scope exits; the key survives in vault storage (outside + // the per-IO DMA arena) and is read back below. + let key_id = pal + .alloc_scoped_async(io, async |_scope| -> HsmResult { + let material = unwrap_key( + pal, + io, + UnwrapParams { + unwrap_key_id, + oaep_hash, + wrapped_blob, + }, + ) + .await?; + // Decode + import into the transient vault inside a nested block + // so the `decoded` borrow of `material` ends before the wipe; + // then scrub the recovered plaintext from the DMA arena on every + // path — scope exit only resets the bump watermark, it does not + // wipe freed memory. + let created = async { + let decoded = decode(pal, io, &mut *material, dclass).await?; + // Transient, partition-scoped (`None` session): deleted below. + pal.vault_key_create(io, decoded.material, decoded.kind, None, attrs) + .await + } + .await; + material.zeroize(); + created + }) + .await?; + + // Register the transient key so a failure before the explicit delete + // still cleans it up (undo walk → delete). If the log is full the key + // is already vaulted but unregistered, so delete it here rather than + // leaking a persisted key holding sensitive material. + if let Err(e) = undo.push_vault_create(key_id) { + let _ = pal.vault_key_delete(io, key_id).await; + return Err(e); + } + + // Phase 2 — mask the vault-resident key into the response, then delete + // the transient key. On any error the key is left for the undo walk to + // delete; on success we delete it here and discard the log. + let mask_ctx = MaskCtx { + scope, + sess_id, + svn, + owner, + attrs, + }; + match encode_and_mask(pal, io, key_id, &mask_ctx).await { + Ok(resp) => { + pal.vault_key_delete(io, key_id).await?; + undo.discard(); + Ok(resp) + } + Err(e) => Err(e), + } +} + +/// Build the `UnwrapKey` response around the vault-resident recovered key: +/// reserve the `masked_key` and `pub_key` slots, derive the wire public key +/// (RSA / ECC) straight into its slot, and mask the private key straight +/// into its slot — no scratch buffers, no copies. +async fn encode_and_mask<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + key_id: HsmKeyId, + ctx: &MaskCtx, +) -> HsmResult<&'p DmaBuf> { + let kind = pal.vault_key_kind(io, key_id)?; + let priv_blob = pal.vault_key(io, key_id)?; + + // Size the masked blob and the wire public key up front so both response + // slots can be reserved to exactly fit. + let masked_len = masked_blob_len(AeadAlg::AesGcm256, priv_blob.len()); + if masked_len > UNWRAP_MASKED_KEY_MAX_LEN { + return Err(HsmError::InternalError); + } + let deriv = pub_deriv(kind); + let pub_len = match deriv { + Some(PubDeriv::Rsa) => pal.rsa_priv_pub_key(io, priv_blob, None)?, + Some(PubDeriv::Ecc) => pal.ecc_priv_pub_key(io, priv_blob, None).await?, + None => 0, + }; + if pub_len > UNWRAP_PUB_KEY_MAX_LEN { + return Err(HsmError::InternalError); + } + + // Build the response with both data slots reserved (sized exactly). + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborUnwrapKeyResp::encode(buf, 0, false)? + .key_kind(kind.0)? + .masked_key_reserve(masked_len)? + .pub_key_reserve(pub_len)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + + // Fill both slots in place: derive the public key into its slot, then + // mask the private key into its slot (mask's IV / AAD / label scratch is + // scoped and freed on return). + { + let out = TborUnwrapKeyResp::decode_mut(resp)?; + match deriv { + Some(PubDeriv::Rsa) => { + if pal.rsa_priv_pub_key(io, priv_blob, Some(out.pub_key))? != pub_len { + return Err(HsmError::InternalError); + } + } + Some(PubDeriv::Ecc) => { + if pal + .ecc_priv_pub_key(io, priv_blob, Some(out.pub_key)) + .await? + != pub_len + { + return Err(HsmError::InternalError); + } + } + None => {} + } + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<()> { + let masking_key = resolve_masking_key(pal, io, ctx.scope, ctx.sess_id)?; + let key_label = alloc.dma_alloc(UNWRAP_KEY_LABEL.len())?; + key_label.copy_from_slice(UNWRAP_KEY_LABEL); + let params = MaskParams { + key_kind: kind, + key_attrs: ctx.attrs, + svn: ctx.svn, + owner_seed_id: ctx.owner, + key_label, + }; + let written = mask( + pal, + io, + alloc, + AeadAlg::AesGcm256, + masking_key, + ¶ms, + priv_blob, + Some(out.masked_key), + ) + .await?; + // `mask` leaves any trailing bytes of the reserved slot + // untouched; the slot was reserved to exactly `masked_len`, so a + // short write would leave uninitialized bytes in the response. + if written != masked_len { + return Err(HsmError::InternalError); + } + Ok(()) + }) + .await?; + } + + Ok(resp) +} diff --git a/fw/core/lib/src/op.rs b/fw/core/lib/src/op.rs index 958cbc576..962e623f9 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -268,7 +268,9 @@ impl SessionCtrl { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP - | opcode::KEY_REPORT => Self::InSession, + | opcode::KEY_REPORT + | opcode::GET_UNWRAPPING_KEY + | opcode::UNWRAP_KEY => Self::InSession, opcode::SESSION_CLOSE => Self::Close, _ => Self::NoSession, } diff --git a/fw/pal/traits/src/session.rs b/fw/pal/traits/src/session.rs index 1efb80df2..bd83c74e6 100644 --- a/fw/pal/traits/src/session.rs +++ b/fw/pal/traits/src/session.rs @@ -493,9 +493,17 @@ pub trait HsmSessionManager { /// than expected (corruption indicator). fn session_param_key(&self, io: &impl HsmIo, id: HsmSessId) -> HsmResult<&DmaBuf>; - /// Returns a borrowed view of the active session's 80-byte masking - /// key (the AES-CBC-256 ‖ HMAC-SHA-384 key installed at session - /// creation). + /// Returns a borrowed view of the active session's masking key. Its + /// length depends on the session's schedule-blob format: + /// + /// * a TBOR **SessionEx (CU/CO)** blob yields + /// [`SESSION_MASKING_KEY_LEN`] (32 B) of AES-256-GCM key material, + /// consumed by the `key_masking::aead` masked-key system; + /// * a **legacy MBOR `Session`** blob yields a **separate** 80 B + /// `aes32 ‖ hmac48` AES-CBC key, consumed by `key_masking::cbc`. + /// + /// Callers must therefore take the length from the returned slice + /// rather than assuming a single fixed size. /// /// Zero-copy: like [`session_param_key`](Self::session_param_key) /// the returned `&DmaBuf` borrows directly from the PAL's @@ -513,8 +521,10 @@ pub trait HsmSessionManager { /// /// # Returns /// - /// - `Ok(&DmaBuf)` — a sub-view of the schedule blob, exactly - /// [`SESSION_MASKING_KEY_LEN`] bytes long. + /// - `Ok(&DmaBuf)` — a sub-view of the schedule blob whose length + /// depends on the blob format: 32 B ([`SESSION_MASKING_KEY_LEN`]) + /// for a TBOR `SessionEx` session, or 80 B for a legacy MBOR + /// `Session` blob. /// - `Err(HsmError::SessionNotFound)` — `id` does not refer to a /// live Active session in the caller's partition (slot free, /// destroyed, or still Pending). From 3cda8d73f4f78ac6c0cb0342f52bd8d5a362b329 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Mon, 20 Jul 2026 23:06:23 +0000 Subject: [PATCH 4/8] refactor(tbor): import HMAC unwrap keys as variable-length VarLenHmacSha* Address review feedback on UnwrapKey: HMAC keys are variable-length, so inferring the SHA variant from the key length (32/48/64) and rejecting any other length wrongly failed valid HMAC keys, and the fixed-length `_HmacSha*` vault kinds are deprecated. Split the wire `KeyClass::Hmac` into `HmacSha256/384/512` so the SHA variant is carried explicitly, and decode into the matching `VarLenHmacSha*` vault kind with the raw material passed through verbatim (length validated by the vault at create time). Update host constants, the emu HMAC unwrap test (recovered kind is now VarLenHmacSha256 = 32), and the command docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cd9bfef-fd58-4ac9-bbb0-eb67febe865d --- ddi/tbor/types/src/unwrap_key.rs | 12 ++++--- ddi/tbor/types/tests/commands/unwrap_key.rs | 26 +++++++------- docs/tbor-ddi/commands/unwrap_key.md | 9 ++--- fw/core/ddi/tbor/types/src/unwrap_key.rs | 12 ++++--- fw/core/key-decode/src/hmac.rs | 40 ++++++++++++++------- fw/core/key-decode/src/lib.rs | 12 +++++-- fw/core/lib/src/ddi/tbor/unwrap_key.rs | 8 +++-- 7 files changed, 77 insertions(+), 42 deletions(-) diff --git a/ddi/tbor/types/src/unwrap_key.rs b/ddi/tbor/types/src/unwrap_key.rs index 679a71a8f..8aeb54164 100644 --- a/ddi/tbor/types/src/unwrap_key.rs +++ b/ddi/tbor/types/src/unwrap_key.rs @@ -36,8 +36,12 @@ pub const KEY_CLASS_RSA: u8 = 1; pub const KEY_CLASS_RSA_CRT: u8 = 2; /// `KeyClass` discriminant for a PKCS#8 DER ECC private key. pub const KEY_CLASS_ECC: u8 = 3; -/// `KeyClass` discriminant for a raw HMAC key. -pub const KEY_CLASS_HMAC: u8 = 4; +/// `KeyClass` discriminant for a variable-length HMAC-SHA-256 key. +pub const KEY_CLASS_HMAC_SHA256: u8 = 4; +/// `KeyClass` discriminant for a variable-length HMAC-SHA-384 key. +pub const KEY_CLASS_HMAC_SHA384: u8 = 5; +/// `KeyClass` discriminant for a variable-length HMAC-SHA-512 key. +pub const KEY_CLASS_HMAC_SHA512: u8 = 6; /// Host-facing TBOR `UnwrapKey` request. #[tbor(opcode = TBOR_OP_UNWRAP_KEY, session_ctrl = in_session)] @@ -89,14 +93,14 @@ mod tests { let req = TborUnwrapKeyReq { session_id: 7, scope: 0b011, - key_class: KEY_CLASS_HMAC, + key_class: KEY_CLASS_HMAC_SHA256, oaep_hash: 1, wrapped_blob: alloc::vec![0x5Au8; 300], }; let mut buf = [0u8; 4096]; let frame = req.encode_request(&mut buf).expect("encode"); assert!( - frame.contains(&KEY_CLASS_HMAC), + frame.contains(&KEY_CLASS_HMAC_SHA256), "encoded frame must carry the key-class discriminant", ); } diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs index 09f8401d9..07b6c0712 100644 --- a/ddi/tbor/types/tests/commands/unwrap_key.rs +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -11,10 +11,10 @@ //! //! Coverage: //! * AES key — the masked blob is non-zero and there is no public key. -//! * HMAC key — the recovered key's kind is `_HmacSha256` and it carries -//! no public key. (Using the recovered key via `Hmac` to compute a MAC -//! is exercised by the HMAC command's own emu tests, which build on -//! this command.) +//! * HMAC key — the recovered key's kind is `VarLenHmacSha256` and it +//! carries no public key. (Using the recovered key via `Hmac` to +//! compute a MAC is exercised by the HMAC command's own emu tests, +//! which build on this command.) //! * RSA-4096 (CRT and non-CRT) — the largest supported key and the //! tightest on the per-IO DMA budget; exercises the transient-vault //! unwrap path that keeps it within 8 KB. @@ -35,7 +35,7 @@ use azihsm_ddi_tbor_types::TborGetUnwrappingKeyReq; use azihsm_ddi_tbor_types::TborUnwrapKeyReq; use azihsm_ddi_tbor_types::TborUnwrapKeyResp; use azihsm_ddi_tbor_types::KEY_CLASS_AES; -use azihsm_ddi_tbor_types::KEY_CLASS_HMAC; +use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA256; use azihsm_ddi_tbor_types::KEY_CLASS_RSA; use azihsm_ddi_tbor_types::KEY_CLASS_RSA_CRT; @@ -47,8 +47,8 @@ const OAEP_SHA256: u8 = 1; /// `KeyScope::Local` discriminant — masks the recovered key under the /// partition-local masking key. const SCOPE_LOCAL: u8 = 0b011; -/// `HsmVaultKeyKind::_HmacSha256` discriminant. -const KIND_HMAC_SHA256: u8 = 28; +/// `HsmVaultKeyKind::VarLenHmacSha256` discriminant. +const KIND_HMAC_SHA256: u8 = 32; /// RSA-AES-wrap `data` against the HSM-format unwrapping public key /// (`n_le ‖ e_le`): RSA-OAEP(SHA-256) an ephemeral 32-byte KEK, then @@ -165,15 +165,15 @@ fn unwrap_key_hmac_emu() { let session = finalized_co_session(&ctx); let hmac_key = [0x37u8; 32]; - let resp = unwrap(&ctx, session.session_id, KEY_CLASS_HMAC, &hmac_key); + let resp = unwrap(&ctx, session.session_id, KEY_CLASS_HMAC_SHA256, &hmac_key); - // A 32-byte HMAC key decodes to HMAC-SHA-256; symmetric keys carry no - // public key. Exercising the recovered key via `Hmac` to compute a - // MAC is covered by the HMAC command's own emu tests, which build on - // this command. + // A 32-byte HMAC key decodes to variable-length HMAC-SHA-256; symmetric + // keys carry no public key. Exercising the recovered key via `Hmac` to + // compute a MAC is covered by the HMAC command's own emu tests, which + // build on this command. assert_eq!( resp.key_kind, KIND_HMAC_SHA256, - "recovered kind = HMAC-SHA256" + "recovered kind = VarLenHmacSha256" ); assert!(resp.pub_key.is_empty()); assert!( diff --git a/docs/tbor-ddi/commands/unwrap_key.md b/docs/tbor-ddi/commands/unwrap_key.md index eb1af829f..f09eb08d6 100644 --- a/docs/tbor-ddi/commands/unwrap_key.md +++ b/docs/tbor-ddi/commands/unwrap_key.md @@ -36,12 +36,13 @@ attributes: - `Rsa` → DER RSA private key (non-CRT vault kind); `sign` + `decrypt`. - `RsaCrt` → DER RSA private key (CRT vault kind); `sign` + `decrypt`. - `Ecc` → PKCS#8 DER ECC private key; `sign` + `derive`. -- `Hmac` → raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512); - `sign` + `verify`. +- `HmacSha256` / `HmacSha384` / `HmacSha512` → raw variable-length HMAC + key stored as the matching `VarLenHmacSha*` vault kind; `sign` + + `verify`. Imported keys are never `local`. For the asymmetric classes (`Rsa`, `RsaCrt`, `Ecc`) the recovered key's wire public key is re-derived and -returned in `pub_key`; symmetric classes (`Aes`, `Hmac`) return an empty +returned in `pub_key`; symmetric classes (`Aes`, `HmacSha*`) return an empty `pub_key`. Scope → masking key (resolved on-device): @@ -71,7 +72,7 @@ Available to **both Crypto-Officer and Crypto-User** sessions. |---|---|---|---| | 4 | `session_id` | `session_id` (inline) | Session this request is bound to; cross-checked against the SQE-carried session id. | | 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain. | -| 12 | `key_class` | `uint8` (inline) | Class of the wrapped key (`KeyClass` discriminant): `0` = Aes, `1` = Rsa, `2` = RsaCrt, `3` = Ecc, `4` = Hmac. | +| 12 | `key_class` | `uint8` (inline) | Class of the wrapped key (`KeyClass` discriminant): `0` = Aes, `1` = Rsa, `2` = RsaCrt, `3` = Ecc, `4` = HmacSha256, `5` = HmacSha384, `6` = HmacSha512. | | 16 | `oaep_hash` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | | 20 | `wrapped_blob` | `buffer` (≤ 3072 B) | The RSA-AES-wrapped key: `RSA-OAEP(KEK) ‖ AES-KWP(key)`. The leading modulus-sized (256 B for RSA-2048) OAEP ciphertext is wire little-endian. | diff --git a/fw/core/ddi/tbor/types/src/unwrap_key.rs b/fw/core/ddi/tbor/types/src/unwrap_key.rs index ed70262ec..1c0ca7a09 100644 --- a/fw/core/ddi/tbor/types/src/unwrap_key.rs +++ b/fw/core/ddi/tbor/types/src/unwrap_key.rs @@ -80,8 +80,12 @@ pub enum KeyClass { RsaCrt = 2, /// A PKCS#8 DER-encoded ECC private key. Ecc = 3, - /// A raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512). - Hmac = 4, + /// A raw variable-length HMAC-SHA-256 key (`VarLenHmacSha256`). + HmacSha256 = 4, + /// A raw variable-length HMAC-SHA-384 key (`VarLenHmacSha384`). + HmacSha384 = 5, + /// A raw variable-length HMAC-SHA-512 key (`VarLenHmacSha512`). + HmacSha512 = 6, } /// `UnwrapKey` request schema. @@ -154,7 +158,7 @@ mod tests { .unwrap() .scope(KeyScope::Local) .unwrap() - .key_class(KeyClass::Hmac) + .key_class(KeyClass::HmacSha256) .unwrap() .oaep_hash(HashAlgo::Sha256) .unwrap() @@ -162,7 +166,7 @@ mod tests { .unwrap() .finish(); - assert_eq!(frame.key_class(), KeyClass::Hmac); + assert_eq!(frame.key_class(), KeyClass::HmacSha256); assert_eq!(frame.oaep_hash(), HashAlgo::Sha256); assert_eq!(frame.wrapped_blob(), &wrapped[..]); } diff --git a/fw/core/key-decode/src/hmac.rs b/fw/core/key-decode/src/hmac.rs index 650a64c45..3a869103c 100644 --- a/fw/core/key-decode/src/hmac.rs +++ b/fw/core/key-decode/src/hmac.rs @@ -3,11 +3,15 @@ //! HMAC-key decode path. //! -//! Classifies a raw 32 / 48 / 64-byte HMAC key into its fixed-length HMAC -//! vault kind (SHA-256 / 384 / 512) and returns the -//! [`DecodedKey`](super::DecodedKey) the caller persists / masks. Any -//! other byte length is a host contract violation. The raw key is itself -//! the vault material. +//! Wraps a raw HMAC key into the caller-selected variable-length HMAC +//! vault kind ([`VarLenHmacSha256`](HsmVaultKeyKind::VarLenHmacSha256) / +//! `384` / `512`) and returns the [`DecodedKey`](super::DecodedKey) the +//! caller persists / masks. HMAC keys are variable-length by +//! construction (any non-empty byte string is a valid key), so the SHA +//! variant is selected by the caller (from the wire key class), **not** +//! inferred from the key length — the vault enforces per-kind length +//! bounds at `vault_key_create`. The raw key is itself the vault +//! material. use azihsm_fw_hsm_pal_traits::DmaBuf; use azihsm_fw_hsm_pal_traits::HsmError; @@ -16,14 +20,26 @@ use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; use super::DecodedKey; -/// Classify a raw HMAC key by length: 32 / 48 / 64 B → SHA-256 / 384 / 512. -pub(super) fn decode(material: &DmaBuf) -> HsmResult> { - let kind = match material.len() { - 32 => HsmVaultKeyKind::_HmacSha256, - 48 => HsmVaultKeyKind::_HmacSha384, - 64 => HsmVaultKeyKind::_HmacSha512, +/// Wrap a raw HMAC key into the caller-selected variable-length HMAC +/// vault `kind`. +/// +/// `kind` must be one of the `VarLenHmacSha*` kinds (chosen from the wire +/// key class); any other kind is a caller contract violation. The key +/// material is passed through verbatim — its length is validated against +/// the kind's bounds later, by `vault_key_create`. +pub(super) fn decode(material: &DmaBuf, kind: HsmVaultKeyKind) -> HsmResult> { + match kind { + HsmVaultKeyKind::VarLenHmacSha256 + | HsmVaultKeyKind::VarLenHmacSha384 + | HsmVaultKeyKind::VarLenHmacSha512 => {} _ => return Err(HsmError::InvalidArg), - }; + } + + // An HMAC key must be non-empty; the vault enforces the per-kind + // maximum at create time. + if material.is_empty() { + return Err(HsmError::InvalidArg); + } // Symmetric — no public key. Ok(DecodedKey { diff --git a/fw/core/key-decode/src/lib.rs b/fw/core/key-decode/src/lib.rs index fe8f78b62..4a85b3e2d 100644 --- a/fw/core/key-decode/src/lib.rs +++ b/fw/core/key-decode/src/lib.rs @@ -43,8 +43,12 @@ pub enum KeyClass { RsaCrt, /// A PKCS#8 DER-encoded ECC private key. Ecc, - /// A raw 32 / 48 / 64-byte HMAC key (SHA-256 / 384 / 512). - Hmac, + /// A raw variable-length HMAC-SHA-256 key (`VarLenHmacSha256`). + HmacSha256, + /// A raw variable-length HMAC-SHA-384 key (`VarLenHmacSha384`). + HmacSha384, + /// A raw variable-length HMAC-SHA-512 key (`VarLenHmacSha512`). + HmacSha512, } /// A decoded key in vault-ready form, for the caller to persist. @@ -91,6 +95,8 @@ pub async fn decode<'p, P: HsmPal>( KeyClass::Rsa => rsa::decode(pal, io, material, false).await, KeyClass::RsaCrt => rsa::decode(pal, io, material, true).await, KeyClass::Ecc => ecc::decode(pal, io, material).await, - KeyClass::Hmac => hmac::decode(material), + KeyClass::HmacSha256 => hmac::decode(material, HsmVaultKeyKind::VarLenHmacSha256), + KeyClass::HmacSha384 => hmac::decode(material, HsmVaultKeyKind::VarLenHmacSha384), + KeyClass::HmacSha512 => hmac::decode(material, HsmVaultKeyKind::VarLenHmacSha512), } } diff --git a/fw/core/lib/src/ddi/tbor/unwrap_key.rs b/fw/core/lib/src/ddi/tbor/unwrap_key.rs index 021d3911f..6cd07c4e2 100644 --- a/fw/core/lib/src/ddi/tbor/unwrap_key.rs +++ b/fw/core/lib/src/ddi/tbor/unwrap_key.rs @@ -70,7 +70,9 @@ fn decode_class(class: KeyClass) -> HsmResult { KeyClass::Rsa => Ok(DecodeKeyClass::Rsa), KeyClass::RsaCrt => Ok(DecodeKeyClass::RsaCrt), KeyClass::Ecc => Ok(DecodeKeyClass::Ecc), - KeyClass::Hmac => Ok(DecodeKeyClass::Hmac), + KeyClass::HmacSha256 => Ok(DecodeKeyClass::HmacSha256), + KeyClass::HmacSha384 => Ok(DecodeKeyClass::HmacSha384), + KeyClass::HmacSha512 => Ok(DecodeKeyClass::HmacSha512), _ => Err(HsmError::UnsupportedCmd), } } @@ -87,7 +89,9 @@ fn attrs_for_class(class: KeyClass, scope: HsmKeyScope) -> HsmVaultKeyAttrs { // ECC private key: sign / derive. KeyClass::Ecc => base.with_sign(true).with_derive(true), // HMAC key: sign / verify. - KeyClass::Hmac => base.with_sign(true).with_verify(true), + KeyClass::HmacSha256 | KeyClass::HmacSha384 | KeyClass::HmacSha512 => { + base.with_sign(true).with_verify(true) + } _ => base, } } From bfaa19d0e4b9877335125584f60e67649b2572d4 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Tue, 21 Jul 2026 00:20:57 +0000 Subject: [PATCH 5/8] refactor(tbor): address UnwrapKey review feedback (naming + session-key len) - Rename the `oaep_hash` request field to `oaep_hash_algo` (fw + host wire types, handler, tests, docs) for clarity, per review. - `resolve_masking_key` now validates that a `Session`-scope masking key is exactly SESSION_MASKING_KEY_LEN (32) bytes, rejecting a legacy 80-byte MBOR `Session` blob key with `UnsupportedKeyScope` so a TBOR masked-key command can't accidentally run under a legacy session schedule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cd9bfef-fd58-4ac9-bbb0-eb67febe865d --- ddi/tbor/types/src/unwrap_key.rs | 6 +++--- ddi/tbor/types/tests/commands/unwrap_key.rs | 2 +- docs/tbor-ddi/commands/unwrap_key.md | 6 +++--- fw/core/ddi/tbor/types/src/unwrap_key.rs | 8 ++++---- fw/core/lib/src/ddi/tbor/mod.rs | 15 ++++++++++++++- fw/core/lib/src/ddi/tbor/unwrap_key.rs | 4 ++-- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/ddi/tbor/types/src/unwrap_key.rs b/ddi/tbor/types/src/unwrap_key.rs index 8aeb54164..9dfb6cea3 100644 --- a/ddi/tbor/types/src/unwrap_key.rs +++ b/ddi/tbor/types/src/unwrap_key.rs @@ -10,7 +10,7 @@ //! **masked** blob under the requested scope's masking key — plus the //! re-derived wire public key for RSA / ECC. //! -//! The `scope`, `key_class`, and `oaep_hash` are raw 1-byte discriminants +//! The `scope`, `key_class`, and `oaep_hash_algo` are raw 1-byte discriminants //! (the firmware types them as the `KeyScope` / `KeyClass` / `HashAlgo` //! open-enums; this host crate is firewalled from the firmware PAL types). @@ -58,7 +58,7 @@ pub struct TborUnwrapKeyReq { pub key_class: u8, /// OAEP hash used to wrap the KEK, 1-byte `HashAlgo`. - pub oaep_hash: u8, + pub oaep_hash_algo: u8, /// The RSA-AES-wrapped key (`RSA-OAEP(KEK) ‖ AES-KWP(key)`). #[tbor(max_len = 3072)] @@ -94,7 +94,7 @@ mod tests { session_id: 7, scope: 0b011, key_class: KEY_CLASS_HMAC_SHA256, - oaep_hash: 1, + oaep_hash_algo: 1, wrapped_blob: alloc::vec![0x5Au8; 300], }; let mut buf = [0u8; 4096]; diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs index 07b6c0712..7b22beb0b 100644 --- a/ddi/tbor/types/tests/commands/unwrap_key.rs +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -97,7 +97,7 @@ fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKe session_id, scope: SCOPE_LOCAL, key_class: class, - oaep_hash: OAEP_SHA256, + oaep_hash_algo: OAEP_SHA256, wrapped_blob: wrapped, }) .expect("UnwrapKey") diff --git a/docs/tbor-ddi/commands/unwrap_key.md b/docs/tbor-ddi/commands/unwrap_key.md index f09eb08d6..00212246c 100644 --- a/docs/tbor-ddi/commands/unwrap_key.md +++ b/docs/tbor-ddi/commands/unwrap_key.md @@ -73,7 +73,7 @@ Available to **both Crypto-Officer and Crypto-User** sessions. | 4 | `session_id` | `session_id` (inline) | Session this request is bound to; cross-checked against the SQE-carried session id. | | 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain. | | 12 | `key_class` | `uint8` (inline) | Class of the wrapped key (`KeyClass` discriminant): `0` = Aes, `1` = Rsa, `2` = RsaCrt, `3` = Ecc, `4` = HmacSha256, `5` = HmacSha384, `6` = HmacSha512. | -| 16 | `oaep_hash` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | +| 16 | `oaep_hash_algo` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | | 20 | `wrapped_blob` | `buffer` (≤ 3072 B) | The RSA-AES-wrapped key: `RSA-OAEP(KEK) ‖ AES-KWP(key)`. The leading modulus-sized (256 B for RSA-2048) OAEP ciphertext is wire little-endian. | ### Data section @@ -100,13 +100,13 @@ keys). | Error | Cause | |---|---| | `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | -| `InvalidArg` | A non-`Session` scope was requested before the partition is `Initialized`, or an unknown `oaep_hash` | +| `InvalidArg` | A non-`Session` scope was requested before the partition is `Initialized`, or an unknown `oaep_hash_algo` | | `UnsupportedKeyScope` | The requested scope has no masking key yet (e.g. `SecurityDomain` before `CreateSD`) | | `UnsupportedCmd` | An unknown `key_class` discriminant | | `PendingKeyGeneration` | The partition's unwrapping key is still being generated; call `GetUnwrappingKey` and retry | | `RsaUnwrapInvalidRequest` | The wrapped blob is shorter than the modulus-sized OAEP segment | | `RsaUnwrapInvalidKek` | The recovered KEK has an invalid length | -| `RsaDecryptFailed` | OAEP-decrypt of the KEK failed (wrong unwrapping key, corrupt ciphertext, or `oaep_hash` mismatch) | +| `RsaDecryptFailed` | OAEP-decrypt of the KEK failed (wrong unwrapping key, corrupt ciphertext, or `oaep_hash_algo` mismatch) | | `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | | `DdiDecodeFailed` | Malformed request body | diff --git a/fw/core/ddi/tbor/types/src/unwrap_key.rs b/fw/core/ddi/tbor/types/src/unwrap_key.rs index 1c0ca7a09..76a8f3dc9 100644 --- a/fw/core/ddi/tbor/types/src/unwrap_key.rs +++ b/fw/core/ddi/tbor/types/src/unwrap_key.rs @@ -20,7 +20,7 @@ //! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. //! * `scope` — the [`KeyScope`] whose masking key wraps the recovered key. //! * `key_class` — the [`KeyClass`] of the wrapped key. -//! * `oaep_hash` — the OAEP [`HashAlgo`] used to wrap the KEK. +//! * `oaep_hash_algo` — the OAEP [`HashAlgo`] used to wrap the KEK. //! * `wrapped_blob` — the RSA-AES-wrapped key //! (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), up to [`UNWRAP_WRAPPED_BLOB_MAX_LEN`]. //! @@ -105,7 +105,7 @@ pub struct TborUnwrapKeyReq<'a> { /// OAEP hash used to wrap the KEK, 1-byte [`HashAlgo`]. #[tbor(U8)] - pub oaep_hash: HashAlgo, + pub oaep_hash_algo: HashAlgo, /// The RSA-AES-wrapped key (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), up to /// [`UNWRAP_WRAPPED_BLOB_MAX_LEN`] bytes. @@ -160,14 +160,14 @@ mod tests { .unwrap() .key_class(KeyClass::HmacSha256) .unwrap() - .oaep_hash(HashAlgo::Sha256) + .oaep_hash_algo(HashAlgo::Sha256) .unwrap() .wrapped_blob(&wrapped) .unwrap() .finish(); assert_eq!(frame.key_class(), KeyClass::HmacSha256); - assert_eq!(frame.oaep_hash(), HashAlgo::Sha256); + assert_eq!(frame.oaep_hash_algo(), HashAlgo::Sha256); assert_eq!(frame.wrapped_blob(), &wrapped[..]); } diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index 72f77ea73..5cf2cc7b7 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -56,6 +56,7 @@ use azihsm_fw_hsm_pal_traits::HsmResult; use azihsm_fw_hsm_pal_traits::HsmSessId; use azihsm_fw_hsm_pal_traits::HsmSessionState; use azihsm_fw_hsm_pal_traits::SessionRole; +use azihsm_fw_hsm_pal_traits::SESSION_MASKING_KEY_LEN; use azihsm_fw_hsm_undo::UndoLog; use super::*; @@ -267,7 +268,19 @@ fn resolve_masking_key<'p, P: HsmPal>( sess_id: HsmSessId, ) -> HsmResult<&'p DmaBuf> { match scope { - HsmKeyScope::Session => pal.session_masking_key(io, sess_id), + HsmKeyScope::Session => { + // The std PAL's `session_masking_key` may return an 80-byte + // legacy MBOR `Session` blob key or the 32-byte TBOR `SessionEx` + // key. The TBOR masked-key system is AES-256-GCM and requires + // exactly `SESSION_MASKING_KEY_LEN` (32) bytes, so reject a + // legacy-length key up front rather than letting AEAD masking + // fail deeper with a less specific error. + let key = pal.session_masking_key(io, sess_id)?; + if key.len() != SESSION_MASKING_KEY_LEN { + return Err(HsmError::UnsupportedKeyScope); + } + Ok(key) + } _ => { let mk_key_id = masking_key_id_for_scope(pal, io, scope)?; pal.vault_key(io, mk_key_id) diff --git a/fw/core/lib/src/ddi/tbor/unwrap_key.rs b/fw/core/lib/src/ddi/tbor/unwrap_key.rs index 6cd07c4e2..c2485ebfb 100644 --- a/fw/core/lib/src/ddi/tbor/unwrap_key.rs +++ b/fw/core/lib/src/ddi/tbor/unwrap_key.rs @@ -165,7 +165,7 @@ pub(crate) async fn handle<'p, P: HsmPal>( let scope = HsmKeyScope(req.scope().0); validate_active_session(pal, io, sess_id)?; - let oaep_hash = oaep_hsm_hash(req.oaep_hash())?; + let oaep_hash_algo = oaep_hsm_hash(req.oaep_hash_algo())?; let class = req.key_class(); let dclass = decode_class(class)?; let wrapped_blob = req.wrapped_blob(); @@ -196,7 +196,7 @@ pub(crate) async fn handle<'p, P: HsmPal>( io, UnwrapParams { unwrap_key_id, - oaep_hash, + oaep_hash: oaep_hash_algo, wrapped_blob, }, ) From 6b0c7271eb3e1e167c233c8dd2d1f890408fecd1 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Tue, 21 Jul 2026 00:32:53 +0000 Subject: [PATCH 6/8] feat(tbor): honor host-requested key usage on UnwrapKey Address review feedback (jaygmsft): the handler hardcoded both permissions per key class (e.g. RSA always got sign+decrypt), deviating from the MBOR import path where the caller selects the usage. Add a compact 1-byte `KeyUsage` bitfield to the shared TBOR `key_props` (encrypt/decrypt/sign/verify/derive/wrap/unwrap) and carry it on the `UnwrapKey` request. The handler now derives the recovered key's vault attributes from the requested usage, mirroring the MBOR `key_attrs` policy: sign+verify and encrypt+decrypt are matched pairs, exactly one usage group may be set, and each class restricts the valid group(s) (AES: enc/dec; RSA: sign/verify or enc/dec; ECC: sign/verify or derive; HMAC: sign/verify). Invalid combinations are rejected with InvalidPermissions. Adds host KEY_USAGE_* constants, an emu rejection test, and doc updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cd9bfef-fd58-4ac9-bbb0-eb67febe865d --- ddi/tbor/types/src/unwrap_key.rs | 23 ++++- ddi/tbor/types/tests/commands/unwrap_key.rs | 63 +++++++++++++- docs/tbor-ddi/commands/unwrap_key.md | 30 ++++--- fw/core/ddi/tbor/types/src/key_props.rs | 93 +++++++++++++++++++++ fw/core/ddi/tbor/types/src/unwrap_key.rs | 13 +++ fw/core/lib/src/ddi/tbor/unwrap_key.rs | 79 +++++++++++++---- 6 files changed, 272 insertions(+), 29 deletions(-) diff --git a/ddi/tbor/types/src/unwrap_key.rs b/ddi/tbor/types/src/unwrap_key.rs index 9dfb6cea3..df1441149 100644 --- a/ddi/tbor/types/src/unwrap_key.rs +++ b/ddi/tbor/types/src/unwrap_key.rs @@ -10,7 +10,7 @@ //! **masked** blob under the requested scope's masking key — plus the //! re-derived wire public key for RSA / ECC. //! -//! The `scope`, `key_class`, and `oaep_hash_algo` are raw 1-byte discriminants +//! The `scope`, `key_class`, `key_usage`, and `oaep_hash_algo` are raw 1-byte discriminants //! (the firmware types them as the `KeyScope` / `KeyClass` / `HashAlgo` //! open-enums; this host crate is firewalled from the firmware PAL types). @@ -43,6 +43,21 @@ pub const KEY_CLASS_HMAC_SHA384: u8 = 5; /// `KeyClass` discriminant for a variable-length HMAC-SHA-512 key. pub const KEY_CLASS_HMAC_SHA512: u8 = 6; +/// `KeyUsage` bit: key may encrypt. +pub const KEY_USAGE_ENCRYPT: u8 = 1 << 0; +/// `KeyUsage` bit: key may decrypt. +pub const KEY_USAGE_DECRYPT: u8 = 1 << 1; +/// `KeyUsage` bit: key may sign / compute a MAC. +pub const KEY_USAGE_SIGN: u8 = 1 << 2; +/// `KeyUsage` bit: key may verify a signature / MAC. +pub const KEY_USAGE_VERIFY: u8 = 1 << 3; +/// `KeyUsage` bit: key may derive other keys. +pub const KEY_USAGE_DERIVE: u8 = 1 << 4; +/// `KeyUsage` bit: key may wrap other keys. +pub const KEY_USAGE_WRAP: u8 = 1 << 5; +/// `KeyUsage` bit: key may unwrap other keys. +pub const KEY_USAGE_UNWRAP: u8 = 1 << 6; + /// Host-facing TBOR `UnwrapKey` request. #[tbor(opcode = TBOR_OP_UNWRAP_KEY, session_ctrl = in_session)] #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -57,6 +72,11 @@ pub struct TborUnwrapKeyReq { /// Class of the wrapped key, 1-byte `KeyClass` (see `KEY_CLASS_*`). pub key_class: u8, + /// Requested key-usage permissions, 1-byte `KeyUsage` bitfield (see + /// `KEY_USAGE_*`). The device enforces which usage(s) are valid for + /// `key_class`. + pub key_usage: u8, + /// OAEP hash used to wrap the KEK, 1-byte `HashAlgo`. pub oaep_hash_algo: u8, @@ -94,6 +114,7 @@ mod tests { session_id: 7, scope: 0b011, key_class: KEY_CLASS_HMAC_SHA256, + key_usage: KEY_USAGE_SIGN | KEY_USAGE_VERIFY, oaep_hash_algo: 1, wrapped_blob: alloc::vec![0x5Au8; 300], }; diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs index 7b22beb0b..b259e8dae 100644 --- a/ddi/tbor/types/tests/commands/unwrap_key.rs +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -32,12 +32,19 @@ use azihsm_crypto::RsaEncryptAlgo; use azihsm_crypto::RsaPrivateKey; use azihsm_crypto::RsaPublicKey; use azihsm_ddi_tbor_types::TborGetUnwrappingKeyReq; +use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::TborUnwrapKeyReq; use azihsm_ddi_tbor_types::TborUnwrapKeyResp; use azihsm_ddi_tbor_types::KEY_CLASS_AES; use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA256; +use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA384; +use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA512; use azihsm_ddi_tbor_types::KEY_CLASS_RSA; use azihsm_ddi_tbor_types::KEY_CLASS_RSA_CRT; +use azihsm_ddi_tbor_types::KEY_USAGE_DECRYPT; +use azihsm_ddi_tbor_types::KEY_USAGE_ENCRYPT; +use azihsm_ddi_tbor_types::KEY_USAGE_SIGN; +use azihsm_ddi_tbor_types::KEY_USAGE_VERIFY; use crate::commands::sd_sealing_key_gen::finalized_co_session; use crate::harness::TestCtx; @@ -85,9 +92,34 @@ fn rsa_aes_wrap(hsm_pub: &[u8], data: &[u8]) -> Vec { wrapped } +/// Canonical valid `KeyUsage` for a wrapped-key `class`, used by the test +/// `unwrap` helper so callers need not spell out permissions. +fn usage_for_class(class: u8) -> u8 { + match class { + KEY_CLASS_AES => KEY_USAGE_ENCRYPT | KEY_USAGE_DECRYPT, + KEY_CLASS_RSA | KEY_CLASS_RSA_CRT => KEY_USAGE_SIGN | KEY_USAGE_VERIFY, + KEY_CLASS_HMAC_SHA256 | KEY_CLASS_HMAC_SHA384 | KEY_CLASS_HMAC_SHA512 => { + KEY_USAGE_SIGN | KEY_USAGE_VERIFY + } + _ => 0, + } +} + /// Fetch the unwrapping public key, wrap `key` for `class`, and unwrap it -/// on-device under the `Local` scope. -fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKeyResp { +/// on-device under the `Local` scope with the class's canonical usage. +pub(crate) fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKeyResp { + unwrap_with_usage(ctx, session_id, class, usage_for_class(class), key) +} + +/// Like [`unwrap`], but with an explicit `usage` — for exercising the +/// per-class permission validation. +pub(crate) fn unwrap_with_usage( + ctx: &TestCtx, + session_id: u16, + class: u8, + usage: u8, + key: &[u8], +) -> TborUnwrapKeyResp { let hsm_pub = ctx .tbor(&TborGetUnwrappingKeyReq { session_id }) .expect("GetUnwrappingKey") @@ -97,6 +129,7 @@ fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKe session_id, scope: SCOPE_LOCAL, key_class: class, + key_usage: usage, oaep_hash_algo: OAEP_SHA256, wrapped_blob: wrapped, }) @@ -181,3 +214,29 @@ fn unwrap_key_hmac_emu() { "masked HMAC key must not be all-zero", ); } + +#[test] +fn unwrap_key_rejects_invalid_usage_for_class_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + // Request `sign`+`verify` for an AES key — AES only permits + // `encrypt`+`decrypt`, so the device must reject the import. + let aes_key = [0x42u8; 32]; + let hsm_pub = ctx + .tbor(&TborGetUnwrappingKeyReq { + session_id: session.session_id, + }) + .expect("GetUnwrappingKey") + .pub_key; + let wrapped = rsa_aes_wrap(&hsm_pub, &aes_key); + let req = TborUnwrapKeyReq { + session_id: session.session_id, + scope: SCOPE_LOCAL, + key_class: KEY_CLASS_AES, + key_usage: KEY_USAGE_SIGN | KEY_USAGE_VERIFY, + oaep_hash_algo: OAEP_SHA256, + wrapped_blob: wrapped, + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidPermissions); +} diff --git a/docs/tbor-ddi/commands/unwrap_key.md b/docs/tbor-ddi/commands/unwrap_key.md index 00212246c..db3fd0107 100644 --- a/docs/tbor-ddi/commands/unwrap_key.md +++ b/docs/tbor-ddi/commands/unwrap_key.md @@ -29,16 +29,22 @@ AES-KWP-wrapped under that KEK. The device resolves the unwrapping property (no host key reference), OAEP-decrypts the KEK, AES-KWP-unwraps the payload, decodes it into vault form, and masks it. -`key_class` selects the decode path and the recovered key's usage -attributes: - -- `Aes` → raw 16 / 24 / 32-byte AES key; `encrypt` + `decrypt`. -- `Rsa` → DER RSA private key (non-CRT vault kind); `sign` + `decrypt`. -- `RsaCrt` → DER RSA private key (CRT vault kind); `sign` + `decrypt`. -- `Ecc` → PKCS#8 DER ECC private key; `sign` + `derive`. +`key_class` selects the decode path (and the recovered key's vault kind); +`key_usage` selects the recovered key's usage attributes, which the device +validates against the class: + +- `Aes` → raw 16 / 24 / 32-byte AES key; `encrypt`+`decrypt` only. +- `Rsa` → DER RSA private key (non-CRT vault kind); `sign`+`verify` or + `encrypt`+`decrypt`. +- `RsaCrt` → DER RSA private key (CRT vault kind); `sign`+`verify` or + `encrypt`+`decrypt`. +- `Ecc` → PKCS#8 DER ECC private key; `sign`+`verify` or `derive`. - `HmacSha256` / `HmacSha384` / `HmacSha512` → raw variable-length HMAC - key stored as the matching `VarLenHmacSha*` vault kind; `sign` + - `verify`. + key stored as the matching `VarLenHmacSha*` vault kind; `sign`+`verify`. + +`sign`+`verify` and `encrypt`+`decrypt` are matched pairs and exactly one +usage group may be set; any invalid pairing, multi-usage request, or usage +not permitted for the class is rejected with `InvalidPermissions`. Imported keys are never `local`. For the asymmetric classes (`Rsa`, `RsaCrt`, `Ecc`) the recovered key's wire public key is re-derived and @@ -73,8 +79,9 @@ Available to **both Crypto-Officer and Crypto-User** sessions. | 4 | `session_id` | `session_id` (inline) | Session this request is bound to; cross-checked against the SQE-carried session id. | | 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain. | | 12 | `key_class` | `uint8` (inline) | Class of the wrapped key (`KeyClass` discriminant): `0` = Aes, `1` = Rsa, `2` = RsaCrt, `3` = Ecc, `4` = HmacSha256, `5` = HmacSha384, `6` = HmacSha512. | -| 16 | `oaep_hash_algo` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | -| 20 | `wrapped_blob` | `buffer` (≤ 3072 B) | The RSA-AES-wrapped key: `RSA-OAEP(KEK) ‖ AES-KWP(key)`. The leading modulus-sized (256 B for RSA-2048) OAEP ciphertext is wire little-endian. | +| 16 | `key_usage` | `uint8` (inline) | Requested usage permissions (`KeyUsage` bitfield): `0x01` = encrypt, `0x02` = decrypt, `0x04` = sign, `0x08` = verify, `0x10` = derive, `0x20` = wrap, `0x40` = unwrap. Validated against `key_class`. | +| 20 | `oaep_hash_algo` | `uint8` (inline) | OAEP hash used to wrap the KEK (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | +| 24 | `wrapped_blob` | `buffer` (≤ 3072 B) | The RSA-AES-wrapped key: `RSA-OAEP(KEK) ‖ AES-KWP(key)`. The leading modulus-sized (256 B for RSA-2048) OAEP ciphertext is wire little-endian. | ### Data section @@ -103,6 +110,7 @@ keys). | `InvalidArg` | A non-`Session` scope was requested before the partition is `Initialized`, or an unknown `oaep_hash_algo` | | `UnsupportedKeyScope` | The requested scope has no masking key yet (e.g. `SecurityDomain` before `CreateSD`) | | `UnsupportedCmd` | An unknown `key_class` discriminant | +| `InvalidPermissions` | The requested `key_usage` is an invalid pairing, sets more than one usage group, or is not permitted for `key_class` | | `PendingKeyGeneration` | The partition's unwrapping key is still being generated; call `GetUnwrappingKey` and retry | | `RsaUnwrapInvalidRequest` | The wrapped blob is shorter than the modulus-sized OAEP segment | | `RsaUnwrapInvalidKek` | The recovered KEK has an invalid length | diff --git a/fw/core/ddi/tbor/types/src/key_props.rs b/fw/core/ddi/tbor/types/src/key_props.rs index bad5d34a4..ecaf9b165 100644 --- a/fw/core/ddi/tbor/types/src/key_props.rs +++ b/fw/core/ddi/tbor/types/src/key_props.rs @@ -63,3 +63,96 @@ pub enum HashAlgo { /// SHA-512 (64-byte digest; 64-byte HMAC key / tag). Sha512 = 3, } + +/// Requested key-usage permissions on the TBOR wire — a compact 1-byte +/// bitfield carried by key-creating / key-import commands (e.g. +/// `UnwrapKey`) so the host, not the firmware, selects which operations +/// the imported/created key may perform. +/// +/// The bits mirror the usage semantics of the MBOR +/// `DdiTargetKeyMetadata` flags (minus the `session`/`modifiable` bits, +/// which TBOR carries out of band via the key scope): `sign`+`verify` +/// and `encrypt`+`decrypt` are matched pairs, and each handler enforces +/// which usage(s) are valid for the key's class. Sent inline as a raw +/// `u8` (`#[tbor(U8)]`); an out-of-range/invalid combination is rejected +/// on-device rather than failing to decode. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct KeyUsage(pub u8); + +impl KeyUsage { + /// Key may encrypt. + pub const ENCRYPT: u8 = 1 << 0; + /// Key may decrypt. + pub const DECRYPT: u8 = 1 << 1; + /// Key may sign / compute a MAC. + pub const SIGN: u8 = 1 << 2; + /// Key may verify a signature / MAC. + pub const VERIFY: u8 = 1 << 3; + /// Key may derive other keys. + pub const DERIVE: u8 = 1 << 4; + /// Key may wrap other keys. + pub const WRAP: u8 = 1 << 5; + /// Key may unwrap other keys. + pub const UNWRAP: u8 = 1 << 6; + + /// Build a `KeyUsage` from its raw wire bits. + #[inline] + pub const fn from_bits(bits: u8) -> Self { + Self(bits) + } + + /// The raw wire bits. + #[inline] + pub const fn bits(self) -> u8 { + self.0 + } + + /// Whether `flag` (one of the `KeyUsage::*` bit constants) is set. + #[inline] + pub const fn has(self, flag: u8) -> bool { + self.0 & flag != 0 + } + + /// `encrypt` bit. + #[inline] + pub const fn encrypt(self) -> bool { + self.has(Self::ENCRYPT) + } + + /// `decrypt` bit. + #[inline] + pub const fn decrypt(self) -> bool { + self.has(Self::DECRYPT) + } + + /// `sign` bit. + #[inline] + pub const fn sign(self) -> bool { + self.has(Self::SIGN) + } + + /// `verify` bit. + #[inline] + pub const fn verify(self) -> bool { + self.has(Self::VERIFY) + } + + /// `derive` bit. + #[inline] + pub const fn derive(self) -> bool { + self.has(Self::DERIVE) + } + + /// `wrap` bit. + #[inline] + pub const fn wrap(self) -> bool { + self.has(Self::WRAP) + } + + /// `unwrap` bit. + #[inline] + pub const fn unwrap(self) -> bool { + self.has(Self::UNWRAP) + } +} diff --git a/fw/core/ddi/tbor/types/src/unwrap_key.rs b/fw/core/ddi/tbor/types/src/unwrap_key.rs index 76a8f3dc9..66573857f 100644 --- a/fw/core/ddi/tbor/types/src/unwrap_key.rs +++ b/fw/core/ddi/tbor/types/src/unwrap_key.rs @@ -20,6 +20,7 @@ //! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. //! * `scope` — the [`KeyScope`] whose masking key wraps the recovered key. //! * `key_class` — the [`KeyClass`] of the wrapped key. +//! * `key_usage` — the requested [`KeyUsage`] permissions. //! * `oaep_hash_algo` — the OAEP [`HashAlgo`] used to wrap the KEK. //! * `wrapped_blob` — the RSA-AES-wrapped key //! (`RSA-OAEP(KEK) ‖ AES-KWP(key)`), up to [`UNWRAP_WRAPPED_BLOB_MAX_LEN`]. @@ -38,6 +39,7 @@ use open_enum::open_enum; use crate::key_props::HashAlgo; use crate::key_props::KeyScope; +use crate::key_props::KeyUsage; /// TBOR opcode for `UnwrapKey`. pub const TBOR_OP_UNWRAP_KEY: u8 = 0x14; @@ -103,6 +105,11 @@ pub struct TborUnwrapKeyReq<'a> { #[tbor(U8)] pub key_class: KeyClass, + /// Requested key-usage permissions, 1-byte [`KeyUsage`] bitfield. + /// The handler enforces which usage(s) are valid for `key_class`. + #[tbor(U8)] + pub key_usage: KeyUsage, + /// OAEP hash used to wrap the KEK, 1-byte [`HashAlgo`]. #[tbor(U8)] pub oaep_hash_algo: HashAlgo, @@ -160,6 +167,8 @@ mod tests { .unwrap() .key_class(KeyClass::HmacSha256) .unwrap() + .key_usage(KeyUsage::from_bits(KeyUsage::SIGN | KeyUsage::VERIFY)) + .unwrap() .oaep_hash_algo(HashAlgo::Sha256) .unwrap() .wrapped_blob(&wrapped) @@ -167,6 +176,10 @@ mod tests { .finish(); assert_eq!(frame.key_class(), KeyClass::HmacSha256); + assert_eq!( + frame.key_usage(), + KeyUsage::from_bits(KeyUsage::SIGN | KeyUsage::VERIFY) + ); assert_eq!(frame.oaep_hash_algo(), HashAlgo::Sha256); assert_eq!(frame.wrapped_blob(), &wrapped[..]); } diff --git a/fw/core/lib/src/ddi/tbor/unwrap_key.rs b/fw/core/lib/src/ddi/tbor/unwrap_key.rs index c2485ebfb..c76a43907 100644 --- a/fw/core/lib/src/ddi/tbor/unwrap_key.rs +++ b/fw/core/lib/src/ddi/tbor/unwrap_key.rs @@ -24,6 +24,7 @@ use azihsm_fw_core_crypto_key_masking::aead::AeadAlg; use azihsm_fw_core_crypto_key_masking::aead::MaskParams; use azihsm_fw_ddi_tbor_types::HashAlgo; use azihsm_fw_ddi_tbor_types::KeyClass; +use azihsm_fw_ddi_tbor_types::KeyUsage; use azihsm_fw_ddi_tbor_types::TborUnwrapKeyReq; use azihsm_fw_ddi_tbor_types::TborUnwrapKeyResp; use azihsm_fw_ddi_tbor_types::UNWRAP_MASKED_KEY_MAX_LEN; @@ -77,23 +78,71 @@ fn decode_class(class: KeyClass) -> HsmResult { } } -/// Standard usage attributes for an imported key of `class`, plus the -/// requested `scope`. Imported keys are never `local`. -fn attrs_for_class(class: KeyClass, scope: HsmKeyScope) -> HsmVaultKeyAttrs { +/// Derive the vault usage attributes for an imported key of `class` from +/// the host-requested [`KeyUsage`] permissions, plus the requested +/// `scope`. Imported keys are never `local`. +/// +/// Mirrors the MBOR `key_attrs::for_*` policy: `sign`+`verify` and +/// `encrypt`+`decrypt` are matched pairs, exactly one usage group may be +/// set, and each key class restricts which group(s) are valid: +/// - AES → `encrypt`+`decrypt` only; +/// - RSA → `sign`+`verify` or `encrypt`+`decrypt`; +/// - ECC → `sign`+`verify` or `derive`; +/// - HMAC → `sign`+`verify`. +/// +/// Any invalid pairing, multi-usage request, or usage not permitted for +/// the class is rejected with [`HsmError::InvalidPermissions`]. +fn attrs_for_class( + class: KeyClass, + scope: HsmKeyScope, + usage: KeyUsage, +) -> HsmResult { + // Paired usages must be requested together. + if usage.sign() != usage.verify() || usage.encrypt() != usage.decrypt() { + return Err(HsmError::InvalidPermissions); + } + + let sign_verify = usage.sign() && usage.verify(); + let encrypt_decrypt = usage.encrypt() && usage.decrypt(); + let derive = usage.derive(); + let wrap = usage.wrap(); + let unwrap = usage.unwrap(); + + // Exactly one usage group may be set. + let usage_count = + sign_verify as u8 + encrypt_decrypt as u8 + derive as u8 + wrap as u8 + unwrap as u8; + if usage_count != 1 { + return Err(HsmError::InvalidPermissions); + } + let base = HsmVaultKeyAttrs::new().with_scope(scope); - match class { - // Symmetric cipher key: encrypt / decrypt. - KeyClass::Aes => base.with_encrypt(true).with_decrypt(true), - // RSA private key: sign / decrypt. - KeyClass::Rsa | KeyClass::RsaCrt => base.with_sign(true).with_decrypt(true), - // ECC private key: sign / derive. - KeyClass::Ecc => base.with_sign(true).with_derive(true), - // HMAC key: sign / verify. - KeyClass::HmacSha256 | KeyClass::HmacSha384 | KeyClass::HmacSha512 => { + let attrs = match class { + // Symmetric cipher key: encrypt / decrypt only. + KeyClass::Aes if encrypt_decrypt => base.with_encrypt(true).with_decrypt(true), + // RSA private key: sign / verify or encrypt / decrypt. + KeyClass::Rsa | KeyClass::RsaCrt if sign_verify => base.with_sign(true).with_verify(true), + KeyClass::Rsa | KeyClass::RsaCrt if encrypt_decrypt => { + base.with_encrypt(true).with_decrypt(true) + } + // ECC private key: sign / verify or derive (ECDH). + KeyClass::Ecc if sign_verify => base.with_sign(true).with_verify(true), + KeyClass::Ecc if derive => base.with_derive(true), + // HMAC key: sign / verify (MAC compute / verify). + KeyClass::HmacSha256 | KeyClass::HmacSha384 | KeyClass::HmacSha512 if sign_verify => { base.with_sign(true).with_verify(true) } - _ => base, - } + // Any recognized class with a usage it does not permit. + KeyClass::Aes + | KeyClass::Rsa + | KeyClass::RsaCrt + | KeyClass::Ecc + | KeyClass::HmacSha256 + | KeyClass::HmacSha384 + | KeyClass::HmacSha512 => return Err(HsmError::InvalidPermissions), + // Unrecognized class discriminant. + _ => return Err(HsmError::UnsupportedCmd), + }; + Ok(attrs) } /// Which wire public key (if any) an imported private key kind carries, @@ -183,7 +232,7 @@ pub(crate) async fn handle<'p, P: HsmPal>( // re-import). let svn = part_state::part_mfgr_svn(pal); let owner = u16::try_from(part_state::part_owner_svn(pal)).map_err(|_| HsmError::InvalidArg)?; - let attrs = attrs_for_class(class, scope); + let attrs = attrs_for_class(class, scope, req.key_usage())?; // Phase 1 — unwrap, decode, and vault the recovered key inside an // allocation scope. The (multi-KB, for RSA) unwrap / decode scratch is From aa2b50046b4efb98f1d3704c9da1348371c2370a Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Wed, 22 Jul 2026 21:53:25 +0000 Subject: [PATCH 7/8] docs(pal): update SessionEx blob sizing to 32 B masking key (72/168 B) Address review feedback: the SessionEx masking key is now 32 B (SESSION_MASKING_KEY_LEN, AES-256-GCM), so the SessionEx blob is 72 B (PlainText/CU) / 168 B (Authenticated/CO), not the old 120/216 B with an 80 B masking key. Update the stale `HsmVaultKeyKind::SessionEx` and `session_promote` doc comments accordingly; the legacy MBOR `Session` blob's 80 B CBC key is called out as distinct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cd9bfef-fd58-4ac9-bbb0-eb67febe865d --- fw/pal/traits/src/session.rs | 6 +++--- fw/pal/traits/src/vault.rs | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/fw/pal/traits/src/session.rs b/fw/pal/traits/src/session.rs index bd83c74e6..2b80cc9ff 100644 --- a/fw/pal/traits/src/session.rs +++ b/fw/pal/traits/src/session.rs @@ -412,10 +412,10 @@ pub trait HsmSessionManager { /// by the session-key vault blob: /// /// Length-discriminated by session type: - /// * **PlainText (CU):** 120 B blob = - /// `[api_rev(8) ‖ param_key(32) ‖ masking_key(80)]`. + /// * **PlainText (CU):** 72 B blob = + /// `[api_rev(8) ‖ param_key(32) ‖ masking_key(32)]`. /// `mac_tx_key` and `mac_rx_key` MUST both be `None`. - /// * **Authenticated (CO):** 216 B blob = the above ‖ + /// * **Authenticated (CO):** 168 B blob = the above ‖ /// `mac_tx(48) ‖ mac_rx(48)`. Both `mac_tx_key` and /// `mac_rx_key` MUST be `Some` (and 48 B each). /// diff --git a/fw/pal/traits/src/vault.rs b/fw/pal/traits/src/vault.rs index 9c1f9cf8c..8438226a7 100644 --- a/fw/pal/traits/src/vault.rs +++ b/fw/pal/traits/src/vault.rs @@ -158,10 +158,15 @@ pub enum HsmVaultKeyKind { /// and CU). /// /// Length-discriminated by session type: - /// * **PlainText (CU):** `[api_rev(8) ‖ param_key(32) ‖ masking_key(80)]` - /// = 120 B. + /// * **PlainText (CU):** `[api_rev(8) ‖ param_key(32) ‖ masking_key(32)]` + /// = 72 B. /// * **Authenticated (CO):** the above ‖ `mac_tx(48) ‖ mac_rx(48)` - /// = 216 B. + /// = 168 B. + /// + /// `masking_key` is the 32 B AES-256-GCM + /// [`SESSION_MASKING_KEY_LEN`](crate::SESSION_MASKING_KEY_LEN) key used + /// by the `key_masking::aead` TBOR masked-key system — distinct from the + /// legacy [`Session`](Self::Session) blob's 80 B `aes32 ‖ hmac48` CBC key. /// /// Written by /// [`HsmSessionManager::session_promote`](crate::HsmSessionManager::session_promote) From 2a3bf40ba6ceecfb4af796880ee92a026497b118 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Wed, 22 Jul 2026 22:12:01 +0000 Subject: [PATCH 8/8] fix(tbor): map Ephemeral/Local scope to UnsupportedKeyScope pre-PartFinal; add ECC unwrap test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - `masking_key_id_for_scope` now remaps `PartPropNotFound` to `UnsupportedKeyScope` for `Ephemeral`/`Local` scopes too (not just `SecurityDomain`), so requesting a scope before its masking key is provisioned surfaces the single documented `UnsupportedKeyScope` instead of leaking `PartPropNotFound`. Docs corrected accordingly (the prior `InvalidArg`-before-`Initialized` claim was inaccurate). - Add an emu `UnwrapKey` round-trip for the ECC (P-256) key class, exercising the PKCS#8 decode + re-derived `x‖y` public-key path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cd9bfef-fd58-4ac9-bbb0-eb67febe865d --- ddi/tbor/types/tests/commands/unwrap_key.rs | 26 +++++++++++++++++++++ docs/tbor-ddi/commands/unwrap_key.md | 12 +++++----- fw/core/lib/src/ddi/tbor/mod.rs | 21 +++++++++++------ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs index b259e8dae..240f75908 100644 --- a/ddi/tbor/types/tests/commands/unwrap_key.rs +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -23,6 +23,7 @@ use azihsm_crypto::AesKey; use azihsm_crypto::AesKeyWrapPadAlgo; +use azihsm_crypto::EccPrivateKey; use azihsm_crypto::Encrypter; use azihsm_crypto::ExportableKey; use azihsm_crypto::HashAlgo; @@ -36,6 +37,7 @@ use azihsm_ddi_tbor_types::TborStatus; use azihsm_ddi_tbor_types::TborUnwrapKeyReq; use azihsm_ddi_tbor_types::TborUnwrapKeyResp; use azihsm_ddi_tbor_types::KEY_CLASS_AES; +use azihsm_ddi_tbor_types::KEY_CLASS_ECC; use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA256; use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA384; use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA512; @@ -98,6 +100,7 @@ fn usage_for_class(class: u8) -> u8 { match class { KEY_CLASS_AES => KEY_USAGE_ENCRYPT | KEY_USAGE_DECRYPT, KEY_CLASS_RSA | KEY_CLASS_RSA_CRT => KEY_USAGE_SIGN | KEY_USAGE_VERIFY, + KEY_CLASS_ECC => KEY_USAGE_SIGN | KEY_USAGE_VERIFY, KEY_CLASS_HMAC_SHA256 | KEY_CLASS_HMAC_SHA384 | KEY_CLASS_HMAC_SHA512 => { KEY_USAGE_SIGN | KEY_USAGE_VERIFY } @@ -192,6 +195,29 @@ fn unwrap_key_aes_emu() { assert!(resp.pub_key.is_empty(), "a symmetric key has no public key"); } +#[test] +fn unwrap_key_ecc_p256_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + // Import a host-generated P-256 key (PKCS#8 DER) and check the decode + + // public-key re-derivation path. + let key = EccPrivateKey::generate(32).expect("generate P-256 key"); + let der = key.to_vec().expect("ECC PKCS#8 DER export"); + let resp = unwrap(&ctx, session.session_id, KEY_CLASS_ECC, &der); + + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked ECC key must not be all-zero", + ); + // P-256 wire public key is `x(32) ‖ y(32)` = 64 bytes. + assert_eq!( + resp.pub_key.len(), + 64, + "P-256 recovered public key is x(32) ‖ y(32)", + ); +} + #[test] fn unwrap_key_hmac_emu() { let ctx = TestCtx::new(); diff --git a/docs/tbor-ddi/commands/unwrap_key.md b/docs/tbor-ddi/commands/unwrap_key.md index db3fd0107..2038a3efe 100644 --- a/docs/tbor-ddi/commands/unwrap_key.md +++ b/docs/tbor-ddi/commands/unwrap_key.md @@ -63,10 +63,10 @@ Scope → masking key (resolved on-device): - `Local` → the partition `PartitionLocalMaskingKey`. - `SecurityDomain` → the security-domain masking key (`SDMK`). -The `Ephemeral` / `Local` / `SecurityDomain` masking keys are provisioned -by `PartFinal` / `CreateSD`, so a non-`Session` scope before the partition -is `Initialized` is rejected with `InvalidArg`, and `SecurityDomain` -before `CreateSD` with `UnsupportedKeyScope`. +The `Ephemeral` / `Local` masking keys are provisioned by `PartFinal`, and +the `SecurityDomain` masking key by `CreateSD`. Requesting any of these +scopes before its masking key is provisioned is rejected with +`UnsupportedKeyScope` ("the requested scope has no masking key yet"). Available to **both Crypto-Officer and Crypto-User** sessions. @@ -107,8 +107,8 @@ keys). | Error | Cause | |---|---| | `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | -| `InvalidArg` | A non-`Session` scope was requested before the partition is `Initialized`, or an unknown `oaep_hash_algo` | -| `UnsupportedKeyScope` | The requested scope has no masking key yet (e.g. `SecurityDomain` before `CreateSD`) | +| `InvalidArg` | An unknown `oaep_hash_algo` | +| `UnsupportedKeyScope` | The requested scope has no masking key yet (`Ephemeral` / `Local` before `PartFinal`, or `SecurityDomain` before `CreateSD`) | | `UnsupportedCmd` | An unknown `key_class` discriminant | | `InvalidPermissions` | The requested `key_usage` is an invalid pairing, sets more than one usage group, or is not permitted for `key_class` | | `PendingKeyGeneration` | The partition's unwrapping key is still being generated; call `GetUnwrappingKey` and retry | diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index 5cf2cc7b7..b46be487e 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -224,20 +224,27 @@ fn masking_key_id_for_scope( io: &impl HsmIo, scope: HsmKeyScope, ) -> HsmResult { + // Every provisioned scope's masking-key id is stored in an + // `AbsentUntilSet` partition property that is only written once the + // partition (`PartFinal`) or security domain (`CreateSD`) is + // finalized. Before then the read returns `PartPropNotFound`; remap + // it to the documented `UnsupportedKeyScope` ("the requested scope + // has no masking key yet") so clients never observe a leaked + // `PartPropNotFound`. Genuine read faults still propagate. + let remap_absent = |r: HsmResult| match r { + Err(HsmError::PartPropNotFound) => Err(HsmError::UnsupportedKeyScope), + other => other, + }; match scope { - HsmKeyScope::Ephemeral => part_state::part_ephemeral_mk_key_id(pal, io), - HsmKeyScope::Local => part_state::part_local_mk_key_id(pal, io), + HsmKeyScope::Ephemeral => remap_absent(part_state::part_ephemeral_mk_key_id(pal, io)), + HsmKeyScope::Local => remap_absent(part_state::part_local_mk_key_id(pal, io)), // Resolve the SecurityDomain masking key (`SDMK`) by `SD_MK_KEY_ID` // presence — the single source of truth. A request that observes a // partially-written commit (the `SD_INITIALIZED` claim is set but // `SD_MK_KEY_ID` is not yet written, or a rollback is in flight) // gets the documented `UnsupportedKeyScope` rather than a leaked // `PartPropNotFound`; genuine read faults still propagate. - HsmKeyScope::SecurityDomain => match part_state::part_sd_mk_key_id(pal, io) { - Ok(id) => Ok(id), - Err(HsmError::PartPropNotFound) => Err(HsmError::UnsupportedKeyScope), - Err(e) => Err(e), - }, + HsmKeyScope::SecurityDomain => remap_absent(part_state::part_sd_mk_key_id(pal, io)), _ => Err(HsmError::UnsupportedKeyScope), } }