diff --git a/ddi/tbor/types/src/key_report.rs b/ddi/tbor/types/src/key_report.rs new file mode 100644 index 000000000..b8c839548 --- /dev/null +++ b/ddi/tbor/types/src/key_report.rs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host-side wrapper for the TBOR `KeyReport` command. +//! +//! `KeyReport` is an **in-session** command that takes a **masked key** +//! (e.g. the `masked_key` returned by `SdSealingKeyGen`), unmasks it, +//! derives the attested key's public component on-device, and returns a +//! signed COSE_Sign1 key-attestation report over it. The report is signed +//! by the partition-identity (PID) key. +//! +//! The request carries the masked-key envelope to attest plus a +//! caller-supplied 128-byte `report_data` bound into the report payload. + +use alloc::vec::Vec; + +use crate::tbor; + +/// TBOR opcode for `KeyReport`. +/// +/// `0x0A..=0x0F` are reserved by the Security-Domain backup schema family +/// (`SdCreateRemoteBackup` .. `SdRestorePeerBackup`), so `KeyReport` +/// takes the next free opcode, `0x10`. +pub const TBOR_OP_KEY_REPORT: u8 = 0x10; + +/// Maximum wire length of the `masked_key` request buffer (a masked-key +/// AEAD envelope; the plaintext size depends on the key kind). +pub const KEY_REPORT_MASKED_KEY_MAX_LEN: usize = 512; + +/// Length of the caller-supplied `report_data` bound into the report. +pub const KEY_REPORT_DATA_LEN: usize = 128; + +/// Maximum wire length of the returned COSE_Sign1 `report`. +pub const KEY_REPORT_MAX_LEN: usize = 1024; + +/// Host-facing TBOR `KeyReport` request. +#[tbor(opcode = TBOR_OP_KEY_REPORT, session_ctrl = in_session)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TborKeyReportReq { + /// 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-key envelope to attest. Variable length up to + /// [`KEY_REPORT_MASKED_KEY_MAX_LEN`]. + #[tbor(max_len = 512)] + pub masked_key: Vec, + + /// Caller-supplied [`KEY_REPORT_DATA_LEN`] (128 B) report data. + pub report_data: [u8; KEY_REPORT_DATA_LEN], +} + +/// Host-facing TBOR `KeyReport` response. +#[tbor(response)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TborKeyReportResp { + /// The tagged COSE_Sign1 key-attestation report, signed by the PID + /// key. Variable length up to [`KEY_REPORT_MAX_LEN`]. + #[tbor(max_len = 1024)] + pub report: Vec, +} + +#[cfg(test)] +mod tests { + use azihsm_ddi_tbor_types::TborOpReq; + + use super::*; + + #[test] + fn request_encodes_session_and_masked_key() { + let req = TborKeyReportReq { + session_id: 9, + masked_key: alloc::vec![0xCD; 180], + report_data: [0xAB; KEY_REPORT_DATA_LEN], + }; + + let mut buf = [0u8; 1024]; + let frame = req.encode_request(&mut buf).expect("encode"); + + // The masked-key bytes must appear in the encoded frame. + assert!( + frame.windows(4).any(|w| w == [0xCD; 4]), + "encoded frame must carry the masked key", + ); + } +} diff --git a/ddi/tbor/types/src/lib.rs b/ddi/tbor/types/src/lib.rs index 4a5b6772b..0a51c745d 100644 --- a/ddi/tbor/types/src/lib.rs +++ b/ddi/tbor/types/src/lib.rs @@ -80,6 +80,7 @@ impl From for u8 { mod api_rev; mod evidence; +mod key_report; mod part_final; mod part_info; mod part_init; @@ -99,6 +100,7 @@ mod status; pub use api_rev::*; pub use evidence::*; +pub use key_report::*; pub use part_final::*; pub use part_info::*; pub use part_init::*; diff --git a/ddi/tbor/types/src/status.rs b/ddi/tbor/types/src/status.rs index 6d92abcf6..e87e4c6cb 100644 --- a/ddi/tbor/types/src/status.rs +++ b/ddi/tbor/types/src/status.rs @@ -285,6 +285,10 @@ pub enum TborStatus { /// `HsmError::UnsupportedKeyScope`). UnsupportedKeyScope = 0x08700102, + /// Returned by `KeyReport` when the masked key's kind cannot be + /// attested (mirror of `HsmError::UnsupportedKeyType`). + UnsupportedKeyType = 0x08700103, + /// The SQE's out-of-band descriptor-array length (`oob_len`) is /// malformed (mirror of `HsmError::IoChannelInvalidOobLen`). IoChannelInvalidOobLen = 0x08700104, diff --git a/ddi/tbor/types/tests/commands/key_report.rs b/ddi/tbor/types/tests/commands/key_report.rs new file mode 100644 index 000000000..cd6645aca --- /dev/null +++ b/ddi/tbor/types/tests/commands/key_report.rs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the TBOR `KeyReport` command. +//! +//! `KeyReport` takes a **masked key** (as produced by `SdSealingKeyGen`), +//! unmasks it, derives its public component on-device, and returns a +//! PID-signed COSE_Sign1 key-attestation report over it. The +//! Ephemeral/Local masking keys are provisioned by `PartFinal`, so the +//! happy-path tests first drive `PartInit → PartFinal → SdSealingKeyGen` +//! to obtain a masked key. +//! +//! Coverage: +//! * Happy path (Ephemeral + Local) — the report is a COSE_Sign1 that +//! verifies under the PID pubkey, its embedded COSE_Key re-derives the +//! sealed key's public point, and its `report_data` round-trips. +//! * Tampered masked key (flipped AEAD tag) → `AesGcmDecryptTagDoesNotMatch`. +//! * Before finalize (partition not `Initialized`) → `InvalidArg`. +//! * Crypto-User session → `InvalidPermissions`. +//! * Default-PSK gate → `DefaultPskMustRotate` (dispatcher, pre-handler). + +#![cfg(feature = "emu")] + +use azihsm_ddi_tbor_types::SessionType; +use azihsm_ddi_tbor_types::TborKeyReportReq; +use azihsm_ddi_tbor_types::TborSdSealingKeyGenReq; +use azihsm_ddi_tbor_types::TborStatus; +use azihsm_ddi_tbor_types::KEY_REPORT_DATA_LEN; +use azihsm_ddi_tbor_types::PSK_LEN; + +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::SessionOpenInitOptions; +use crate::harness::TestCtx; + +/// `KeyScope` discriminants (wire mirror of the firmware `HsmKeyScope`). +const SCOPE_EPHEMERAL: u8 = 0b010; +const SCOPE_LOCAL: u8 = 0b011; + +/// P-384 coordinate length (raw, big-endian) — the sealed key is a P-384 +/// keypair, so each attested COSE_Key coordinate is 48 bytes. +const P384_COORD_LEN: usize = 48; + +/// Crypto-User PSK id. +const CU: u8 = 1; + +/// Crypto-Officer PSK id (the default-PSK gate test opens under the +/// public default CO PSK). +const CO_DEFAULT: u8 = 0; + +/// Non-default 32-byte CU PSK, used to clear the default-PSK gate so the +/// CU-role reject path — not the default-PSK gate — is exercised. +const ROTATED_CU_PSK: [u8; PSK_LEN] = [ + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, +]; + +/// Sample caller-supplied report data bound into the report payload. +fn sample_report_data() -> [u8; KEY_REPORT_DATA_LEN] { + let mut data = [0u8; KEY_REPORT_DATA_LEN]; + for (i, b) in data.iter_mut().enumerate() { + *b = (i as u8) ^ 0xA5; + } + data +} + +/// Mint a masked sealing key under `scope` on a finalized CO session, +/// returning `(masked_key, sealing_pub_le)`. The public key is in the +/// little-endian DDI wire form (`x_le ‖ y_le`). +fn masked_sealing_key(ctx: &TestCtx, session_id: u16, scope: u8) -> (Vec, Vec) { + let seal = ctx + .tbor(&TborSdSealingKeyGenReq { session_id, scope }) + .expect("SdSealingKeyGen"); + (seal.masked_key.to_vec(), seal.pub_key.to_vec()) +} + +/// Verify a `KeyReport` COSE_Sign1: (1) the envelope verifies under the +/// PID pubkey; (2) the embedded COSE_Key re-derives `sealing_pub_le`; and +/// (3) the report's `report_data` matches what the caller supplied. +fn verify_key_report( + ctx: &TestCtx, + report: &[u8], + sealing_pub_le: &[u8], + expected_report_data: &[u8; KEY_REPORT_DATA_LEN], +) { + use azihsm_ddi_mbor_sim::attestation::KeyAttester; + use azihsm_ddi_mbor_sim::crypto::ecc::EccOp; + use azihsm_ddi_mbor_sim::crypto::ecc::EccPublicKey as SimEccPublicKey; + use azihsm_ddi_mbor_sim::report::CoseSign1Object; + use azihsm_ddi_mbor_sim::report::KeyAttestationReport; + use x509::X509Certificate; + use x509::X509CertificateOp; + + // 1. PID pubkey from the slot-0 chain leaf. + let info = ctx.cert_chain_info().expect("GetCertChainInfo"); + let n = info.data.num_certs; + assert!( + n >= 1, + "slot-0 cert chain must contain the PID leaf, got {n}" + ); + let leaf_resp = ctx.get_certificate(n - 1).expect("GetCertificate(leaf)"); + let leaf_bytes = leaf_resp.data.certificate.as_slice(); + let leaf = X509Certificate::from_der(leaf_bytes).expect("PID leaf parses as X.509 certificate"); + let pid_spki = leaf.get_public_key_der().expect("PID leaf SPKI extracts"); + let pid_pub = + SimEccPublicKey::from_der(&pid_spki, None).expect("PID pubkey loads from leaf SPKI"); + + // 2. COSE_Sign1 signature verify under PID pubkey. + let attester = KeyAttester::parse(report).expect("report parses as COSE_Sign1"); + attester + .verify(&pid_pub) + .expect("KeyReport COSE_Sign1 must verify under PID pubkey"); + + // 3. Decode the payload and cross-bind the embedded COSE_Key to the + // sealed public point. + let cose = CoseSign1Object::decode(report).expect("re-decode COSE_Sign1 envelope"); + let decoded: KeyAttestationReport = + minicbor::decode(cose.payload).expect("report payload decodes as KeyAttestationReport"); + + assert_eq!( + &decoded.report_data[..], + &expected_report_data[..], + "report_data must round-trip into the report payload", + ); + + let cose_key = &decoded.public_key[..decoded.public_key_size as usize]; + let (x_be, y_be) = cose_key_xy(cose_key); + + // The COSE_Key holds big-endian coordinates; the sealing pubkey is + // little-endian `x_le ‖ y_le`, so reverse each COSE_Key coordinate + // and compare against the corresponding wire half. + assert_eq!(x_be.len(), P384_COORD_LEN, "COSE_Key pk_x is a P-384 coord"); + assert_eq!(y_be.len(), P384_COORD_LEN, "COSE_Key pk_y is a P-384 coord"); + let x_le: Vec = x_be.iter().rev().copied().collect(); + let y_le: Vec = y_be.iter().rev().copied().collect(); + assert_eq!( + x_le.as_slice(), + &sealing_pub_le[..P384_COORD_LEN], + "attested COSE_Key pk_x must re-derive the sealed key's X", + ); + assert_eq!( + y_le.as_slice(), + &sealing_pub_le[P384_COORD_LEN..], + "attested COSE_Key pk_y must re-derive the sealed key's Y", + ); +} + +/// Walk a COSE_Key CBOR map and return its `(x, y)` byte strings +/// (labels -2 / -3). +fn cose_key_xy(cose_key: &[u8]) -> (Vec, Vec) { + use minicbor::data::Type as CborType; + + let mut decoder = minicbor::Decoder::new(cose_key); + let entries = decoder + .map() + .expect("COSE_Key is a CBOR map") + .expect("COSE_Key map length is known"); + let (mut x_bytes, mut y_bytes): (Option>, Option>) = (None, None); + for _ in 0..entries { + let label_ty = decoder.datatype().expect("COSE_Key entry has datatype"); + let label = match label_ty { + CborType::I8 | CborType::I16 | CborType::I32 | CborType::I64 => { + decoder.i64().expect("COSE_Key label decodes as int") + } + CborType::U8 | CborType::U16 | CborType::U32 | CborType::U64 => { + decoder.u64().expect("COSE_Key label decodes as uint") as i64 + } + other => panic!("unexpected COSE_Key label type {other:?}"), + }; + match label { + -2 => x_bytes = Some(decoder.bytes().expect("pk_x bytes").to_vec()), + -3 => y_bytes = Some(decoder.bytes().expect("pk_y bytes").to_vec()), + _ => decoder.skip().expect("skip non-XY label value"), + } + } + ( + x_bytes.expect("COSE_Key carries pk_x (label -2)"), + y_bytes.expect("COSE_Key carries pk_y (label -3)"), + ) +} + +/// Happy path for a supported `scope`: `SdSealingKeyGen` mints a masked +/// key, `KeyReport` attests it, and the report verifies + cross-binds. +fn report_roundtrip_for_scope(scope: u8) { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let (masked_key, sealing_pub) = masked_sealing_key(&ctx, session.session_id, scope); + let report_data = sample_report_data(); + + let req = TborKeyReportReq { + session_id: session.session_id, + masked_key, + report_data, + }; + let resp = ctx.tbor(&req).expect("KeyReport roundtrip"); + + // Tagged COSE_Sign1: CBOR tag 18 (0xD2) opening byte. + assert_eq!( + resp.report.first(), + Some(&0xD2), + "report must begin with the COSE_Sign1 CBOR tag (0xD2)", + ); + verify_key_report(&ctx, &resp.report, &sealing_pub, &report_data); +} + +#[test] +fn key_report_ephemeral_roundtrip_emu() { + report_roundtrip_for_scope(SCOPE_EPHEMERAL); +} + +#[test] +fn key_report_local_roundtrip_emu() { + report_roundtrip_for_scope(SCOPE_LOCAL); +} + +#[test] +fn key_report_rejects_tampered_masked_key_emu() { + let ctx = TestCtx::new(); + let session = finalized_co_session(&ctx); + let (mut masked_key, _pub) = masked_sealing_key(&ctx, session.session_id, SCOPE_EPHEMERAL); + + // Flip the last byte (inside the AEAD tag) so unmask's tag check fails + // — the peeked cleartext scope is untouched, so the reject is the + // authenticity failure, not a scope/state gate. + let last = masked_key.len() - 1; + masked_key[last] ^= 0xFF; + + let req = TborKeyReportReq { + session_id: session.session_id, + masked_key, + report_data: sample_report_data(), + }; + ctx.expect_fw_reject(&req, TborStatus::AesGcmDecryptTagDoesNotMatch); +} + +#[test] +fn key_report_rejects_before_finalize_emu() { + let ctx = TestCtx::new(); + // Rotated CO session but no PartInit/PartFinal → the partition is not + // Initialized, so the handler rejects before it ever unmasks. The + // masked_key is a well-formed-length dummy; the state gate fires first. + let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK); + + let req = TborKeyReportReq { + session_id: session.session_id, + masked_key: vec![0u8; 180], + report_data: sample_report_data(), + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidArg); +} + +#[test] +fn key_report_rejected_on_cu_session_emu() { + let ctx = TestCtx::new(); + + // Rotate the CU PSK out of the default so the dispatcher's default-PSK + // gate does not fire first; then reopen a CU session under it. CU + // sessions are pinned to `SessionType::PlainText`. + let bootstrap = ctx.open_session(CU, SessionType::PlainText); + ctx.psk_change(bootstrap.handshake(), &ROTATED_CU_PSK) + .expect("rotate CU PSK"); + bootstrap.close().expect("close bootstrap CU session"); + + let opts = SessionOpenInitOptions::new(CU, SessionType::PlainText).with_psk(&ROTATED_CU_PSK); + let pending = ctx + .session_open_init_with_options(opts) + .expect("CU session_open_init under rotated PSK"); + let session = ctx + .session_open_finish(pending) + .expect("CU session_open_finish under rotated PSK"); + + // KeyReport is Crypto-Officer-only: the handler's role gate (checked + // before the state/scope gates) rejects a CU session. + let req = TborKeyReportReq { + session_id: session.session_id, + masked_key: vec![0u8; 180], + report_data: sample_report_data(), + }; + ctx.expect_fw_reject(&req, TborStatus::InvalidPermissions); +} + +#[test] +fn key_report_rejected_on_default_psk_emu() { + let ctx = TestCtx::new(); + // Open a CO session WITHOUT rotating the PSK (still the public + // default) — the dispatcher's default-PSK gate must reject the command + // before the handler runs. + let session = ctx.open_session(CO_DEFAULT, SessionType::Authenticated); + + let req = TborKeyReportReq { + session_id: session.session_id(), + masked_key: vec![0u8; 180], + report_data: sample_report_data(), + }; + ctx.expect_fw_reject(&req, TborStatus::DefaultPskMustRotate); +} diff --git a/ddi/tbor/types/tests/commands/mod.rs b/ddi/tbor/types/tests/commands/mod.rs index 6c79d2f17..fbf4ec240 100644 --- a/ddi/tbor/types/tests/commands/mod.rs +++ b/ddi/tbor/types/tests/commands/mod.rs @@ -9,6 +9,7 @@ pub mod api_rev; pub mod default_psk_gate; pub mod forward_compat; pub mod fw_error_decode; +pub mod key_report; pub mod open_session; pub mod part_final; pub mod part_info; diff --git a/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs b/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs index 4f98c07de..641266473 100644 --- a/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs +++ b/ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs @@ -67,7 +67,7 @@ const ROTATED_CU_PSK: [u8; PSK_LEN] = [ /// POTA trust anchor bound into the policy, so this mints a POTA CA, binds /// its public key into the policy, and issues a POTA-anchored PTA chain /// from the `PartInit` CSR (mirrors the `part_final` happy-path setup). -fn finalized_co_session(ctx: &TestCtx) -> SessionHandshake { +pub(crate) fn finalized_co_session(ctx: &TestCtx) -> SessionHandshake { let session = bootstrap_rotated_co(ctx, &ROTATED_CO_PSK); let pota = CaKey::generate(); diff --git a/docs/tbor-ddi/README.md b/docs/tbor-ddi/README.md index f98187c65..9792f6af6 100644 --- a/docs/tbor-ddi/README.md +++ b/docs/tbor-ddi/README.md @@ -64,6 +64,7 @@ single `none` TOC placeholder and no typed body fields. | `0x0D` | `SdRestoreLocalBackup` (schema-only) | InSession | [`commands/sd_restore_local_backup.md`](./commands/sd_restore_local_backup.md) | | `0x0E` | `SdCreatePeerBackup` (schema-only) | InSession | [`commands/sd_create_peer_backup.md`](./commands/sd_create_peer_backup.md) | | `0x0F` | `SdRestorePeerBackup` (schema-only) | InSession | [`commands/sd_restore_peer_backup.md`](./commands/sd_restore_peer_backup.md) | +| `0x10` | `KeyReport` | InSession | [`commands/key_report.md`](./commands/key_report.md) | ## Default-PSK gate diff --git a/docs/tbor-ddi/commands/key_report.md b/docs/tbor-ddi/commands/key_report.md new file mode 100644 index 000000000..ffc301dae --- /dev/null +++ b/docs/tbor-ddi/commands/key_report.md @@ -0,0 +1,100 @@ + + +# KeyReport (Opcode 0x10) + +**Handler:** `fw/core/lib/src/ddi/tbor/key_report.rs` +**Session:** InSession + +## Description + +Attests a **masked key** (as produced by +[`SdSealingKeyGen`](./sd_sealing_key_gen.md)). The handler unmasks the +key, derives its public component **on-device**, and returns a +PID-signed COSE_Sign1 key-attestation report over it. The report is +signed by the partition-identity (PID) key — the same signer as the +`PartInit` PTA report — so a relying party can verify it against the +partition's slot-0 certificate chain. + +Report building: + +1. **Peek** the masked blob's cleartext metadata (AAD) to read the key + `scope`, and resolve the scope's masking key (`Ephemeral` → the + partition `PartitionEphemeralMaskingKey`, `Local` → the partition + `PartitionLocalMaskingKey`). Both masking keys are provisioned by + `PartFinal`, so the partition must be in the `Initialized` lifecycle + state. Any other scope is rejected with `UnsupportedKeyScope`. +2. **Unmask** the blob (AEAD-GCM-256), which verifies the authenticity + tag and recovers the private key plus its validated metadata (key + kind and attributes). +3. **Derive** the attested public key. Only ECC-private kinds (including + the P-384 `SdSealing` key) are attestable: they re-derive the public + point from the recovered private scalar via `pub = priv · G`. Every + other kind — symmetric (no public component to bind), RSA-private + (public-modulus extraction not yet implemented), and non-attestable / + internal kinds — is rejected with `UnsupportedKeyType`. +4. **Sign** the COSE_Sign1 report (ES384 / ECDSA-P384) with the PID key + over the derived key, the caller-supplied `report_data`, the session + app id, and the partition VM launch id. + +Because nothing is persisted, the command records no rollback on the undo +log. The masked blob's `svn` / `owner_seed_id` are **not** enforced +against the current partition lineage: the report reflects the key +as-masked (the AEAD tag still guarantees integrity / authenticity). + +This command is **Crypto-Officer-only**: a Crypto-User session is +rejected with `InvalidPermissions`. + +## Request + +Wire layout: 4-byte header, followed by the TOC entries, then the data +section carrying the masked key and report data. + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 4 | `session_id` | `session_id` (inline) | CO session this request is bound to; cross-checked against the SQE-carried session id. | +| 8 | `masked_key` | `buffer` (≤ 512 B) | The masked-key envelope to attest, as produced by `SdSealingKeyGen`: `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(N) ‖ tag(16)`. | +| — | `report_data` | `buffer` (128 B) | Caller-supplied data bound into the report payload (typically a freshness nonce or a challenge digest). | + +### Data section + +Carries the variable-length `masked_key` followed by the 128-byte +`report_data`. + +## Response + +Wire layout: 8-byte header, followed by the TOC entry, then the data +section carrying the report. + +### TOC entries + +| Offset | Field | Type | Description | +|---|---|---|---| +| 8 | `report` | `buffer` (≤ 1024 B) | The tagged COSE_Sign1 key-attestation report (CBOR tag 18, opening byte `0xD2`), signed by the PID key. The embedded COSE_Key holds the derived public key (big-endian coordinates for ECC). | + +### Data section + +Carries the variable-length COSE_Sign1 `report`. + +## Errors + +| Error | Cause | +|---|---| +| `InvalidPermissions` | The calling session is a Crypto User (this command is Crypto-Officer-only) | +| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` | +| `InvalidArg` | The partition is not `Initialized` (run `PartFinal` first), or `report_data` is the wrong length | +| `UnsupportedKeyScope` | The masked key's scope (`Session`, `SecurityDomain`, or other) has no masking key yet | +| `AesGcmDecryptTagDoesNotMatch` | The masked key failed authentication (tampered or wrong masking key) | +| `UnsupportedKeyType` | The attested key kind cannot be attested (symmetric, RSA-private, or a non-attestable / internal kind) | +| `DdiDecodeFailed` | Malformed request body | + +## See also + +- Wire encoding: [TBOR specification](../../../fw/core/ddi/tbor/docs/spec.md) +- Wire schema: `fw/core/ddi/tbor/types/src/key_report.rs` +- Report format: `fw/core/crypto/key-report/` +- Masked-key producer: [SdSealingKeyGen](./sd_sealing_key_gen.md) diff --git a/fw/core/ddi/tbor/types/src/key_report.rs b/fw/core/ddi/tbor/types/src/key_report.rs new file mode 100644 index 000000000..4fa6e8329 --- /dev/null +++ b/fw/core/ddi/tbor/types/src/key_report.rs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `KeyReport` wire schema. +//! +//! `KeyReport` is an in-session command that takes a **masked key** +//! (e.g. the `masked_key` returned by +//! [`SdSealingKeyGen`](crate::sd_sealing_key_gen)), unmasks it, derives +//! the attested key's public component on-device, and returns a signed +//! COSE_Sign1 key-attestation report over it. The report is signed by the +//! partition-identity (PID) key — the same signer as the `PartInit` PTA +//! report. +//! +//! Inputs: +//! +//! * `session_id` — TOC-carried session id; cross-checked against the +//! SQE-carried session id by the dispatcher (parity with the other +//! in-session commands). +//! * `masked_key` — the masked-key envelope to attest, as produced by a +//! masking command. Variable length (the plaintext size depends on the +//! key kind) up to [`KEY_REPORT_MASKED_KEY_MAX_LEN`]. +//! * `report_data` — caller-supplied [`KEY_REPORT_DATA_LEN`] (128 B) data +//! bound into the report payload (typically a freshness nonce or a +//! challenge digest). +//! +//! Outputs: +//! +//! * `report` — the tagged COSE_Sign1 key-attestation report. Variable +//! length up to [`KEY_REPORT_MAX_LEN`]. + +use azihsm_fw_ddi_tbor_api::tbor; + +/// TBOR opcode for `KeyReport`. +/// +/// `0x0A..=0x0F` are reserved by the Security-Domain backup schema family +/// (`SdCreateRemoteBackup` .. `SdRestorePeerBackup`), so `KeyReport` +/// takes the next free opcode, `0x10`. +pub const TBOR_OP_KEY_REPORT: u8 = 0x10; + +/// Maximum wire length of the `masked_key` request buffer. +/// +/// A masked-key envelope is `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(N) ‖ +/// tag(16)` = `132 + N`, where `N` is the raw key plaintext (48 B for a +/// P-384 sealing scalar). The command currently attests only ECC-private +/// kinds; this bound is sized generously to leave headroom for larger +/// key kinds (e.g. symmetric or larger curves) without a wire change. +/// Pinned into the `#[tbor(buffer, max_len = 512)]` literal on +/// [`TborKeyReportReq::masked_key`]. +pub const KEY_REPORT_MASKED_KEY_MAX_LEN: usize = 512; + +/// Length of the caller-supplied `report_data` field bound into the +/// report payload. Pinned into the `#[tbor(buffer, len = 128)]` literal +/// on [`TborKeyReportReq::report_data`]. +pub const KEY_REPORT_DATA_LEN: usize = 128; + +/// Maximum wire length of the returned COSE_Sign1 `report`. The command +/// currently attests ECC-private keys (P-256/384/521 COSE_Key); this +/// bound carries headroom for a future largest case (a 4096-bit RSA +/// COSE_Key) so the wire size need not change if RSA attestation is +/// later added. Pinned into the `#[tbor(buffer, max_len = 1024)]` +/// literal on [`TborKeyReportResp::report`]. +pub const KEY_REPORT_MAX_LEN: usize = 1024; + +/// `KeyReport` request schema. +/// +/// Attests the key carried by `masked_key`, binding `report_data` into +/// the signed report payload. +#[tbor(opcode = 0x10)] +pub struct TborKeyReportReq<'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-key envelope to attest. Variable length up to + /// [`KEY_REPORT_MASKED_KEY_MAX_LEN`]. + #[tbor(buffer, max_len = 512)] + pub masked_key: &'a [u8], + + /// Caller-supplied [`KEY_REPORT_DATA_LEN`] (128 B) report data bound + /// into the report payload. + #[tbor(buffer, len = 128)] + pub report_data: &'a [u8], +} + +/// `KeyReport` response schema. +#[tbor(response)] +pub struct TborKeyReportResp<'a> { + /// The tagged COSE_Sign1 key-attestation report, signed by the PID + /// key. Variable length up to [`KEY_REPORT_MAX_LEN`]. + #[tbor(buffer, max_len = 1024)] + pub report: &'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_report_data() { + let mut buf = [0u8; 1024]; + let masked = [0xCDu8; 180]; + let report_data = [0xABu8; KEY_REPORT_DATA_LEN]; + let frame = TborKeyReportReq::encode(&mut buf) + .unwrap() + .session_id(SessionId(9)) + .unwrap() + .masked_key(&masked) + .unwrap() + .report_data(&report_data) + .unwrap() + .finish(); + assert_eq!(frame.masked_key(), &masked[..]); + assert_eq!(frame.report_data(), &report_data[..]); + } + + #[test] + fn response_round_trips_report() { + let mut buf = [0u8; 1024]; + let report = [0x5Au8; 300]; + let frame = TborKeyReportResp::encode(&mut buf, 0, false) + .unwrap() + .report(&report) + .unwrap() + .finish(); + assert_eq!(frame.report(), &report[..]); + } + + #[test] + fn schema_lengths_match_pinned_values() { + // The `#[tbor(... len)]` attributes must remain numeric literals; + // pin them against the exported consts. + const _: () = assert!(512 == KEY_REPORT_MASKED_KEY_MAX_LEN); + const _: () = assert!(128 == KEY_REPORT_DATA_LEN); + const _: () = assert!(1024 == KEY_REPORT_MAX_LEN); + assert_eq!(TBOR_OP_KEY_REPORT, 0x10); + } +} diff --git a/fw/core/ddi/tbor/types/src/lib.rs b/fw/core/ddi/tbor/types/src/lib.rs index 656c48708..07183f4fa 100644 --- a/fw/core/ddi/tbor/types/src/lib.rs +++ b/fw/core/ddi/tbor/types/src/lib.rs @@ -37,6 +37,7 @@ pub mod tbor_int { pub mod api_rev; pub mod evidence; pub mod key_props; +pub mod key_report; pub mod part_final; pub mod part_info; pub mod part_init; @@ -56,6 +57,7 @@ pub mod session_open_init; pub use api_rev::*; pub use evidence::*; pub use key_props::*; +pub use key_report::*; pub use part_final::*; pub use part_info::*; pub use part_init::*; diff --git a/fw/core/lib/src/ddi/tbor/from_pal.rs b/fw/core/lib/src/ddi/tbor/from_pal.rs new file mode 100644 index 000000000..ce16224c0 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/from_pal.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! PAL trait type conversions shared by TBOR handlers. +//! +//! The TBOR-side counterpart of the MBOR handlers' `from_pal` table. +//! It is intentionally a **separate** copy rather than a shared import: +//! keeping `tbor` independent of `mbor` (see [`crate::ddi`]) is worth a +//! small, self-contained table here, and TBOR-only vault kinds (e.g. +//! [`HsmVaultKeyKind::SdSealing`]) belong only in this table. + +use azihsm_fw_hsm_pal_traits::HsmEccCurve; +use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; + +/// Map an ECC-private vault key kind to its NIST curve, or `None` if the +/// kind is not an attestable ECC private key. +/// +/// Includes [`HsmVaultKeyKind::SdSealing`] — the SD sealing key is a +/// P-384 key that TBOR `KeyReport` attests. +pub(crate) fn ecc_curve(kind: HsmVaultKeyKind) -> Option { + match kind { + HsmVaultKeyKind::Ecc256Private => Some(HsmEccCurve::P256), + HsmVaultKeyKind::Ecc384Private | HsmVaultKeyKind::SdSealing => Some(HsmEccCurve::P384), + HsmVaultKeyKind::Ecc521Private => Some(HsmEccCurve::P521), + _ => None, + } +} diff --git a/fw/core/lib/src/ddi/tbor/key_report.rs b/fw/core/lib/src/ddi/tbor/key_report.rs new file mode 100644 index 000000000..1a6e1ebe8 --- /dev/null +++ b/fw/core/lib/src/ddi/tbor/key_report.rs @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TBOR `KeyReport` handler. +//! +//! Takes a **masked key** (e.g. the `masked_key` returned by +//! [`SdSealingKeyGen`](super::sd_sealing_key_gen)), unmasks it, derives +//! the attested key's public component on-device, and returns a signed +//! COSE_Sign1 key-attestation report over it. The report is signed by the +//! partition-identity (PID) key — the same signer as the `PartInit` PTA +//! report. +//! +//! Flow: +//! +//! 1. Decode the request; gate to a Crypto-Officer, `Active` session on an +//! `Initialized` partition (parity with `SdSealingKeyGen`). +//! 2. **Peek** the masked blob's cleartext metadata to read the key +//! `scope`, and resolve the scope's masking key. +//! 3. Unmask the blob (verifies the AEAD tag), recovering the private key +//! plus its validated metadata (kind, attributes). +//! 4. Dispatch by key kind to build the attested public key. Only +//! ECC-private kinds are attestable: they derive the public key via +//! [`HsmEcc::ecc_pub_from_priv`](azihsm_fw_hsm_pal_traits::HsmEcc::ecc_pub_from_priv). +//! Every other kind — symmetric (no public component), RSA-private +//! (modulus extraction not yet implemented), and non-attestable / +//! internal kinds — is rejected with +//! [`HsmError::UnsupportedKeyType`]. +//! 5. Build and PID-sign the COSE_Sign1 report over the derived key, +//! caller-supplied `report_data`, session app id, and VM launch id. +//! +//! The blob's `svn` / `owner_seed_id` are **not** enforced against the +//! current partition lineage: the report reflects the key as-masked (the +//! AEAD tag still guarantees integrity / authenticity). +//! +//! This command is **Crypto-Officer-only**. + +use azihsm_fw_core_crypto_key_masking::aead::peek_metadata; +use azihsm_fw_core_crypto_key_masking::aead::unmask; +use azihsm_fw_core_crypto_key_report::key_report; +use azihsm_fw_core_crypto_key_report::AttestedPubKey; +use azihsm_fw_core_crypto_key_report::KeyFlags; +use azihsm_fw_core_crypto_key_report::KeyReportParams; +use azihsm_fw_ddi_tbor_types::TborKeyReportReq; +use azihsm_fw_ddi_tbor_types::TborKeyReportResp; +use azihsm_fw_ddi_tbor_types::KEY_REPORT_MAX_LEN; +use azihsm_fw_hsm_pal_traits::DmaBuf; +use azihsm_fw_hsm_pal_traits::HsmError; +use azihsm_fw_hsm_pal_traits::HsmIo; +use azihsm_fw_hsm_pal_traits::HsmKeyId; +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::masking_key_id_for_scope; +use super::validate_crypto_officer_active_session; +use crate::part_state; + +/// Length of the app-UUID field bound into the report. +const APP_UUID_LEN: usize = 16; + +// The report's app-UUID is the session AppId copied verbatim, so the two +// lengths must stay equal for the infallible `copy_from_slice` in +// `build_report_fields` not to panic. Enforce it at compile time (a +// runtime length check would be unreachable while this holds). +const _: () = assert!(APP_UUID_LEN == azihsm_fw_hsm_pal_traits::APP_ID_LEN); + +/// Translate the recovered vault attributes into report [`KeyFlags`]. +fn key_flags_from_attrs(attrs: HsmVaultKeyAttrs) -> KeyFlags { + KeyFlags::new() + .with_is_imported(!attrs.local()) + .with_is_session_key(attrs.session()) + .with_is_generated(attrs.local()) + .with_can_encrypt(attrs.encrypt()) + .with_can_decrypt(attrs.decrypt()) + .with_can_sign(attrs.sign()) + .with_can_verify(attrs.verify()) + .with_can_wrap(attrs.wrap()) + .with_can_unwrap(attrs.unwrap()) + .with_can_derive(attrs.derive()) +} + +/// Reverse-copy `src` into `dst[..src.len()]` (LE↔BE per coordinate). +fn reverse_copy(dst: &mut [u8], src: &[u8]) { + for (d, s) in dst.iter_mut().zip(src.iter().rev()) { + *d = *s; + } +} + +/// Allocate and populate the report's app-UUID field — the session AppId +/// copied verbatim. It is the only non-key report field that needs owned +/// storage; `report_data` and `vm_launch_id` are borrowed in place. +fn build_app_uuid<'a, P: HsmPal>( + pal: &P, + io: &impl HsmIo, + alloc: &'a impl HsmScopedAlloc, + sess_id: HsmSessId, +) -> HsmResult<&'a mut DmaBuf> { + let app_id = crate::session::session_app_id(pal, io, sess_id)?; + let app_uuid = alloc.dma_alloc(APP_UUID_LEN)?; + app_uuid.copy_from_slice(&app_id); + Ok(app_uuid) +} + +/// Convert the recovered private key into attestable public-key material. +async fn attested_pub_key<'a, P: HsmPal>( + pal: &P, + io: &impl HsmIo, + alloc: &'a impl HsmScopedAlloc, + key_kind: HsmVaultKeyKind, + priv_key: &DmaBuf, +) -> HsmResult> { + let Some(curve) = super::from_pal::ecc_curve(key_kind) else { + // Only ECC-private kinds are attestable. Symmetric keys have no + // public component (an empty COSE_Key would tell the caller + // nothing — not even the key type), RSA-private needs a + // modulus-extraction PAL method, and every other kind is + // non-attestable / internal. + return Err(HsmError::UnsupportedKeyType); + }; + + let coord_raw = curve.priv_key_len(); + let coord_wire = curve.wire_coord_len(); + + let pub_le = alloc.dma_alloc(curve.wire_pub_key_len())?; + pal.ecc_pub_from_priv(io, curve, priv_key, pub_le).await?; + + // The COSE_Key encodes big-endian coordinates; the PAL emits the + // wire-LE `x ‖ y`, so reverse each coordinate. + let x_be = alloc.dma_alloc(coord_raw)?; + let y_be = alloc.dma_alloc(coord_raw)?; + reverse_copy(x_be, &pub_le[..coord_raw]); + reverse_copy(y_be, &pub_le[coord_wire..coord_wire + coord_raw]); + + Ok(AttestedPubKey::Ecc { + curve, + x: x_be, + y: y_be, + }) +} + +/// Handle a TBOR `KeyReport` request. +/// +/// No partition lock or undo log is required: the command **persists +/// nothing** — it unmasks the caller-supplied blob, derives its public +/// component, and returns a signed report. It makes no observable state +/// change, so a concurrently-dispatched command (IOs run in a task pool +/// and interleave at await points) can neither observe it half-done nor +/// require its rollback on failure. +pub(crate) async fn handle<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + req_buf: &DmaBuf, +) -> HsmResult<&'p DmaBuf> { + let req = TborKeyReportReq::decode(req_buf)?; + let sess_id = HsmSessId::from(u16::from(req.session_id())); + + validate_crypto_officer_active_session(pal, io, sess_id)?; + + // The scope's masking key is provisioned by `PartFinal`, so the + // partition must be finalized (`Initialized`). + if part_state::part_state(pal, io)? != PartState::Initialized { + return Err(HsmError::InvalidArg); + } + + // Peek the masked-key metadata (cleartext, tag-bound) to route to the + // right masking key before unmasking. + let masked_key = req.masked_key(); + let report_data = req.report_data(); + let scope = peek_metadata(masked_key)?.usage_flags().scope(); + let mk_key_id = masking_key_id_for_scope(pal, io, scope)?; + + pal.alloc_scoped_async(io, async |alloc| { + let (report, report_len) = + build_key_report(pal, io, alloc, masked_key, mk_key_id, sess_id, report_data).await?; + encode_response(pal, io, &report[..report_len]) + }) + .await +} + +/// Unmask the key, derive its public component, and build the PID-signed +/// COSE_Sign1 report over it. +/// +/// Returns the report bytes in `alloc`-scoped scratch (`&mut DmaBuf`) plus +/// its exact length; the caller copies them into the IO-scoped response. +async fn build_key_report<'a, P: HsmPal>( + pal: &P, + io: &impl HsmIo, + alloc: &'a impl HsmScopedAlloc, + masked_key: &DmaBuf, + mk_key_id: HsmKeyId, + sess_id: HsmSessId, + report_data: &DmaBuf, +) -> HsmResult<(&'a mut DmaBuf, usize)> { + // Copy the masked blob into a scratch buffer for in-place unmask. + let blob = alloc.dma_alloc(masked_key.len())?; + blob.copy_from_slice(masked_key); + + // Unmask (verifies the AEAD tag) and copy out the recovered private + // key plus its metadata, releasing the blob borrow. + let masking_key = pal.vault_key(io, mk_key_id)?; + let unmask_res = async { + let view = unmask(pal, io, masking_key, blob).await?; + let priv_key = alloc.dma_alloc(view.target_key.len())?; + priv_key.copy_from_slice(view.target_key); + Ok::<_, HsmError>((view.key_kind, view.key_attrs, priv_key)) + } + .await; + + // `unmask` decrypts the private key in place into `blob`; on tag + // mismatch it can leave partial plaintext there. Scope rewind does not + // clear DMA memory, so wipe it on every path — whether unmask succeeded + // or failed — before proceeding or propagating, so no key material + // lingers in, and leaks through, a later per-IO allocation. + blob.zeroize(); + let (key_kind, key_attrs, priv_key) = unmask_res?; + + let flags: u32 = key_flags_from_attrs(key_attrs).into(); + let key_res = attested_pub_key(pal, io, alloc, key_kind, priv_key).await; + + // The recovered private scalar is no longer needed once public-key + // derivation has been attempted; wipe the copy on every path (success + // or failure) for the same reason. + priv_key.zeroize(); + let key = key_res?; + + build_signed_report(pal, io, alloc, sess_id, key, flags, report_data).await +} + +/// Encode the `KeyReport` response frame around the finished report. +fn encode_response<'p, P: HsmPal>( + pal: &'p P, + io: &impl HsmIo, + report_bytes: &[u8], +) -> HsmResult<&'p DmaBuf> { + let resp = pal.dma_alloc_var(io, |buf| { + let frame = TborKeyReportResp::encode(buf, 0, false)? + .report(report_bytes)? + .finish(); + Ok(frame.as_bytes().len()) + })?; + Ok(resp) +} + +/// Assemble the report inputs and build the PID-signed COSE_Sign1 report +/// via the key-report crate's two-pass builder. +async fn build_signed_report<'a, P: HsmPal>( + pal: &P, + io: &impl HsmIo, + alloc: &'a impl HsmScopedAlloc, + sess_id: HsmSessId, + key: AttestedPubKey<'_>, + flags: u32, + report_data_src: &DmaBuf, +) -> HsmResult<(&'a mut DmaBuf, usize)> { + let pid_priv = pal.vault_key(io, part_state::part_id_key_id(pal, io)?)?; + + let vm_launch_id = part_state::part_vm_launch_guid(pal, io)?; + + let app_uuid = build_app_uuid(pal, io, alloc, sess_id)?; + + // `report_data` borrows straight from the request buffer (no copy); + // `key_report` validates its exact `KEY_REPORT_DATA_LEN` length. + let params = KeyReportParams { + key, + flags, + app_uuid, + report_data: report_data_src, + vm_launch_id, + }; + + // Two-pass: query the exact size, then build into an exact buffer. + let report_len = key_report(pal, io, alloc, ¶ms, pid_priv, None).await?; + if report_len > KEY_REPORT_MAX_LEN { + return Err(HsmError::InternalError); + } + let report = alloc.dma_alloc(report_len)?; + key_report(pal, io, alloc, ¶ms, pid_priv, Some(report)).await?; + Ok((report, report_len)) +} diff --git a/fw/core/lib/src/ddi/tbor/mod.rs b/fw/core/lib/src/ddi/tbor/mod.rs index db1c03036..b72363c2b 100644 --- a/fw/core/lib/src/ddi/tbor/mod.rs +++ b/fw/core/lib/src/ddi/tbor/mod.rs @@ -21,6 +21,8 @@ //! per-IO allocator scope). pub(crate) mod api_rev; +pub(crate) mod from_pal; +pub(crate) mod key_report; pub(crate) mod part_final; pub mod part_info; pub mod part_init; @@ -37,12 +39,18 @@ use azihsm_fw_ddi_tbor::TocEntry; use azihsm_fw_ddi_tbor::PROTOCOL_VERSION; use azihsm_fw_hsm_oob::OobPtr; 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::HsmKeyId; 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::HsmSessionState; use azihsm_fw_hsm_pal_traits::SessionRole; use azihsm_fw_hsm_undo::UndoLog; use super::*; +use crate::part_state; /// TBOR opcodes recognised by the firmware dispatcher. /// @@ -101,6 +109,44 @@ pub(crate) mod opcode { /// masking key (nothing is persisted on-device) plus the public key. /// See [`super::sd_sealing_key_gen`]. pub(crate) const SD_SEALING_KEY_GEN: u8 = 0x09; + + /// `KeyReport` — attest a masked key: unmask it, derive its public + /// component on-device, and return a PID-signed COSE_Sign1 + /// key-attestation report over it. See [`super::key_report`]. + /// + /// `0x0A..=0x0F` are reserved by the Security-Domain backup schema + /// family, so `KeyReport` takes the next free opcode, `0x10`. + pub(crate) const KEY_REPORT: u8 = 0x10; +} + +/// Validate that `sess_id` belongs to an active Crypto-Officer session. +fn validate_crypto_officer_active_session( + pal: &P, + io: &impl HsmIo, + sess_id: HsmSessId, +) -> HsmResult<()> { + if sess_id.role() != SessionRole::CryptoOfficer { + return Err(HsmError::InvalidPermissions); + } + + if !matches!(pal.session_state(io, sess_id), HsmSessionState::Active) { + return Err(HsmError::SessionNotFound); + } + + Ok(()) +} + +/// Resolve the vault id of the masking key associated with `scope`. +fn masking_key_id_for_scope( + pal: &P, + io: &impl HsmIo, + scope: HsmKeyScope, +) -> HsmResult { + match scope { + HsmKeyScope::Ephemeral => part_state::part_ephemeral_mk_key_id(pal, io), + HsmKeyScope::Local => part_state::part_local_mk_key_id(pal, io), + _ => Err(HsmError::UnsupportedKeyScope), + } } /// Dispatch a parsed TBOR request to its handler. @@ -185,6 +231,7 @@ pub(crate) async fn dispatch<'p, P: HsmPal>( opcode::PART_FINAL => part_final::handle(pal, io, req_buf, oob, undo).await, opcode::PART_INFO => part_info::handle(pal, io, req_buf), opcode::SD_SEALING_KEY_GEN => sd_sealing_key_gen::handle(pal, io, req_buf).await, + opcode::KEY_REPORT => key_report::handle(pal, io, req_buf).await, _ => Err(HsmError::UnsupportedCmd), } } @@ -205,6 +252,7 @@ fn is_known_opcode(opcode: u8) -> bool { | opcode::PART_FINAL | opcode::PART_INFO | opcode::SD_SEALING_KEY_GEN + | opcode::KEY_REPORT ) } @@ -231,7 +279,8 @@ fn is_in_session(opcode: u8) -> bool { | opcode::PSK_CHANGE | opcode::PART_INIT | opcode::PART_FINAL - | opcode::SD_SEALING_KEY_GEN => true, + | opcode::SD_SEALING_KEY_GEN + | opcode::KEY_REPORT => true, // Default-deny: any future opcode is treated as in-session // until classified, so the default-PSK gate applies to it. _ => true, @@ -266,7 +315,8 @@ fn needs_session_id_cross_check(opcode: u8) -> bool { | opcode::PSK_CHANGE | opcode::PART_INIT | opcode::PART_FINAL - | opcode::SD_SEALING_KEY_GEN => true, + | opcode::SD_SEALING_KEY_GEN + | opcode::KEY_REPORT => true, _ => true, } } @@ -353,6 +403,7 @@ mod tests { opcode::PART_INFO, opcode::PART_FINAL, opcode::SD_SEALING_KEY_GEN, + opcode::KEY_REPORT, ] { assert!(is_known_opcode(op), "{op:#04x} should be known"); } @@ -383,23 +434,8 @@ mod tests { } #[test] - fn part_commands_are_in_session_and_cross_checked() { - for op in [opcode::PART_INIT] { - // The opcode must actually be wired into `dispatch`; classifying - // an opcode without implementing it would be a latent bug. - assert!(is_known_opcode(op), "{op:#04x} must be a known opcode"); - assert!(is_in_session(op), "{op:#04x} must be in-session"); - assert!( - needs_session_id_cross_check(op), - "{op:#04x} must cross-check the SQE/body session_id", - ); - // Provisioning commands are NOT on the default-PSK - // allow-list: they require a rotated PSK. - assert!( - !allowed_with_default_psk(op), - "{op:#04x} must NOT bypass the default-PSK gate", - ); - } + fn key_report_is_in_session() { + assert!(is_in_session(opcode::KEY_REPORT)); } #[test] diff --git a/fw/core/lib/src/ddi/tbor/sd_sealing_key_gen.rs b/fw/core/lib/src/ddi/tbor/sd_sealing_key_gen.rs index 065879f3b..91f8ed535 100644 --- a/fw/core/lib/src/ddi/tbor/sd_sealing_key_gen.rs +++ b/fw/core/lib/src/ddi/tbor/sd_sealing_key_gen.rs @@ -49,12 +49,12 @@ 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::HsmSessionState; use azihsm_fw_hsm_pal_traits::HsmVaultKeyAttrs; use azihsm_fw_hsm_pal_traits::HsmVaultKeyKind; use azihsm_fw_hsm_pal_traits::PartState; -use azihsm_fw_hsm_pal_traits::SessionRole; +use super::masking_key_id_for_scope; +use super::validate_crypto_officer_active_session; use crate::part_state; /// NIST curve for security-domain sealing keys. Fixed at P-384 to match @@ -65,25 +65,6 @@ const SD_SEALING_CURVE: HsmEccCurve = HsmEccCurve::P384; /// Envelope key-label recorded in the masked blob's `MaskedKeyMetadata`. const SEALING_KEY_LABEL: &[u8] = b"SDSealingKey"; -fn validate_crypto_officer_active_session( - pal: &P, - io: &impl HsmIo, - sess_id: HsmSessId, -) -> HsmResult<()> { - // SdSealingKeyGen is Crypto-Officer-only. - if sess_id.role() != SessionRole::CryptoOfficer { - return Err(HsmError::InvalidPermissions); - } - - // The dispatcher validates only SQE/body consistency; the slot must - // still be checked for liveness at the handler boundary. - if !matches!(pal.session_state(io, sess_id), HsmSessionState::Active) { - return Err(HsmError::SessionNotFound); - } - - Ok(()) -} - /// Attributes recorded in the masked blob's metadata (restored on /// re-import). Per the SD-sealing-key contract the only usage attribute /// is `derive`; `local`/`private`/`never_extractable` are HSM-internal @@ -97,26 +78,6 @@ fn sealing_key_attrs(scope: HsmKeyScope) -> HsmVaultKeyAttrs { .with_scope(scope) } -/// Resolve the vault id of the masking key associated with `scope`. -/// -/// Only [`Ephemeral`](HsmKeyScope::Ephemeral) and -/// [`Local`](HsmKeyScope::Local) are supported for now (their masking -/// keys are provisioned by `PartFinal`). Every other scope — -/// [`Session`](HsmKeyScope::Session), -/// [`SecurityDomain`](HsmKeyScope::SecurityDomain), or an unrecognized -/// discriminant — is rejected with [`HsmError::UnsupportedKeyScope`]. -fn masking_key_id_for_scope( - pal: &P, - io: &impl HsmIo, - scope: HsmKeyScope, -) -> HsmResult { - match scope { - HsmKeyScope::Ephemeral => part_state::part_ephemeral_mk_key_id(pal, io), - HsmKeyScope::Local => part_state::part_local_mk_key_id(pal, io), - _ => Err(HsmError::UnsupportedKeyScope), - } -} - async fn generate_sealing_keypair<'p, P: HsmPal>( pal: &'p P, io: &impl HsmIo, diff --git a/fw/core/lib/src/op.rs b/fw/core/lib/src/op.rs index bfe3e1b7a..92db93a00 100644 --- a/fw/core/lib/src/op.rs +++ b/fw/core/lib/src/op.rs @@ -261,7 +261,8 @@ impl SessionCtrl { | opcode::PSK_CHANGE | opcode::PART_INIT | opcode::PART_FINAL - | opcode::SD_SEALING_KEY_GEN => Self::InSession, + | opcode::SD_SEALING_KEY_GEN + | opcode::KEY_REPORT => Self::InSession, opcode::SESSION_CLOSE => Self::Close, _ => Self::NoSession, } diff --git a/fw/pal/traits/src/crypto/ecc.rs b/fw/pal/traits/src/crypto/ecc.rs index 5595fa8e9..8a0d9ea76 100644 --- a/fw/pal/traits/src/crypto/ecc.rs +++ b/fw/pal/traits/src/crypto/ecc.rs @@ -380,6 +380,44 @@ pub trait HsmEcc { pub_out: Option<&mut DmaBuf>, ) -> HsmResult; + /// Derive the public key from a raw private scalar (`pub = priv · G`). + /// + /// Computes the public point by base-point scalar multiplication and + /// serializes it in the little-endian DDI wire form. Performs no + /// hashing and no signing. + /// + /// # Parameters + /// + /// - `io` — caller's I/O context (per-IO scope). + /// - `curve` — NIST curve the private key is on. + /// - `priv_key` — private key in raw HSM-format scalar bytes + /// (`curve.wire_priv_key_len()`: 32 / 48 / 68 bytes for + /// P-256 / P-384 / P-521), **little-endian** to match the + /// wire-native format produced by real PKA hardware. + /// - `pub_key` — output buffer for the uncompressed point `x || y`; + /// must be **at least** `curve.wire_pub_key_len()` bytes. Only the + /// first `wire_pub_key_len()` bytes are written (any tail is left + /// untouched — see *Returns*). **Each coordinate is written + /// little-endian** with P-521 coordinates padded to 68 bytes — + /// matching the on-wire DDI representation and real PKA hardware. + /// Implementations that delegate to a big-endian-native primitive + /// (e.g. OpenSSL) must reverse each coordinate internally. + /// + /// # Returns + /// + /// - `Ok(())` — `pub_key[..wire_pub_key_len]` populated. + /// - `Err(HsmError::InvalidArg)` — `priv_key` not `wire_priv_key_len()` + /// bytes, `pub_key` shorter than `wire_pub_key_len()`, or an invalid + /// private scalar. + /// - `Err(HsmError)` — propagated from the PKA driver. + async fn ecc_pub_from_priv( + &self, + io: &impl HsmIo, + curve: HsmEccCurve, + priv_key: &DmaBuf, + pub_key: &mut DmaBuf, + ) -> HsmResult<()>; + /// ECDH key agreement: derives a shared secret from a local /// private key and a remote public key. /// diff --git a/fw/pal/traits/src/error.rs b/fw/pal/traits/src/error.rs index 70270cf7d..6a5841590 100644 --- a/fw/pal/traits/src/error.rs +++ b/fw/pal/traits/src/error.rs @@ -370,6 +370,14 @@ pub enum HsmError { /// request is well-formed, the scope is simply unsupported for now. UnsupportedKeyScope = 0x08700102, + /// Returned by `KeyReport` when the masked key's kind cannot be + /// attested: symmetric keys (no public component to bind), RSA + /// private keys (public-modulus extraction not yet implemented), and + /// every non-attestable / internal kind. Distinct from + /// [`UnsupportedCmd`](Self::UnsupportedCmd) (an unknown command) — + /// the command is supported, the key type simply is not. + UnsupportedKeyType = 0x08700103, + /// The SQE's out-of-band descriptor-array length (`oob_len`) is /// malformed: not a whole number of 16-byte SGL descriptors, or /// larger than a single descriptor page. diff --git a/fw/plat/std/pal/src/drivers/ecc.rs b/fw/plat/std/pal/src/drivers/ecc.rs index a6f2ed08e..9dd641ffe 100644 --- a/fw/plat/std/pal/src/drivers/ecc.rs +++ b/fw/plat/std/pal/src/drivers/ecc.rs @@ -40,6 +40,7 @@ use azihsm_crypto::PrivateKey; use azihsm_crypto::SignOp; use azihsm_crypto::VerifyOp; use azihsm_fw_hsm_pal_traits::*; +use zeroize::Zeroizing; use super::reverse_copy; use crate::worker::WorkerPool; @@ -364,6 +365,57 @@ impl StdEcc { secret_out[..coord_len].reverse(); Ok(()) } + + /// Derive the public key from a raw HSM-format private scalar + /// (`pub = priv · G`) and serialize it as wire-LE `x || y`. + /// + /// All OpenSSL work — key reconstruction (which computes + /// `Q = d · G`), public-point extraction, and coordinate readout — + /// runs on the worker pool. The BE→LE per-coordinate flip (and + /// P-521 padding) is applied here, mirroring [`Self::pub_coords`]. + /// + /// `priv_hsm.len()` must be `wire_coord_len(curve)` and `out.len()` + /// must be `wire_pub_key_len(curve)` (`2 × wire_coord_len`). + pub async fn pub_from_priv_le( + &self, + curve: EccCurve, + priv_hsm: &[u8], + out: &mut [u8], + ) -> HsmResult<()> { + let coord_len = priv_key_len(curve); + let wire_coord = wire_coord_len(curve); + if priv_hsm.len() != wire_coord || out.len() != wire_coord * 2 { + return Err(HsmError::InvalidArg); + } + + // `Zeroizing` scrubs this transient heap copy of the private + // scalar when the worker closure drops it, so the plaintext key + // bytes don't linger in freed process heap memory. + let priv_owned = Zeroizing::new(priv_hsm.to_vec()); + let (x_be, y_be) = self + .pool + .submit_with_result(async move { + let pk = + EccPrivateKey::from_hsm_bytes(&priv_owned).map_err(|_| HsmError::InvalidArg)?; + let pub_key = pk + .public_key() + .map_err(|_| HsmError::EccGetCoordinatesError)?; + let mut x_be = [0u8; 66]; + let mut y_be = [0u8; 66]; + pub_key + .coord(Some((&mut x_be[..coord_len], &mut y_be[..coord_len]))) + .map_err(|_| HsmError::EccGetCoordinatesError)?; + Ok::<_, HsmError>((x_be, y_be)) + }) + .await?; + + // BE→LE per coordinate; the driver owns the byte-order flip. + out.fill(0); + let (x_dst, y_dst) = out.split_at_mut(wire_coord); + reverse_copy(x_dst, &x_be[..coord_len]); + reverse_copy(y_dst, &y_be[..coord_len]); + Ok(()) + } } /// Raw private-key (and per-coordinate) length in bytes for the @@ -455,7 +507,61 @@ mod tests { assert_eq!(le, expected); } - // ── Sign / verify roundtrip ───────────────────────────────── + // ── Public-key derivation from private scalar ─────────────── + + async fn pub_from_priv_roundtrip(curve: EccCurve, priv_len: usize, pub_len: usize) { + use azihsm_crypto::ExportableHsmKey; + let driver = make_driver(); + let (priv_key, pub_key) = driver.gen_keypair(curve).await.unwrap(); + + // Export the private scalar in the HSM-bytes format the mask / + // unmask pipeline round-trips (the same format `ecc_sign` reads). + let mut priv_hsm = [0u8; 68]; + let n = priv_key.to_hsm_bytes(&mut priv_hsm[..priv_len]).unwrap(); + assert_eq!(n, priv_len); + + // Derive the public key from the private scalar. + let mut derived_le = [0u8; 136]; + driver + .pub_from_priv_le(curve, &priv_hsm[..priv_len], &mut derived_le[..pub_len]) + .await + .unwrap(); + + // It must equal the generated public key serialized wire-LE. + let mut expected_le = [0u8; 136]; + driver + .pub_coords(&pub_key, false, &mut expected_le[..pub_len]) + .await + .unwrap(); + + assert_eq!(derived_le[..pub_len], expected_le[..pub_len]); + } + + #[tokio::test] + async fn pub_from_priv_matches_gen_p256() { + pub_from_priv_roundtrip(EccCurve::P256, 32, 64).await; + } + + #[tokio::test] + async fn pub_from_priv_matches_gen_p384() { + pub_from_priv_roundtrip(EccCurve::P384, 48, 96).await; + } + + #[tokio::test] + async fn pub_from_priv_matches_gen_p521() { + pub_from_priv_roundtrip(EccCurve::P521, 68, 136).await; + } + + #[tokio::test] + async fn pub_from_priv_rejects_wrong_out_len() { + let driver = make_driver(); + let priv_hsm = [1u8; 48]; + let mut out = [0u8; 64]; // wrong: P-384 needs 96 + let err = driver + .pub_from_priv_le(EccCurve::P384, &priv_hsm, &mut out) + .await; + assert!(err.is_err()); + } #[tokio::test] async fn sign_verify_p256() { diff --git a/fw/plat/std/pal/src/ecc.rs b/fw/plat/std/pal/src/ecc.rs index d2dc0dc34..bdf544b17 100644 --- a/fw/plat/std/pal/src/ecc.rs +++ b/fw/plat/std/pal/src/ecc.rs @@ -236,6 +236,32 @@ impl HsmEcc for StdHsmPal { Ok(()) } + /// Derive the public key from a raw private scalar (`pub = priv · G`). + /// + /// Delegates to the driver's `pub_from_priv_le`, which runs the + /// OpenSSL key reconstruction and public-point derivation on the + /// worker pool and emits the little-endian DDI wire form. + async fn ecc_pub_from_priv( + &self, + _io: &impl HsmIo, + curve: HsmEccCurve, + priv_key: &DmaBuf, + pub_key: &mut DmaBuf, + ) -> HsmResult<()> { + let wire_priv_len = curve.wire_priv_key_len(); + let wire_pub_len = curve.wire_pub_key_len(); + if priv_key.len() != wire_priv_len || pub_key.len() < wire_pub_len { + return Err(HsmError::InvalidArg); + } + self.ecc + .pub_from_priv_le( + to_ecc_curve(curve), + &priv_key[..wire_priv_len], + &mut pub_key[..wire_pub_len], + ) + .await + } + /// ECDH key agreement — derives a shared secret. /// /// Parses the local raw HSM-format private into an OpenSSL diff --git a/fw/plat/uno/fw/pal/src/crypto/ecc.rs b/fw/plat/uno/fw/pal/src/crypto/ecc.rs index c4477c7e3..88fbd40e2 100644 --- a/fw/plat/uno/fw/pal/src/crypto/ecc.rs +++ b/fw/plat/uno/fw/pal/src/crypto/ecc.rs @@ -379,4 +379,41 @@ impl HsmEcc for UnoHsmPal { // key on Uno PKA (RsaUnwrap ECC import). Err(HsmError::UnsupportedCmd) } + + /// Derive the public key from a raw private scalar (`pub = priv · G`). + /// + /// Delegates to [`UnoHsmPal::pka.ecc_gen_pub_key`] (PKA base-point + /// scalar multiplication) after curve mapping. Both buffers are in + /// the little-endian PKA wire format. + /// + /// # Parameters + /// * `curve` — NIST curve the private key is on. + /// * `priv_key` — raw HSM-format private scalar + /// ([`HsmEccCurve::wire_priv_key_len`] bytes). + /// * `pub_key` — output buffer for `X ‖ Y` + /// ([`HsmEccCurve::wire_pub_key_len`] bytes). + /// + /// # Errors + /// * [`HsmError::InvalidArg`] if `curve` is unsupported or a buffer is + /// undersized. + /// * Any [`HsmError`] surfaced by the PKA driver. + async fn ecc_pub_from_priv( + &self, + _io: &impl HsmIo, + curve: HsmEccCurve, + priv_key: &DmaBuf, + pub_key: &mut DmaBuf, + ) -> HsmResult<()> { + let pka_curve = map_ecc_curve(curve)?; + let wire_pub_len = curve.wire_pub_key_len(); + if priv_key.len() != curve.wire_priv_key_len() || pub_key.len() < wire_pub_len { + return Err(HsmError::InvalidArg); + } + // Pass an exact-sized sub-view: the PKA writes a fixed number of + // bytes per curve and is not given a length, so an oversized caller + // buffer would otherwise keep stale bytes in its tail. + self.pka + .ecc_gen_pub_key(pka_curve, priv_key, &mut pub_key[..wire_pub_len]) + .await + } }