From fcc8307e3c6865a39f07244014da4c106b2b7649 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 17:58:11 +0000 Subject: [PATCH 1/4] 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 d8952d5f574324b9c4f2edeab7d21bbcf9294408 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 18:09:09 +0000 Subject: [PATCH 2/4] 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 2e032a40ae86c0d5df22f6e0e02735ccc356f099 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 20:01:32 +0000 Subject: [PATCH 3/4] 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 e46394c56..658b2b333 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; @@ -21,3 +22,4 @@ pub mod sd_restore_local_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 26acf96c4..1b4c7fca9 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` (schema-only) | InSession | [`commands/sd_create_peer_backup.md`](./commands/sd_create_peer_backup.md) | | `0x0F` | `SdRestorePeerBackup` (schema-only) | 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 4fb96e7de..5a9ade009 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; @@ -36,6 +37,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; @@ -142,6 +144,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. @@ -161,6 +174,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, @@ -185,6 +216,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 @@ -277,6 +342,8 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( sd_restore_local_backup::handle(pal, io, req_buf, 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), } } @@ -301,6 +368,8 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::SD_RESEAL_REMOTE_BACKUP | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::KEY_REPORT + | opcode::GET_UNWRAPPING_KEY + | opcode::UNWRAP_KEY ) } @@ -331,7 +400,9 @@ fn is_in_session(opcode: u8) -> bool { | opcode::SD_CREATE_REMOTE_BACKUP | opcode::SD_RESEAL_REMOTE_BACKUP | opcode::SD_RESTORE_LOCAL_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, @@ -370,7 +441,9 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::SD_CREATE_REMOTE_BACKUP | opcode::SD_RESEAL_REMOTE_BACKUP | opcode::SD_RESTORE_LOCAL_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 1ce0330bb..7c6d97198 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -265,7 +265,9 @@ impl SessionCtrl { | opcode::SD_CREATE_REMOTE_BACKUP | opcode::SD_RESEAL_REMOTE_BACKUP | opcode::SD_RESTORE_LOCAL_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 2e8344fce2fdf95c7ca95f356e1614a13c029251 Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 23:50:18 +0000 Subject: [PATCH 4/4] feat(tbor): implement ECC keygen, sign, and ECDH derive Add three stateless masked-key TBOR crypto commands mirroring the MBOR vault-key-handle equivalents: - EccGenerateKey (0x17): generate an ECC keypair (P-256/384/521) and return the private key AEAD-GCM-256 masked under the requested scope plus the wire public key. Nothing is persisted on-device. - EccSign (0x18): unmask a caller-held masked ECC private key in place, ECDSA-sign a host-supplied pre-hashed digest, return raw r||s. - EcdhDerive (0x19): unmask a masked local ECC private key in place, derive an ECDH shared secret against a host peer public key, and re-mask the secret under a target scope. Uses the zero-copy reserve+fill encoder and in-place unmask (decode_mut) patterns. Recovered plaintext keys and derived secrets are scrubbed on every return path (scope exit only resets the bump watermark). ECC import is provided by the existing UnwrapKey (Ecc class); a cross-command test covers UnwrapKey-Ecc -> EccSign. Shared curve/kind mappings added to tbor from_pal (ecc_private, ecdh_secret, ecc_private_curve); dispatch/classifier wiring in mod.rs and op.rs. Wire schemas (fw + host mirror), emu integration tests (host- verified ECDSA roundtrip for all curves), and per-command docs included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09e50a-a9be-4bae-b347-d42dc775a258 --- ddi/tbor/types/src/ecc_generate_key.rs | 82 +++++++ ddi/tbor/types/src/ecc_sign.rs | 84 +++++++ ddi/tbor/types/src/ecdh_derive.rs | 78 +++++++ ddi/tbor/types/src/lib.rs | 6 + .../types/tests/commands/ecc_generate_key.rs | 119 ++++++++++ ddi/tbor/types/tests/commands/ecc_sign.rs | 216 ++++++++++++++++++ ddi/tbor/types/tests/commands/ecdh_derive.rs | 154 +++++++++++++ ddi/tbor/types/tests/commands/mod.rs | 3 + ddi/tbor/types/tests/commands/unwrap_key.rs | 2 +- docs/tbor-ddi/README.md | 3 + docs/tbor-ddi/commands/ecc_generate_key.md | 72 ++++++ docs/tbor-ddi/commands/ecc_sign.md | 75 ++++++ docs/tbor-ddi/commands/ecdh_derive.md | 78 +++++++ .../ddi/tbor/types/src/ecc_generate_key.rs | 150 ++++++++++++ fw/core/ddi/tbor/types/src/ecc_sign.rs | 132 +++++++++++ fw/core/ddi/tbor/types/src/ecdh_derive.rs | 137 +++++++++++ fw/core/ddi/tbor/types/src/lib.rs | 6 + fw/core/lib/src/ddi/tbor/ecc_generate_key.rs | 176 ++++++++++++++ fw/core/lib/src/ddi/tbor/ecc_sign.rs | 112 +++++++++ fw/core/lib/src/ddi/tbor/ecdh_derive.rs | 183 +++++++++++++++ fw/core/lib/src/ddi/tbor/from_pal.rs | 46 +++- fw/core/lib/src/ddi/tbor/mod.rs | 36 ++- fw/core/lib/src/op.rs | 5 +- 23 files changed, 1950 insertions(+), 5 deletions(-) create mode 100644 ddi/tbor/types/src/ecc_generate_key.rs create mode 100644 ddi/tbor/types/src/ecc_sign.rs create mode 100644 ddi/tbor/types/src/ecdh_derive.rs create mode 100644 ddi/tbor/types/tests/commands/ecc_generate_key.rs create mode 100644 ddi/tbor/types/tests/commands/ecc_sign.rs create mode 100644 ddi/tbor/types/tests/commands/ecdh_derive.rs create mode 100644 docs/tbor-ddi/commands/ecc_generate_key.md create mode 100644 docs/tbor-ddi/commands/ecc_sign.md create mode 100644 docs/tbor-ddi/commands/ecdh_derive.md create mode 100644 fw/core/ddi/tbor/types/src/ecc_generate_key.rs create mode 100644 fw/core/ddi/tbor/types/src/ecc_sign.rs create mode 100644 fw/core/ddi/tbor/types/src/ecdh_derive.rs create mode 100644 fw/core/lib/src/ddi/tbor/ecc_generate_key.rs create mode 100644 fw/core/lib/src/ddi/tbor/ecc_sign.rs create mode 100644 fw/core/lib/src/ddi/tbor/ecdh_derive.rs diff --git a/ddi/tbor/types/src/ecc_generate_key.rs b/ddi/tbor/types/src/ecc_generate_key.rs new file mode 100644 index 000000000..eaf2c02ca --- /dev/null +++ b/ddi/tbor/types/src/ecc_generate_key.rs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `EccGenerateKey` command. +//! +//! `EccGenerateKey` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that generates a fresh ECC keypair and returns the private +//! key as a **masked** blob plus the wire public key. The private key is +//! not stored on-device; the caller holds the masked blob and passes it +//! back to [`EccSign`](crate::ecc_sign) / [`EcdhDerive`](crate::ecdh_derive). + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `EccGenerateKey`. +pub const TBOR_OP_ECC_GENERATE_KEY: u8 = 0x17; + +/// Minimum masked ECC private-key envelope length (P-256). +pub const MASKED_ECC_KEY_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; +/// Maximum masked ECC private-key envelope length (P-521). +pub const MASKED_ECC_KEY_MAX_LEN: usize = 8 + 12 + 96 + 68 + 16; +/// Maximum wire public-key length (`x ‖ y`, P-521 padded). +pub const ECC_PUB_KEY_MAX_LEN: usize = 136; + +/// `EccCurve` discriminant for NIST P-256. +pub const ECC_CURVE_P256: u8 = 1; +/// `EccCurve` discriminant for NIST P-384. +pub const ECC_CURVE_P384: u8 = 2; +/// `EccCurve` discriminant for NIST P-521. +pub const ECC_CURVE_P521: u8 = 3; + +/// Host-facing TBOR `EccGenerateKey` request. +#[tbor(opcode = TBOR_OP_ECC_GENERATE_KEY, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TborEccGenerateKeyReq { + /// Session id this request is bound to. + #[tbor(session_id)] + pub session_id: u16, + + /// Requested key scope as the 1-byte `KeyScope` discriminant. + pub scope: u8, + + /// NIST curve as the 1-byte `EccCurve` discriminant (see + /// [`ECC_CURVE_P256`] / [`ECC_CURVE_P384`] / [`ECC_CURVE_P521`]). + pub curve: u8, +} + +/// Host-facing TBOR `EccGenerateKey` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborEccGenerateKeyResp { + /// The generated private key, masked under the scope's masking key. + #[tbor(max_len = 200)] + pub masked_key: Vec, + + /// The wire public key `x ‖ y` (little-endian, P-521 padded). + #[tbor(max_len = 136)] + pub pub_key: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_scope_and_curve() { + let req = TborEccGenerateKeyReq { + session_id: 5, + scope: 0b011, + curve: ECC_CURVE_P384, + }; + let mut buf = [0u8; 256]; + let frame = req.encode_request(&mut buf).expect("encode"); + assert!( + frame.contains(&ECC_CURVE_P384), + "encoded frame must carry the curve discriminant", + ); + } +} diff --git a/ddi/tbor/types/src/ecc_sign.rs b/ddi/tbor/types/src/ecc_sign.rs new file mode 100644 index 000000000..1f060a743 --- /dev/null +++ b/ddi/tbor/types/src/ecc_sign.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `EccSign` command. +//! +//! `EccSign` is an **in-session** command (Crypto-Officer or Crypto-User) +//! that produces a raw ECDSA `r ‖ s` signature over a host-supplied +//! **pre-computed digest** using a caller-held **masked** ECC private key. +//! Firmware does no hashing — the caller supplies the digest in wire +//! little-endian order. + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `EccSign`. +pub const TBOR_OP_ECC_SIGN: u8 = 0x18; + +/// Maximum digest length (bytes): the SHA-512 digest. +pub const ECC_DIGEST_MAX_LEN: usize = 64; +/// Maximum wire ECDSA signature length (`r ‖ s`, P-521 padded). +pub const ECC_SIG_MAX_LEN: usize = 136; + +/// `HashAlgo` discriminant for a SHA-256 digest. +pub const ECC_DIGEST_SHA256: u8 = 1; +/// `HashAlgo` discriminant for a SHA-384 digest. +pub const ECC_DIGEST_SHA384: u8 = 2; +/// `HashAlgo` discriminant for a SHA-512 digest. +pub const ECC_DIGEST_SHA512: u8 = 3; + +/// Host-facing TBOR `EccSign` request. +#[tbor(opcode = TBOR_OP_ECC_SIGN, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborEccSignReq { + /// Session id this request is bound to. + #[tbor(session_id)] + pub session_id: u16, + + /// The masked ECC private key (from `EccGenerateKey` / `UnwrapKey`). + #[tbor(min_len = 164, max_len = 200)] + pub masked_key: Vec, + + /// Hash algorithm of the supplied digest, 1-byte `HashAlgo` (see + /// [`ECC_DIGEST_SHA256`] / [`ECC_DIGEST_SHA384`] / [`ECC_DIGEST_SHA512`]). + pub digest_algo: u8, + + /// The pre-computed message digest in wire little-endian order, exactly + /// the algorithm's digest length (32 / 48 / 64 B). + #[tbor(max_len = 64)] + pub digest: Vec, +} + +/// Host-facing TBOR `EccSign` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborEccSignResp { + /// Raw ECDSA `r ‖ s`, each component little-endian and padded to the + /// curve wire coordinate length (64 / 96 / 136 B). + #[tbor(max_len = 136)] + pub signature: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_digest() { + let req = TborEccSignReq { + session_id: 7, + masked_key: alloc::vec![0x11u8; 164], + digest_algo: ECC_DIGEST_SHA256, + digest: alloc::vec![0x22u8; 32], + }; + let mut buf = [0u8; 512]; + let frame = req.encode_request(&mut buf).expect("encode"); + assert!( + frame.windows(4).any(|w| w == [0x22u8; 4]), + "encoded frame must carry the digest bytes", + ); + } +} diff --git a/ddi/tbor/types/src/ecdh_derive.rs b/ddi/tbor/types/src/ecdh_derive.rs new file mode 100644 index 000000000..54a76b328 --- /dev/null +++ b/ddi/tbor/types/src/ecdh_derive.rs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `EcdhDerive` command. +//! +//! `EcdhDerive` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that derives an ECDH shared secret from a caller-held +//! **masked** local ECC private key and a host-supplied peer public key, +//! and returns the derived secret as a **masked** blob under the requested +//! scope's masking key. + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `EcdhDerive`. +pub const TBOR_OP_ECDH_DERIVE: u8 = 0x19; + +/// Maximum peer public-key length (`x ‖ y`, P-521 padded). +pub const ECDH_PEER_PUB_MAX_LEN: usize = 136; +/// Minimum masked derived-secret envelope length (P-256). +pub const MASKED_SECRET_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; +/// Maximum masked derived-secret envelope length (P-521). +pub const MASKED_SECRET_MAX_LEN: usize = 8 + 12 + 96 + 66 + 16; + +/// Host-facing TBOR `EcdhDerive` request. +#[tbor(opcode = TBOR_OP_ECDH_DERIVE, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborEcdhDeriveReq { + /// Session id this request is bound to. + #[tbor(session_id)] + pub session_id: u16, + + /// Requested key scope (masks the derived secret), 1-byte `KeyScope`. + pub scope: u8, + + /// The masked local ECC private key (from `EccGenerateKey` / + /// `UnwrapKey`). + #[tbor(min_len = 164, max_len = 200)] + pub masked_key: Vec, + + /// The peer's wire public key `x ‖ y` (little-endian, P-521 padded). + #[tbor(max_len = 136)] + pub peer_pub_key: Vec, +} + +/// Host-facing TBOR `EcdhDerive` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborEcdhDeriveResp { + /// The derived ECDH shared secret, masked under the scope's masking + /// key. + #[tbor(max_len = 198)] + pub masked_secret: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_peer_pub() { + let req = TborEcdhDeriveReq { + session_id: 7, + scope: 0b011, + masked_key: alloc::vec![0x11u8; 164], + peer_pub_key: alloc::vec![0x22u8; 64], + }; + let mut buf = [0u8; 512]; + let frame = req.encode_request(&mut buf).expect("encode"); + assert!( + frame.windows(4).any(|w| w == [0x22u8; 4]), + "encoded frame must carry the peer public-key bytes", + ); + } +} diff --git a/ddi/tbor/types/src/lib.rs b/ddi/tbor/types/src/lib.rs index 2b3bd2ad9..e8a065b92 100644 --- a/ddi/tbor/types/src/lib.rs +++ b/ddi/tbor/types/src/lib.rs @@ -79,6 +79,9 @@ impl From for u8 { } mod api_rev; +mod ecc_generate_key; +mod ecc_sign; +mod ecdh_derive; mod evidence; mod get_unwrapping_key; mod key_report; @@ -101,6 +104,9 @@ mod status; mod unwrap_key; pub use api_rev::*; +pub use ecc_generate_key::*; +pub use ecc_sign::*; +pub use ecdh_derive::*; pub use evidence::*; pub use get_unwrapping_key::*; pub use key_report::*; diff --git a/ddi/tbor/types/tests/commands/ecc_generate_key.rs b/ddi/tbor/types/tests/commands/ecc_generate_key.rs new file mode 100644 index 000000000..90efd6746 --- /dev/null +++ b/ddi/tbor/types/tests/commands/ecc_generate_key.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `EccGenerateKey` command. +//! +//! `EccGenerateKey` generates a fresh ECC keypair on the requested NIST +//! curve and returns the private key **masked** under the requested scope +//! plus the wire public key (nothing is persisted on-device). These tests +//! check the masked-blob / public-key lengths per curve, that keys can be +//! generated under each provisioned scope, and that an unknown curve is +//! rejected. +//! +//! Signing / deriving with the generated key is exercised by the +//! [`ecc_sign`](super::ecc_sign) and [`ecdh_derive`](super::ecdh_derive) +//! tests, which build on this command. + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::TborEccGenerateKeyReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::ECC_CURVE_P256; +use azihsm_ddi_tbor_types::ECC_CURVE_P384; +use azihsm_ddi_tbor_types::ECC_CURVE_P521; + +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::harness::TestCtx; + +/// `KeyScope::Session` discriminant — masks under the per-session key. +const SCOPE_SESSION: u8 = 0b001; +/// `KeyScope::Ephemeral` discriminant. +const SCOPE_EPHEMERAL: u8 = 0b010; +/// `KeyScope::Local` discriminant. +const SCOPE_LOCAL: u8 = 0b011; + +/// Expected wire public-key length (`x ‖ y`, P-521 padded) per curve. +fn wire_pub_len(curve: u8) -> usize { + match curve { + ECC_CURVE_P256 => 64, + ECC_CURVE_P384 => 96, + ECC_CURVE_P521 => 136, + _ => unreachable!(), + } +} + +/// Expected masked private-key envelope length per curve: +/// `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(wire_priv) ‖ tag(16)` = 132 + priv. +fn masked_key_len(curve: u8) -> usize { + match curve { + ECC_CURVE_P256 => 132 + 32, + ECC_CURVE_P384 => 132 + 48, + ECC_CURVE_P521 => 132 + 68, + _ => unreachable!(), + } +} + +/// Generate an ECC key on `curve` under `scope` and assert well-formedness. +fn generate(ctx: &TestCtx, session_id: u16, scope: u8, curve: u8) { + let resp = ctx + .tbor(&TborEccGenerateKeyReq { + session_id, + scope, + curve, + }) + .expect("EccGenerateKey"); + + assert_eq!( + resp.pub_key.len(), + wire_pub_len(curve), + "public key length must match the curve", + ); + assert_eq!( + resp.masked_key.len(), + masked_key_len(curve), + "masked private key envelope length must match the curve", + ); + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked private key must not be all-zero", + ); + assert!( + resp.pub_key.iter().any(|&b| b != 0), + "public key must not be all-zero", + ); +} + +#[test] +fn ecc_generate_key_all_curves_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + for curve in [ECC_CURVE_P256, ECC_CURVE_P384, ECC_CURVE_P521] { + generate(&ctx, session.session_id, SCOPE_LOCAL, curve); + } +} + +#[test] +fn ecc_generate_key_scopes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + // `finalized_co_session` provisions the Ephemeral and Local masking + // keys; the Session masking key exists for any active session. + for scope in [SCOPE_SESSION, SCOPE_EPHEMERAL, SCOPE_LOCAL] { + generate(&ctx, session.session_id, scope, ECC_CURVE_P256); + } +} + +#[test] +fn ecc_generate_key_unknown_curve_rejected_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + // Curve discriminant `0` is not one of P-256 / P-384 / P-521. + ctx.expect_fw_reject( + &TborEccGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_LOCAL, + curve: 0, + }, + TborStatus::InvalidArg, + ); +} diff --git a/ddi/tbor/types/tests/commands/ecc_sign.rs b/ddi/tbor/types/tests/commands/ecc_sign.rs new file mode 100644 index 000000000..d3d8d2b9a --- /dev/null +++ b/ddi/tbor/types/tests/commands/ecc_sign.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `EccSign` command. +//! +//! `EccSign` produces a raw ECDSA `r ‖ s` signature over a host-supplied +//! pre-computed digest using a caller-held **masked** ECC private key +//! (from [`EccGenerateKey`](super::ecc_generate_key)). These tests +//! generate a key on-device, sign a digest, and verify the signature on +//! the host with `azihsm_crypto` (OpenSSL) against the returned public key +//! — exercising the full unmask → sign → return path for every curve. +//! +//! The device speaks the PKA-native **little-endian** wire format: the +//! public key is `x_le ‖ y_le` and the signature is `r_le ‖ s_le` (each +//! component zero-padded to the curve's wire coordinate length; P-521 pads +//! 66→68). `azihsm_crypto` is big-endian native, so the test reverses +//! each component before verifying. Likewise the device internally +//! reverses the supplied wire-LE digest to big-endian before signing, so +//! the host verifies against the reversed digest. + +#![cfg(feature = "emu")] + +use azihsm_crypto::EccAlgo; +use azihsm_crypto::EccCurve; +use azihsm_crypto::EccPrivateKey; +use azihsm_crypto::EccPublicKey; +use azihsm_crypto::ExportableKey; +use azihsm_crypto::Verifier; +use azihsm_ddi_tbor_types::TborEccGenerateKeyReq; +use azihsm_ddi_tbor_types::TborEccSignReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::ECC_CURVE_P256; +use azihsm_ddi_tbor_types::ECC_CURVE_P384; +use azihsm_ddi_tbor_types::ECC_CURVE_P521; +use azihsm_ddi_tbor_types::ECC_DIGEST_SHA256; +use azihsm_ddi_tbor_types::ECC_DIGEST_SHA384; +use azihsm_ddi_tbor_types::ECC_DIGEST_SHA512; +use azihsm_ddi_tbor_types::KEY_CLASS_ECC; + +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::commands::unwrap_key::unwrap; +use crate::harness::TestCtx; + +/// `KeyScope::Local` discriminant. +const SCOPE_LOCAL: u8 = 0b011; + +/// Per-curve wire sizes: `(wire_coord_len, raw_coord_len)`. +/// +/// `wire_coord_len` is the padded on-wire component size (P-521 → 68); +/// `raw_coord_len` is the cryptographic component size (P-521 → 66). +fn coord_sizes(pub_len: usize) -> (usize, usize) { + match pub_len { + 64 => (32, 32), + 96 => (48, 48), + 136 => (68, 66), + _ => panic!("unexpected public-key length {pub_len}"), + } +} + +/// Generate an ECC key on-device for `curve`, returning `(masked_key, +/// wire_pub_key)`. +fn generate(ctx: &TestCtx, session_id: u16, curve: u8) -> (Vec, Vec) { + let resp = ctx + .tbor(&TborEccGenerateKeyReq { + session_id, + scope: SCOPE_LOCAL, + curve, + }) + .expect("EccGenerateKey"); + (resp.masked_key, resp.pub_key) +} + +/// Reverse the low `len` bytes of `src` into a fresh big-endian vec. +fn rev(src: &[u8], len: usize) -> Vec { + src[..len].iter().rev().copied().collect() +} + +/// Verify a wire-LE ECDSA signature on the host with `azihsm_crypto`. +/// +/// * `pub_le` — `x_le ‖ y_le`, each `wire_coord_len` bytes. +/// * `sig_le` — `r_le ‖ s_le`, each `wire_coord_len` bytes. +/// * `digest_le` — the wire-LE digest that was handed to `EccSign`. +fn verify_wire_ecdsa(pub_le: &[u8], sig_le: &[u8], digest_le: &[u8]) -> bool { + let (wire_coord, raw_coord) = coord_sizes(pub_le.len()); + assert_eq!(sig_le.len(), wire_coord * 2, "signature length mismatch"); + + // Public key: reverse each full padded wire coordinate → big-endian + // `hsm_point_size` coordinates (trailing LE pad becomes leading BE + // zeros, which `from_hsm_bytes` tolerates). + let (x_le, y_le) = pub_le.split_at(wire_coord); + let mut pub_be = rev(x_le, wire_coord); + pub_be.extend(rev(y_le, wire_coord)); + let pubkey = EccPublicKey::from_hsm_bytes(&pub_be).expect("import public key"); + + // Signature: reverse the meaningful `raw_coord` bytes of each component + // → big-endian `r ‖ s` (each `raw_coord` = the curve's point size). + let (r_le, s_le) = sig_le.split_at(wire_coord); + let mut sig_be = rev(r_le, raw_coord); + sig_be.extend(rev(s_le, raw_coord)); + + // The device reversed the wire-LE digest to big-endian before signing; + // verify against that same big-endian digest. + let digest_be = rev(digest_le, digest_le.len()); + + Verifier::verify(&mut EccAlgo::default(), &pubkey, &digest_be, &sig_be) + .expect("host ECDSA verify") +} + +#[test] +fn ecc_sign_roundtrip_all_curves_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + for (curve, digest_algo, digest_len) in [ + (ECC_CURVE_P256, ECC_DIGEST_SHA256, 32usize), + (ECC_CURVE_P384, ECC_DIGEST_SHA384, 48usize), + (ECC_CURVE_P521, ECC_DIGEST_SHA512, 64usize), + ] { + let (masked_key, pub_key) = generate(&ctx, session.session_id, curve); + + // A deterministic, non-trivial digest of the algorithm's length. + let digest: Vec = (0..digest_len) + .map(|i| (i as u8).wrapping_mul(7).wrapping_add(0x11)) + .collect(); + + let resp = ctx + .tbor(&TborEccSignReq { + session_id: session.session_id, + masked_key, + digest_algo, + digest: digest.clone(), + }) + .expect("EccSign"); + + assert_eq!( + resp.signature.len(), + pub_key.len(), + "wire signature length equals wire public-key length for the curve", + ); + assert!( + verify_wire_ecdsa(&pub_key, &resp.signature, &digest), + "ECDSA signature must verify against the generated public key (curve {curve})", + ); + } +} + +#[test] +fn ecc_sign_with_unwrapped_key_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + // Import a host-generated P-256 ECC private key via `UnwrapKey` (Ecc + // class): RSA-AES-wrap its PKCS#8 DER, unwrap on-device into a masked + // blob. This exercises the full unwrap → sign interoperability — an + // ECC key imported through `UnwrapKey` must be consumable by `EccSign`. + let host_key = EccPrivateKey::from_curve(EccCurve::P256).expect("generate host ECC key"); + let der = host_key.to_vec().expect("PKCS#8 DER export"); + let imported = unwrap(&ctx, session.session_id, KEY_CLASS_ECC, &der); + assert!( + !imported.pub_key.is_empty(), + "an imported ECC key returns a re-derived public key", + ); + + let digest: Vec = (0..32) + .map(|i| (i as u8).wrapping_mul(5).wrapping_add(3)) + .collect(); + let sign_resp = ctx + .tbor(&TborEccSignReq { + session_id: session.session_id, + masked_key: imported.masked_key, + digest_algo: ECC_DIGEST_SHA256, + digest: digest.clone(), + }) + .expect("EccSign with an unwrapped key"); + + assert!( + verify_wire_ecdsa(&imported.pub_key, &sign_resp.signature, &digest), + "signature from the unwrapped ECC key must verify", + ); +} + +#[test] +fn ecc_sign_wrong_digest_len_rejected_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let (masked_key, _pub_key) = generate(&ctx, session.session_id, ECC_CURVE_P256); + + // SHA-256 requires a 32-byte digest; a 31-byte digest is rejected. + ctx.expect_fw_reject( + &TborEccSignReq { + session_id: session.session_id, + masked_key, + digest_algo: ECC_DIGEST_SHA256, + digest: vec![0xAB; 31], + }, + TborStatus::InvalidArg, + ); +} + +#[test] +fn ecc_sign_unknown_digest_algo_rejected_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let (masked_key, _pub_key) = generate(&ctx, session.session_id, ECC_CURVE_P256); + + // Digest-algo discriminant `0` is not one of SHA-256 / 384 / 512. + ctx.expect_fw_reject( + &TborEccSignReq { + session_id: session.session_id, + masked_key, + digest_algo: 0, + digest: vec![0xAB; 32], + }, + TborStatus::InvalidArg, + ); +} diff --git a/ddi/tbor/types/tests/commands/ecdh_derive.rs b/ddi/tbor/types/tests/commands/ecdh_derive.rs new file mode 100644 index 000000000..5fa8f1617 --- /dev/null +++ b/ddi/tbor/types/tests/commands/ecdh_derive.rs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `EcdhDerive` command. +//! +//! `EcdhDerive` derives an ECDH shared secret from a caller-held +//! **masked** local ECC private key (from +//! [`EccGenerateKey`](super::ecc_generate_key)) and a host-supplied peer +//! public key, returning the secret **masked** under the requested scope. +//! +//! Because the derived secret is returned only in masked form (there is no +//! TBOR command to observe its plaintext), these tests validate the +//! command's plumbing: a well-formed masked secret of the correct length +//! for each curve, derivation under each provisioned scope, and rejection +//! of a peer public key of the wrong length. The underlying ECDH +//! primitive's correctness is covered by the MBOR `EcdhKeyExchange` tests +//! and the std-PAL ECC driver tests, which share the same `pal.ecdh_derive`. + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::TborEccGenerateKeyReq; +use azihsm_ddi_tbor_types::TborEcdhDeriveReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::ECC_CURVE_P256; +use azihsm_ddi_tbor_types::ECC_CURVE_P384; +use azihsm_ddi_tbor_types::ECC_CURVE_P521; + +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::harness::TestCtx; + +/// `KeyScope::Session` discriminant. +const SCOPE_SESSION: u8 = 0b001; +/// `KeyScope::Ephemeral` discriminant. +const SCOPE_EPHEMERAL: u8 = 0b010; +/// `KeyScope::Local` discriminant. +const SCOPE_LOCAL: u8 = 0b011; + +/// Expected masked shared-secret envelope length per curve: +/// `header(8) ‖ iv(12) ‖ aad(96) ‖ secret(raw_coord) ‖ tag(16)` = 132 + raw. +fn masked_secret_len(curve: u8) -> usize { + match curve { + ECC_CURVE_P256 => 132 + 32, + ECC_CURVE_P384 => 132 + 48, + ECC_CURVE_P521 => 132 + 66, + _ => unreachable!(), + } +} + +/// Generate an ECC key on-device for `curve`, returning `(masked_key, +/// wire_pub_key)`. +fn generate(ctx: &TestCtx, session_id: u16, curve: u8) -> (Vec, Vec) { + let resp = ctx + .tbor(&TborEccGenerateKeyReq { + session_id, + scope: SCOPE_LOCAL, + curve, + }) + .expect("EccGenerateKey"); + (resp.masked_key, resp.pub_key) +} + +/// Derive a shared secret from local key `masked_key` against `peer_pub` +/// under `scope`. +fn derive( + ctx: &TestCtx, + session_id: u16, + scope: u8, + masked_key: Vec, + peer_pub: Vec, +) -> Vec { + ctx.tbor(&TborEcdhDeriveReq { + session_id, + scope, + masked_key, + peer_pub_key: peer_pub, + }) + .expect("EcdhDerive") + .masked_secret +} + +#[test] +fn ecdh_derive_all_curves_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + for curve in [ECC_CURVE_P256, ECC_CURVE_P384, ECC_CURVE_P521] { + // Two device-generated keypairs on the same curve; each side's + // public key is a valid wire-LE peer point for the other. + let (masked_a, pub_a) = generate(&ctx, session.session_id, curve); + let (masked_b, pub_b) = generate(&ctx, session.session_id, curve); + + let secret_ab = derive(&ctx, session.session_id, SCOPE_LOCAL, masked_a, pub_b); + let secret_ba = derive(&ctx, session.session_id, SCOPE_LOCAL, masked_b, pub_a); + + for secret in [&secret_ab, &secret_ba] { + assert_eq!( + secret.len(), + masked_secret_len(curve), + "masked shared-secret envelope length must match the curve", + ); + assert!( + secret.iter().any(|&b| b != 0), + "masked shared secret must not be all-zero", + ); + } + } +} + +#[test] +fn ecdh_derive_scopes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let (masked_a, _pub_a) = generate(&ctx, session.session_id, ECC_CURVE_P256); + let (_masked_b, pub_b) = generate(&ctx, session.session_id, ECC_CURVE_P256); + + // The derived secret can be masked under any provisioned scope. + for scope in [SCOPE_SESSION, SCOPE_EPHEMERAL, SCOPE_LOCAL] { + let secret = derive( + &ctx, + session.session_id, + scope, + masked_a.clone(), + pub_b.clone(), + ); + assert_eq!(secret.len(), masked_secret_len(ECC_CURVE_P256)); + assert!(secret.iter().any(|&b| b != 0)); + } +} + +#[test] +fn ecdh_derive_bad_peer_pub_len_rejected_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let (masked_a, pub_b) = { + let (ma, _) = generate(&ctx, session.session_id, ECC_CURVE_P256); + let (_, pb) = generate(&ctx, session.session_id, ECC_CURVE_P256); + (ma, pb) + }; + + // A peer public key one byte short of the P-256 wire length (64) is + // rejected before any derivation. + let mut truncated = pub_b; + truncated.pop(); + ctx.expect_fw_reject( + &TborEcdhDeriveReq { + session_id: session.session_id, + scope: SCOPE_LOCAL, + masked_key: masked_a, + peer_pub_key: truncated, + }, + TborStatus::InvalidArg, + ); +} diff --git a/ddi/tbor/types/tests/commands/mod.rs b/ddi/tbor/types/tests/commands/mod.rs index 658b2b333..5d8974e36 100644 --- a/ddi/tbor/types/tests/commands/mod.rs +++ b/ddi/tbor/types/tests/commands/mod.rs @@ -7,6 +7,9 @@ pub mod api_rev; pub mod default_psk_gate; +pub mod ecc_generate_key; +pub mod ecc_sign; +pub mod ecdh_derive; pub mod forward_compat; pub mod fw_error_decode; pub mod get_unwrapping_key; diff --git a/ddi/tbor/types/tests/commands/unwrap_key.rs b/ddi/tbor/types/tests/commands/unwrap_key.rs index 09f8401d9..5290b8ef0 100644 --- a/ddi/tbor/types/tests/commands/unwrap_key.rs +++ b/ddi/tbor/types/tests/commands/unwrap_key.rs @@ -87,7 +87,7 @@ fn rsa_aes_wrap(hsm_pub: &[u8], data: &[u8]) -> Vec { /// 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 { +pub(crate) fn unwrap(ctx: &TestCtx, session_id: u16, class: u8, key: &[u8]) -> TborUnwrapKeyResp { let hsm_pub = ctx .tbor(&TborGetUnwrappingKeyReq { session_id }) .expect("GetUnwrappingKey") diff --git a/docs/tbor-ddi/README.md b/docs/tbor-ddi/README.md index 1b4c7fca9..f8586083d 100644 --- a/docs/tbor-ddi/README.md +++ b/docs/tbor-ddi/README.md @@ -67,6 +67,9 @@ single `none` TOC placeholder and no typed body fields. | `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) | +| `0x17` | `EccGenerateKey` | InSession | [`commands/ecc_generate_key.md`](./commands/ecc_generate_key.md) | +| `0x18` | `EccSign` | InSession | [`commands/ecc_sign.md`](./commands/ecc_sign.md) | +| `0x19` | `EcdhDerive` | InSession | [`commands/ecdh_derive.md`](./commands/ecdh_derive.md) | ## Default-PSK gate diff --git a/docs/tbor-ddi/commands/ecc_generate_key.md b/docs/tbor-ddi/commands/ecc_generate_key.md new file mode 100644 index 000000000..77326859d --- /dev/null +++ b/docs/tbor-ddi/commands/ecc_generate_key.md @@ -0,0 +1,72 @@ + + +# EccGenerateKey (Opcode 0x17) + +**Handler:** `fw/core/lib/src/ddi/tbor/ecc_generate_key.rs` +**Session:** InSession + +## Description + +Generates a fresh ECC keypair on the requested NIST curve (P-256 / P-384 +/ P-521) and returns the private key as a **masked** blob under the +requested scope's masking key, plus the wire public key. + +The private key is **not** persisted on-device: the caller holds the +masked blob and passes it back to [`EccSign`](./ecc_sign.md) / +[`EcdhDerive`](./ecdh_derive.md) (unmask-on-use). This is the TBOR +analogue of MBOR `EccGenerateKeyPair`, but without a vault `key_id`. + +The masked blob records the key kind (which recovers the curve on +unmask), the `sign` + `derive` usage attributes, the requested scope, and +the platform `{svn, owner}` identity (bound by the AEAD tag for +anti-rollback on re-import). + +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. | +| — | `scope` | `u8` (inline) | [`KeyScope`] whose masking key wraps the private key. | +| — | `curve` | `u8` (inline) | NIST curve: `1` = P-256, `2` = P-384, `3` = P-521. | + +### Data section + +_Empty._ + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `masked_key` | `buffer` (164 / 180 / 200 B) | The private key, masked (AEAD-GCM-256) under the scope's masking key. | +| — | `pub_key` | `buffer` (64 / 96 / 136 B) | The wire public key `x_le ‖ y_le` (little-endian, P-521 padded). | + +### Data section + +Carries the masked private key followed by the wire public key. The +masked-key length is `132 + wire_priv_len` (P-521 uses a 68-byte padded +scalar). + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an `Active` slot | +| `InvalidArg` | Unknown curve, or a non-`Session` scope requested before the partition is `Initialized` | +| `UnsupportedKeyScope` | The requested scope's masking key is not provisioned | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- Sign with the generated key: [`ecc_sign.md`](./ecc_sign.md) +- Derive with the generated key: [`ecdh_derive.md`](./ecdh_derive.md) +- Wire schema: `fw/core/ddi/tbor/types/src/ecc_generate_key.rs` diff --git a/docs/tbor-ddi/commands/ecc_sign.md b/docs/tbor-ddi/commands/ecc_sign.md new file mode 100644 index 000000000..5a0a2752a --- /dev/null +++ b/docs/tbor-ddi/commands/ecc_sign.md @@ -0,0 +1,75 @@ + + +# EccSign (Opcode 0x18) + +**Handler:** `fw/core/lib/src/ddi/tbor/ecc_sign.rs` +**Session:** InSession + +## Description + +Produces a raw ECDSA `r ‖ s` signature over a host-supplied +**pre-computed digest** using a caller-held **masked** ECC private key +(from [`EccGenerateKey`](./ecc_generate_key.md) or imported via +[`UnwrapKey`](./unwrap_key.md)). + +The device unmasks the key **in place** in the request buffer (recovering +its curve from the blob's key kind), checks the `sign` usage attribute, +signs, and returns the signature. The recovered plaintext key is +scrubbed from the request buffer on every path. Firmware does **no** +hashing — the caller supplies the digest. This is the TBOR analogue of +MBOR `EccSign`, keyed by a masked blob instead of a vault id. + +The digest is supplied and consumed in PKA-native **little-endian** byte +order (the natural big-endian digest with all bytes reversed); the device +flips endianness internally if its signing primitive is big-endian native +(e.g. OpenSSL on the emulator). + +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 | `masked_key` | `buffer` (164..=200 B) | The masked ECC private key; unmasked in place. Its kind recovers the curve. | +| — | `digest_algo` | `u8` (inline) | Hash algorithm of the supplied digest: `1` = SHA-256, `2` = SHA-384, `3` = SHA-512 (fixes the digest length). | +| — | `digest` | `buffer` (32 / 48 / 64 B) | The pre-computed message digest in wire little-endian order, exactly the algorithm's digest length. | + +### Data section + +Carries the masked key followed by the digest. + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `signature` | `buffer` (64 / 96 / 136 B) | Raw ECDSA `r ‖ s`, each component little-endian and padded to the curve wire coordinate length. | + +### Data section + +Carries the wire-format signature. + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an `Active` slot | +| `InvalidArg` | Unknown `digest_algo`, or `digest` length ≠ the algorithm's digest length | +| `InvalidKeyType` | The masked blob is not an ECC private key | +| `InvalidPermissions` | The key's `sign` usage attribute is not set | +| `MaskedKeyDecodeFailed` / `AesGcmDecryptTagDoesNotMatch` | The masked key is malformed or fails authentication (wrong scope / tampered) | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- Generate a signing key: [`ecc_generate_key.md`](./ecc_generate_key.md) +- Import a signing key: [`unwrap_key.md`](./unwrap_key.md) +- Wire schema: `fw/core/ddi/tbor/types/src/ecc_sign.rs` diff --git a/docs/tbor-ddi/commands/ecdh_derive.md b/docs/tbor-ddi/commands/ecdh_derive.md new file mode 100644 index 000000000..5b873f671 --- /dev/null +++ b/docs/tbor-ddi/commands/ecdh_derive.md @@ -0,0 +1,78 @@ + + +# EcdhDerive (Opcode 0x19) + +**Handler:** `fw/core/lib/src/ddi/tbor/ecdh_derive.rs` +**Session:** InSession + +## Description + +Derives an ECDH shared secret from a caller-held **masked** local ECC +private key (from [`EccGenerateKey`](./ecc_generate_key.md) or imported +via [`UnwrapKey`](./unwrap_key.md)) and a host-supplied peer public key, +and returns the secret as a **masked** blob under the requested scope's +masking key. + +The device unmasks the local key **in place** in the request buffer +(recovering its curve from the blob's key kind), checks the `derive` +usage attribute, derives the secret, re-masks it under the target scope, +and scrubs both the recovered local key and the raw secret on every path. +This is the TBOR analogue of MBOR `EcdhKeyExchange`, but re-masking the +secret instead of vaulting it. + +The derived-secret blob records the ECDH-secret key kind, `local` + +`derive` usage attributes, the requested scope, and the platform +`{svn, owner}` identity. + +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. | +| — | `scope` | `u8` (inline) | [`KeyScope`] whose masking key wraps the derived secret. | +| 8 | `masked_key` | `buffer` (164..=200 B) | The masked local ECC private key; unmasked in place. Its kind recovers the curve. | +| — | `peer_pub_key` | `buffer` (64 / 96 / 136 B) | The peer's wire public key `x_le ‖ y_le` (little-endian, P-521 padded), exactly the curve's wire public-key length. | + +### Data section + +Carries the masked local key followed by the peer public key. + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `masked_secret` | `buffer` (164 / 180 / 198 B) | The derived ECDH shared secret, masked (AEAD-GCM-256) under the scope's masking key. | + +### Data section + +Carries the masked shared secret. The masked length is +`132 + secret_len`, where `secret_len` is the curve's raw coordinate size +(32 / 48 / 66 B for P-256 / P-384 / P-521). + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an `Active` slot | +| `InvalidArg` | `peer_pub_key` length ≠ the curve's wire public-key length, or a non-`Session` scope requested before the partition is `Initialized` | +| `InvalidKeyType` | The masked blob is not an ECC private key | +| `InvalidPermissions` | The key's `derive` usage attribute is not set | +| `UnsupportedKeyScope` | The requested target scope's masking key is not provisioned | +| `MaskedKeyDecodeFailed` / `AesGcmDecryptTagDoesNotMatch` | The masked local key is malformed or fails authentication (wrong scope / tampered) | +| `EccPublicKeyValidationFailed` / `EccPointValidationFailed` | The peer public key is out of range or not on the curve | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- Generate a local key: [`ecc_generate_key.md`](./ecc_generate_key.md) +- Wire schema: `fw/core/ddi/tbor/types/src/ecdh_derive.rs` diff --git a/fw/core/ddi/tbor/types/src/ecc_generate_key.rs b/fw/core/ddi/tbor/types/src/ecc_generate_key.rs new file mode 100644 index 000000000..298bc0958 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/ecc_generate_key.rs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EccGenerateKey` wire schema. +//! +//! `EccGenerateKey` is an in-session command that generates a fresh ECC +//! keypair on the requested NIST curve (P-256 / P-384 / P-521) and returns +//! the private key as a **masked** blob plus the wire public key. The +//! private key is **not** stored on the device: the caller holds the +//! masked blob and passes it back to [`EccSign`](crate::ecc_sign) / +//! [`EcdhDerive`](crate::ecdh_derive) (unmask-on-use). This is the TBOR +//! analogue of MBOR `EccGenerateKeyPair`, but without a vault `key_id`. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. +//! * `scope` — the [`KeyScope`] whose masking key wraps the private key. +//! * `curve` — the [`EccCurve`] selecting the NIST curve. +//! +//! Outputs: +//! +//! * `masked_key` — the private key, masked (AEAD-GCM-256) under the +//! scope's masking key. +//! * `pub_key` — the wire public key `x ‖ y` (little-endian, P-521 padded). + +use azihsm_fw_ddi_tbor_api::tbor; +use open_enum::open_enum; + +use crate::key_props::KeyScope; + +/// TBOR opcode for `EccGenerateKey`. +pub const TBOR_OP_ECC_GENERATE_KEY: u8 = 0x17; + +/// Minimum masked ECC private-key envelope length (P-256, 32-byte scalar): +/// `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(32) ‖ tag(16)`. +pub const MASKED_ECC_KEY_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// Maximum masked ECC private-key envelope length (P-521, 68-byte +/// wire scalar). Pinned into the `#[tbor(buffer, max_len = 200)]` literal +/// on [`TborEccGenerateKeyResp::masked_key`]. +pub const MASKED_ECC_KEY_MAX_LEN: usize = 8 + 12 + 96 + 68 + 16; + +/// Maximum wire public-key length (`x ‖ y`, P-521 padded coordinates). +/// Pinned into the `#[tbor(buffer, max_len = 136)]` literal on +/// [`TborEccGenerateKeyResp::pub_key`]. +pub const ECC_PUB_KEY_MAX_LEN: usize = 136; + +/// ECC curve selector on the TBOR wire. +/// +/// The 1-byte discriminants mirror the MBOR `DdiEccCurve` values +/// (`P256 = 1`, `P384 = 2`, `P521 = 3`). Kept as an [`open_enum`] so an +/// unrecognized discriminant round-trips as `EccCurve(x)` and is rejected +/// on-device rather than failing to decode. +#[repr(u8)] +#[open_enum] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EccCurve { + /// NIST P-256 (secp256r1). + P256 = 1, + + /// NIST P-384 (secp384r1). + P384 = 2, + + /// NIST P-521 (secp521r1). + P521 = 3, +} + +/// `EccGenerateKey` request schema. +#[tbor(opcode = 0x17)] +pub struct TborEccGenerateKeyReq { + /// CO/CU session id this request is bound to. + #[tbor(session_id)] + pub session_id: SessionId, + + /// Requested key scope (masks the private key), 1-byte [`KeyScope`]. + #[tbor(U8)] + pub scope: KeyScope, + + /// NIST curve, 1-byte [`EccCurve`]. + #[tbor(U8)] + pub curve: EccCurve, +} + +/// `EccGenerateKey` response schema. +/// +/// `masked_key` is `#[tbor(mutable)]` so the handler can reserve the slot +/// and mask the private key straight into it (`decode_mut`) — no scratch +/// copy of the masked blob. +#[tbor(response)] +pub struct TborEccGenerateKeyResp<'a> { + /// The generated private key, masked (AEAD-GCM-256) under the scope's + /// masking key. 164 / 180 / 200 B for P-256 / P-384 / P-521. + #[tbor(buffer, max_len = 200, mutable)] + pub masked_key: &'a [u8], + + /// The wire public key `x ‖ y` (little-endian, P-521 padded): + /// 64 / 96 / 136 B for P-256 / P-384 / P-521. + #[tbor(buffer, max_len = 136)] + 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_scope_and_curve() { + let mut buf = [0u8; 256]; + let frame = TborEccGenerateKeyReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(5)) + .unwrap() + .scope(KeyScope::Local) + .unwrap() + .curve(EccCurve::P384) + .unwrap() + .finish(); + + assert_eq!(frame.scope(), KeyScope::Local); + assert_eq!(frame.curve(), EccCurve::P384); + } + + #[test] + fn response_round_trips_masked_and_pub() { + let mut buf = [0u8; 512]; + let masked = [0xABu8; MASKED_ECC_KEY_MAX_LEN]; + let pub_key = [0xCDu8; ECC_PUB_KEY_MAX_LEN]; + let frame = TborEccGenerateKeyResp::encode(&mut buf, 0, true) + .unwrap() + .masked_key(&masked) + .unwrap() + .pub_key(&pub_key) + .unwrap() + .finish(); + assert_eq!(frame.masked_key(), &masked[..]); + assert_eq!(frame.pub_key(), &pub_key[..]); + } + + #[test] + fn lengths_match_pinned_values() { + const _: () = assert!(200 == MASKED_ECC_KEY_MAX_LEN); + const _: () = assert!(136 == ECC_PUB_KEY_MAX_LEN); + assert_eq!(MASKED_ECC_KEY_MIN_LEN, 164); + assert_eq!(MASKED_ECC_KEY_MAX_LEN, 200); + } +} diff --git a/fw/core/ddi/tbor/types/src/ecc_sign.rs b/fw/core/ddi/tbor/types/src/ecc_sign.rs new file mode 100644 index 000000000..47a7c1d0f --- /dev/null +++ b/fw/core/ddi/tbor/types/src/ecc_sign.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EccSign` wire schema. +//! +//! `EccSign` is an in-session command that produces a raw ECDSA `r ‖ s` +//! signature over a host-supplied **pre-computed digest** using a +//! caller-held **masked** ECC private key (from +//! [`EccGenerateKey`](crate::ecc_generate_key) or imported via +//! [`UnwrapKey`](crate::unwrap_key)). The device unmasks the key +//! on-device (recovering its curve from the key kind), signs, and returns +//! the signature. Firmware does **no** hashing — the caller supplies the +//! digest. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. +//! * `masked_key` — the masked ECC private key (unmasked in place). +//! * `digest_algo` — the [`HashAlgo`] of the supplied digest (fixes its +//! length). +//! * `digest` — the pre-computed message digest in wire little-endian +//! order, exactly the algorithm's digest length. +//! +//! Outputs: +//! +//! * `signature` — raw `r ‖ s`, each component little-endian and padded to +//! the curve's wire coordinate length (64 / 96 / 136 B for +//! P-256 / P-384 / P-521). + +use azihsm_fw_ddi_tbor_api::tbor; + +pub use crate::ecc_generate_key::MASKED_ECC_KEY_MAX_LEN; +pub use crate::ecc_generate_key::MASKED_ECC_KEY_MIN_LEN; +pub use crate::key_props::HashAlgo; + +/// TBOR opcode for `EccSign`. +pub const TBOR_OP_ECC_SIGN: u8 = 0x18; + +/// Maximum digest length (bytes) accepted by `EccSign` — the SHA-512 +/// digest. Pinned into the `#[tbor(buffer, max_len = 64)]` literal on +/// [`TborEccSignReq::digest`]. +pub const ECC_DIGEST_MAX_LEN: usize = 64; + +/// Maximum wire ECDSA signature length (`r ‖ s`, P-521 padded). Pinned +/// into the `#[tbor(buffer, max_len = 136)]` literal on +/// [`TborEccSignResp::signature`]. +pub const ECC_SIG_MAX_LEN: usize = 136; + +/// `EccSign` request schema. +/// +/// `masked_key` is `#[tbor(mutable)]` so the handler can `unmask` it **in +/// place** in the request buffer (via `decode_mut`) — no scratch copy — and +/// sign directly from the recovered key. +#[tbor(opcode = 0x18)] +pub struct TborEccSignReq<'a> { + /// CO/CU session id this request is bound to. + #[tbor(session_id)] + pub session_id: SessionId, + + /// The masked ECC private key (from `EccGenerateKey` / `UnwrapKey`), an + /// AEAD-GCM-256 envelope of 164..=200 B. Its kind recovers the curve. + #[tbor(buffer, min_len = 164, max_len = 200, mutable)] + pub masked_key: &'a [u8], + + /// Hash algorithm of the supplied digest, 1-byte [`HashAlgo`]. + #[tbor(U8)] + pub digest_algo: HashAlgo, + + /// The pre-computed message digest in wire little-endian order, exactly + /// [`HashAlgo`]'s digest length (32 / 48 / 64 B). + #[tbor(buffer, max_len = 64)] + pub digest: &'a [u8], +} + +/// `EccSign` response schema. +#[tbor(response)] +pub struct TborEccSignResp<'a> { + /// Raw ECDSA `r ‖ s`, each component little-endian and padded to the + /// curve wire coordinate length: 64 / 96 / 136 B for + /// P-256 / P-384 / P-521. + #[tbor(buffer, max_len = 136, mutable)] + pub signature: &'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; 512]; + let masked = [0x11u8; MASKED_ECC_KEY_MIN_LEN]; + let digest = [0x22u8; 32]; + let frame = TborEccSignReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(7)) + .unwrap() + .masked_key(&masked) + .unwrap() + .digest_algo(HashAlgo::Sha256) + .unwrap() + .digest(&digest) + .unwrap() + .finish(); + assert_eq!(frame.digest_algo(), HashAlgo::Sha256); + assert_eq!(frame.digest(), &digest[..]); + } + + #[test] + fn response_round_trips_signature() { + let mut buf = [0u8; 256]; + let sig = [0x33u8; ECC_SIG_MAX_LEN]; + let frame = TborEccSignResp::encode(&mut buf, 0, true) + .unwrap() + .signature(&sig) + .unwrap() + .finish(); + assert_eq!(frame.signature(), &sig[..]); + } + + #[test] + fn lengths_match_pinned_values() { + const _: () = assert!(64 == ECC_DIGEST_MAX_LEN); + const _: () = assert!(136 == ECC_SIG_MAX_LEN); + assert_eq!(ECC_DIGEST_MAX_LEN, 64); + assert_eq!(ECC_SIG_MAX_LEN, 136); + } +} diff --git a/fw/core/ddi/tbor/types/src/ecdh_derive.rs b/fw/core/ddi/tbor/types/src/ecdh_derive.rs new file mode 100644 index 000000000..b126414c8 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/ecdh_derive.rs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EcdhDerive` wire schema. +//! +//! `EcdhDerive` is an in-session command that derives an ECDH shared +//! secret from a caller-held **masked** local ECC private key (from +//! [`EccGenerateKey`](crate::ecc_generate_key) or imported via +//! [`UnwrapKey`](crate::unwrap_key)) and a host-supplied peer public key, +//! and returns the derived secret as a **masked** blob under the requested +//! scope's masking key. This is the TBOR analogue of MBOR +//! `EcdhKeyExchange`, but re-masking the secret instead of vaulting it. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked by the dispatcher. +//! * `scope` — the [`KeyScope`] whose masking key wraps the derived secret. +//! * `masked_key` — the masked local ECC private key (unmasked in place; +//! its kind recovers the curve). +//! * `peer_pub_key` — the peer's wire public key `x ‖ y` (little-endian, +//! P-521 padded), exactly the curve's wire public-key length. +//! +//! Outputs: +//! +//! * `masked_secret` — the derived ECDH shared secret, masked +//! (AEAD-GCM-256) under the scope's masking key. + +use azihsm_fw_ddi_tbor_api::tbor; + +pub use crate::ecc_generate_key::MASKED_ECC_KEY_MAX_LEN; +pub use crate::ecc_generate_key::MASKED_ECC_KEY_MIN_LEN; +use crate::key_props::KeyScope; + +/// TBOR opcode for `EcdhDerive`. +pub const TBOR_OP_ECDH_DERIVE: u8 = 0x19; + +/// Maximum peer public-key length (`x ‖ y`, P-521 padded). Pinned into the +/// `#[tbor(buffer, max_len = 136)]` literal on +/// [`TborEcdhDeriveReq::peer_pub_key`]. +pub const ECDH_PEER_PUB_MAX_LEN: usize = 136; + +/// Minimum masked derived-secret envelope length (P-256, 32-byte secret): +/// `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(32) ‖ tag(16)`. +pub const MASKED_SECRET_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// Maximum masked derived-secret envelope length (P-521, 66-byte secret). +/// Pinned into the `#[tbor(buffer, max_len = 198)]` literal on +/// [`TborEcdhDeriveResp::masked_secret`]. +pub const MASKED_SECRET_MAX_LEN: usize = 8 + 12 + 96 + 66 + 16; + +/// `EcdhDerive` request schema. +/// +/// `masked_key` is `#[tbor(mutable)]` so the handler can `unmask` the local +/// key **in place** in the request buffer (via `decode_mut`) — no scratch +/// copy — and derive directly from the recovered key. +#[tbor(opcode = 0x19)] +pub struct TborEcdhDeriveReq<'a> { + /// CO/CU session id this request is bound to. + #[tbor(session_id)] + pub session_id: SessionId, + + /// Requested key scope (masks the derived secret), 1-byte [`KeyScope`]. + #[tbor(U8)] + pub scope: KeyScope, + + /// The masked local ECC private key (from `EccGenerateKey` / + /// `UnwrapKey`), an AEAD-GCM-256 envelope of 164..=200 B. Its kind + /// recovers the curve. + #[tbor(buffer, min_len = 164, max_len = 200, mutable)] + pub masked_key: &'a [u8], + + /// The peer's wire public key `x ‖ y` (little-endian, P-521 padded), + /// exactly the curve's wire public-key length (64 / 96 / 136 B). + #[tbor(buffer, max_len = 136)] + pub peer_pub_key: &'a [u8], +} + +/// `EcdhDerive` response schema. +/// +/// `masked_secret` is `#[tbor(mutable)]` so the handler can reserve the +/// slot and mask the derived secret straight into it (`decode_mut`). +#[tbor(response)] +pub struct TborEcdhDeriveResp<'a> { + /// The derived ECDH shared secret, masked (AEAD-GCM-256) under the + /// scope's masking key. 164 / 180 / 198 B for P-256 / P-384 / P-521. + #[tbor(buffer, max_len = 198, mutable)] + pub masked_secret: &'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; 512]; + let masked = [0x11u8; MASKED_ECC_KEY_MIN_LEN]; + let peer = [0x22u8; 64]; + let frame = TborEcdhDeriveReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(7)) + .unwrap() + .scope(KeyScope::Local) + .unwrap() + .masked_key(&masked) + .unwrap() + .peer_pub_key(&peer) + .unwrap() + .finish(); + assert_eq!(frame.scope(), KeyScope::Local); + assert_eq!(frame.peer_pub_key(), &peer[..]); + } + + #[test] + fn response_round_trips_masked_secret() { + let mut buf = [0u8; 256]; + let masked = [0x33u8; MASKED_SECRET_MAX_LEN]; + let frame = TborEcdhDeriveResp::encode(&mut buf, 0, true) + .unwrap() + .masked_secret(&masked) + .unwrap() + .finish(); + assert_eq!(frame.masked_secret(), &masked[..]); + } + + #[test] + fn lengths_match_pinned_values() { + const _: () = assert!(136 == ECDH_PEER_PUB_MAX_LEN); + const _: () = assert!(198 == MASKED_SECRET_MAX_LEN); + assert_eq!(MASKED_SECRET_MIN_LEN, 164); + assert_eq!(MASKED_SECRET_MAX_LEN, 198); + } +} diff --git a/fw/core/ddi/tbor/types/src/lib.rs b/fw/core/ddi/tbor/types/src/lib.rs index 907846571..a18903293 100644 --- a/fw/core/ddi/tbor/types/src/lib.rs +++ b/fw/core/ddi/tbor/types/src/lib.rs @@ -35,6 +35,9 @@ pub mod tbor_int { } pub mod api_rev; +pub mod ecc_generate_key; +pub mod ecc_sign; +pub mod ecdh_derive; pub mod evidence; pub mod get_unwrapping_key; pub mod key_props; @@ -57,6 +60,9 @@ pub mod session_open_init; pub mod unwrap_key; pub use api_rev::*; +pub use ecc_generate_key::*; +pub use ecc_sign::*; +pub use ecdh_derive::*; pub use evidence::*; pub use get_unwrapping_key::*; pub use key_props::*; diff --git a/fw/core/lib/src/ddi/tbor/ecc_generate_key.rs b/fw/core/lib/src/ddi/tbor/ecc_generate_key.rs new file mode 100644 index 000000000..ff1201532 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/ecc_generate_key.rs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EccGenerateKey` command handler. +//! +//! Within an open session, generate a fresh ECC keypair on the requested +//! NIST curve, mask the private key (AEAD-GCM-256) under the requested +//! scope's masking key, and return the masked blob plus the wire public +//! key. The private key is **not** persisted on-device: the caller holds +//! the masked blob and passes it back to [`EccSign`](super::ecc_sign) / +//! [`EcdhDerive`](super::ecdh_derive) (unmask-on-use). This is the TBOR +//! analogue of MBOR `EccGenerateKeyPair`, without a vault key id. +//! +//! 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::EccCurve; +use azihsm_fw_ddi_tbor_types::TborEccGenerateKeyReq; +use azihsm_fw_ddi_tbor_types::TborEccGenerateKeyResp; +use azihsm_fw_hsm_pal_traits::DmaBuf; +use azihsm_fw_hsm_pal_traits::HsmEccCurve; +use azihsm_fw_hsm_pal_traits::HsmEccPct; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmIo; +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::PartState; + +use super::from_pal::ecc_private; +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 ECC_KEY_LABEL: &[u8] = b"EccKey"; + +/// Map the wire [`EccCurve`] onto the PAL's NIST curve selector. Kept +/// local (single-use, wire→PAL direction) per the TBOR convention that +/// per-handler wire mappings live with their handler. +fn curve_from_wire(curve: EccCurve) -> HsmResult { + match curve { + EccCurve::P256 => Ok(HsmEccCurve::P256), + EccCurve::P384 => Ok(HsmEccCurve::P384), + EccCurve::P521 => Ok(HsmEccCurve::P521), + _ => Err(HsmError::InvalidArg), + } +} + +/// Attributes recorded in the masked blob's metadata (re-applied on +/// unmask). A generated ECC private key can `sign` (ECDSA) and `derive` +/// (ECDH); `scope` records the lifecycle / visibility domain. +fn ecc_key_attrs(scope: HsmKeyScope) -> HsmVaultKeyAttrs { + HsmVaultKeyAttrs::new() + .with_local(true) + .with_sign(true) + .with_derive(true) + .with_scope(scope) +} + +/// Handle a TBOR `EccGenerateKey` request. +/// +/// No partition lock or undo log is required: the command **persists +/// nothing** — it generates a keypair, masks the private key, and returns +/// the blob plus the public key. The raw private key is wiped once masked. +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &DmaBuf, +) -> HsmResult<&'p DmaBuf> { + let req = TborEccGenerateKeyReq::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)?; + + // Non-`Session` scopes need a masking key provisioned by `PartFinal` / + // the SD lifecycle; fail cheaply before keygen. + if scope != HsmKeyScope::Session && part_state::part_state(pal, io)? != PartState::Initialized { + return Err(HsmError::InvalidArg); + } + + let curve: HsmEccCurve = curve_from_wire(req.curve())?; + let kind = ecc_private(curve); + 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 = ecc_key_attrs(scope); + + // Generate the keypair into IO-scoped buffers sized from the curve's + // wire lengths (the single source of truth for PAL-boundary buffers). + let priv_key = pal.dma_alloc(io, curve.wire_priv_key_len())?; + let pub_key = pal.dma_alloc(io, curve.wire_pub_key_len())?; + + // Generate the keypair and mask the private key into the response + // inside a block that yields a `Result`, so **every** path (success or + // error — keygen, length check, encode, or mask failure) falls through + // to the `priv_key` wipe below. The raw private scalar must never + // survive in DMA scratch: scope exit only resets the bump watermark, it + // does not zero freed memory. + let outcome: HsmResult<&'p DmaBuf> = async { + let (priv_len, pub_len) = pal + .alloc_scoped_async(io, async |a| -> HsmResult<(usize, usize)> { + pal.ecc_gen_keypair( + io, + a, + curve, + Some((&mut *priv_key, &mut *pub_key)), + HsmEccPct::SignVerify, + ) + .await + }) + .await?; + // The wire lengths are fixed per curve; a mismatch is an internal bug. + if priv_len != curve.wire_priv_key_len() || pub_len != curve.wire_pub_key_len() { + return Err(HsmError::InternalError); + } + + let masked_len = masked_blob_len(AeadAlg::AesGcm256, priv_len); + + // Build the response with the masked-key slot reserved and the + // public key copied in; the masked private key is filled in place. + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborEccGenerateKeyResp::encode(buf, 0, false)? + .masked_key_reserve(masked_len)? + .pub_key(&pub_key[..pub_len])? + .finish(); + Ok(frame.as_bytes().len()) + })?; + + // Mask the private key straight into the reserved response slot. + { + let out = TborEccGenerateKeyResp::decode_mut(resp)?; + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<()> { + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + let key_label = alloc.dma_alloc(ECC_KEY_LABEL.len())?; + key_label.copy_from_slice(ECC_KEY_LABEL); + let params = MaskParams { + key_kind: kind, + key_attrs: attrs, + svn, + owner_seed_id: owner, + key_label, + }; + mask( + pal, + io, + alloc, + AeadAlg::AesGcm256, + masking_key, + ¶ms, + priv_key, + Some(out.masked_key), + ) + .await + .map(|_| ()) + }) + .await?; + } + + // Coerce the `&mut` response buffer to a shared `&DmaBuf` (preserving + // the `'p` allocator lifetime) so the async block's output matches + // the handler's `&'p DmaBuf` return. + let resp: &'p DmaBuf = resp; + Ok(resp) + } + .await; + + // Scrub the raw private key from DMA scratch on every path. + priv_key.zeroize(); + outcome +} diff --git a/fw/core/lib/src/ddi/tbor/ecc_sign.rs b/fw/core/lib/src/ddi/tbor/ecc_sign.rs new file mode 100644 index 000000000..defcab139 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/ecc_sign.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EccSign` command handler. +//! +//! Within an open session, produce a raw ECDSA `r ‖ s` signature over a +//! host-supplied **pre-computed digest** using a caller-held **masked** +//! ECC private key (from [`EccGenerateKey`](super::ecc_generate_key) or +//! imported via [`UnwrapKey`](super::unwrap_key)). The key is unmasked +//! **in place** in the request buffer (no scratch copy), its curve is +//! recovered from the blob's key kind, and the signature is written +//! straight into the reserved response slot. Firmware does **no** +//! hashing — the caller supplies the digest. This is the TBOR analogue of +//! MBOR `EccSign`, keyed by a masked blob instead of a vault id. +//! +//! Available to both Crypto-Officer and Crypto-User sessions. + +use azihsm_fw_core_crypto_key_masking::aead::peek_metadata; +use azihsm_fw_core_crypto_key_masking::aead::unmask; +use azihsm_fw_ddi_tbor_types::HashAlgo; +use azihsm_fw_ddi_tbor_types::TborEccSignReq; +use azihsm_fw_ddi_tbor_types::TborEccSignResp; +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::HsmPal; +use azihsm_fw_hsm_pal_traits::HsmResult; +use azihsm_fw_hsm_pal_traits::HsmSessId; + +use super::from_pal::ecc_private_curve; +use super::resolve_masking_key; +use super::validate_active_session; + +/// Map the wire signing [`HashAlgo`] onto the firmware hash algorithm, +/// used to pin the exact expected pre-hashed digest length. Kept local +/// (single-use, wire→PAL direction) per the TBOR convention that +/// per-handler wire mappings live with their handler. +fn sign_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), + } +} + +/// Handle a TBOR `EccSign` request. +/// +/// No partition lock or undo log is required: the command reads no mutable +/// partition state and **persists nothing** — it unmasks the caller's key, +/// signs, and returns the signature. Takes `req_buf: &mut DmaBuf` so the +/// masked key can be unmasked in place (`decode_mut`). +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &mut DmaBuf, +) -> HsmResult<&'p DmaBuf> { + let req = TborEccSignReq::decode_mut(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id)); + validate_active_session(pal, io, sess_id)?; + + // Pin the supplied digest to the algorithm's exact native length; the + // PAL consumes it as-is (wire little-endian) and flips endianness + // internally if its primitive is big-endian native. + let digest_algo = sign_hsm_hash(HashAlgo(req.digest_algo))?; + if req.digest.len() != digest_algo.digest_len() { + return Err(HsmError::InvalidArg); + } + + // The scope that masked this key is recorded (cleartext, tag-bound) in + // the blob metadata; resolve its masking key before unmasking. The + // peek borrow is transient — it ends before the in-place unmask. + let scope = peek_metadata(req.masked_key)?.usage_flags().scope(); + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + + // Unmask the private key in place, sign, and build the response inside a + // block that yields a `Result`, so **every** post-unmask path (success + // or error) falls through to the `masked_key` wipe below — the recovered + // plaintext must never survive in the request buffer. + let outcome: HsmResult<&'p DmaBuf> = async { + let view = unmask(pal, io, masking_key, req.masked_key).await?; + let curve = ecc_private_curve(view.key_kind)?; + if !view.key_attrs.sign() { + return Err(HsmError::InvalidPermissions); + } + + // Reserve the wire-format signature slot (`r ‖ s`, curve-sized) and + // have the PAL sign straight into it — no scratch, no copy. + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborEccSignResp::encode(buf, 0, false)? + .signature_reserve(curve.wire_sig_len())? + .finish(); + Ok(frame.as_bytes().len()) + })?; + { + let out = TborEccSignResp::decode_mut(resp)?; + pal.ecc_sign(io, curve, view.target_key, req.digest, out.signature) + .await?; + } + // Coerce the `&mut` response buffer to a shared `&DmaBuf` (preserving + // the `'p` allocator lifetime) so the async block's output matches + // the handler's `&'p DmaBuf` return. + let resp: &'p DmaBuf = resp; + Ok(resp) + } + .await; + + // Scrub the recovered plaintext key from the request buffer. + req.masked_key.zeroize(); + outcome +} diff --git a/fw/core/lib/src/ddi/tbor/ecdh_derive.rs b/fw/core/lib/src/ddi/tbor/ecdh_derive.rs new file mode 100644 index 000000000..79d689f47 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/ecdh_derive.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `EcdhDerive` command handler. +//! +//! Within an open session, derive an ECDH shared secret from a caller-held +//! **masked** local ECC private key (from +//! [`EccGenerateKey`](super::ecc_generate_key) or imported via +//! [`UnwrapKey`](super::unwrap_key)) and a host-supplied peer public key, +//! then return the secret as a **masked** blob under the requested scope's +//! masking key. The local key is unmasked **in place** in the request +//! buffer (no scratch copy); its curve is recovered from the blob's key +//! kind. This is the TBOR analogue of MBOR `EcdhKeyExchange`, re-masking +//! the derived secret instead of vaulting it. +//! +//! 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::peek_metadata; +use azihsm_fw_core_crypto_key_masking::aead::unmask; +use azihsm_fw_core_crypto_key_masking::aead::AeadAlg; +use azihsm_fw_core_crypto_key_masking::aead::MaskParams; +use azihsm_fw_ddi_tbor_types::TborEcdhDeriveReq; +use azihsm_fw_ddi_tbor_types::TborEcdhDeriveResp; +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::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::PartState; + +use super::from_pal::ecc_private_curve; +use super::from_pal::ecdh_secret; +use super::resolve_masking_key; +use super::validate_active_session; +use crate::part_state; + +/// Envelope key-label recorded in the derived-secret masked blob's +/// `MaskedKeyMetadata`. +const ECDH_SECRET_LABEL: &[u8] = b"EcdhSecret"; + +/// Attributes recorded in the derived-secret masked blob's metadata. An +/// ECDH shared secret is derived on-device (so `local`) and usable only as +/// a key-derivation key (`derive`) for a further KDF — matching MBOR's +/// [`for_ecdh_secret`](crate::ddi::mbor::key_attrs::for_ecdh_secret). +/// `scope` records the lifecycle / visibility domain. +fn ecdh_secret_attrs(scope: HsmKeyScope) -> HsmVaultKeyAttrs { + HsmVaultKeyAttrs::new() + .with_local(true) + .with_derive(true) + .with_scope(scope) +} + +/// Handle a TBOR `EcdhDerive` request. +/// +/// No partition lock or undo log is required: the command **persists +/// nothing** — it unmasks the local key, derives the secret, masks it, and +/// returns the blob. Takes `req_buf: &mut DmaBuf` so the local key can be +/// unmasked in place (`decode_mut`). +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &mut DmaBuf, +) -> HsmResult<&'p DmaBuf> { + let req = TborEcdhDeriveReq::decode_mut(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id)); + let target_scope = HsmKeyScope(req.scope); + validate_active_session(pal, io, sess_id)?; + + // The derived secret is masked under `target_scope`; a non-`Session` + // scope needs a masking key provisioned by `PartFinal` / the SD + // lifecycle. Fail cheaply before any crypto work. + if target_scope != HsmKeyScope::Session + && part_state::part_state(pal, io)? != PartState::Initialized + { + return Err(HsmError::InvalidArg); + } + + // Platform identity bound into the derived-secret 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 = ecdh_secret_attrs(target_scope); + + // The scope that masked the local key is recorded (cleartext, + // tag-bound) in its blob metadata; resolve its masking key before + // unmasking. The peek borrow is transient — it ends before the in-place + // unmask. + let scope_local = peek_metadata(req.masked_key)?.usage_flags().scope(); + let masking_key_local = resolve_masking_key(pal, io, scope_local, sess_id)?; + + // Unmask the local key in place, derive, and mask the secret inside a + // block that yields a `Result`, so **every** post-unmask path (success + // or error) falls through to the `masked_key` wipe below — the recovered + // plaintext must never survive in the request buffer. + let outcome: HsmResult<&'p DmaBuf> = async { + let view = unmask(pal, io, masking_key_local, req.masked_key).await?; + let curve = ecc_private_curve(view.key_kind)?; + if !view.key_attrs.derive() { + return Err(HsmError::InvalidPermissions); + } + + // The host emits a fixed-length peer public key for the curve; the + // PAL requires exactly `wire_pub_key_len` bytes — reject any + // non-exact length so trailing junk is not silently accepted. + if req.peer_pub_key.len() != curve.wire_pub_key_len() { + return Err(HsmError::InvalidArg); + } + + let secret_len = curve.secret_len(); + let masked_len = masked_blob_len(AeadAlg::AesGcm256, secret_len); + let kind = ecdh_secret(curve); + + // Build the response with the masked-secret slot reserved; the + // derived secret is masked straight into it below. + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborEcdhDeriveResp::encode(buf, 0, false)? + .masked_secret_reserve(masked_len)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + { + let out = TborEcdhDeriveResp::decode_mut(resp)?; + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<()> { + // Resolve the target masking key and staging label BEFORE + // deriving, so that once `secret` holds key material the only + // remaining fallible step is `mask` — whose result is bound + // (not `?`-propagated) so every path reaches `secret.zeroize()`. + // Scope exit only resets the bump watermark; it does not zero + // freed memory, so the derived secret must be scrubbed here. + let masking_key_target = resolve_masking_key(pal, io, target_scope, sess_id)?; + let key_label = alloc.dma_alloc(ECDH_SECRET_LABEL.len())?; + key_label.copy_from_slice(ECDH_SECRET_LABEL); + let params = MaskParams { + key_kind: kind, + key_attrs: attrs, + svn, + owner_seed_id: owner, + key_label, + }; + + // Derive into DMA scratch, then mask it into the reserved + // response slot. `secret` is scrubbed on every path. + let secret = alloc.dma_alloc(secret_len)?; + let derive_and_mask = async { + pal.ecdh_derive(io, curve, view.target_key, req.peer_pub_key, secret) + .await?; + mask( + pal, + io, + alloc, + AeadAlg::AesGcm256, + masking_key_target, + ¶ms, + secret, + Some(out.masked_secret), + ) + .await + .map(|_| ()) + } + .await; + secret.zeroize(); + derive_and_mask + }) + .await?; + } + // Coerce the `&mut` response buffer to a shared `&DmaBuf` (preserving + // the `'p` allocator lifetime) so the async block's output matches + // the handler's `&'p DmaBuf` return. + let resp: &'p DmaBuf = resp; + Ok(resp) + } + .await; + + // Scrub the recovered local plaintext key from the request buffer. + req.masked_key.zeroize(); + outcome +} diff --git a/fw/core/lib/src/ddi/tbor/from_pal.rs b/fw/core/lib/src/ddi/tbor/from_pal.rs index ce16224c0..58989a139 100644 --- a/fw/core/lib/src/ddi/tbor/from_pal.rs +++ b/fw/core/lib/src/ddi/tbor/from_pal.rs @@ -10,13 +10,17 @@ //! [`HsmVaultKeyKind::SdSealing`]) belong only in this table. use azihsm_fw_hsm_pal_traits::HsmEccCurve; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmResult; use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; /// Map an ECC-private vault key kind to its NIST curve, or `None` if the /// kind is not an attestable ECC private key. /// /// Includes [`HsmVaultKeyKind::SdSealing`] — the SD sealing key is a -/// P-384 key that TBOR `KeyReport` attests. +/// P-384 key that TBOR `KeyReport` attests. For the general crypto +/// commands (`EccSign` / `EcdhDerive`), which must reject non-ECC-private +/// blobs, use the stricter [`ecc_private_curve`] instead. pub(crate) fn ecc_curve(kind: HsmVaultKeyKind) -> Option { match kind { HsmVaultKeyKind::Ecc256Private => Some(HsmEccCurve::P256), @@ -25,3 +29,43 @@ pub(crate) fn ecc_curve(kind: HsmVaultKeyKind) -> Option { _ => None, } } + +/// Recover the NIST curve of an unmasked ECC **private** key from its +/// vault key kind, strictly. +/// +/// Only the three ECC-private kinds are accepted; any other kind — a +/// public key, an ECDH secret, an [`HsmVaultKeyKind::SdSealing`] key, or a +/// non-ECC kind — maps to [`HsmError::InvalidKeyType`]. Unlike +/// [`ecc_curve`] (which admits `SdSealing` for `KeyReport` attestation), +/// this is the mapping the `EccSign` / `EcdhDerive` handlers use so a +/// masked blob of the wrong class is rejected before use — mirroring the +/// MBOR `from_pal::ecc_curve` precedent. +pub(crate) fn ecc_private_curve(kind: HsmVaultKeyKind) -> HsmResult { + match kind { + HsmVaultKeyKind::Ecc256Private => Ok(HsmEccCurve::P256), + HsmVaultKeyKind::Ecc384Private => Ok(HsmEccCurve::P384), + HsmVaultKeyKind::Ecc521Private => Ok(HsmEccCurve::P521), + _ => Err(HsmError::InvalidKeyType), + } +} + +/// Map a NIST curve onto its ECC-private vault key kind (recorded in the +/// masked blob's metadata so `EccSign` / `EcdhDerive` can recover the +/// curve on unmask). +pub(crate) fn ecc_private(curve: HsmEccCurve) -> HsmVaultKeyKind { + match curve { + HsmEccCurve::P256 => HsmVaultKeyKind::Ecc256Private, + HsmEccCurve::P384 => HsmVaultKeyKind::Ecc384Private, + HsmEccCurve::P521 => HsmVaultKeyKind::Ecc521Private, + } +} + +/// Map a NIST curve onto its ECDH shared-secret vault key kind (recorded +/// in the derived-secret masked blob's metadata). +pub(crate) fn ecdh_secret(curve: HsmEccCurve) -> HsmVaultKeyKind { + match curve { + HsmEccCurve::P256 => HsmVaultKeyKind::Secret256, + HsmEccCurve::P384 => HsmVaultKeyKind::Secret384, + HsmEccCurve::P521 => HsmVaultKeyKind::Secret521, + } +} diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index 5a9ade009..76d1b7cf1 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -21,6 +21,9 @@ //! per-IO allocator scope). pub(crate) mod api_rev; +pub(crate) mod ecc_generate_key; +pub(crate) mod ecc_sign; +pub(crate) mod ecdh_derive; pub(crate) mod from_pal; pub(crate) mod get_unwrapping_key; pub(crate) mod key_report; @@ -155,6 +158,23 @@ pub(crate) mod opcode { /// 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; + + /// `EccGenerateKey` — generate a fresh ECC keypair on the requested + /// NIST curve and return the private key masked under the requested + /// scope's masking key plus the wire public key (nothing is persisted + /// on-device). See [`super::ecc_generate_key`]. + pub(crate) const ECC_GENERATE_KEY: u8 = 0x17; + + /// `EccSign` — produce a raw ECDSA `r ‖ s` signature over a + /// host-supplied pre-computed digest using a caller-held masked ECC + /// private key (unmasked on-device). See [`super::ecc_sign`]. + pub(crate) const ECC_SIGN: u8 = 0x18; + + /// `EcdhDerive` — derive an ECDH shared secret from a caller-held + /// masked local ECC private key and a host-supplied peer public key, + /// and return the secret masked under the requested scope's masking + /// key. See [`super::ecdh_derive`]. + pub(crate) const ECDH_DERIVE: u8 = 0x19; } /// Validate that `sess_id` belongs to an active Crypto-Officer session. @@ -344,6 +364,9 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( 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, + opcode::ECC_GENERATE_KEY => ecc_generate_key::handle(pal, io, req_buf).await, + opcode::ECC_SIGN => ecc_sign::handle(pal, io, req_buf).await, + opcode::ECDH_DERIVE => ecdh_derive::handle(pal, io, req_buf).await, _ => Err(HsmError::UnsupportedCmd), } } @@ -370,6 +393,9 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::KEY_REPORT | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY + | opcode::ECC_GENERATE_KEY + | opcode::ECC_SIGN + | opcode::ECDH_DERIVE ) } @@ -402,7 +428,10 @@ fn is_in_session(opcode: u8) -> bool { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::KEY_REPORT | opcode::GET_UNWRAPPING_KEY - | opcode::UNWRAP_KEY => true, + | opcode::UNWRAP_KEY + | opcode::ECC_GENERATE_KEY + | opcode::ECC_SIGN + | opcode::ECDH_DERIVE => true, // Default-deny: any future opcode is treated as in-session // until classified, so the default-PSK gate applies to it. _ => true, @@ -443,7 +472,10 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::KEY_REPORT | opcode::GET_UNWRAPPING_KEY - | opcode::UNWRAP_KEY => true, + | opcode::UNWRAP_KEY + | opcode::ECC_GENERATE_KEY + | opcode::ECC_SIGN + | opcode::ECDH_DERIVE => true, _ => true, } } diff --git a/fw/core/lib/src/op.rs b/fw/core/lib/src/op.rs index 7c6d97198..341dd1431 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -267,7 +267,10 @@ impl SessionCtrl { | opcode::SD_RESTORE_LOCAL_BACKUP | opcode::KEY_REPORT | opcode::GET_UNWRAPPING_KEY - | opcode::UNWRAP_KEY => Self::InSession, + | opcode::UNWRAP_KEY + | opcode::ECC_GENERATE_KEY + | opcode::ECC_SIGN + | opcode::ECDH_DERIVE => Self::InSession, opcode::SESSION_CLOSE => Self::Close, _ => Self::NoSession, }