From 8926bc5913d7a919ada6f98c7e5d2f9aceb842dc Mon Sep 17 00:00:00 2001 From: Vishal Soni Date: Sat, 18 Jul 2026 20:05:53 +0000 Subject: [PATCH] feat(tbor): implement HmacGenerateKey + Hmac (masked-key HMAC crypto) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the TBOR HMAC crypto command pair, stacked on the RSA-AES key-unwrap commands. HmacGenerateKey (opcode 0x11): generate a fresh random HMAC key of the caller-selected SHA variant (SHA-256/384/512 -> 32/48/64 B) and return it masked (AEAD-GCM-256) under the requested scope's masking key. The key is not stored on-device; the caller holds the masked blob. The masked-blob length is fixed by the key length, so the handler reserves the response slot up front and masks the generated key straight into it (the encoder's `*_reserve` + `decode_mut` "reserve + fill") — no scratch buffer and no copy. Hmac (opcode 0x12): compute an HMAC tag over a host-supplied message using a caller-held masked HMAC key. The key is `#[tbor(mutable)]`, so the handler `decode_mut`s the request and `unmask`s the blob **in place** in the request buffer, then MACs directly from the recovered `target_key` (a `&DmaBuf` into that buffer) — no scratch copy of the blob or the key. The recovered key is wiped on every path. Both commands reuse the shared session/masking helpers (`validate_active_session`, `resolve_masking_key`) and the shared `HashAlgo` wire enum introduced with the key-unwrap commands below them in the stack; `HmacGenerateKey` selects its SHA variant via `HashAlgo` from the shared `key_props` module. 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, command docs, and emu tests: keygen round-trip across all hashes/scopes, MAC round-trip, tamper-reject, scope/hash rejects, plus a cross-command `UnwrapKey` -> `Hmac` round-trip that imports an HMAC key via RSA-AES unwrap and MACs with the recovered key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09e50a-a9be-4bae-b347-d42dc775a258 --- ddi/tbor/types/src/hmac.rs | 85 +++++++ ddi/tbor/types/src/hmac_generate_key.rs | 102 ++++++++ ddi/tbor/types/src/lib.rs | 4 + ddi/tbor/types/tests/commands/hmac.rs | 161 +++++++++++++ .../types/tests/commands/hmac_generate_key.rs | 217 ++++++++++++++++++ ddi/tbor/types/tests/commands/mod.rs | 2 + docs/tbor-ddi/README.md | 2 + docs/tbor-ddi/commands/hmac.md | 72 ++++++ docs/tbor-ddi/commands/hmac_generate_key.md | 93 ++++++++ fw/core/ddi/tbor/types/src/hmac.rs | 128 +++++++++++ .../ddi/tbor/types/src/hmac_generate_key.rs | 150 ++++++++++++ fw/core/ddi/tbor/types/src/lib.rs | 4 + fw/core/lib/src/ddi/tbor/hmac.rs | 106 +++++++++ fw/core/lib/src/ddi/tbor/hmac_generate_key.rs | 205 +++++++++++++++++ fw/core/lib/src/ddi/tbor/mod.rs | 21 ++ fw/core/lib/src/op.rs | 2 + 16 files changed, 1354 insertions(+) create mode 100644 ddi/tbor/types/src/hmac.rs create mode 100644 ddi/tbor/types/src/hmac_generate_key.rs create mode 100644 ddi/tbor/types/tests/commands/hmac.rs create mode 100644 ddi/tbor/types/tests/commands/hmac_generate_key.rs create mode 100644 docs/tbor-ddi/commands/hmac.md create mode 100644 docs/tbor-ddi/commands/hmac_generate_key.md create mode 100644 fw/core/ddi/tbor/types/src/hmac.rs create mode 100644 fw/core/ddi/tbor/types/src/hmac_generate_key.rs create mode 100644 fw/core/lib/src/ddi/tbor/hmac.rs create mode 100644 fw/core/lib/src/ddi/tbor/hmac_generate_key.rs diff --git a/ddi/tbor/types/src/hmac.rs b/ddi/tbor/types/src/hmac.rs new file mode 100644 index 000000000..656ac7283 --- /dev/null +++ b/ddi/tbor/types/src/hmac.rs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `Hmac` command. +//! +//! `Hmac` is an **in-session** command (Crypto-Officer or Crypto-User) +//! that computes an HMAC tag over `msg` using a caller-held **masked** +//! HMAC key (the `masked_key` returned by +//! [`HmacGenerateKey`](crate::hmac_generate_key) or imported via unwrap). +//! The device unmasks the key on-device, computes the MAC, and returns the +//! tag; nothing is persisted. + +use alloc::vec::Vec; + +pub use crate::hmac_generate_key::MASKED_HMAC_KEY_MAX_LEN; +pub use crate::hmac_generate_key::MASKED_HMAC_KEY_MIN_LEN; +use crate::tbor; + +/// TBOR opcode for `Hmac`. +pub const TBOR_OP_HMAC: u8 = 0x12; + +/// Maximum message length (bytes) accepted by `Hmac`. +/// +/// `1024`, matching the MBOR `Hmac` command's message bound +/// (`DdiHmacReq::msg`), which is itself sized to the AES-CBC +/// single-shot message cap (`MAX_MSG_SIZE`) so the two symmetric +/// primitives share one host-visible limit. Larger inputs are hashed +/// host-side or chunked by the caller. +pub const HMAC_MSG_MAX_LEN: usize = 1024; + +/// Maximum HMAC tag length (bytes): the SHA-512 digest. +pub const HMAC_TAG_MAX_LEN: usize = 64; + +/// Host-facing TBOR `Hmac` request. +#[tbor(opcode = TBOR_OP_HMAC, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborHmacReq { + /// 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, + + /// The masked HMAC key (from `HmacGenerateKey` / unwrap), an + /// AEAD-GCM-256 envelope of 164..=260 B. + #[tbor(min_len = 164, max_len = 260)] + pub masked_key: Vec, + + /// The message to MAC, up to [`HMAC_MSG_MAX_LEN`] (1024) bytes. + #[tbor(max_len = 1024)] + pub msg: Vec, +} + +/// Host-facing TBOR `Hmac` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborHmacResp { + /// The HMAC tag over `msg`: 32 / 48 / 64 B for SHA-256 / 384 / 512. + #[tbor(max_len = 64)] + pub tag: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_masked_key_and_msg() { + let req = TborHmacReq { + session_id: 7, + masked_key: alloc::vec![0x11u8; MASKED_HMAC_KEY_MIN_LEN], + msg: alloc::vec![0x22u8; 40], + }; + + let mut buf = [0u8; 1536]; + let frame = req.encode_request(&mut buf).expect("encode"); + + // The message bytes must appear in the encoded frame. + assert!( + frame.windows(4).any(|w| w == [0x22u8; 4]), + "encoded frame must carry the message bytes", + ); + } +} diff --git a/ddi/tbor/types/src/hmac_generate_key.rs b/ddi/tbor/types/src/hmac_generate_key.rs new file mode 100644 index 000000000..f1ce9e8af --- /dev/null +++ b/ddi/tbor/types/src/hmac_generate_key.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `HmacGenerateKey` command. +//! +//! `HmacGenerateKey` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that generates a fresh random HMAC key of the requested +//! SHA variant and returns it as a **masked** blob. The key is not stored +//! on-device; the caller holds the masked blob and passes it back to +//! [`Hmac`](crate::hmac) to compute a MAC (unmask-on-use). +//! +//! The request carries the requested key `scope` (lifecycle / visibility +//! domain) and `hash_algo` (SHA variant) as raw 1-byte discriminants — the +//! firmware types them as the `KeyScope` / `HashAlgo` open-enums, but this +//! host crate is firewalled from the firmware PAL types so it carries the +//! same bytes as raw `u8`. + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `HmacGenerateKey`. +pub const TBOR_OP_HMAC_GENERATE_KEY: u8 = 0x11; + +/// Minimum masked HMAC-key envelope length (SHA-256, 32-byte key): an +/// AEAD-GCM-256 masked-key envelope `header(8) ‖ iv(12) ‖ aad(96) ‖ +/// pt(32) ‖ tag(16)`. +pub const MASKED_HMAC_KEY_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// Maximum masked HMAC-key envelope length (128-byte key): an AEAD-GCM-256 +/// masked-key envelope `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(128) ‖ tag(16)`. +pub const MASKED_HMAC_KEY_MAX_LEN: usize = 8 + 12 + 96 + 128 + 16; + +/// `HashAlgo` discriminant for HMAC-SHA-256 (mirror of the firmware +/// `HsmHashAlgo` / TBOR `HashAlgo` value). +pub const HMAC_HASH_SHA256: u8 = 1; +/// `HashAlgo` discriminant for HMAC-SHA-384. +pub const HMAC_HASH_SHA384: u8 = 2; +/// `HashAlgo` discriminant for HMAC-SHA-512. +pub const HMAC_HASH_SHA512: u8 = 3; + +/// Host-facing TBOR `HmacGenerateKey` request. +#[tbor(opcode = TBOR_OP_HMAC_GENERATE_KEY, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TborHmacGenerateKeyReq { + /// 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, + + /// Requested key scope as the 1-byte `KeyScope` discriminant. + pub scope: u8, + + /// Hash algorithm as the 1-byte `HashAlgo` discriminant (see + /// [`HMAC_HASH_SHA256`] / [`HMAC_HASH_SHA384`] / [`HMAC_HASH_SHA512`]). + /// Selects the HMAC SHA variant and the valid `key_length` range. + pub hash_algo: u8, + + /// Requested key length in bytes. HMAC keys are variable-length; the + /// value must be in the SHA variant's range (SHA-256: 32–64, + /// SHA-384: 48–128, SHA-512: 64–128), else the device returns + /// `InvalidKeyLength`. + pub key_length: u8, +} + +/// Host-facing TBOR `HmacGenerateKey` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborHmacGenerateKeyResp { + /// The freshly generated HMAC key, masked (AEAD-GCM-256) under the + /// requested scope's masking key. `132 + key_length` B (164 … 260 B + /// for a 32 … 128-byte key); not stored on-device. + #[tbor(max_len = 260)] + pub masked_key: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_scope_and_hash() { + let req = TborHmacGenerateKeyReq { + session_id: 5, + // KeyScope::Session discriminant (0b001). + scope: 0b001, + hash_algo: HMAC_HASH_SHA384, + key_length: 96, + }; + + let mut buf = [0u8; 256]; + let frame = req.encode_request(&mut buf).expect("encode"); + + // The 1-byte hash-algo discriminant must appear in the encoded frame. + assert!( + frame.contains(&HMAC_HASH_SHA384), + "encoded frame must carry the hash-algo discriminant", + ); + } +} diff --git a/ddi/tbor/types/src/lib.rs b/ddi/tbor/types/src/lib.rs index 2b3bd2ad9..72c48bea9 100644 --- a/ddi/tbor/types/src/lib.rs +++ b/ddi/tbor/types/src/lib.rs @@ -81,6 +81,8 @@ impl From for u8 { mod api_rev; mod evidence; mod get_unwrapping_key; +mod hmac; +mod hmac_generate_key; mod key_report; mod part_final; mod part_info; @@ -103,6 +105,8 @@ mod unwrap_key; pub use api_rev::*; pub use evidence::*; pub use get_unwrapping_key::*; +pub use hmac::*; +pub use hmac_generate_key::*; pub use key_report::*; pub use part_final::*; pub use part_info::*; diff --git a/ddi/tbor/types/tests/commands/hmac.rs b/ddi/tbor/types/tests/commands/hmac.rs new file mode 100644 index 000000000..9c3d2d223 --- /dev/null +++ b/ddi/tbor/types/tests/commands/hmac.rs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `Hmac` command. +//! +//! `Hmac` computes an HMAC tag over a host message using a caller-held +//! **masked** HMAC key (from `HmacGenerateKey`). The device unmasks the +//! key on-device, computes the MAC, and returns the tag. +//! +//! Coverage: +//! * Generate → MAC round-trip per hash variant (SHA-256/384/512): the tag +//! has the digest length, is non-zero and deterministic, and a different +//! message yields a different tag. +//! * Session-scoped key round-trip (masked under the per-session key). +//! * Unwrapped-key → MAC round-trip: an HMAC key imported via `UnwrapKey` +//! computes a valid MAC, and a second independent import reproduces the +//! tag (proving the key material survives wrap/unwrap). +//! * Tampered masked key → `AesGcmDecryptTagDoesNotMatch`. +//! * Empty message is accepted. + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::TborHmacGenerateKeyReq; +use azihsm_ddi_tbor_types::TborHmacReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA256; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA384; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA512; +use azihsm_ddi_tbor_types::KEY_CLASS_HMAC_SHA256; + +use crate::commands::hmac_generate_key::SCOPE_EPHEMERAL; +use crate::commands::hmac_generate_key::SCOPE_SESSION; +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::commands::unwrap_key::unwrap; +use crate::harness::TestCtx; + +/// Expected tag length (bytes) for a wire hash discriminant. +fn tag_len_for_hash(hash: u8) -> usize { + match hash { + HMAC_HASH_SHA256 => 32, + HMAC_HASH_SHA384 => 48, + HMAC_HASH_SHA512 => 64, + other => panic!("unexpected hash discriminant {other}"), + } +} + +/// Generate a masked HMAC key of `(scope, hash)` on `session_id`. Uses +/// the digest-size key length (a valid in-range `VarLenHmac` length) — the +/// MAC tests exercise MAC behaviour, not the key-length range. +fn generate_key(ctx: &TestCtx, session_id: u16, scope: u8, hash: u8) -> Vec { + let req = TborHmacGenerateKeyReq { + session_id, + scope, + hash_algo: hash, + key_length: tag_len_for_hash(hash) as u8, + }; + ctx.tbor(&req).expect("HmacGenerateKey").masked_key +} + +/// Compute a MAC tag over `msg` with the masked key. +fn mac(ctx: &TestCtx, session_id: u16, masked_key: &[u8], msg: &[u8]) -> Vec { + let req = TborHmacReq { + session_id, + masked_key: masked_key.to_vec(), + msg: msg.to_vec(), + }; + ctx.tbor(&req).expect("Hmac").tag +} + +#[test] +fn hmac_roundtrip_all_hashes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let msg = b"the quick brown fox"; + + for hash in [HMAC_HASH_SHA256, HMAC_HASH_SHA384, HMAC_HASH_SHA512] { + let masked = generate_key(&ctx, session.session_id, SCOPE_EPHEMERAL, hash); + let tag = mac(&ctx, session.session_id, &masked, msg); + + // Tag length matches the hash digest length, and is non-zero. + assert_eq!(tag.len(), tag_len_for_hash(hash), "tag length"); + assert!(tag.iter().any(|&b| b != 0), "tag must not be all-zero"); + + // HMAC is deterministic: the same key + message reproduce the tag. + let tag_again = mac(&ctx, session.session_id, &masked, msg); + assert_eq!(tag, tag_again, "HMAC must be deterministic"); + + // A different message yields a different tag. + let tag_other = mac(&ctx, session.session_id, &masked, b"a different message"); + assert_ne!(tag, tag_other, "distinct messages must yield distinct tags"); + } +} + +#[test] +fn hmac_session_scope_roundtrip_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let masked = generate_key(&ctx, session.session_id, SCOPE_SESSION, HMAC_HASH_SHA256); + let tag = mac(&ctx, session.session_id, &masked, b"session-scoped mac"); + assert_eq!(tag.len(), 32); + assert!(tag.iter().any(|&b| b != 0)); +} + +/// Unwrap an HMAC key (RSA-AES key import) and use it via `Hmac`. +/// +/// Exercises the cross-command integration between `UnwrapKey` (imports a +/// host-wrapped HMAC key as a masked blob) and `Hmac` (MACs with the +/// recovered key). A second independent unwrap of the same key must +/// reproduce the tag, proving the key material survives wrap → unwrap → +/// mask → unmask intact. +#[test] +fn hmac_unwrapped_key_roundtrip_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + let hmac_key = [0x37u8; 32]; + let msg = b"unwrap then mac"; + + let masked = unwrap(&ctx, session.session_id, KEY_CLASS_HMAC_SHA256, &hmac_key).masked_key; + let tag = mac(&ctx, session.session_id, &masked, msg); + assert_eq!(tag.len(), 32, "HMAC-SHA-256 tag length"); + assert!(tag.iter().any(|&b| b != 0), "tag must not be all-zero"); + + // A second independent unwrap of the same key recovers the same key + // material, so MAC-ing the same message reproduces the tag. + let masked2 = unwrap(&ctx, session.session_id, KEY_CLASS_HMAC_SHA256, &hmac_key).masked_key; + let tag2 = mac(&ctx, session.session_id, &masked2, msg); + assert_eq!(tag, tag2, "same unwrapped key must reproduce the MAC"); +} + +#[test] +fn hmac_empty_message_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let masked = generate_key(&ctx, session.session_id, SCOPE_EPHEMERAL, HMAC_HASH_SHA256); + let tag = mac(&ctx, session.session_id, &masked, b""); + assert_eq!(tag.len(), 32); + assert!( + tag.iter().any(|&b| b != 0), + "HMAC of empty message is non-zero" + ); +} + +#[test] +fn hmac_rejects_tampered_key_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let mut masked = generate_key(&ctx, session.session_id, SCOPE_EPHEMERAL, HMAC_HASH_SHA256); + + // Flip a byte in the AEAD tag region (last 16 bytes) so the unmask tag + // check fails without changing the cleartext scope metadata. + let last = masked.len() - 1; + masked[last] ^= 0x01; + + let req = TborHmacReq { + session_id: session.session_id, + masked_key: masked, + msg: b"whatever".to_vec(), + }; + ctx.expect_fw_reject(&req, TborStatus::AesGcmDecryptTagDoesNotMatch); +} diff --git a/ddi/tbor/types/tests/commands/hmac_generate_key.rs b/ddi/tbor/types/tests/commands/hmac_generate_key.rs new file mode 100644 index 000000000..5a23cfb0d --- /dev/null +++ b/ddi/tbor/types/tests/commands/hmac_generate_key.rs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `HmacGenerateKey` command. +//! +//! The command generates a random HMAC key of the requested SHA variant +//! and returns it **masked** under the requested scope's masking key — +//! nothing is stored on-device. Unlike the security-domain commands, +//! `HmacGenerateKey` is available to both Crypto-Officer and Crypto-User +//! sessions. +//! +//! Coverage: +//! * Happy path per hash variant (SHA-256/384/512) — the masked key has +//! the expected length and is non-zero; a second call yields a distinct +//! key. +//! * Variable key lengths across each variant's `[min, max]` range +//! (SHA-256: 32–64, SHA-384: 48–128, SHA-512: 64–128), incl. the 128 B +//! maximum → 260 B masked blob. +//! * Out-of-range `key_length` (below min / above max) → `InvalidKeyLength`. +//! * Every masking-key scope: `Session` (masked under the per-session +//! key, works pre-finalize), `Ephemeral` / `Local` (provisioned by +//! `PartFinal`). +//! * `SecurityDomain` scope before `CreateSD` → `UnsupportedKeyScope`. +//! * `Ephemeral` scope before `PartFinal` → `InvalidArg`. + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::TborHmacGenerateKeyReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA256; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA384; +use azihsm_ddi_tbor_types::HMAC_HASH_SHA512; + +use crate::commands::part_init::bootstrap_rotated_co; +use crate::commands::part_init::ROTATED_CO_PSK; +use crate::commands::sd_sealing_key_gen::finalized_co_session; +use crate::harness::TestCtx; + +/// `KeyScope` discriminants (wire mirror of the firmware `HsmKeyScope`). +pub(crate) const SCOPE_SESSION: u8 = 0b001; +pub(crate) const SCOPE_EPHEMERAL: u8 = 0b010; +pub(crate) const SCOPE_LOCAL: u8 = 0b011; +pub(crate) const SCOPE_SECURITY_DOMAIN: u8 = 0b100; + +/// Masked-key envelope length for a given HMAC key length: `header(8) ‖ +/// iv(12) ‖ aad(96) ‖ pt(key) ‖ tag(16)`. +fn masked_len(key_len: usize) -> usize { + 8 + 12 + 96 + key_len + 16 +} + +/// A representative in-range key length for a wire hash discriminant (the +/// digest size = the minimum of each variant's `VarLenHmac` range), used +/// where the exact length is not the focus of the test. +fn default_key_len(hash: u8) -> usize { + match hash { + HMAC_HASH_SHA256 => 32, + HMAC_HASH_SHA384 => 48, + HMAC_HASH_SHA512 => 64, + other => panic!("unexpected hash discriminant {other}"), + } +} + +/// Happy path for a `(scope, hash, key_len)` tuple on the given finalized +/// session: the masked key has the expected length, is non-zero, and a +/// second call yields a distinct blob. +fn roundtrip(ctx: &TestCtx, session_id: u16, scope: u8, hash: u8, key_len: usize) { + let req = TborHmacGenerateKeyReq { + session_id, + scope, + hash_algo: hash, + key_length: key_len as u8, + }; + let resp = ctx.tbor(&req).expect("HmacGenerateKey roundtrip"); + + assert_eq!( + resp.masked_key.len(), + masked_len(key_len), + "masked key length must match the requested key_length", + ); + assert!( + resp.masked_key.iter().any(|&b| b != 0), + "masked_key must not be all-zero", + ); + + // Each call samples fresh randomness → a distinct masked blob. + let resp2 = ctx.tbor(&req).expect("second HmacGenerateKey"); + assert_ne!( + resp.masked_key, resp2.masked_key, + "each generation must yield a distinct masked key", + ); +} + +#[test] +fn hmac_generate_key_roundtrip_all_hashes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + for hash in [HMAC_HASH_SHA256, HMAC_HASH_SHA384, HMAC_HASH_SHA512] { + roundtrip( + &ctx, + session.session_id, + SCOPE_EPHEMERAL, + hash, + default_key_len(hash), + ); + } +} + +#[test] +fn hmac_generate_key_variable_lengths_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + // (hash, [min, mid, max]) across each variant's VarLenHmac range. + let cases: &[(u8, [usize; 3])] = &[ + (HMAC_HASH_SHA256, [32, 48, 64]), + (HMAC_HASH_SHA384, [48, 96, 128]), + (HMAC_HASH_SHA512, [64, 96, 128]), + ]; + for &(hash, lens) in cases { + for key_len in lens { + roundtrip(&ctx, session.session_id, SCOPE_LOCAL, hash, key_len); + } + } +} + +#[test] +fn hmac_generate_key_rejects_out_of_range_length_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + // (hash, key_length) pairs just outside the variant's [min, max]. + let cases: &[(u8, u8)] = &[ + (HMAC_HASH_SHA256, 0), // zero is always invalid + (HMAC_HASH_SHA256, 31), // below min (32) + (HMAC_HASH_SHA256, 65), // above max (64) + (HMAC_HASH_SHA384, 47), // below min (48) + (HMAC_HASH_SHA384, 129), // above max (128) + (HMAC_HASH_SHA512, 63), // below min (64) + ]; + for &(hash, key_length) in cases { + let req = TborHmacGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_LOCAL, + hash_algo: hash, + key_length, + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidKeyLength); + } +} + +#[test] +fn hmac_generate_key_roundtrip_all_scopes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + // Session / Ephemeral / Local masking keys all exist on an Initialized + // partition with an Active session. + for scope in [SCOPE_SESSION, SCOPE_EPHEMERAL, SCOPE_LOCAL] { + roundtrip(&ctx, session.session_id, scope, HMAC_HASH_SHA256, 32); + } +} + +#[test] +fn hmac_generate_key_session_scope_before_finalize_emu() { + // Session-scoped keys are masked under the per-session masking key, so + // they do not require a finalized partition — only an Active session. + let ctx = TestCtx::new(); + let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); + roundtrip( + &ctx, + session.session_id, + SCOPE_SESSION, + HMAC_HASH_SHA256, + 32, + ); +} + +#[test] +fn hmac_generate_key_rejects_security_domain_scope_emu() { + // The SecurityDomain masking key (SDMK) is only provisioned by + // CreateSD, so the scope is rejected with the dedicated error. + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let req = TborHmacGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_SECURITY_DOMAIN, + hash_algo: HMAC_HASH_SHA256, + key_length: 32, + }; + ctx.expect_fw_reject(&req, TborStatus::UnsupportedKeyScope); +} + +#[test] +fn hmac_generate_key_rejects_ephemeral_before_finalize_emu() { + // Ephemeral / Local masking keys are provisioned at PartFinal, so a + // non-Session scope before finalize is rejected with InvalidArg. + let ctx = TestCtx::new(); + let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); + let req = TborHmacGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_EPHEMERAL, + hash_algo: HMAC_HASH_SHA256, + key_length: 32, + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidArg); +} + +#[test] +fn hmac_generate_key_rejects_unknown_hash_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let req = TborHmacGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_EPHEMERAL, + // 0 is SHA-1's discriminant (not a valid HMAC variant) / unknown. + hash_algo: 0, + key_length: 32, + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidArg); +} diff --git a/ddi/tbor/types/tests/commands/mod.rs b/ddi/tbor/types/tests/commands/mod.rs index 705b61c12..21bb5c930 100644 --- a/ddi/tbor/types/tests/commands/mod.rs +++ b/ddi/tbor/types/tests/commands/mod.rs @@ -10,6 +10,8 @@ pub mod default_psk_gate; pub mod forward_compat; pub mod fw_error_decode; pub mod get_unwrapping_key; +pub mod hmac; +pub mod hmac_generate_key; pub mod key_report; pub mod open_session; pub mod part_final; diff --git a/docs/tbor-ddi/README.md b/docs/tbor-ddi/README.md index 4298d6b1a..17775ea1b 100644 --- a/docs/tbor-ddi/README.md +++ b/docs/tbor-ddi/README.md @@ -65,6 +65,8 @@ single `none` TOC placeholder and no typed body fields. | `0x0E` | `SdCreatePeerBackup` | InSession | [`commands/sd_create_peer_backup.md`](./commands/sd_create_peer_backup.md) | | `0x0F` | `SdRestorePeerBackup` | InSession | [`commands/sd_restore_peer_backup.md`](./commands/sd_restore_peer_backup.md) | | `0x10` | `KeyReport` | InSession | [`commands/key_report.md`](./commands/key_report.md) | +| `0x11` | `HmacGenerateKey` | InSession | [`commands/hmac_generate_key.md`](./commands/hmac_generate_key.md) | +| `0x12` | `Hmac` | InSession | [`commands/hmac.md`](./commands/hmac.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) | diff --git a/docs/tbor-ddi/commands/hmac.md b/docs/tbor-ddi/commands/hmac.md new file mode 100644 index 000000000..c27b94808 --- /dev/null +++ b/docs/tbor-ddi/commands/hmac.md @@ -0,0 +1,72 @@ + + +# Hmac (Opcode 0x12) + +**Handler:** `fw/core/lib/src/ddi/tbor/hmac.rs` +**Session:** InSession + +## Description + +Computes an HMAC tag over a host-supplied message using a caller-held +**masked** HMAC key (the `masked_key` returned by +[`HmacGenerateKey`](./hmac_generate_key.md) or imported via unwrap). + +The masked key's scope is read from its cleartext, tag-bound metadata to +select the masking key; the key is unmasked **in place** in the inbound +request buffer (verifying the AEAD tag), the MAC is computed, and the tag +is returned. Nothing is persisted, and the recovered key is wiped in +place from the request buffer once the MAC is computed. + +The key's kind selects the MAC algorithm and tag length (SHA-256 / 384 / +512 → 32 / 48 / 64 bytes). The key must carry the `sign` (`C_Sign`) +permission. + +Unlike the security-domain administrative commands, this command is +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..=260 B) | The masked HMAC key (from `HmacGenerateKey` / unwrap), an AEAD-GCM-256 envelope. Its scope selects the masking key; its kind selects the SHA variant. | +| 12 | `msg` | `buffer` (≤ 1024 B) | The message to MAC. | + +### Data section + +Carries the masked key followed by the message. + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `tag` | `buffer` (32 / 48 / 64 B) | The HMAC tag over `msg` (SHA-256 / 384 / 512). | + +### Data section + +Carries the tag. + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | +| `InvalidKeyType` | The masked key is not an HMAC key | +| `InvalidPermissions` | The masked key lacks the `sign` (`C_Sign`) permission | +| `AesGcmDecryptTagDoesNotMatch` | The masked key failed AEAD authentication (tampered / wrong scope / wrong masking key) | +| `UnsupportedKeyScope` | The masked key's scope has no masking key on this partition | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- [`HmacGenerateKey`](./hmac_generate_key.md) — generate the masked HMAC key +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/hmac.rs` diff --git a/docs/tbor-ddi/commands/hmac_generate_key.md b/docs/tbor-ddi/commands/hmac_generate_key.md new file mode 100644 index 000000000..64fb60b93 --- /dev/null +++ b/docs/tbor-ddi/commands/hmac_generate_key.md @@ -0,0 +1,93 @@ + + +# HmacGenerateKey (Opcode 0x11) + +**Handler:** `fw/core/lib/src/ddi/tbor/hmac_generate_key.rs` +**Session:** InSession + +## Description + +Generates a fresh random **variable-length** HMAC key of the +caller-selected SHA variant and returns it as a **masked** blob. The key +is **not** stored on the device: it is masked (AEAD-GCM-256) under the +masking key associated with the requested `scope`, and the caller holds +the masked blob and passes it back to [`Hmac`](./hmac.md) to compute a MAC +(unmask-on-use). Because nothing is persisted, the command records no +rollback on the undo log. + +The `hash_algo` selects the SHA variant (the HMAC PRF and the MAC tag +length), and `key_length` selects the key length. HMAC keys are stored +as the variable-length `VarLenHmacSha*` kind, so `key_length` must fall in +the variant's `[min, max]` range — matching the reference firmware's +`VarLenHmacSha*` bounds — else the command returns `InvalidKeyLength`: + +| `hash_algo` | key length (min–max) | masked blob (`132 + key_length`) | +|---|---|---| +| SHA-256 | 32–64 | 164–196 B | +| SHA-384 | 48–128 | 180–260 B | +| SHA-512 | 64–128 | 196–260 B | + +Scope → masking key (resolved on-device): + +- `Session` → the per-session masking key (works for any Active session, + including before `PartFinal`). +- `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`. The masked key's metadata +records the key as an HMAC signing key (`sign` + `verify`, `local`) plus +the requested scope. + +Unlike the security-domain administrative commands, this command is +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 | `hash_algo` | `uint8` (inline) | HMAC hash variant (`HashAlgo` discriminant): `1` = SHA-256, `2` = SHA-384, `3` = SHA-512. | +| 16 | `key_length` | `uint8` (inline) | Requested key length in bytes; must be in the variant's `[min, max]` range (see table above). | + +### Data section + +_Empty — all fields are carried inline within their TOC entries._ + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `masked_key` | `buffer` (164–260 B) | The generated HMAC 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. | + +### Data section + +Carries the masked key (`132 + key_length` B). + +## 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 `hash_algo` | +| `InvalidKeyLength` | `key_length` is outside the variant's `[min, max]` range (incl. `0`) | +| `UnsupportedKeyScope` | The requested scope has no masking key yet (e.g. `SecurityDomain` before `CreateSD`) | +| `DefaultPskMustRotate` | The calling role's PSK is still the compiled-in default (dispatcher, pre-handler) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- [`Hmac`](./hmac.md) — compute a MAC with the masked key +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/hmac_generate_key.rs` diff --git a/fw/core/ddi/tbor/types/src/hmac.rs b/fw/core/ddi/tbor/types/src/hmac.rs new file mode 100644 index 000000000..76d5afcda --- /dev/null +++ b/fw/core/ddi/tbor/types/src/hmac.rs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `Hmac` wire schema. +//! +//! `Hmac` is an in-session command that computes an HMAC tag over a +//! host-supplied message using a caller-held **masked** HMAC key (the +//! `masked_key` returned by [`HmacGenerateKey`](crate::hmac_generate_key) +//! or imported via unwrap). The device unmasks the key on-device, +//! computes the MAC, and returns the tag — nothing is persisted. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked against the +//! SQE-carried session id by the dispatcher. +//! * `masked_key` — the masked HMAC key (an AEAD-GCM-256 envelope, +//! [`MASKED_HMAC_KEY_MIN_LEN`]..=[`MASKED_HMAC_KEY_MAX_LEN`] B). Its +//! scope (read from the cleartext, tag-bound metadata) selects the +//! masking key; the key kind selects the SHA variant / tag length. +//! * `msg` — the message to MAC, up to [`HMAC_MSG_MAX_LEN`] bytes. +//! +//! Outputs: +//! +//! * `tag` — the HMAC tag: 32 / 48 / 64 B for SHA-256 / 384 / 512. + +use azihsm_fw_ddi_tbor_api::tbor; + +pub use crate::hmac_generate_key::MASKED_HMAC_KEY_MAX_LEN; +pub use crate::hmac_generate_key::MASKED_HMAC_KEY_MIN_LEN; + +/// TBOR opcode for `Hmac`. +pub const TBOR_OP_HMAC: u8 = 0x12; + +/// Maximum message length (bytes) accepted by `Hmac`, matching the MBOR +/// `Hmac` command's message bound (`DdiHmacReq::msg`), which is itself +/// sized to the AES-CBC single-shot message cap (`MAX_MSG_SIZE`) so the +/// two symmetric primitives share one host-visible limit. Pinned into +/// the `#[tbor(buffer, max_len = 1024)]` literal on [`TborHmacReq::msg`]. +pub const HMAC_MSG_MAX_LEN: usize = 1024; + +/// Maximum HMAC tag length (bytes): the SHA-512 digest. Pinned into the +/// `#[tbor(buffer, max_len = 64)]` literal on [`TborHmacResp::tag`]. +pub const HMAC_TAG_MAX_LEN: usize = 64; + +/// `Hmac` request schema. +/// +/// Computes an HMAC tag over `msg` using the masked HMAC key. +#[tbor(opcode = 0x12)] +pub struct TborHmacReq<'a> { + /// 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, + + /// The masked HMAC key (from `HmacGenerateKey` / unwrap), an + /// AEAD-GCM-256 envelope of 164..=260 B. Unmasked on-device to + /// recover the key, its kind (SHA variant), and its `sign` attribute. + /// + /// Marked `#[tbor(mutable)]` so the handler can `unmask` it **in place** + /// in the request buffer (via `decode_mut`) — no scratch copy of the + /// blob, and the recovered key is used straight from `target_key`. + #[tbor(buffer, min_len = 164, max_len = 260, mutable)] + pub masked_key: &'a [u8], + + /// The message to MAC, up to [`HMAC_MSG_MAX_LEN`] (1024) bytes. + #[tbor(buffer, max_len = 1024)] + pub msg: &'a [u8], +} + +/// `Hmac` response schema. +#[tbor(response)] +pub struct TborHmacResp<'a> { + /// The HMAC tag over `msg`: 32 / 48 / 64 B for SHA-256 / 384 / 512. + #[tbor(buffer, max_len = 64)] + pub tag: &'a [u8], +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use azihsm_fw_ddi_tbor_api::SessionId; + + use super::*; + + #[test] + fn request_round_trips_masked_key_and_msg() { + let mut buf = [0u8; 1536]; + let masked = [0x11u8; MASKED_HMAC_KEY_MIN_LEN]; + let msg = [0x22u8; 300]; + let frame = TborHmacReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(7)) + .unwrap() + .masked_key(&masked) + .unwrap() + .msg(&msg) + .unwrap() + .finish(); + + assert_eq!(frame.masked_key(), &masked[..]); + assert_eq!(frame.msg(), &msg[..]); + } + + #[test] + fn response_round_trips_tag() { + let mut buf = [0u8; 256]; + let tag = [0x33u8; HMAC_TAG_MAX_LEN]; + let frame = TborHmacResp::encode(&mut buf, 0, true) + .unwrap() + .tag(&tag) + .unwrap() + .finish(); + assert_eq!(frame.tag(), &tag[..]); + } + + #[test] + fn lengths_match_pinned_values() { + // The `#[tbor(buffer, ... = N)]` attributes must remain numeric + // literals; pin them against the exported consts. + const _: () = assert!(1024 == HMAC_MSG_MAX_LEN); + const _: () = assert!(64 == HMAC_TAG_MAX_LEN); + const _: () = assert!(164 == MASKED_HMAC_KEY_MIN_LEN); + const _: () = assert!(260 == MASKED_HMAC_KEY_MAX_LEN); + assert_eq!(HMAC_MSG_MAX_LEN, 1024); + assert_eq!(HMAC_TAG_MAX_LEN, 64); + } +} diff --git a/fw/core/ddi/tbor/types/src/hmac_generate_key.rs b/fw/core/ddi/tbor/types/src/hmac_generate_key.rs new file mode 100644 index 000000000..262939175 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/hmac_generate_key.rs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `HmacGenerateKey` wire schema. +//! +//! `HmacGenerateKey` is an in-session command that generates a fresh +//! random HMAC key of the caller-selected SHA variant and returns it as a +//! masked-key blob. The key is **not** stored on the device: the caller +//! holds the masked blob and passes it back to [`Hmac`](crate::hmac) to +//! compute a MAC (unmask-on-use), exactly like the security-domain +//! sealing key. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked against the +//! SQE-carried session id by the dispatcher. +//! * `scope` — the requested key [`KeyScope`] (lifecycle / visibility +//! domain), carried as its 1-byte discriminant. Selects which masking +//! key wraps the returned blob. +//! * `hash_algo` — the [`HashAlgo`] selecting the HMAC SHA variant, which +//! selects the HMAC PRF and the per-variant valid key-length range. +//! * `key_length` — the requested key length in bytes. HMAC keys are +//! variable-length ([`HsmVaultKeyKind::VarLenHmacSha256`] etc.); the +//! value must fall in the SHA variant's `[min, max]` range +//! (SHA-256: 32–64, SHA-384: 48–128, SHA-512: 64–128 — matching the +//! reference firmware's `VarLenHmacSha*` bounds), else the handler +//! rejects it with `InvalidKeyLength`. +//! +//! Outputs: +//! +//! * `masked_key` — the freshly generated HMAC key, masked (AEAD-GCM-256) +//! under the requested scope's masking key. Its length depends on the +//! requested `key_length`: [`MASKED_HMAC_KEY_MIN_LEN`] (164 B, 32-byte +//! key) … [`MASKED_HMAC_KEY_MAX_LEN`] (260 B, 128-byte key). + +use azihsm_fw_ddi_tbor_api::tbor; + +use crate::key_props::HashAlgo; +use crate::key_props::KeyScope; + +/// TBOR opcode for `HmacGenerateKey`. +pub const TBOR_OP_HMAC_GENERATE_KEY: u8 = 0x11; + +/// Minimum masked HMAC-key envelope length (32-byte key, the SHA-256 +/// minimum): an AEAD-GCM-256 masked-key envelope `header(8) ‖ iv(12) ‖ +/// aad(96) ‖ pt(32) ‖ tag(16)`. Lower bound of the `#[tbor(buffer, +/// max_len = 260)]` masked-key output. +pub const MASKED_HMAC_KEY_MIN_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// Maximum masked HMAC-key envelope length (128-byte key, the SHA-384 / +/// SHA-512 maximum): the same envelope with a 128-byte plaintext. Pinned +/// into the `#[tbor(buffer, max_len = 260)]` literal on +/// [`TborHmacGenerateKeyResp::masked_key`]. +pub const MASKED_HMAC_KEY_MAX_LEN: usize = 8 + 12 + 96 + 128 + 16; + +/// `HmacGenerateKey` request schema. +/// +/// Generates a random variable-length HMAC key of the requested +/// [`HashAlgo`] and `key_length` under the active session's partition, +/// masked with the requested [`KeyScope`]'s masking key. +#[tbor(opcode = 0x11)] +pub struct TborHmacGenerateKeyReq { + /// 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, + + /// Requested key scope (lifecycle / visibility domain), carried as + /// the 1-byte [`KeyScope`] discriminant. + #[tbor(U8)] + pub scope: KeyScope, + + /// Hash algorithm selecting the HMAC SHA variant (the PRF and the + /// valid `key_length` range), carried as the 1-byte [`HashAlgo`] + /// discriminant. + #[tbor(U8)] + pub hash_algo: HashAlgo, + + /// Requested key length in bytes. HMAC keys are variable-length; the + /// value must be in the SHA variant's range (SHA-256: 32–64, + /// SHA-384: 48–128, SHA-512: 64–128), else the handler returns + /// `InvalidKeyLength`. + #[tbor(U8)] + pub key_length: u8, +} + +/// `HmacGenerateKey` response schema. +/// +/// `masked_key` is `#[tbor(mutable)]` so the handler can reserve the slot +/// (via `masked_key_reserve`) and mask the generated key straight into it +/// (`decode_mut`) — no scratch buffer and no copy. +#[tbor(response)] +pub struct TborHmacGenerateKeyResp<'a> { + /// The freshly generated HMAC key, masked (AEAD-GCM-256) under the + /// requested scope's masking key. `132 + key_length` B (164 … 260 B + /// for a 32 … 128-byte key). The key is not stored on the device; + /// the caller passes this blob back to `Hmac`. + #[tbor(buffer, max_len = 260, mutable)] + pub masked_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_hash() { + let mut buf = [0u8; 256]; + let frame = TborHmacGenerateKeyReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(5)) + .unwrap() + .scope(KeyScope::Session) + .unwrap() + .hash_algo(HashAlgo::Sha384) + .unwrap() + .key_length(96) + .unwrap() + .finish(); + + assert_eq!(frame.scope(), KeyScope::Session); + assert_eq!(frame.hash_algo(), HashAlgo::Sha384); + assert_eq!(frame.key_length(), 96); + } + + #[test] + fn response_round_trips_masked_key() { + let mut buf = [0u8; 512]; + let masked = [0xABu8; MASKED_HMAC_KEY_MAX_LEN]; + let frame = TborHmacGenerateKeyResp::encode(&mut buf, 0, true) + .unwrap() + .masked_key(&masked) + .unwrap() + .finish(); + assert_eq!(frame.masked_key(), &masked[..]); + } + + #[test] + fn masked_key_lengths_match_pinned_values() { + // The `#[tbor(buffer, max_len = N)]` attribute must remain a + // numeric literal; pin it against the exported const. + const _: () = assert!(260 == MASKED_HMAC_KEY_MAX_LEN); + assert_eq!(MASKED_HMAC_KEY_MIN_LEN, 164); + assert_eq!(MASKED_HMAC_KEY_MAX_LEN, 260); + } +} diff --git a/fw/core/ddi/tbor/types/src/lib.rs b/fw/core/ddi/tbor/types/src/lib.rs index 907846571..8be86e435 100644 --- a/fw/core/ddi/tbor/types/src/lib.rs +++ b/fw/core/ddi/tbor/types/src/lib.rs @@ -37,6 +37,8 @@ pub mod tbor_int { pub mod api_rev; pub mod evidence; pub mod get_unwrapping_key; +pub mod hmac; +pub mod hmac_generate_key; pub mod key_props; pub mod key_report; pub mod part_final; @@ -59,6 +61,8 @@ pub mod unwrap_key; pub use api_rev::*; pub use evidence::*; pub use get_unwrapping_key::*; +pub use hmac::*; +pub use hmac_generate_key::*; pub use key_props::*; pub use key_report::*; pub use part_final::*; diff --git a/fw/core/lib/src/ddi/tbor/hmac.rs b/fw/core/lib/src/ddi/tbor/hmac.rs new file mode 100644 index 000000000..ca1491dc9 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/hmac.rs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `Hmac` command handler. +//! +//! Within an open session, compute an HMAC tag over a host-supplied +//! message using a caller-held **masked** HMAC key (the `masked_key` +//! returned by [`HmacGenerateKey`](super::hmac_generate_key) or imported +//! via unwrap). The masked key's scope is read from its cleartext, +//! tag-bound metadata to select the masking key; the key is unmasked +//! on-device (verifying the AEAD tag), the MAC is computed, and the tag is +//! returned — nothing is persisted and the recovered key is wiped. +//! +//! The key must be an HMAC kind; its hash variant selects the MAC +//! algorithm and tag length (SHA-256 / 384 / 512 → 32 / 48 / 64 B). A +//! non-HMAC key is rejected with `InvalidKeyType`; a key lacking the +//! `sign` (`C_Sign`) permission with `InvalidPermissions`. 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::TborHmacReq; +use azihsm_fw_ddi_tbor_types::TborHmacResp; +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::HsmScopedAlloc; +use azihsm_fw_hsm_pal_traits::HsmSessId; + +use super::resolve_masking_key; +use super::validate_active_session; + +/// Handle a TBOR `Hmac` 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 **in place** in the request buffer, computes a tag, and +/// returns it. There is no scratch copy of the masked blob or the +/// recovered key. +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &mut DmaBuf, +) -> HsmResult<&'p DmaBuf> { + // `decode_mut` exposes `masked_key` as `&mut` (a slice of the request + // buffer) so `unmask` can decrypt it in place, while `msg` is a + // disjoint shared borrow. + let req = TborHmacReq::decode_mut(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id)); + validate_active_session(pal, io, sess_id)?; + + // Peek the masked-key metadata (cleartext, tag-bound) to route to the + // right masking key before unmasking. + let scope = peek_metadata(req.masked_key)?.usage_flags().scope(); + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + + let masked_key = req.masked_key; + let msg = req.msg; + + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<&'p DmaBuf> { + // Unmask the key in place and MAC into a scratch tag. Capture the + // result so the recovered key (now sitting in the request buffer) + // can be wiped on EVERY path — success or failure — before + // proceeding or propagating. `view`'s borrow of `masked_key` ends + // with the inner block, releasing it for the wipe. + let mac_res = async { + let view = unmask(pal, io, masking_key, masked_key).await?; + // The kind must be an HMAC variant (selects the MAC algorithm + // and tag length); a non-HMAC kind is rejected as + // `InvalidKeyType`. + let algo = crate::ddi::hmac_hash(view.key_kind)?; + // Generating a MAC is a PKCS#11 `C_Sign` operation, so the key + // must carry the `sign` permission. + if !view.key_attrs.sign() { + return Err(HsmError::InvalidPermissions); + } + // The recovered key (`view.target_key`, a `&DmaBuf` into the + // request buffer) is used directly — no copy. + let tag_len = algo.digest_len(); + let tag = alloc.dma_alloc(tag_len)?; + pal.hmac_sign(io, algo, view.target_key, msg, tag).await?; + Ok::<_, HsmError>((tag, tag_len)) + } + .await; + + masked_key.zeroize(); + let (tag, tag_len) = mac_res?; + encode_response(pal, io, &tag[..tag_len]) + }) + .await +} + +/// Encode the `Hmac` response around the computed tag. +fn encode_response<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + tag: &[u8], +) -> HsmResult<&'p DmaBuf> { + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborHmacResp::encode(buf, 0, false)?.tag(tag)?.finish(); + Ok(frame.as_bytes().len()) + })?; + Ok(resp) +} diff --git a/fw/core/lib/src/ddi/tbor/hmac_generate_key.rs b/fw/core/lib/src/ddi/tbor/hmac_generate_key.rs new file mode 100644 index 000000000..11dd9878b --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/hmac_generate_key.rs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `HmacGenerateKey` command handler. +//! +//! Within an open session, generate a fresh random **variable-length** +//! HMAC key of the caller-selected SHA variant and `key_length`, mask it +//! (AEAD-GCM-256) under the requested +//! [`KeyScope`](azihsm_fw_ddi_tbor_types::KeyScope)'s masking key, and +//! return the masked blob. The key is stamped as the variable-length +//! `VarLenHmacSha*` kind; its length must fall in the variant's +//! `[min, max]` range (SHA-256: 32–64, SHA-384: 48–128, SHA-512: 64–128). +//! The key is **not** persisted on-device: the caller holds the masked +//! blob and passes it back to [`Hmac`](super::hmac) (unmask-on-use), +//! exactly like the security-domain sealing key. +//! +//! Available to both Crypto-Officer and Crypto-User sessions. The scope's +//! masking key is resolved by [`resolve_masking_key`](super::resolve_masking_key): +//! `Session` scope uses the per-session masking key; `Ephemeral` / `Local` +//! / `SecurityDomain` use the partition / SD masking keys provisioned by +//! `PartFinal` / the SD lifecycle — an unavailable scope fails cheaply +//! before any key is generated. + +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::TborHmacGenerateKeyReq; +use azihsm_fw_ddi_tbor_types::TborHmacGenerateKeyResp; +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::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_pal_traits::PartState; + +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 HMAC_KEY_LABEL: &[u8] = b"HmacKey"; + +/// Map the wire [`HashAlgo`] onto the firmware hash algorithm, the +/// variable-length HMAC vault kind stamped into the masked blob's +/// metadata, and the `[min, max]` key-length range for the variant +/// (matching the reference firmware's `VarLenHmacSha*` bounds: +/// SHA-256 → 32..=64, SHA-384 → 48..=128, SHA-512 → 64..=128). An +/// unrecognized `HashAlgo` discriminant is rejected with +/// [`HsmError::InvalidArg`]. +fn hmac_variant(algo: HashAlgo) -> HsmResult<(HsmHashAlgo, HsmVaultKeyKind, usize, usize)> { + match algo { + HashAlgo::Sha256 => Ok(( + HsmHashAlgo::Sha256, + HsmVaultKeyKind::VarLenHmacSha256, + 32, + 64, + )), + HashAlgo::Sha384 => Ok(( + HsmHashAlgo::Sha384, + HsmVaultKeyKind::VarLenHmacSha384, + 48, + 128, + )), + HashAlgo::Sha512 => Ok(( + HsmHashAlgo::Sha512, + HsmVaultKeyKind::VarLenHmacSha512, + 64, + 128, + )), + _ => Err(HsmError::InvalidArg), + } +} + +/// Validate the caller-requested key length against the variant's +/// `[min, max]` range, returning it as a `usize`. Out-of-range lengths +/// (including `0`) are rejected with [`HsmError::InvalidKeyLength`]. +fn validate_key_len(key_length: u8, min: usize, max: usize) -> HsmResult { + let len = usize::from(key_length); + if len < min || len > max { + return Err(HsmError::InvalidKeyLength); + } + Ok(len) +} + +/// Attributes recorded in the masked blob's metadata (re-applied on +/// unmask). An HMAC MAC key is a `C_Sign` / `C_Verify` symmetric key +/// generated on-device; `scope` records the lifecycle / visibility domain +/// selecting the masking key. +fn hmac_key_attrs(scope: HsmKeyScope) -> HsmVaultKeyAttrs { + HsmVaultKeyAttrs::new() + .with_local(true) + .with_sign(true) + .with_verify(true) + .with_scope(scope) +} + +/// Handle a TBOR `HmacGenerateKey` request. +/// +/// No partition lock or undo log is required: the command **persists +/// nothing** — it generates a key, masks it, and returns the blob. It +/// makes no observable state change, so a concurrently-dispatched command +/// can neither observe it half-done nor require its rollback on failure. +/// The masked blob is written straight into the reserved response slot (no +/// scratch copy) and the raw 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 = TborHmacGenerateKeyReq::decode(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id())); + + // Losslessly map the wire `KeyScope` onto the PAL `HsmKeyScope` + // (byte-identical discriminants; unknown values round-trip and are + // rejected by `resolve_masking_key`). + let scope = HsmKeyScope(req.scope().0); + validate_active_session(pal, io, sess_id)?; + + // Session-scoped keys are masked under the per-session masking key, + // available to any Active session. Every other scope's masking key is + // provisioned by `PartFinal` / the SD lifecycle, so require an + // `Initialized` partition to fail cheaply and clearly before keygen. + if scope != HsmKeyScope::Session && part_state::part_state(pal, io)? != PartState::Initialized { + return Err(HsmError::InvalidArg); + } + + let (algo, kind, min_len, max_len) = hmac_variant(req.hash_algo())?; + let key_len = validate_key_len(req.key_length(), min_len, max_len)?; + // The masked-blob length is fixed by the requested key length + // (32..=128 B → 164..=260 B), so the response slot can be reserved up + // front, before any key material exists. + let masked_len = masked_blob_len(AeadAlg::AesGcm256, key_len); + + // Platform identity that binds the masked blob (anti-rollback on + // re-import): SVN (BKS1 lineage) and owner-seed id (BKS2 lineage). + 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 = hmac_key_attrs(scope); + + // Build the response with the masked-key slot reserved (no copy at + // encode time). + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborHmacGenerateKeyResp::encode(buf, 0, false)? + .masked_key_reserve(masked_len)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + + // Generate the key and mask it straight into the reserved slot. The + // `decode_mut` view is scoped so its borrow of `resp` ends before + // `resp` is returned. + { + let out = TborHmacGenerateKeyResp::decode_mut(resp)?; + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<()> { + // Generate the random HMAC key into scratch. + let key_buf = alloc.dma_alloc(key_len)?; + pal.hmac_gen_key(io, algo, key_buf).await?; + + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + let key_label = alloc.dma_alloc(HMAC_KEY_LABEL.len())?; + key_label.copy_from_slice(HMAC_KEY_LABEL); + let params = MaskParams { + key_kind: kind, + key_attrs: attrs, + svn, + owner_seed_id: owner, + key_label, + }; + // Mask straight into the reserved response slot, then wipe the + // raw key on every path (success or masking failure); scope + // rewind does not clear DMA memory. + let mask_res = mask( + pal, + io, + alloc, + AeadAlg::AesGcm256, + masking_key, + ¶ms, + key_buf, + Some(out.masked_key), + ) + .await; + key_buf.zeroize(); + // `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. + let written = mask_res?; + if written != masked_len { + return Err(HsmError::InternalError); + } + Ok(()) + }) + .await?; + } + + Ok(resp) +} diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index b46be487e..aa617302f 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -23,6 +23,8 @@ pub(crate) mod api_rev; pub(crate) mod from_pal; pub(crate) mod get_unwrapping_key; +pub(crate) mod hmac; +pub(crate) mod hmac_generate_key; pub(crate) mod key_report; pub(crate) mod part_final; pub mod part_info; @@ -171,6 +173,17 @@ pub(crate) mod opcode { /// family, so `KeyReport` takes the next free opcode, `0x10`. pub(crate) const KEY_REPORT: u8 = 0x10; + /// `HmacGenerateKey` — generate a fresh random HMAC key of the + /// requested SHA variant under the active session's partition; return + /// it **masked** under the requested scope's masking key (nothing is + /// persisted on-device). See [`super::hmac_generate_key`]. + pub(crate) const HMAC_GENERATE_KEY: u8 = 0x11; + + /// `Hmac` — compute an HMAC tag over a host-supplied message using a + /// caller-held masked HMAC key (unmasked on-device for the operation, + /// then discarded). See [`super::hmac`]. + pub(crate) const HMAC: u8 = 0x12; + /// `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`]. @@ -394,6 +407,8 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( sd_restore_peer_backup::handle(pal, io, req_buf, oob, undo).await } opcode::KEY_REPORT => key_report::handle(pal, io, req_buf).await, + opcode::HMAC_GENERATE_KEY => hmac_generate_key::handle(pal, io, req_buf).await, + opcode::HMAC => hmac::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), @@ -423,6 +438,8 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP | opcode::KEY_REPORT + | opcode::HMAC_GENERATE_KEY + | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY ) @@ -459,6 +476,8 @@ fn is_in_session(opcode: u8) -> bool { | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP | opcode::KEY_REPORT + | opcode::HMAC_GENERATE_KEY + | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY => true, // Default-deny: any future opcode is treated as in-session @@ -503,6 +522,8 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP | opcode::KEY_REPORT + | opcode::HMAC_GENERATE_KEY + | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY => true, _ => true, diff --git a/fw/core/lib/src/op.rs b/fw/core/lib/src/op.rs index 962e623f9..0dd73af13 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -269,6 +269,8 @@ impl SessionCtrl { | opcode::SD_CREATE_PEER_BACKUP | opcode::SD_RESTORE_PEER_BACKUP | opcode::KEY_REPORT + | opcode::HMAC_GENERATE_KEY + | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY => Self::InSession, opcode::SESSION_CLOSE => Self::Close,