diff --git a/ddi/tbor/types/src/aes_encrypt_decrypt.rs b/ddi/tbor/types/src/aes_encrypt_decrypt.rs new file mode 100644 index 000000000..24f27b5f1 --- /dev/null +++ b/ddi/tbor/types/src/aes_encrypt_decrypt.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `AesEncryptDecrypt` command. +//! +//! `AesEncryptDecrypt` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that AES-CBC encrypts or decrypts `msg` using a +//! caller-held **masked** AES key (the `masked_key` from +//! [`AesGenerateKey`](crate::aes_generate_key) or imported via +//! [`UnwrapKey`](crate::unwrap_key)). The device unmasks the key +//! on-device, runs the AES-CBC transform, and returns the transformed +//! message plus the updated chaining IV; nothing is persisted. + +use alloc::vec::Vec; + +pub use crate::aes_generate_key::MASKED_AES_KEY_MAX_LEN; +pub use crate::aes_generate_key::MASKED_AES_KEY_MIN_LEN; +use crate::tbor; + +/// TBOR opcode for `AesEncryptDecrypt`. +pub const TBOR_OP_AES_ENCRYPT_DECRYPT: u8 = 0x16; + +/// AES-CBC block size in bytes — also the required IV length. +pub const AES_IV_LEN: usize = 16; + +/// Maximum message length (bytes) accepted by `AesEncryptDecrypt`. +pub const AES_MSG_MAX_LEN: usize = 1024; + +/// `AesOp` discriminant selecting encryption. +pub const AES_OP_ENCRYPT: u8 = 1; +/// `AesOp` discriminant selecting decryption. +pub const AES_OP_DECRYPT: u8 = 2; + +/// Host-facing TBOR `AesEncryptDecrypt` request. +#[tbor(opcode = TBOR_OP_AES_ENCRYPT_DECRYPT, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborAesEncryptDecryptReq { + /// 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 AES key (from `AesGenerateKey` / `UnwrapKey`), an + /// AEAD-GCM-256 envelope of 148..=164 B. + #[tbor(min_len = 148, max_len = 164)] + pub masked_key: Vec, + + /// The direction as the 1-byte `AesOp` discriminant (see + /// [`AES_OP_ENCRYPT`] / [`AES_OP_DECRYPT`]). + pub op: u8, + + /// The message to transform: a non-empty multiple of the 16-byte AES + /// block, up to [`AES_MSG_MAX_LEN`] (1024) bytes. + #[tbor(max_len = 1024)] + pub msg: Vec, + + /// The 16-byte CBC initialization vector. A fixed-size array encodes + /// the exact length (the FW codec is the length authority). + pub iv: [u8; 16], +} + +/// Host-facing TBOR `AesEncryptDecrypt` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborAesEncryptDecryptResp { + /// The transformed message (same length as the input `msg`). + #[tbor(max_len = 1024)] + pub msg: Vec, + + /// The updated chaining IV (the last ciphertext block). A fixed-size + /// array encodes the exact length. + pub iv: [u8; 16], +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_op_and_iv() { + let req = TborAesEncryptDecryptReq { + session_id: 7, + masked_key: alloc::vec![0x11u8; MASKED_AES_KEY_MIN_LEN], + op: AES_OP_ENCRYPT, + msg: alloc::vec![0x22u8; 32], + iv: [0x33u8; AES_IV_LEN], + }; + + 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/aes_generate_key.rs b/ddi/tbor/types/src/aes_generate_key.rs new file mode 100644 index 000000000..568f905ac --- /dev/null +++ b/ddi/tbor/types/src/aes_generate_key.rs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `AesGenerateKey` command. +//! +//! `AesGenerateKey` is an **in-session** command (Crypto-Officer or +//! Crypto-User) that generates a fresh random AES key of the requested +//! size 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 +//! [`AesEncryptDecrypt`](crate::aes_encrypt_decrypt) to transform data +//! (unmask-on-use). +//! +//! The request carries the requested key `scope` (lifecycle / visibility +//! domain) and `key_size` as raw 1-byte discriminants — the firmware types +//! them as the `KeyScope` / `AesKeySize` 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 `AesGenerateKey`. +pub const TBOR_OP_AES_GENERATE_KEY: u8 = 0x15; + +/// Minimum masked AES-key envelope length (AES-128, 16-byte key): an +/// AEAD-GCM-256 masked-key envelope `header(8) ‖ iv(12) ‖ aad(96) ‖ +/// pt(16) ‖ tag(16)`. +pub const MASKED_AES_KEY_MIN_LEN: usize = 8 + 12 + 96 + 16 + 16; + +/// Maximum masked AES-key envelope length (AES-256, 32-byte key). +pub const MASKED_AES_KEY_MAX_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// `AesKeySize` discriminant for AES-128 (16-byte key). +pub const AES_KEY_SIZE_128: u8 = 1; +/// `AesKeySize` discriminant for AES-192 (24-byte key). +pub const AES_KEY_SIZE_192: u8 = 2; +/// `AesKeySize` discriminant for AES-256 (32-byte key). +pub const AES_KEY_SIZE_256: u8 = 3; + +/// Host-facing TBOR `AesGenerateKey` request. +#[tbor(opcode = TBOR_OP_AES_GENERATE_KEY, session_ctrl = in_session)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TborAesGenerateKeyReq { + /// 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, + + /// AES key size as the 1-byte `AesKeySize` discriminant (see + /// [`AES_KEY_SIZE_128`] / [`AES_KEY_SIZE_192`] / [`AES_KEY_SIZE_256`]). + pub key_size: u8, +} + +/// Host-facing TBOR `AesGenerateKey` response. +#[tbor(response)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TborAesGenerateKeyResp { + /// The freshly generated AES key, masked (AEAD-GCM-256) under the + /// requested scope's masking key. 148 / 156 / 164 B for + /// AES-128 / 192 / 256; not stored on-device. + #[tbor(max_len = 164)] + pub masked_key: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_scope_and_size() { + let req = TborAesGenerateKeyReq { + session_id: 5, + // KeyScope::Local discriminant (0b011). + scope: 0b011, + key_size: AES_KEY_SIZE_256, + }; + + let mut buf = [0u8; 256]; + let frame = req.encode_request(&mut buf).expect("encode"); + + // The 1-byte key-size discriminant must appear in the encoded frame. + assert!( + frame.contains(&AES_KEY_SIZE_256), + "encoded frame must carry the key-size discriminant", + ); + } +} diff --git a/ddi/tbor/types/src/lib.rs b/ddi/tbor/types/src/lib.rs index 1093cf1e0..7206d01fb 100644 --- a/ddi/tbor/types/src/lib.rs +++ b/ddi/tbor/types/src/lib.rs @@ -78,6 +78,8 @@ impl From for u8 { } } +mod aes_encrypt_decrypt; +mod aes_generate_key; mod api_rev; mod ecc_generate_key; mod ecc_sign; @@ -106,6 +108,8 @@ mod session_open_init; mod status; mod unwrap_key; +pub use aes_encrypt_decrypt::*; +pub use aes_generate_key::*; pub use api_rev::*; pub use ecc_generate_key::*; pub use ecc_sign::*; diff --git a/ddi/tbor/types/tests/commands/aes_encrypt_decrypt.rs b/ddi/tbor/types/tests/commands/aes_encrypt_decrypt.rs new file mode 100644 index 000000000..031462a77 --- /dev/null +++ b/ddi/tbor/types/tests/commands/aes_encrypt_decrypt.rs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `AesEncryptDecrypt` command. +//! +//! `AesEncryptDecrypt` AES-CBC transforms a host message using a +//! caller-held **masked** AES key (from `AesGenerateKey` or imported via +//! `UnwrapKey`). The device unmasks the key on-device, runs the +//! transform, and returns the result plus the updated chaining IV. +//! +//! Coverage: +//! * Encrypt → decrypt round-trip per key size (128/192/256): the +//! ciphertext differs from the plaintext and decrypt recovers it. +//! * The returned IV is the last ciphertext block (CBC chaining). +//! * Tampered masked key → `AesGcmDecryptTagDoesNotMatch`. +//! * A message that is not a whole number of AES blocks → `InvalidArg`. +//! * An AES key imported via `UnwrapKey` encrypts/decrypts (cross-command). + +#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +// The `aes_op` transform helper stays available under any backend (so the +// module isn't emu-limited); only the emu-gated tests exercise it today. +#![cfg_attr(not(feature = "emu"), allow(dead_code))] + +use azihsm_ddi_tbor_types::TborAesEncryptDecryptReq; +use azihsm_ddi_tbor_types::TborAesEncryptDecryptResp; +// Test-only imports: the round-trip / reject tests need the emu FW handler +// plus the masked-key + unwrap helpers, so keep them emu-gated. +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::TborStatus; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::AES_KEY_SIZE_128; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::AES_KEY_SIZE_192; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::AES_KEY_SIZE_256; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::AES_OP_DECRYPT; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::AES_OP_ENCRYPT; +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::KEY_CLASS_AES; + +#[cfg(feature = "emu")] +use crate::commands::aes_generate_key::generate_key; +#[cfg(feature = "emu")] +use crate::commands::aes_generate_key::SCOPE_LOCAL; +#[cfg(feature = "emu")] +use crate::commands::sd_sealing_key_gen::finalized_co_session; +#[cfg(feature = "emu")] +use crate::commands::unwrap_key::unwrap; +use crate::harness::TestCtx; + +/// AES block / IV length. +const IV_LEN: usize = 16; + +/// AES-CBC transform `msg` under the masked key + `iv`. +fn aes_op( + ctx: &TestCtx, + session_id: u16, + masked_key: &[u8], + op: u8, + msg: &[u8], + iv: &[u8], +) -> TborAesEncryptDecryptResp { + let req = TborAesEncryptDecryptReq { + session_id, + masked_key: masked_key.to_vec(), + op, + msg: msg.to_vec(), + iv: iv.try_into().expect("IV must be 16 bytes"), + }; + ctx.tbor(&req).expect("AesEncryptDecrypt") +} + +#[cfg(feature = "emu")] +#[test] +fn aes_encrypt_decrypt_roundtrip_all_sizes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let msg = [0xA5u8; 32]; // two AES blocks + let iv = [0x11u8; IV_LEN]; + + for size in [AES_KEY_SIZE_128, AES_KEY_SIZE_192, AES_KEY_SIZE_256] { + let key = generate_key(&ctx, session.session_id, SCOPE_LOCAL, size); + + let enc = aes_op(&ctx, session.session_id, &key, AES_OP_ENCRYPT, &msg, &iv); + assert_eq!(enc.msg.len(), msg.len(), "ciphertext length matches input"); + assert_ne!(enc.msg, msg, "ciphertext must differ from plaintext"); + + // Decrypt the ciphertext with the *original* IV to recover the + // plaintext (the returned chaining IV is for the next block). + let dec = aes_op( + &ctx, + session.session_id, + &key, + AES_OP_DECRYPT, + &enc.msg, + &iv, + ); + assert_eq!(dec.msg, msg, "decrypt must recover the plaintext"); + } +} + +#[cfg(feature = "emu")] +#[test] +fn aes_encrypt_decrypt_chaining_iv_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let key = generate_key(&ctx, session.session_id, SCOPE_LOCAL, AES_KEY_SIZE_256); + let msg = [0x5Au8; 48]; // three AES blocks + let iv = [0x22u8; IV_LEN]; + + let enc = aes_op(&ctx, session.session_id, &key, AES_OP_ENCRYPT, &msg, &iv); + // For CBC, the updated chaining IV is the last ciphertext block. + assert_eq!( + enc.iv, + enc.msg[enc.msg.len() - IV_LEN..], + "chaining IV must equal the last ciphertext block", + ); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_encrypt_decrypt_rejects_tampered_key_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let mut key = generate_key(&ctx, session.session_id, SCOPE_LOCAL, AES_KEY_SIZE_256); + + // Flip a byte in the AEAD tag region (last 16 bytes) so the unmask tag + // check fails without disturbing the cleartext scope metadata. + let last = key.len() - 1; + key[last] ^= 0x01; + + let req = TborAesEncryptDecryptReq { + session_id: session.session_id, + masked_key: key, + op: AES_OP_ENCRYPT, + msg: vec![0u8; 16], + iv: [0u8; IV_LEN], + }; + ctx.expect_fw_reject(&req, TborStatus::AesGcmDecryptTagDoesNotMatch); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_encrypt_decrypt_rejects_bad_msg_len_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let key = generate_key(&ctx, session.session_id, SCOPE_LOCAL, AES_KEY_SIZE_256); + + let req = TborAesEncryptDecryptReq { + session_id: session.session_id, + masked_key: key, + op: AES_OP_ENCRYPT, + // 20 bytes is not a whole number of 16-byte AES blocks. + msg: vec![0u8; 20], + iv: [0u8; IV_LEN], + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidArg); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_encrypt_decrypt_unwrapped_key_roundtrip_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + + // Import an AES-256 key via UnwrapKey (RSA-AES key import), then use + // the recovered masked key to encrypt and decrypt. + let aes_key = [0x42u8; 32]; + let masked = unwrap(&ctx, session.session_id, KEY_CLASS_AES, &aes_key).masked_key; + + let msg = [0x37u8; 16]; + let iv = [0x88u8; IV_LEN]; + let enc = aes_op(&ctx, session.session_id, &masked, AES_OP_ENCRYPT, &msg, &iv); + let dec = aes_op( + &ctx, + session.session_id, + &masked, + AES_OP_DECRYPT, + &enc.msg, + &iv, + ); + assert_eq!(dec.msg, msg, "unwrapped AES key must round-trip"); +} diff --git a/ddi/tbor/types/tests/commands/aes_generate_key.rs b/ddi/tbor/types/tests/commands/aes_generate_key.rs new file mode 100644 index 000000000..56e7683e4 --- /dev/null +++ b/ddi/tbor/types/tests/commands/aes_generate_key.rs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `AesGenerateKey` command. +//! +//! The command generates a random AES key of the requested size and +//! returns it **masked** under the requested scope's masking key — nothing +//! is stored on-device. Like the other general crypto commands, +//! `AesGenerateKey` is available to both Crypto-Officer and Crypto-User +//! sessions. +//! +//! Coverage: +//! * Happy path per key size (128/192/256) — the masked key has the +//! expected length (148/156/164 B) and is non-zero; a second call yields +//! a distinct key. +//! * Every masking-key scope: `Session` (works pre-finalize), `Ephemeral` +//! / `Local` (provisioned by `PartFinal`). +//! * `SecurityDomain` scope before `CreateSD` → `UnsupportedKeyScope`. +//! * `Ephemeral` scope before `PartFinal` → `InvalidArg`. +//! * Unknown key size → `InvalidArg`. + +#![cfg(any(feature = "emu", feature = "mock", feature = "sock"))] +// The shared masked-key helpers/constants below stay available under any +// backend (so the module isn't emu-limited), but only the emu-gated tests +// exercise them today; suppress dead-code noise in non-emu builds. +#![cfg_attr(not(feature = "emu"), allow(dead_code))] + +use azihsm_ddi_tbor_types::TborAesGenerateKeyReq; +// The reject/scope tests need the emu FW handler and its partition +// bootstrap helpers; keep those imports emu-only so the shared masked-key +// helpers below stay available under any backend. +#[cfg(feature = "emu")] +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::AES_KEY_SIZE_128; +use azihsm_ddi_tbor_types::AES_KEY_SIZE_192; +use azihsm_ddi_tbor_types::AES_KEY_SIZE_256; + +#[cfg(feature = "emu")] +use crate::commands::part_init::bootstrap_rotated_co; +#[cfg(feature = "emu")] +use crate::commands::part_init::ROTATED_CO_PSK; +#[cfg(feature = "emu")] +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 AES 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 +} + +/// Expected AES key length (bytes) for a wire key-size discriminant. +pub(crate) fn key_len_for_size(size: u8) -> usize { + match size { + AES_KEY_SIZE_128 => 16, + AES_KEY_SIZE_192 => 24, + AES_KEY_SIZE_256 => 32, + other => panic!("unexpected key-size discriminant {other}"), + } +} + +/// Generate a masked AES key of `(scope, size)` on `session_id`. +pub(crate) fn generate_key(ctx: &TestCtx, session_id: u16, scope: u8, size: u8) -> Vec { + let req = TborAesGenerateKeyReq { + session_id, + scope, + key_size: size, + }; + ctx.tbor(&req).expect("AesGenerateKey").masked_key +} + +/// Happy path for a `(scope, size)` pair: 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, size: u8) { + let masked = generate_key(ctx, session_id, scope, size); + assert_eq!( + masked.len(), + masked_len(key_len_for_size(size)), + "masked key length must match the key size", + ); + assert!( + masked.iter().any(|&b| b != 0), + "masked_key must not be all-zero", + ); + + // Each call samples fresh randomness → a distinct masked blob. + let masked2 = generate_key(ctx, session_id, scope, size); + assert_ne!( + masked, masked2, + "each generation must yield a distinct masked key", + ); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_generate_key_roundtrip_all_sizes_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + for size in [AES_KEY_SIZE_128, AES_KEY_SIZE_192, AES_KEY_SIZE_256] { + roundtrip(&ctx, session.session_id, SCOPE_EPHEMERAL, size); + } +} + +#[cfg(feature = "emu")] +#[test] +fn aes_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, AES_KEY_SIZE_256); + } +} + +#[cfg(feature = "emu")] +#[test] +fn aes_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, AES_KEY_SIZE_256); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_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 = TborAesGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_SECURITY_DOMAIN, + key_size: AES_KEY_SIZE_256, + }; + ctx.expect_fw_reject(&req, TborStatus::UnsupportedKeyScope); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_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 = TborAesGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_EPHEMERAL, + key_size: AES_KEY_SIZE_256, + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidArg); +} + +#[cfg(feature = "emu")] +#[test] +fn aes_generate_key_rejects_unknown_size_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let req = TborAesGenerateKeyReq { + session_id: session.session_id, + scope: SCOPE_EPHEMERAL, + // 0 is not a valid AesKeySize discriminant. + key_size: 0, + }; + 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 0b8f667b9..87de1cc75 100644 --- a/ddi/tbor/types/tests/commands/mod.rs +++ b/ddi/tbor/types/tests/commands/mod.rs @@ -5,6 +5,8 @@ //! backend feature(s) that can satisfy it (e.g., TBOR commands require //! `emu` for a real round-trip). +pub mod aes_encrypt_decrypt; +pub mod aes_generate_key; pub mod api_rev; pub mod default_psk_gate; pub mod ecc_generate_key; diff --git a/docs/tbor-ddi/README.md b/docs/tbor-ddi/README.md index 50ddccd1a..12361b64f 100644 --- a/docs/tbor-ddi/README.md +++ b/docs/tbor-ddi/README.md @@ -69,6 +69,8 @@ single `none` TOC placeholder and no typed body fields. | `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) | +| `0x15` | `AesGenerateKey` | InSession | [`commands/aes_generate_key.md`](./commands/aes_generate_key.md) | +| `0x16` | `AesEncryptDecrypt` | InSession | [`commands/aes_encrypt_decrypt.md`](./commands/aes_encrypt_decrypt.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) | diff --git a/docs/tbor-ddi/commands/aes_encrypt_decrypt.md b/docs/tbor-ddi/commands/aes_encrypt_decrypt.md new file mode 100644 index 000000000..4de002edd --- /dev/null +++ b/docs/tbor-ddi/commands/aes_encrypt_decrypt.md @@ -0,0 +1,83 @@ + + +# AesEncryptDecrypt (Opcode 0x16) + +**Handler:** `fw/core/lib/src/ddi/tbor/aes_encrypt_decrypt.rs` +**Session:** InSession + +## Description + +AES-**CBC** encrypts or decrypts a host-supplied message using a +caller-held **masked** AES key (the `masked_key` from +[`AesGenerateKey`](./aes_generate_key.md) or imported via +[`UnwrapKey`](./unwrap_key.md)). The device reads the masked key's scope +from its cleartext, tag-bound metadata to select the masking key, unmasks +the key on-device (verifying the AEAD tag), runs the AES-CBC transform +zero-copy — reading the request message and writing the transformed message +plus the updated chaining IV straight into the response buffer — so the +host can chain subsequent CBC blocks. This is the TBOR analogue of +MBOR `AesEncryptDecrypt`, keyed by a masked blob rather than a vault +`key_id`. Nothing is persisted and the recovered key is wiped. + +The recovered key must be a non-bulk AES kind (`InvalidKeyType` +otherwise) and must carry the permission matching the direction (`encrypt` +for `Encrypt`, `decrypt` for `Decrypt`; `InvalidPermissions` otherwise). A +key generated by `AesGenerateKey` or imported via `UnwrapKey` carries both +`encrypt` and `decrypt`. + +The message must be a non-empty whole number of 16-byte AES blocks, up to +1024 bytes; the IV must be exactly 16 bytes. + +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` (148–164 B) | The masked AES key (from `AesGenerateKey` / `UnwrapKey`), an AEAD-GCM-256 envelope. Unmasked on-device to recover the key and confirm its AES kind + direction permission. | +| 12 | `op` | `uint8` (inline) | Direction (`AesOp` discriminant): `1` = Encrypt, `2` = Decrypt. | +| 16 | `msg` | `buffer` (≤ 1024 B) | The message to transform: a non-empty multiple of the 16-byte AES block. | +| 20 | `iv` | `buffer` (16 B) | The CBC initialization vector. | + +### Data section + +Carries the masked key, message, and IV. + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `msg` | `buffer` (≤ 1024 B) | The transformed message (same length as the input `msg`). | +| 12 | `iv` | `buffer` (16 B) | The updated chaining IV (the last ciphertext block), for chaining subsequent CBC calls. | + +### Data section + +Carries the transformed message followed by the 16-byte chaining IV. + +## Errors + +| Error | Cause | +|---|---| +| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | +| `InvalidArg` | Unknown `op`, IV not exactly 16 bytes, or `msg` empty / not a multiple of 16 / over 1024 bytes | +| `AesGcmDecryptTagDoesNotMatch` | The masked key's AEAD tag failed to verify (tampered or wrong-scope blob) | +| `InvalidKeyType` | The recovered key is not a non-bulk AES key | +| `InvalidPermissions` | The key lacks the permission for the direction (`encrypt` / `decrypt`) | +| `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 + +- [`AesGenerateKey`](./aes_generate_key.md) — generate a masked AES key +- [`UnwrapKey`](./unwrap_key.md) — import an existing AES key as a masked blob +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/aes_encrypt_decrypt.rs` diff --git a/docs/tbor-ddi/commands/aes_generate_key.md b/docs/tbor-ddi/commands/aes_generate_key.md new file mode 100644 index 000000000..d0078c1cf --- /dev/null +++ b/docs/tbor-ddi/commands/aes_generate_key.md @@ -0,0 +1,89 @@ + + +# AesGenerateKey (Opcode 0x15) + +**Handler:** `fw/core/lib/src/ddi/tbor/aes_generate_key.rs` +**Session:** InSession + +## Description + +Generates a fresh random AES key of the caller-selected size (128 / 192 / +256 bits) 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 [`AesEncryptDecrypt`](./aes_encrypt_decrypt.md) +to transform data (unmask-on-use). This is the TBOR analogue of MBOR +`AesGenerateKey`, but with no vault `key_id` / `key_tag` — nothing is +persisted, so the command records no rollback on the undo log. + +The `key_size` selects the AES key length: + +- AES-128 → 16-byte key, 148-byte masked blob. +- AES-192 → 24-byte key, 156-byte masked blob. +- AES-256 → 32-byte key, 164-byte masked blob. + +Only the non-bulk key sizes are generated here (mirroring MBOR +`AesGenerateKey`); the XTS / GCM bulk variants are intentionally absent. + +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 AES cipher key (`encrypt` + `decrypt`, `local`) plus +the requested scope. + +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_size` | `uint8` (inline) | AES key size (`AesKeySize` discriminant): `1` = AES-128, `2` = AES-192, `3` = AES-256. | + +### Data section + +_Empty — all fields are carried inline within their TOC entries._ + +## Response + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `masked_key` | `buffer` (148 / 156 / 164 B) | The generated AES 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 (148 / 156 / 164 B for AES-128 / 192 / 256). + +## 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 `key_size` | +| `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 + +- [`AesEncryptDecrypt`](./aes_encrypt_decrypt.md) — transform data with the masked key +- [`UnwrapKey`](./unwrap_key.md) — import an existing AES key as a masked blob +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/aes_generate_key.rs` diff --git a/fw/core/ddi/tbor/types/src/aes_encrypt_decrypt.rs b/fw/core/ddi/tbor/types/src/aes_encrypt_decrypt.rs new file mode 100644 index 000000000..7ad90cb71 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/aes_encrypt_decrypt.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `AesEncryptDecrypt` wire schema. +//! +//! `AesEncryptDecrypt` is an in-session command that AES-CBC encrypts or +//! decrypts a host-supplied message using a caller-held **masked** AES key +//! (the `masked_key` from [`AesGenerateKey`](crate::aes_generate_key) or +//! imported via [`UnwrapKey`](crate::unwrap_key)). The device unmasks the +//! key on-device (verifying the AEAD tag), runs the AES-CBC transform, and +//! returns the transformed message plus the updated chaining IV so the +//! host can chain subsequent CBC blocks — the TBOR analogue of MBOR +//! `AesEncryptDecrypt`, but keyed by a masked blob rather than a vault +//! `key_id`. 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 AES key (an AEAD-GCM-256 envelope, +//! [`MASKED_AES_KEY_MIN_LEN`]..=[`MASKED_AES_KEY_MAX_LEN`] B). Its +//! scope (read from the cleartext, tag-bound metadata) selects the +//! masking key; the key kind confirms it is an AES key. +//! * `op` — the [`AesOp`] selecting encrypt or decrypt. +//! * `msg` — the message to transform: a non-empty multiple of the 16-byte +//! AES block, up to [`AES_MSG_MAX_LEN`] bytes. +//! * `iv` — the 16-byte ([`AES_IV_LEN`]) CBC initialization vector. +//! +//! Outputs: +//! +//! * `msg` — the transformed message (same length as the input). +//! * `iv` — the updated chaining IV (the last ciphertext block), for +//! chaining subsequent CBC calls. + +use azihsm_fw_ddi_tbor_api::tbor; +use open_enum::open_enum; + +pub use crate::aes_generate_key::MASKED_AES_KEY_MAX_LEN; +pub use crate::aes_generate_key::MASKED_AES_KEY_MIN_LEN; + +/// TBOR opcode for `AesEncryptDecrypt`. +pub const TBOR_OP_AES_ENCRYPT_DECRYPT: u8 = 0x16; + +/// AES-CBC block size in bytes — also the required IV length. +pub const AES_IV_LEN: usize = 16; + +/// Maximum message length (bytes) accepted by `AesEncryptDecrypt`, +/// matching the MBOR `AesEncryptDecrypt` command's message bound. Pinned +/// into the `#[tbor(buffer, max_len = 1024)]` literals on +/// [`TborAesEncryptDecryptReq::msg`] / [`TborAesEncryptDecryptResp::msg`]. +pub const AES_MSG_MAX_LEN: usize = 1024; + +/// AES encrypt / decrypt operation selector on the TBOR wire. +/// +/// The 1-byte discriminants mirror the MBOR `DdiAesOp` values +/// (`Encrypt = 1`, `Decrypt = 2`). Kept as an [`open_enum`] so an +/// unrecognized discriminant round-trips as `AesOp(x)` and is rejected +/// on-device rather than failing to decode. +#[repr(u8)] +#[open_enum] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AesOp { + /// Encrypt the message. + Encrypt = 1, + + /// Decrypt the message. + Decrypt = 2, +} + +/// `AesEncryptDecrypt` request schema. +/// +/// AES-CBC encrypts or decrypts `msg` under the masked AES key and `iv`. +#[tbor(opcode = 0x16)] +pub struct TborAesEncryptDecryptReq<'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 AES key (from `AesGenerateKey` / `UnwrapKey`), an + /// AEAD-GCM-256 envelope of 148..=164 B. Unmasked on-device to + /// recover the key and confirm its AES kind and direction permission. + /// + /// 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 = 148, max_len = 164, mutable)] + pub masked_key: &'a [u8], + + /// The direction, carried as the 1-byte [`AesOp`] discriminant. + #[tbor(U8)] + pub op: AesOp, + + /// The message to transform: a non-empty multiple of the 16-byte AES + /// block, up to [`AES_MSG_MAX_LEN`] (1024) bytes. + #[tbor(buffer, max_len = 1024)] + pub msg: &'a [u8], + + /// The 16-byte CBC initialization vector. + #[tbor(buffer, len = 16)] + pub iv: &'a [u8], +} + +/// `AesEncryptDecrypt` response schema. +/// +/// The `msg` / `iv` fields are `#[tbor(mutable)]` so the handler can build +/// the response with the two slots **reserved** (via `msg_reserve` / +/// `iv_reserve`) and then have the AES engine write the ciphertext and +/// chaining IV straight into them (`decode_mut`) — no scratch buffer and +/// no copy of the message. +#[tbor(response)] +pub struct TborAesEncryptDecryptResp<'a> { + /// The transformed message (same length as the input `msg`). + #[tbor(buffer, max_len = 1024, mutable)] + pub msg: &'a [u8], + + /// The updated chaining IV (the last ciphertext block), for chaining + /// subsequent CBC calls. + #[tbor(buffer, len = 16, mutable)] + pub iv: &'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; 1536]; + let masked = [0x11u8; MASKED_AES_KEY_MIN_LEN]; + let msg = [0x22u8; 64]; + let iv = [0x33u8; AES_IV_LEN]; + let frame = TborAesEncryptDecryptReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(7)) + .unwrap() + .masked_key(&masked) + .unwrap() + .op(AesOp::Encrypt) + .unwrap() + .msg(&msg) + .unwrap() + .iv(&iv) + .unwrap() + .finish(); + + assert_eq!(frame.op(), AesOp::Encrypt); + assert_eq!(frame.msg(), &msg[..]); + assert_eq!(frame.iv(), &iv[..]); + } + + #[test] + fn response_round_trips_msg_and_iv() { + let mut buf = [0u8; 1536]; + let msg = [0x44u8; 48]; + let iv = [0x55u8; AES_IV_LEN]; + let frame = TborAesEncryptDecryptResp::encode(&mut buf, 0, true) + .unwrap() + .msg(&msg) + .unwrap() + .iv(&iv) + .unwrap() + .finish(); + assert_eq!(frame.msg(), &msg[..]); + assert_eq!(frame.iv(), &iv[..]); + } + + #[test] + fn response_reserve_matches_value_layout() { + // A frame built with the `*_reserve` setters must have the same + // wire layout (header + TOC + total length) as one built with the + // value setters; only the data bytes are left for fill-later. + let mut buf_v = [0u8; 1536]; + let msg = [0x44u8; 48]; + let iv = [0x55u8; AES_IV_LEN]; + let frame_v = TborAesEncryptDecryptResp::encode(&mut buf_v, 0, true) + .unwrap() + .msg(&msg) + .unwrap() + .iv(&iv) + .unwrap() + .finish(); + let len_v = frame_v.as_bytes().len(); + + let mut buf_r = [0u8; 1536]; + let frame_r = TborAesEncryptDecryptResp::encode(&mut buf_r, 0, true) + .unwrap() + .msg_reserve(48) + .unwrap() + .iv_reserve(AES_IV_LEN) + .unwrap() + .finish(); + + // Identical total length, and the reserved slots report the + // reserved sizes (their bytes are whatever was in the buffer — the + // zero-initialized `buf_r` here). + assert_eq!(frame_r.as_bytes().len(), len_v, "reserve length == value"); + assert_eq!(frame_r.msg().len(), 48); + assert_eq!(frame_r.iv().len(), AES_IV_LEN); + assert!( + frame_r.msg().iter().all(|&b| b == 0), + "reserved region is left untouched for fill-later", + ); + } + + #[test] + fn response_reserve_rejects_oversize() { + // The reserve setter enforces the same length bound as the value + // setter: the IV slot is fixed at 16 bytes. + let mut buf = [0u8; 1536]; + let err = TborAesEncryptDecryptResp::encode(&mut buf, 0, true) + .unwrap() + .msg_reserve(48) + .unwrap() + .iv_reserve(17); + assert!(err.is_err(), "IV reserve must reject a non-16-byte length"); + } + + #[test] + fn lengths_match_pinned_values() { + // The `#[tbor(buffer, ... = N)]` attributes must remain numeric + // literals; pin them against the exported consts. + const _: () = assert!(1024 == AES_MSG_MAX_LEN); + const _: () = assert!(16 == AES_IV_LEN); + const _: () = assert!(148 == MASKED_AES_KEY_MIN_LEN); + const _: () = assert!(164 == MASKED_AES_KEY_MAX_LEN); + assert_eq!(AES_MSG_MAX_LEN, 1024); + assert_eq!(AES_IV_LEN, 16); + } +} diff --git a/fw/core/ddi/tbor/types/src/aes_generate_key.rs b/fw/core/ddi/tbor/types/src/aes_generate_key.rs new file mode 100644 index 000000000..5b87d3034 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/aes_generate_key.rs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `AesGenerateKey` wire schema. +//! +//! `AesGenerateKey` is an in-session command that generates a fresh random +//! AES key (128 / 192 / 256 bits) and returns it as a **masked** blob. +//! The key is **not** stored on the device: the caller holds the masked +//! blob and passes it back to +//! [`AesEncryptDecrypt`](crate::aes_encrypt_decrypt) to transform data +//! (unmask-on-use), exactly like the masked HMAC key. This is the TBOR +//! analogue of MBOR `AesGenerateKey`, but without a vault `key_id` / +//! `key_tag`: nothing is persisted on-device. +//! +//! 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. +//! * `key_size` — the [`AesKeySize`] selecting the AES key length +//! (128 / 192 / 256 → 16 / 24 / 32 B). +//! +//! Outputs: +//! +//! * `masked_key` — the freshly generated AES key, masked (AEAD-GCM-256) +//! under the requested scope's masking key. Its length depends on the +//! key size: [`MASKED_AES_KEY_MIN_LEN`] (148 B, AES-128) … +//! [`MASKED_AES_KEY_MAX_LEN`] (164 B, AES-256). + +use azihsm_fw_ddi_tbor_api::tbor; +use open_enum::open_enum; + +use crate::key_props::KeyScope; + +/// TBOR opcode for `AesGenerateKey`. +pub const TBOR_OP_AES_GENERATE_KEY: u8 = 0x15; + +/// Minimum masked AES-key envelope length (AES-128, 16-byte key): an +/// AEAD-GCM-256 masked-key envelope `header(8) ‖ iv(12) ‖ aad(96) ‖ +/// pt(16) ‖ tag(16)`. Lower bound of the masked-key output. +pub const MASKED_AES_KEY_MIN_LEN: usize = 8 + 12 + 96 + 16 + 16; + +/// Maximum masked AES-key envelope length (AES-256, 32-byte key): the same +/// envelope with a 32-byte plaintext. Pinned into the `#[tbor(buffer, +/// max_len = 164)]` literal on [`TborAesGenerateKeyResp::masked_key`]. +pub const MASKED_AES_KEY_MAX_LEN: usize = 8 + 12 + 96 + 32 + 16; + +/// AES key-size selector on the TBOR wire. +/// +/// The 1-byte discriminants mirror the non-bulk MBOR `DdiAesKeySize` +/// values (`Aes128 = 1`, `Aes192 = 2`, `Aes256 = 3`). Kept as an +/// [`open_enum`] so an unrecognized discriminant round-trips as +/// `AesKeySize(x)` and is rejected on-device rather than failing to +/// decode. The XTS / GCM bulk variants are intentionally absent — like +/// MBOR `AesGenerateKey`, only non-bulk keys are generated here. +#[repr(u8)] +#[open_enum] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AesKeySize { + /// AES-128: 16-byte key. + Aes128 = 1, + + /// AES-192: 24-byte key. + Aes192 = 2, + + /// AES-256: 32-byte key. + Aes256 = 3, +} + +/// `AesGenerateKey` request schema. +/// +/// Generates a random AES key of the requested [`AesKeySize`] under the +/// active session's partition, masked with the requested [`KeyScope`]'s +/// masking key. +#[tbor(opcode = 0x15)] +pub struct TborAesGenerateKeyReq { + /// 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, + + /// AES key size (and thus key length), carried as the 1-byte + /// [`AesKeySize`] discriminant. + #[tbor(U8)] + pub key_size: AesKeySize, +} + +/// `AesGenerateKey` 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 TborAesGenerateKeyResp<'a> { + /// The freshly generated AES key, masked (AEAD-GCM-256) under the + /// requested scope's masking key. 148 / 156 / 164 B for + /// AES-128 / 192 / 256. The key is not stored on the device; the + /// caller passes this blob back to `AesEncryptDecrypt`. + #[tbor(buffer, max_len = 164, 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_size() { + let mut buf = [0u8; 256]; + let frame = TborAesGenerateKeyReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(5)) + .unwrap() + .scope(KeyScope::Local) + .unwrap() + .key_size(AesKeySize::Aes256) + .unwrap() + .finish(); + + assert_eq!(frame.scope(), KeyScope::Local); + assert_eq!(frame.key_size(), AesKeySize::Aes256); + } + + #[test] + fn response_round_trips_masked_key() { + let mut buf = [0u8; 512]; + let masked = [0xABu8; MASKED_AES_KEY_MAX_LEN]; + let frame = TborAesGenerateKeyResp::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!(164 == MASKED_AES_KEY_MAX_LEN); + assert_eq!(MASKED_AES_KEY_MIN_LEN, 148); + assert_eq!(MASKED_AES_KEY_MAX_LEN, 164); + } +} diff --git a/fw/core/ddi/tbor/types/src/lib.rs b/fw/core/ddi/tbor/types/src/lib.rs index 2d8363283..7007052ae 100644 --- a/fw/core/ddi/tbor/types/src/lib.rs +++ b/fw/core/ddi/tbor/types/src/lib.rs @@ -34,6 +34,8 @@ pub mod tbor_int { pub use zerocopy::little_endian::U64; } +pub mod aes_encrypt_decrypt; +pub mod aes_generate_key; pub mod api_rev; pub mod ecc_generate_key; pub mod ecc_sign; @@ -62,6 +64,8 @@ pub mod session_open_finish; pub mod session_open_init; pub mod unwrap_key; +pub use aes_encrypt_decrypt::*; +pub use aes_generate_key::*; pub use api_rev::*; pub use ecc_generate_key::*; pub use ecc_sign::*; diff --git a/fw/core/lib/src/ddi/tbor/aes_encrypt_decrypt.rs b/fw/core/lib/src/ddi/tbor/aes_encrypt_decrypt.rs new file mode 100644 index 000000000..28451ac3e --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/aes_encrypt_decrypt.rs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `AesEncryptDecrypt` command handler. +//! +//! Within an open session, AES-CBC encrypt or decrypt a host-supplied +//! message using a caller-held **masked** AES key (the `masked_key` from +//! [`AesGenerateKey`](super::aes_generate_key) or imported via +//! [`UnwrapKey`](super::unwrap_key)). 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 request buffer (verifying the AEAD tag), +//! the AES-CBC transform runs zero-copy — reading the request message and +//! writing the transformed message plus updated chaining IV straight into +//! the response buffer — and nothing is persisted; the recovered key is +//! wiped. This is the TBOR analogue of MBOR `AesEncryptDecrypt`, keyed by +//! a masked blob rather than a vault `key_id`. +//! +//! The key must be a non-bulk AES kind (`InvalidKeyType` otherwise) and +//! must carry the permission matching the direction (`encrypt` for +//! `Encrypt`, `decrypt` for `Decrypt`; `InvalidPermissions` otherwise). +//! 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::AesOp; +use azihsm_fw_ddi_tbor_types::TborAesEncryptDecryptReq; +use azihsm_fw_ddi_tbor_types::TborAesEncryptDecryptResp; +use azihsm_fw_ddi_tbor_types::AES_IV_LEN; +use azihsm_fw_ddi_tbor_types::AES_MSG_MAX_LEN; +use azihsm_fw_hsm_pal_traits::AesOp as PalAesOp; +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::HsmSessId; +use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; + +use super::resolve_masking_key; +use super::validate_active_session; + +/// AES-CBC block size in bytes — also the required IV length. +const AES_BLOCK_LEN: usize = AES_IV_LEN; + +/// Map the wire [`AesOp`] onto the PAL [`PalAesOp`]. An unrecognized +/// discriminant is rejected with [`HsmError::InvalidArg`]. +fn pal_aes_op(op: AesOp) -> HsmResult { + match op { + AesOp::Encrypt => Ok(PalAesOp::Encrypt), + AesOp::Decrypt => Ok(PalAesOp::Decrypt), + _ => Err(HsmError::InvalidArg), + } +} + +/// Require an unmasked key to be a non-bulk AES kind (128 / 192 / 256). +/// The XTS / GCM bulk kinds are not valid for the CBC transform. +fn assert_aes_kind(kind: HsmVaultKeyKind) -> HsmResult<()> { + match kind { + HsmVaultKeyKind::Aes128 | HsmVaultKeyKind::Aes192 | HsmVaultKeyKind::Aes256 => Ok(()), + _ => Err(HsmError::InvalidKeyType), + } +} + +/// Unmask the AES key **in place** and run the CBC transform, reading +/// `input` and writing the ciphertext / chaining IV straight into the +/// response slots `out_msg` / `out_iv`. +/// +/// `masked_key` is a `&mut` slice of the request buffer: `unmask` decrypts +/// it in place and the recovered `target_key` is used directly — no scratch +/// copy of the blob or the key. `input` (request) and `out_msg` (response +/// slot) are distinct buffers, so the AES engine performs the read → write +/// with no intermediate copy. The recovered key is wiped on every path. +#[allow(clippy::too_many_arguments)] +async fn transform( + pal: &P, + io: &impl HsmIo, + op: PalAesOp, + scope: HsmKeyScope, + sess_id: HsmSessId, + masked_key: &mut DmaBuf, + input: &DmaBuf, + iv: &DmaBuf, + out_msg: &mut DmaBuf, + out_iv: &mut DmaBuf, +) -> HsmResult<()> { + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + + // Unmask in place; validate the key is an AES key that permits the + // requested direction; transform straight from the recovered key into + // the response. Capture the result so the recovered key (now in the + // request buffer) is wiped on EVERY path — `view`'s borrow of + // `masked_key` ends with the inner block, releasing it for the wipe. + let crypt_res = async { + let view = unmask(pal, io, masking_key, masked_key).await?; + assert_aes_kind(view.key_kind)?; + // Encrypt is a `C_Encrypt` op, Decrypt a `C_Decrypt` op; the key + // must carry the matching permission. + let permitted = match op { + PalAesOp::Encrypt => view.key_attrs.encrypt(), + PalAesOp::Decrypt => view.key_attrs.decrypt(), + }; + if !permitted { + return Err(HsmError::InvalidPermissions); + } + pal.aes_cbc_enc_dec(io, op, view.target_key, input, iv, out_msg, Some(out_iv)) + .await + } + .await; + + masked_key.zeroize(); + crypt_res +} + +/// Handle a TBOR `AesEncryptDecrypt` 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 +/// and transforms the message into the response buffer. +/// +/// Fully zero-copy: `decode_mut` exposes the request `masked_key` as `&mut` +/// so `unmask` decrypts it in place (no blob / key scratch copy); the +/// response `msg` / `iv` slots are **reserved** and filled in place +/// (`decode_mut`) by the AES engine, which reads the request message and +/// writes the ciphertext / chaining IV straight into the response. +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; `msg` / `iv` are disjoint + // shared borrows. + let req = TborAesEncryptDecryptReq::decode_mut(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id)); + validate_active_session(pal, io, sess_id)?; + + let op = pal_aes_op(AesOp(req.op))?; + + // IV must be exactly one block; the message must be non-empty, a whole + // number of blocks, and within the wire max (single source of truth: + // `AES_MSG_MAX_LEN`). + if req.iv.len() != AES_BLOCK_LEN { + return Err(HsmError::InvalidArg); + } + if req.msg.is_empty() + || !req.msg.len().is_multiple_of(AES_BLOCK_LEN) + || req.msg.len() > AES_MSG_MAX_LEN + { + return Err(HsmError::InvalidArg); + } + let msg_len = req.msg.len(); + + // 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(); + + // Build the response with the `msg` / `iv` slots reserved (sized but + // unwritten) — no data copy at encode time. + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborAesEncryptDecryptResp::encode(buf, 0, false)? + .msg_reserve(msg_len)? + .iv_reserve(AES_BLOCK_LEN)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + + let masked_key = req.masked_key; + let msg = req.msg; + let iv = req.iv; + + // Fill the reserved slots in place: unmask the key in the request buffer + // and have the AES engine write straight into the response. The view is + // scoped so its borrow of `resp` ends before `resp` is returned. + { + let out = TborAesEncryptDecryptResp::decode_mut(resp)?; + transform( + pal, io, op, scope, sess_id, masked_key, msg, iv, out.msg, out.iv, + ) + .await?; + } + + Ok(resp) +} diff --git a/fw/core/lib/src/ddi/tbor/aes_generate_key.rs b/fw/core/lib/src/ddi/tbor/aes_generate_key.rs new file mode 100644 index 000000000..6bc4c05df --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/aes_generate_key.rs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `AesGenerateKey` command handler. +//! +//! Within an open session, generate a fresh random AES key (128 / 192 / +//! 256 bits), 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 **not** persisted on-device: the +//! caller holds the masked blob and passes it back to +//! [`AesEncryptDecrypt`](super::aes_encrypt_decrypt) (unmask-on-use). This +//! is the TBOR analogue of MBOR `AesGenerateKey`, but with no vault +//! `key_id` / `key_tag`. +//! +//! 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::AesKeySize; +use azihsm_fw_ddi_tbor_types::TborAesGenerateKeyReq; +use azihsm_fw_ddi_tbor_types::TborAesGenerateKeyResp; +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::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 AES_KEY_LABEL: &[u8] = b"AesKey"; + +/// Map the wire [`AesKeySize`] onto the key length (bytes) and the AES +/// vault kind stamped into the masked blob's metadata. An unrecognized +/// discriminant is rejected with [`HsmError::InvalidArg`]. +fn aes_size_kind(size: AesKeySize) -> HsmResult<(usize, HsmVaultKeyKind)> { + match size { + AesKeySize::Aes128 => Ok((16, HsmVaultKeyKind::Aes128)), + AesKeySize::Aes192 => Ok((24, HsmVaultKeyKind::Aes192)), + AesKeySize::Aes256 => Ok((32, HsmVaultKeyKind::Aes256)), + _ => Err(HsmError::InvalidArg), + } +} + +/// Attributes recorded in the masked blob's metadata (re-applied on +/// unmask). A generated AES key is a `C_Encrypt` / `C_Decrypt` symmetric +/// key created on-device; `scope` records the lifecycle / visibility +/// domain selecting the masking key. +fn aes_key_attrs(scope: HsmKeyScope) -> HsmVaultKeyAttrs { + HsmVaultKeyAttrs::new() + .with_local(true) + .with_encrypt(true) + .with_decrypt(true) + .with_scope(scope) +} + +/// Handle a TBOR `AesGenerateKey` 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 = TborAesGenerateKeyReq::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 (key_len, kind) = aes_size_kind(req.key_size())?; + // The masked-blob length is fixed by the key length (16 / 24 / 32 B → + // 148 / 156 / 164 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 = aes_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 = TborAesGenerateKeyResp::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 = TborAesGenerateKeyResp::decode_mut(resp)?; + pal.alloc_scoped_async(io, async |alloc| -> HsmResult<()> { + // Resolve the masking key and build the mask params *before* + // generating any key material. Every fallible step here (an + // unsupported / unprovisioned scope, or the label allocation) + // must reject before a key exists — both to match this handler's + // contract that scope failures happen before keygen, and so no + // early return can leave a raw key sitting in DMA scratch. + let masking_key = resolve_masking_key(pal, io, scope, sess_id)?; + let key_label = alloc.dma_alloc(AES_KEY_LABEL.len())?; + key_label.copy_from_slice(AES_KEY_LABEL); + let params = MaskParams { + key_kind: kind, + key_attrs: attrs, + svn, + owner_seed_id: owner, + key_label, + }; + + // Generate the random AES key into scratch, then mask it + // straight into the reserved response slot. The generate + + // mask run inside a block that yields a `Result`, so the raw + // key is wiped on *every* path below — keygen failure, mask + // failure, or a short write — before the error propagates; + // scope rewind does not clear DMA memory. + let key_buf = alloc.dma_alloc(key_len)?; + let outcome = async { + pal.aes_gen_key(io, key_buf).await?; + let written = mask( + pal, + io, + alloc, + AeadAlg::AesGcm256, + masking_key, + ¶ms, + key_buf, + Some(out.masked_key), + ) + .await?; + // `mask` returns the number of bytes written and leaves any + // trailing bytes of the reserved slot untouched; the slot + // was reserved to exactly `masked_len`, so a short write + // would leave uninitialized response bytes. Enforce a full + // write (same guard as `unwrap_key` / `hmac_generate_key`). + if written != masked_len { + return Err(HsmError::InternalError); + } + Ok(()) + } + .await; + key_buf.zeroize(); + outcome + }) + .await?; + } + + Ok(resp) +} diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index 3a266acc0..fabde80af 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -20,6 +20,8 @@ //! and returns the resulting `&DmaBuf` slice (lifetime tied to the //! per-IO allocator scope). +pub(crate) mod aes_encrypt_decrypt; +pub(crate) mod aes_generate_key; pub(crate) mod api_rev; pub(crate) mod ecc_generate_key; pub(crate) mod ecc_sign; @@ -199,6 +201,17 @@ pub(crate) mod opcode { /// re-derived public key for RSA / ECC). See [`super::unwrap_key`]. pub(crate) const UNWRAP_KEY: u8 = 0x14; + /// `AesGenerateKey` — generate a fresh random AES key (128 / 192 / + /// 256) under the active session's partition; return it **masked** + /// under the requested scope's masking key (nothing is persisted + /// on-device). See [`super::aes_generate_key`]. + pub(crate) const AES_GENERATE_KEY: u8 = 0x15; + + /// `AesEncryptDecrypt` — AES-CBC encrypt or decrypt a host-supplied + /// message using a caller-held masked AES key (unmasked on-device for + /// the operation, then discarded). See [`super::aes_encrypt_decrypt`]. + pub(crate) const AES_ENCRYPT_DECRYPT: u8 = 0x16; + /// `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 @@ -438,6 +451,8 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( 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, + opcode::AES_GENERATE_KEY => aes_generate_key::handle(pal, io, req_buf).await, + opcode::AES_ENCRYPT_DECRYPT => aes_encrypt_decrypt::handle(pal, io, req_buf).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, @@ -473,6 +488,8 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY + | opcode::AES_GENERATE_KEY + | opcode::AES_ENCRYPT_DECRYPT | opcode::ECC_GENERATE_KEY | opcode::ECC_SIGN | opcode::ECDH_DERIVE @@ -515,6 +532,8 @@ fn is_in_session(opcode: u8) -> bool { | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY + | opcode::AES_GENERATE_KEY + | opcode::AES_ENCRYPT_DECRYPT | opcode::ECC_GENERATE_KEY | opcode::ECC_SIGN | opcode::ECDH_DERIVE @@ -565,6 +584,8 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY + | opcode::AES_GENERATE_KEY + | opcode::AES_ENCRYPT_DECRYPT | opcode::ECC_GENERATE_KEY | opcode::ECC_SIGN | opcode::ECDH_DERIVE diff --git a/fw/core/lib/src/op.rs b/fw/core/lib/src/op.rs index 2938860fb..6f4ba6969 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -273,6 +273,8 @@ impl SessionCtrl { | opcode::HMAC | opcode::GET_UNWRAPPING_KEY | opcode::UNWRAP_KEY + | opcode::AES_GENERATE_KEY + | opcode::AES_ENCRYPT_DECRYPT | opcode::ECC_GENERATE_KEY | opcode::ECC_SIGN | opcode::ECDH_DERIVE