From dc63ab9814ac399638a923c64cffb307917e1ff2 Mon Sep 17 00:00:00 2001 From: Rajesh Gali Date: Fri, 17 Jul 2026 03:51:52 +0000 Subject: [PATCH] wip: sd create remote backup host layer (part_info, masked-blob key report, sd_backup, session method) --- api/lib/src/algo/sealing/key.rs | 16 + api/lib/src/ddi/descriptor_utils.rs | 64 ++++ api/lib/src/ddi/mod.rs | 12 + api/lib/src/ddi/partition_ex.rs | 67 ++-- api/lib/src/ddi/sd_create_remote_backup.rs | 87 +++++ api/lib/src/ddi/sd_evidence.rs | 58 +++ api/lib/src/ddi/sd_reseal_remote_backup.rs | 88 +++++ api/lib/src/ddi/sd_sealing_key_gen.rs | 72 ++++ api/lib/src/error.rs | 1 + api/lib/src/partition.rs | 20 + api/lib/src/session.rs | 60 +++ api/lib/src/shared_types.rs | 34 ++ api/tests/src/algo/sealing/key_gen_tests.rs | 8 +- api/tests/src/algo/sealing/mod.rs | 1 - api/tests/src/lib.rs | 4 +- api/tests/src/partition_ex_tests.rs | 2 +- api/tests/src/sd/create_backup_tests.rs | 89 +++++ api/tests/src/sd/mod.rs | 8 + api/tests/src/sd/reseal_tests.rs | 128 +++++++ api/tests/src/session_ex_tests.rs | 2 +- api/tests/src/{ => utils}/emu_helpers.rs | 0 api/tests/src/utils/mod.rs | 4 + .../provision.rs => utils/sd_provision.rs} | 349 +++++++++++++++++- ddi/interface/src/error.rs | 16 +- ddi/tbor/types/tests/commands/open_session.rs | 4 +- .../types/tests/commands/session_close.rs | 4 +- ddi/tbor/types/tests/harness/assertions.rs | 18 +- ddi/tbor/types/tests/hw/open_session.rs | 12 +- 28 files changed, 1159 insertions(+), 69 deletions(-) create mode 100644 api/lib/src/ddi/descriptor_utils.rs create mode 100644 api/lib/src/ddi/sd_create_remote_backup.rs create mode 100644 api/lib/src/ddi/sd_evidence.rs create mode 100644 api/lib/src/ddi/sd_reseal_remote_backup.rs create mode 100644 api/tests/src/sd/create_backup_tests.rs create mode 100644 api/tests/src/sd/mod.rs create mode 100644 api/tests/src/sd/reseal_tests.rs rename api/tests/src/{ => utils}/emu_helpers.rs (100%) rename api/tests/src/{algo/sealing/provision.rs => utils/sd_provision.rs} (51%) diff --git a/api/lib/src/algo/sealing/key.rs b/api/lib/src/algo/sealing/key.rs index c9fe2f15d..0d5164940 100644 --- a/api/lib/src/algo/sealing/key.rs +++ b/api/lib/src/algo/sealing/key.rs @@ -78,6 +78,22 @@ impl HsmSecretKey for HsmSealingKey {} impl HsmDerivationKey for HsmSealingKey {} +impl HsmKeyReportOp for HsmSealingKey { + type Error = HsmError; + + /// Attests this non-resident sealing key via TBOR `KeyReport`, + /// routing on its masked-key envelope since there is no device + /// handle to reference. + fn generate_key_report( + &self, + report_data: &[u8], + report: Option<&mut [u8]>, + ) -> Result { + let masked_key = self.masked_key_vec()?; + ddi::masked_key_report(&self.session(), &masked_key, report_data, report) + } +} + #[derive(Default)] pub struct HsmSealingKeyGenAlgo {} diff --git a/api/lib/src/ddi/descriptor_utils.rs b/api/lib/src/ddi/descriptor_utils.rs new file mode 100644 index 000000000..0f5086151 --- /dev/null +++ b/api/lib/src/ddi/descriptor_utils.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Out-of-band SGL descriptor packing shared by TBOR commands that ship +//! DER certificate chains / reports out of band (`PartFinal`, the SD +//! backup family). +//! +//! Each item ships as its own OOB SGL Data Block; the firmware locates it +//! by the descriptor's `index` (its position in the shared `oob` item +//! list) and reads `length` bytes from it. + +use azihsm_ddi_tbor_types::*; + +use super::*; + +/// Appends a certificate chain's DER bytes to `oob`, returning the +/// matching `(index, length)` descriptors. Rejects an empty chain, a +/// chain longer than `max_certs`, an empty (zero-length) cert, or a cert +/// whose length overflows the 16-bit descriptor field. +pub(crate) fn push_cert_chain<'a>( + chain: &'a [HsmCert<'a>], + oob: &mut Vec<&'a [u8]>, + max_certs: usize, +) -> HsmResult> { + // Firmware evidence verification rejects an empty chain as InvalidArg; + // fail fast rather than round-trip a guaranteed rejection. + if chain.is_empty() || chain.len() > max_certs { + return Err(HsmError::InvalidArgument); + } + let mut descriptors = Vec::with_capacity(chain.len()); + for cert in chain { + let der = cert.cert; + if der.is_empty() || der.len() > u16::MAX as usize { + return Err(HsmError::InvalidArgument); + } + // Descriptor index = position in the shared OOB list (must fit u8). + let index = u8::try_from(oob.len()).map_err(|_| HsmError::InvalidArgument)?; + descriptors.push(CertDescriptor { + index, + length: tbor_int::U16::new(der.len() as u16), + }); + oob.push(der); + } + Ok(descriptors) +} + +/// Appends a COSE_Sign1 report DER to `oob`, returning its descriptor. +/// Rejects an empty report (firmware verification rejects `report_len == +/// 0` as InvalidArg) or one exceeding the 16-bit length. +pub(crate) fn push_report<'a>( + report: &'a [u8], + oob: &mut Vec<&'a [u8]>, +) -> HsmResult { + if report.is_empty() || report.len() > u16::MAX as usize { + return Err(HsmError::InvalidArgument); + } + let index = u8::try_from(oob.len()).map_err(|_| HsmError::InvalidArgument)?; + let descriptor = ReportDescriptor { + index, + length: tbor_int::U16::new(report.len() as u16), + }; + oob.push(report); + Ok(descriptor) +} diff --git a/api/lib/src/ddi/mod.rs b/api/lib/src/ddi/mod.rs index 348026e14..8edc049d3 100644 --- a/api/lib/src/ddi/mod.rs +++ b/api/lib/src/ddi/mod.rs @@ -3,6 +3,7 @@ mod aes; mod aes_xts_key; +mod descriptor_utils; mod dev; mod ecc; mod hkdf; @@ -13,6 +14,9 @@ mod masked_key; mod partition; mod partition_ex; mod rsa; +mod sd_create_remote_backup; +mod sd_evidence; +mod sd_reseal_remote_backup; mod sd_sealing_key_gen; mod session; mod session_ex; @@ -31,6 +35,8 @@ pub use azihsm_ddi_tbor_types::MAX_CERTS; pub use azihsm_ddi_tbor_types::PTA_CSR_MAX_LEN; /// Maximum size, in bytes, of the `part_init` `pta_report` buffer. pub use azihsm_ddi_tbor_types::PTA_REPORT_MAX_LEN; +use azihsm_ddi_tbor_types::TborStatus; +pub(crate) use descriptor_utils::*; pub(crate) use dev::*; pub(crate) use ecc::*; pub(crate) use hkdf::*; @@ -41,6 +47,9 @@ pub(crate) use masked_key::*; pub(crate) use partition::*; pub(crate) use partition_ex::*; pub(crate) use rsa::*; +pub(crate) use sd_create_remote_backup::*; +pub(crate) use sd_evidence::*; +pub(crate) use sd_reseal_remote_backup::*; pub(crate) use sd_sealing_key_gen::*; pub(crate) use session::*; pub(crate) use session_ex::*; @@ -103,6 +112,9 @@ impl From for HsmError { DdiError::DdiStatus(DdiStatus::CannotDeleteInternalKeys) => { HsmError::CannotDeleteInternalKeys } + DdiError::TborStatus(TborStatus::SdAlreadyInitialized) => { + HsmError::SdAlreadyInitialized + } _ => { tracing::error!(?err, hsm_error = ?HsmError::DdiCmdFailure, "Unmapped DDI error"); HsmError::DdiCmdFailure diff --git a/api/lib/src/ddi/partition_ex.rs b/api/lib/src/ddi/partition_ex.rs index 16a037f87..a8f8d5235 100644 --- a/api/lib/src/ddi/partition_ex.rs +++ b/api/lib/src/ddi/partition_ex.rs @@ -234,9 +234,6 @@ pub(crate) fn part_final_ex( if part_policy.len() != PART_POLICY_LEN { return Err(HsmError::InvalidArgument); } - if pta_cert_chain.is_empty() || pta_cert_chain.len() > MAX_CERTS { - return Err(HsmError::InvalidArgument); - } // The firmware treats a non-empty `prev_local_mk_backup` as a // fixed-size envelope of exactly `LOCAL_MK_BACKUP_LEN` bytes, so // reject any other present length up front (deterministic guard). @@ -244,27 +241,9 @@ pub(crate) fn part_final_ex( return Err(HsmError::InvalidArgument); } - // Each DER cert ships as its own out-of-band SGL Data Block; the - // firmware locates each one by the descriptor's `index` (its position - // in the OOB item list) and reads `length` bytes from it. + // Each DER cert ships as its own out-of-band SGL Data Block. let mut oob: Vec<&[u8]> = Vec::with_capacity(pta_cert_chain.len()); - let mut cert_descriptors = Vec::with_capacity(pta_cert_chain.len()); - for (i, desc) in pta_cert_chain.iter().enumerate() { - let cert = desc.cert; - let length = cert.len(); - // An empty cert is not valid DER and would yield a zero-length - // descriptor; reject it up front alongside the other - // deterministic host-side guards. - if length == 0 || length > u16::MAX as usize { - return Err(HsmError::InvalidArgument); - } - // `i` is bounded by the `MAX_CERTS` check above, so it fits `u8`. - cert_descriptors.push(CertDescriptor { - index: i as u8, - length: tbor_int::U16::new(length as u16), - }); - oob.push(cert); - } + let cert_descriptors = push_cert_chain(pta_cert_chain, &mut oob, MAX_CERTS)?; let mut req = TborPartFinalReq { session_id, @@ -293,6 +272,48 @@ pub(crate) fn part_final_ex( Ok(HsmPartFinalExResult::from(resp)) } +/// Partition identity returned by a `PartInfo` query: the stable PID plus +/// the raw ECC-P384 identity public key. +/// +/// DDI-internal carrier only. Public callers reach these fields through +/// the [`HsmPartition::pid`] / [`HsmPartition::ex_pub_key`] getters, so the +/// compound type never surfaces in the public API. +pub(crate) struct HsmPartInfo { + /// 16-byte partition identity (PID). + pub pid: Vec, + /// Raw ECC-P384 identity public-key coordinates (`x ‖ y`, 96 B). + pub pid_pub_key: Vec, +} + +/// Converts the DDI/wire `PartInfo` response into the DDI-internal +/// [`HsmPartInfo`] with owned bytes, keeping the wire response type +/// confined to the DDI layer. +impl From for HsmPartInfo { + fn from(resp: TborPartInfoResp) -> Self { + Self { + pid: resp.pid.to_vec(), + pid_pub_key: resp.pid_pub_key.to_vec(), + } + } +} + +/// Issue `PartInfo` on the partition, returning its identity (PID) and +/// raw identity public key. +/// +/// This is a partition-level query and carries no session id. +/// +/// # Errors +/// +/// Surfaces DDI/device failures from the round-trip. +pub(crate) fn part_info(partition: &HsmPartition) -> HsmResult { + let inner = partition.inner().read(); + let dev = inner.dev(); + let mut cookie = None; + dev.exec_op_tbor(&TborPartInfoReq::new(), None, &mut cookie) + .map(HsmPartInfo::from) + .map_err(HsmError::from) +} + #[cfg(test)] mod tests { use super::*; diff --git a/api/lib/src/ddi/sd_create_remote_backup.rs b/api/lib/src/ddi/sd_create_remote_backup.rs new file mode 100644 index 000000000..57923f525 --- /dev/null +++ b/api/lib/src/ddi/sd_create_remote_backup.rs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `SdCreateRemoteBackup` (opcode `0x0A`) over the TBOR transport at the +//! DDI layer. +//! +//! Creates a new security domain from a caller-supplied unified +//! `PartPolicy`, returning the remote partition-owner-key backup together +//! with the device-local partition-owner-key and security-domain +//! masking-key backups. Runs **inside an already-open session**; the +//! request carries the active session id, which the firmware dispatcher +//! cross-checks against the SQE-carried session id. Attestation evidence +//! travels out of band (see [`super::sd_evidence`]). + +use azihsm_ddi_tbor_types::*; + +use super::*; + +/// Converts the wire `SdCreateRemoteBackup` response into the owned +/// API-layer [`HsmSdRemoteBackupResult`]. +impl From for HsmSdRemoteBackupResult { + fn from(resp: TborSdCreateRemoteBackupResp) -> Self { + Self { + pok_remote_backup: resp.pok_remote_backup.to_vec(), + pok_local_backup: resp.pok_local_backup.to_vec(), + sd_mk_backup: resp.sd_mk_backup.to_vec(), + } + } +} + +/// Issue `SdCreateRemoteBackup` (opcode `0x0A`) on the active session. +/// +/// Creates a new security domain from the caller-supplied unified +/// `policy`, using the sender's masked sealing key and the receiver's +/// attestation evidence, and returns the remote backup together with the +/// device-local backups. +/// +/// # Arguments +/// +/// * `partition` - The HSM partition handle. +/// * `session_id` - The active session id this request binds to. +/// * `masked_sealing_key` - The sender's masked SD-sealing key (from +/// `SdSealingKeyGen`), exactly [`MASKED_SEALING_KEY_LEN`] bytes. +/// * `receiver_evidence` - Receiver attestation evidence (cert chains and +/// report), transmitted out of band. +/// * `policy` - Unified [`PartPolicy`] image ([`PART_POLICY_LEN`] bytes). +/// +/// # Errors +/// +/// Returns [`HsmError::InvalidArgument`] for a wrong-length +/// `masked_sealing_key` or a malformed `policy`, and surfaces DDI/device +/// failures from the round-trip. +pub(crate) fn sd_create_remote_backup_ex( + partition: &HsmPartition, + session_id: u16, + masked_sealing_key: &[u8], + receiver_evidence: &HsmSdEvidence<'_>, + policy: &[u8], +) -> HsmResult { + // Exact-length array conversion enforces MASKED_SEALING_KEY_LEN. + let masked_sealing_key: [u8; MASKED_SEALING_KEY_LEN] = masked_sealing_key + .try_into() + .map_err(|_| HsmError::InvalidArgument)?; + let policy = decode_policy(policy)?; + + // Flatten the receiver evidence into descriptors + shared OOB items. + let mut oob: Vec<&[u8]> = Vec::new(); + let receiver = push_evidence(receiver_evidence, &mut oob)?; + + let req = TborSdCreateRemoteBackupReq { + session_id, + masked_sealing_key, + receiver_mfgr_cert_chain: receiver.mfgr, + receiver_owner_cert_chain: receiver.owner, + receiver_part_owner_cert_chain: receiver.part_owner, + receiver_report: receiver.report, + policy, + }; + + let inner = partition.inner().read(); + let dev = inner.dev(); + let mut cookie = None; + let oob_items = (!oob.is_empty()).then_some(oob.as_slice()); + dev.exec_op_tbor(&req, oob_items, &mut cookie) + .map(HsmSdRemoteBackupResult::from) + .map_err(HsmError::from) +} diff --git a/api/lib/src/ddi/sd_evidence.rs b/api/lib/src/ddi/sd_evidence.rs new file mode 100644 index 000000000..03351ed27 --- /dev/null +++ b/api/lib/src/ddi/sd_evidence.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared helpers for the security-domain backup commands +//! (`SdCreateRemoteBackup`, `SdResealRemoteBackup`). +//! +//! # Out-of-band evidence +//! +//! Bulk attestation evidence (DER cert chains and a COSE_Sign1 report) +//! travels out of band: each [`HsmSdEvidence`] is flattened into +//! `(index, length)` descriptors (via [`super::descriptor_utils`]) and its +//! DER bytes +//! are shipped as `oob_items`, mirroring the `PartFinal` cert-chain +//! transport. + +use azihsm_ddi_tbor_types::*; + +use super::*; + +/// Decodes a caller-supplied unified `PartPolicy` image +/// ([`PART_POLICY_LEN`] bytes), failing fast with +/// [`HsmError::InvalidArgument`] on a wrong length or malformed image. +pub(crate) fn decode_policy(policy: &[u8]) -> HsmResult { + if policy.len() != PART_POLICY_LEN { + return Err(HsmError::InvalidArgument); + } + ::try_read_from_bytes(policy) + .map_err(|_| HsmError::InvalidArgument) +} + +/// Wire descriptors for one attestation party: the three cert-chain +/// lists plus the report descriptor. Produced by [`push_evidence`]. +pub(crate) struct EvidenceDescriptors { + pub(crate) mfgr: Vec, + pub(crate) owner: Vec, + pub(crate) part_owner: Vec, + pub(crate) report: ReportDescriptor, +} + +/// Flattens one [`HsmSdEvidence`] party into its wire descriptors, +/// appending all referenced DER bytes (the three cert chains, then the +/// report) to the shared `oob` list so their descriptor indices are +/// contiguous. +pub(crate) fn push_evidence<'a>( + evidence: &HsmSdEvidence<'a>, + oob: &mut Vec<&'a [u8]>, +) -> HsmResult { + Ok(EvidenceDescriptors { + mfgr: push_cert_chain(evidence.mfgr_cert_chain, oob, EVIDENCE_CHAIN_MAX_CERTS)?, + owner: push_cert_chain(evidence.owner_cert_chain, oob, EVIDENCE_CHAIN_MAX_CERTS)?, + part_owner: push_cert_chain( + evidence.part_owner_cert_chain, + oob, + EVIDENCE_CHAIN_MAX_CERTS, + )?, + report: push_report(evidence.report, oob)?, + }) +} diff --git a/api/lib/src/ddi/sd_reseal_remote_backup.rs b/api/lib/src/ddi/sd_reseal_remote_backup.rs new file mode 100644 index 000000000..d7d073064 --- /dev/null +++ b/api/lib/src/ddi/sd_reseal_remote_backup.rs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `SdResealRemoteBackup` (opcode `0x0B`) over the TBOR transport at the +//! DDI layer. +//! +//! Reseals an existing remote backup from a source recipient to a +//! destination recipient. Runs **inside an already-open session**; the +//! request carries the active session id, which the firmware dispatcher +//! cross-checks against the SQE-carried session id. Attestation evidence +//! travels out of band (see [`super::sd_evidence`]). + +use azihsm_ddi_tbor_types::*; + +use super::*; + +/// Issue `SdResealRemoteBackup` (opcode `0x0B`) on the active session. +/// +/// HPKE-opens `src_remote_backup` with the receiver's `masked_sealing_key` +/// (authenticated by the source sender key in `src_evidence`) and reseals +/// the recovered backup to the destination receiver (`dest_evidence`), +/// returning the resealed remote backup. +/// +/// # Arguments +/// +/// * `partition` - The HSM partition handle. +/// * `session_id` - The active session id this request binds to. +/// * `masked_sealing_key` - The receiver's masked SD-sealing key (from +/// `SdSealingKeyGen`) that unseals the source backup, exactly +/// [`MASKED_SEALING_KEY_LEN`] bytes. +/// * `src_evidence` - Source (sender) attestation evidence, transmitted +/// out of band. +/// * `dest_evidence` - Destination (receiver) attestation evidence. +/// * `policy` - Unified [`PartPolicy`] image ([`PART_POLICY_LEN`] bytes). +/// * `src_remote_backup` - The source remote backup to reseal, exactly +/// [`POK_REMOTE_BACKUP_LEN`] bytes. +/// +/// # Errors +/// +/// Returns [`HsmError::InvalidArgument`] for a wrong-length +/// `masked_sealing_key` or `src_remote_backup`, or a malformed `policy`, +/// and surfaces DDI/device failures from the round-trip. +pub(crate) fn sd_reseal_remote_backup_ex( + partition: &HsmPartition, + session_id: u16, + masked_sealing_key: &[u8], + src_evidence: &HsmSdEvidence<'_>, + dest_evidence: &HsmSdEvidence<'_>, + policy: &[u8], + src_remote_backup: &[u8], +) -> HsmResult> { + let masked_sealing_key: [u8; MASKED_SEALING_KEY_LEN] = masked_sealing_key + .try_into() + .map_err(|_| HsmError::InvalidArgument)?; + let src_remote_backup: [u8; POK_REMOTE_BACKUP_LEN] = src_remote_backup + .try_into() + .map_err(|_| HsmError::InvalidArgument)?; + let policy = decode_policy(policy)?; + + // Both parties' evidence share one OOB list; push the source first, + // then the destination, so descriptor indices stay contiguous. + let mut oob: Vec<&[u8]> = Vec::new(); + let src = push_evidence(src_evidence, &mut oob)?; + let dest = push_evidence(dest_evidence, &mut oob)?; + + let req = TborSdResealRemoteBackupReq { + session_id, + masked_sealing_key, + policy, + src_mfgr_cert_chain: src.mfgr, + src_owner_cert_chain: src.owner, + src_part_owner_cert_chain: src.part_owner, + src_report: src.report, + dest_mfgr_cert_chain: dest.mfgr, + dest_owner_cert_chain: dest.owner, + dest_part_owner_cert_chain: dest.part_owner, + dest_report: dest.report, + src_remote_backup, + }; + + let inner = partition.inner().read(); + let dev = inner.dev(); + let mut cookie = None; + let oob_items = (!oob.is_empty()).then_some(oob.as_slice()); + dev.exec_op_tbor(&req, oob_items, &mut cookie) + .map(|resp| resp.dst_remote_backup.to_vec()) + .map_err(HsmError::from) +} diff --git a/api/lib/src/ddi/sd_sealing_key_gen.rs b/api/lib/src/ddi/sd_sealing_key_gen.rs index 232ca13d8..f4fba484c 100644 --- a/api/lib/src/ddi/sd_sealing_key_gen.rs +++ b/api/lib/src/ddi/sd_sealing_key_gen.rs @@ -13,6 +13,10 @@ //! not stored on the device; the masked blob is returned to the host //! and unmasked on-use by the security-domain backup commands. //! +//! It also hosts the companion **`KeyReport`** dispatch, which attests a +//! non-resident masked key (the sealing key) by its masked-key envelope +//! rather than a device handle. +//! //! It runs **inside an already-open session** established by //! [`super::session_ex::open_session_ex`]: the request carries the //! active session id, which the firmware dispatcher cross-checks @@ -136,6 +140,74 @@ pub(crate) fn sd_sealing_key_gen( Ok((resp.masked_key, pub_key_der)) } +/// Issue `KeyReport` on the active session to attest a non-resident +/// masked key (such as the sealing key produced by +/// [`sd_sealing_key_gen`]). +/// +/// The key is attested by its masked-key envelope rather than a device +/// handle. Ships the active session id, the masked-key envelope, and the +/// caller's [`KEY_REPORT_DATA_LEN`]-byte report data; returns the tagged +/// COSE_Sign1 attestation report signed by the PID key. +/// +/// Follows the size-query convention: a `None` `report` returns the +/// maximum report size ([`KEY_REPORT_MAX_LEN`]) without a round-trip. +/// +/// # Arguments +/// +/// * `session` - The active security-domain (V2) session. +/// * `masked_key` - The masked-key envelope to attest (at most +/// [`KEY_REPORT_MASKED_KEY_MAX_LEN`] bytes). +/// * `report_data` - Caller-supplied [`KEY_REPORT_DATA_LEN`]-byte report +/// data bound into the report. +/// * `report` - Optional output buffer for the report; `None` returns the +/// maximum report size. +/// +/// # Errors +/// +/// Returns [`HsmError::InvalidArgument`] when `report_data` is not +/// [`KEY_REPORT_DATA_LEN`] bytes or `masked_key` is empty or exceeds +/// [`KEY_REPORT_MASKED_KEY_MAX_LEN`], [`HsmError::BufferTooSmall`] when +/// the supplied buffer is shorter than the returned report, and surfaces +/// DDI/device failures from the round-trip. +pub(crate) fn masked_key_report( + session: &HsmSession, + masked_key: &[u8], + report_data: &[u8], + report: Option<&mut [u8]>, +) -> HsmResult { + if report_data.len() != KEY_REPORT_DATA_LEN + || masked_key.is_empty() + || masked_key.len() > KEY_REPORT_MASKED_KEY_MAX_LEN + { + return Err(HsmError::InvalidArgument); + } + + let Some(report) = report else { + return Ok(KEY_REPORT_MAX_LEN); + }; + + let mut report_data_arr = [0u8; KEY_REPORT_DATA_LEN]; + report_data_arr.copy_from_slice(report_data); + + let req = TborKeyReportReq { + session_id: session.ex_session_id()?, + masked_key: masked_key.to_vec(), + report_data: report_data_arr, + }; + + let mut cookie = None; + let resp = session.with_dev(|dev| { + dev.exec_op_tbor(&req, None, &mut cookie) + .map_err(HsmError::from) + })?; + + if report.len() < resp.report.len() { + return Err(HsmError::BufferTooSmall); + } + report[..resp.report.len()].copy_from_slice(&resp.report); + Ok(resp.report.len()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/api/lib/src/error.rs b/api/lib/src/error.rs index 80607fea1..091bd0824 100644 --- a/api/lib/src/error.rs +++ b/api/lib/src/error.rs @@ -65,6 +65,7 @@ pub enum HsmError { InvalidContextState = -40, Bk3AlreadyInitialized = -41, InvalidSession = -42, + SdAlreadyInitialized = -43, Panic = i32::MIN, } diff --git a/api/lib/src/partition.rs b/api/lib/src/partition.rs index dce52844f..299fd440c 100644 --- a/api/lib/src/partition.rs +++ b/api/lib/src/partition.rs @@ -841,6 +841,26 @@ impl HsmPartition { ddi::get_cert_chain(self, slot) } + /// Queries the partition's stable identity (PID) via `PartInfo`. + /// + /// Returns the 16-byte PID, which a caller uses to name this + /// partition as the backing partition when building a + /// security-domain backup policy. + pub fn pid(&self) -> HsmResult> { + Ok(ddi::part_info(self)?.pid) + } + + /// Queries the partition's raw identity public key via `PartInfo`. + /// + /// Returns the raw ECC-P384 identity public-key coordinates + /// (`x ‖ y`, 96 B). Unlike [`Self::pub_key`] — which returns the + /// DER-encoded public key parsed from the PID certificate — this + /// comes straight from the `PartInfo` query and is available before + /// the partition is endorsed. + pub fn ex_pub_key(&self) -> HsmResult> { + Ok(ddi::part_info(self)?.pid_pub_key) + } + /// Retrieves the public key of the partition identity (PID) certificate. /// /// # Returns diff --git a/api/lib/src/session.rs b/api/lib/src/session.rs index b50b1e84e..9be7061e4 100644 --- a/api/lib/src/session.rs +++ b/api/lib/src/session.rs @@ -220,6 +220,66 @@ impl HsmSession { SessionKind::Ver1 { .. } => Err(HsmError::InvalidSession), } } + + /// Issues TBOR `SdCreateRemoteBackup` (opcode `0x0A`) on this CO + /// session. + /// + /// Creates a new security domain from the caller-supplied unified + /// `policy`, using the sender's `masked_sealing_key` (from + /// `SdSealingKeyGen`) and the receiver's attestation `evidence`. + /// Returns the remote backup together with the device-local backups. + /// Only valid on a V2 session; a V1 session returns + /// [`HsmError::InvalidSession`]. + pub fn sd_create_remote_backup( + &self, + masked_sealing_key: &[u8], + receiver_evidence: &HsmSdEvidence<'_>, + policy: &[u8], + ) -> HsmResult { + let inner = self.inner.read(); + match &inner.kind { + SessionKind::Ver2 { .. } => ddi::sd_create_remote_backup_ex( + &inner.partition, + inner.id, + masked_sealing_key, + receiver_evidence, + policy, + ), + SessionKind::Ver1 { .. } => Err(HsmError::InvalidSession), + } + } + + /// Issues TBOR `SdResealRemoteBackup` (opcode `0x0B`) on this CO + /// session. + /// + /// HPKE-opens `src_remote_backup` with the receiver's + /// `masked_sealing_key` (authenticated by the source sender in + /// `src_evidence`) and reseals the recovered backup to the destination + /// receiver (`dest_evidence`), returning the resealed remote backup. + /// Only valid on a V2 session; a V1 session returns + /// [`HsmError::InvalidSession`]. + pub fn sd_reseal_remote_backup( + &self, + masked_sealing_key: &[u8], + src_evidence: &HsmSdEvidence<'_>, + dest_evidence: &HsmSdEvidence<'_>, + policy: &[u8], + src_remote_backup: &[u8], + ) -> HsmResult> { + let inner = self.inner.read(); + match &inner.kind { + SessionKind::Ver2 { .. } => ddi::sd_reseal_remote_backup_ex( + &inner.partition, + inner.id, + masked_sealing_key, + src_evidence, + dest_evidence, + policy, + src_remote_backup, + ), + SessionKind::Ver1 { .. } => Err(HsmError::InvalidSession), + } + } } /// Transport-specific session state. diff --git a/api/lib/src/shared_types.rs b/api/lib/src/shared_types.rs index b60d59b5f..413b52665 100644 --- a/api/lib/src/shared_types.rs +++ b/api/lib/src/shared_types.rs @@ -39,6 +39,40 @@ pub struct HsmPartFinalExResult { pub local_mk_backup: Vec, } +/// Borrowed attestation evidence for one party in a security-domain +/// backup: the three DER certificate chains and the COSE_Sign1 report. +/// +/// The DER bytes travel out of band; the SDK builds the wire descriptors +/// internally. +#[derive(Debug, Clone, Copy)] +pub struct HsmSdEvidence<'a> { + /// Manufacturer certificate chain. + pub mfgr_cert_chain: &'a [HsmCert<'a>], + /// Owner certificate chain. + pub owner_cert_chain: &'a [HsmCert<'a>], + /// Partition-owner certificate chain. + pub part_owner_cert_chain: &'a [HsmCert<'a>], + /// COSE_Sign1 attestation report (DER/COSE bytes). + pub report: &'a [u8], +} + +/// Result of `sd_create_remote_backup`: the three backups the device +/// returns after creating a security domain. +/// +/// API-layer type with owned bytes. The DDI/wire response type +/// (`TborSdCreateRemoteBackupResp`) is converted into it inside the DDI +/// layer, so the wire type never surfaces to public callers. +#[derive(Debug, Clone, Default)] +pub struct HsmSdRemoteBackupResult { + /// Remote partition-owner-key backup (HPKE-Auth seal of BKS3). + pub pok_remote_backup: Vec, + /// Local partition-owner-key backup (BKS3 masked under the + /// partition-local masking key). + pub pok_local_backup: Vec, + /// Security-domain masking-key backup envelope. + pub sd_mk_backup: Vec, +} + /// Cryptographic key class. /// /// Defines the fundamental category of a cryptographic key. diff --git a/api/tests/src/algo/sealing/key_gen_tests.rs b/api/tests/src/algo/sealing/key_gen_tests.rs index ea7e26ca3..46cd47000 100644 --- a/api/tests/src/algo/sealing/key_gen_tests.rs +++ b/api/tests/src/algo/sealing/key_gen_tests.rs @@ -7,14 +7,14 @@ //! //! Property-validation guards run before the device round-trip, so they are //! deterministic. The `roundtrip_*` tests provision the partition to -//! `Initialized` via [`super::provision::finalized_co_session`], then +//! `Initialized` via [`crate::utils::sd_provision::finalized_co_session`], then //! generate a sealing key end to end and validate the masked blob and //! public key. use azihsm_api::*; use azihsm_ddi_tbor_types::MASKED_SEALING_KEY_LEN; -use crate::emu_helpers::*; +use crate::utils::emu_helpers::*; /// Well-formed sealing key props: a `Sealing`-kind P-384 secret key /// permitted for derivation only, matching the wire contract. @@ -157,7 +157,7 @@ fn sealing_key_gen_valid_props_pass_host_guards() { #[test] fn sealing_key_gen_roundtrip_generates_usable_sealing_key() { let _guard = EMU_LOCK.lock(); - let session = super::provision::finalized_co_session(); + let session = crate::utils::sd_provision::finalized_co_session(); let mut algo = HsmSealingKeyGenAlgo::default(); let key = HsmKeyManager::generate_key(&session, &mut algo, sealing_props()) @@ -188,7 +188,7 @@ fn sealing_key_gen_roundtrip_generates_usable_sealing_key() { #[test] fn sealing_key_gen_roundtrip_yields_distinct_keys() { let _guard = EMU_LOCK.lock(); - let session = super::provision::finalized_co_session(); + let session = crate::utils::sd_provision::finalized_co_session(); let generate = || { let mut algo = HsmSealingKeyGenAlgo::default(); diff --git a/api/tests/src/algo/sealing/mod.rs b/api/tests/src/algo/sealing/mod.rs index 2ed55e550..fa835486c 100644 --- a/api/tests/src/algo/sealing/mod.rs +++ b/api/tests/src/algo/sealing/mod.rs @@ -2,4 +2,3 @@ // Licensed under the MIT License. mod key_gen_tests; -mod provision; diff --git a/api/tests/src/lib.rs b/api/tests/src/lib.rs index 298970fa5..2f7acaebb 100644 --- a/api/tests/src/lib.rs +++ b/api/tests/src/lib.rs @@ -12,11 +12,11 @@ mod resiliency_tests; mod session_tests; mod utils; -#[cfg(feature = "emu")] -mod emu_helpers; #[cfg(feature = "emu")] mod partition_ex_tests; #[cfg(feature = "emu")] +mod sd; +#[cfg(feature = "emu")] mod session_ex_tests; use azihsm_api::*; diff --git a/api/tests/src/partition_ex_tests.rs b/api/tests/src/partition_ex_tests.rs index 8be0c9e88..d84a1a45b 100644 --- a/api/tests/src/partition_ex_tests.rs +++ b/api/tests/src/partition_ex_tests.rs @@ -20,7 +20,7 @@ use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::SAPOTA_THUMBPRINT_LEN; use azihsm_ddi_tbor_types::SATA_THUMBPRINT_LEN; -use crate::emu_helpers::*; +use crate::utils::emu_helpers::*; /// Well-formed fixed-size inputs for the non-`part_policy` `PartInit` /// fields. diff --git a/api/tests/src/sd/create_backup_tests.rs b/api/tests/src/sd/create_backup_tests.rs new file mode 100644 index 000000000..8e55d765f --- /dev/null +++ b/api/tests/src/sd/create_backup_tests.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! api-level `SdCreateRemoteBackup` round trip against the emulator. +//! +//! Drives the full public-surface flow: provision a partition whose policy +//! names itself as the backup backing partition +//! ([`crate::utils::sd_provision::finalized_backing_session`]), mint an SD sealing +//! key, attest it via the masked-blob `KeyReport`, build the receiver's +//! three-chain attestation evidence, then call +//! [`HsmSession::sd_create_remote_backup`] and validate the three returned +//! backups. This is a **self-backup** (sender == receiver): the partition +//! seals to its own attested identity key. + +use azihsm_api::*; +use azihsm_ddi_tbor_types::MASKED_SD_LEN; +use azihsm_ddi_tbor_types::POK_REMOTE_BACKUP_LEN; +use azihsm_ddi_tbor_types::SD_MK_BACKUP_LEN; + +use crate::utils::emu_helpers::EMU_LOCK; +use crate::utils::sd_provision::CaKey; +use crate::utils::sd_provision::build_receiver_evidence; +use crate::utils::sd_provision::finalized_backing_session; +use crate::utils::sd_provision::masked_key_and_report; + +/// Happy path: creating a security domain on a partition that names +/// itself as the backing partition returns three non-zero backups of the +/// pinned wire lengths. +#[test] +fn sd_create_remote_backup_roundtrip() { + let _guard = EMU_LOCK.lock(); + let sata_key = CaKey::generate(); + let (session, policy, pid_pub) = finalized_backing_session(&sata_key); + + let (masked, report) = masked_key_and_report(&session); + let evidence = build_receiver_evidence(&pid_pub, &sata_key, &report); + let result = evidence + .with_hsm_evidence(|receiver| session.sd_create_remote_backup(&masked, receiver, &policy)) + .expect("create remote backup"); + + // Remote backup: HPKE-Auth seal of BKS3, 161 B, non-zero. + assert_eq!(result.pok_remote_backup.len(), POK_REMOTE_BACKUP_LEN); + assert!( + result.pok_remote_backup.iter().any(|&b| b != 0), + "pok_remote_backup must not be all-zero", + ); + + // Local backup: BKS3 masked under the partition-local key, 180 B, + // non-zero. + assert_eq!(result.pok_local_backup.len(), MASKED_SD_LEN); + assert!( + result.pok_local_backup.iter().any(|&b| b != 0), + "pok_local_backup must not be all-zero", + ); + + // Masking-key backup: SDMK masked under the derived SDBMK, 164 B, + // non-zero. + assert_eq!(result.sd_mk_backup.len(), SD_MK_BACKUP_LEN); + assert!( + result.sd_mk_backup.iter().any(|&b| b != 0), + "sd_mk_backup must not be all-zero", + ); +} + +/// One-shot: creating a security domain is a once-per-partition +/// operation, so a second `sd_create_remote_backup` on the now-initialized +/// partition is rejected by the firmware. +#[test] +fn sd_create_remote_backup_is_one_shot() { + let _guard = EMU_LOCK.lock(); + let sata_key = CaKey::generate(); + let (session, policy, pid_pub) = finalized_backing_session(&sata_key); + + let (masked, report) = masked_key_and_report(&session); + let evidence = build_receiver_evidence(&pid_pub, &sata_key, &report); + + evidence + .with_hsm_evidence(|receiver| session.sd_create_remote_backup(&masked, receiver, &policy)) + .expect("first create remote backup"); + + // A second create on the same (now initialized) partition must fail. + let second = evidence + .with_hsm_evidence(|receiver| session.sd_create_remote_backup(&masked, receiver, &policy)); + assert!( + matches!(second, Err(HsmError::SdAlreadyInitialized)), + "second create on an initialized partition must be rejected with \ + SdAlreadyInitialized, got {second:?}", + ); +} diff --git a/api/tests/src/sd/mod.rs b/api/tests/src/sd/mod.rs new file mode 100644 index 000000000..076f99558 --- /dev/null +++ b/api/tests/src/sd/mod.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! api-level tests for the security-domain backup command family +//! (`SdCreateRemoteBackup`, `SdResealRemoteBackup`). + +mod create_backup_tests; +mod reseal_tests; diff --git a/api/tests/src/sd/reseal_tests.rs b/api/tests/src/sd/reseal_tests.rs new file mode 100644 index 000000000..235defe00 --- /dev/null +++ b/api/tests/src/sd/reseal_tests.rs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! api-level `SdResealRemoteBackup` round trip against the emulator. +//! +//! Self-reseal on one partition: mint receiver / sender / destination SD +//! sealing keys, use `sd_create_remote_backup` to produce a real source +//! backup (BKS3 sealed to the receiver by the sender), then reseal it to +//! the destination. A successful reseal is itself the correctness check — +//! the HPKE open only succeeds if the receiver key and the attested sender +//! key match those that sealed the source. + +use azihsm_api::*; +use azihsm_ddi_tbor_types::POK_REMOTE_BACKUP_LEN; + +use crate::utils::emu_helpers::EMU_LOCK; +use crate::utils::sd_provision::CaKey; +use crate::utils::sd_provision::RAW_PUB_LEN; +use crate::utils::sd_provision::build_receiver_evidence; +use crate::utils::sd_provision::finalized_backing_session; +use crate::utils::sd_provision::masked_key_and_report; + +/// Create a real source backup: a fresh BKS3 sealed to the receiver's +/// attested public key (`receiver_report`) by the `masked_sender_key`. +fn create_source_backup( + session: &HsmSession, + sata_key: &CaKey, + pid_pub: &[u8; RAW_PUB_LEN], + masked_sender_key: &[u8], + receiver_report: &[u8], + policy: &[u8], +) -> Vec { + let receiver = build_receiver_evidence(pid_pub, sata_key, receiver_report); + receiver + .with_hsm_evidence(|rcvr| session.sd_create_remote_backup(masked_sender_key, rcvr, policy)) + .expect("source backup") + .pok_remote_backup +} + +/// Happy path: resealing a real source backup yields a fresh 161-byte, +/// non-zero backup distinct from the source ciphertext. +#[test] +fn sd_reseal_remote_backup_roundtrip() { + let _guard = EMU_LOCK.lock(); + let sata_key = CaKey::generate(); + let (session, policy, pid_pub) = finalized_backing_session(&sata_key); + + // Receiver (unseals the source), sender (sealed the source), and + // destination (the reseal target) SD sealing keys, each attested. + let (masked_rcvr, report_rcvr) = masked_key_and_report(&session); + let (masked_sndr, report_sndr) = masked_key_and_report(&session); + let (_masked_dst, report_dst) = masked_key_and_report(&session); + + let src_backup = create_source_backup( + &session, + &sata_key, + &pid_pub, + &masked_sndr, + &report_rcvr, + &policy, + ); + + // Reseal: open with the receiver key (auth = sender), reseal to the + // destination receiver. + let src_ev = build_receiver_evidence(&pid_pub, &sata_key, &report_sndr); + let dst_ev = build_receiver_evidence(&pid_pub, &sata_key, &report_dst); + let dst_backup = src_ev + .with_hsm_evidence(|src| { + dst_ev.with_hsm_evidence(|dest| { + session.sd_reseal_remote_backup(&masked_rcvr, src, dest, &policy, &src_backup) + }) + }) + .expect("reseal remote backup"); + + // A successful HPKE open -> seal yields a 161-byte, non-zero backup. + assert_eq!(dst_backup.len(), POK_REMOTE_BACKUP_LEN); + assert!( + dst_backup.iter().any(|&b| b != 0), + "dst_remote_backup must not be all-zero", + ); + // The resealed backup is a fresh HPKE encapsulation, not the source. + assert_ne!( + dst_backup, + src_backup.to_vec(), + "reseal must produce a fresh encapsulation, not echo the source", + ); +} + +/// Re-randomization: two reseals of the same source produce distinct +/// ciphertexts (a fresh HPKE ephemeral each call). +#[test] +fn sd_reseal_remote_backup_rerandomizes() { + let _guard = EMU_LOCK.lock(); + let sata_key = CaKey::generate(); + let (session, policy, pid_pub) = finalized_backing_session(&sata_key); + + let (masked_rcvr, report_rcvr) = masked_key_and_report(&session); + let (masked_sndr, report_sndr) = masked_key_and_report(&session); + let (_masked_dst, report_dst) = masked_key_and_report(&session); + + let src_backup = create_source_backup( + &session, + &sata_key, + &pid_pub, + &masked_sndr, + &report_rcvr, + &policy, + ); + + let src_ev = build_receiver_evidence(&pid_pub, &sata_key, &report_sndr); + let dst_ev = build_receiver_evidence(&pid_pub, &sata_key, &report_dst); + let reseal = || { + src_ev + .with_hsm_evidence(|src| { + dst_ev.with_hsm_evidence(|dest| { + session.sd_reseal_remote_backup(&masked_rcvr, src, dest, &policy, &src_backup) + }) + }) + .expect("reseal remote backup") + }; + + let first = reseal(); + let second = reseal(); + assert_ne!( + first, second, + "each reseal must re-randomize the HPKE encapsulation", + ); +} diff --git a/api/tests/src/session_ex_tests.rs b/api/tests/src/session_ex_tests.rs index 0bef5ea47..4ba52b647 100644 --- a/api/tests/src/session_ex_tests.rs +++ b/api/tests/src/session_ex_tests.rs @@ -11,7 +11,7 @@ use azihsm_api::*; -use crate::emu_helpers::*; +use crate::utils::emu_helpers::*; /// Happy path: CO pairs with an Authenticated session and returns a /// live `HsmSession` over the public API. diff --git a/api/tests/src/emu_helpers.rs b/api/tests/src/utils/emu_helpers.rs similarity index 100% rename from api/tests/src/emu_helpers.rs rename to api/tests/src/utils/emu_helpers.rs diff --git a/api/tests/src/utils/mod.rs b/api/tests/src/utils/mod.rs index 3e8eae2b4..4ac1328d8 100644 --- a/api/tests/src/utils/mod.rs +++ b/api/tests/src/utils/mod.rs @@ -3,6 +3,10 @@ pub(crate) mod aes_xts; pub(crate) mod api; +#[cfg(feature = "emu")] +pub(crate) mod emu_helpers; pub(crate) mod partition; pub(crate) mod resiliency; +#[cfg(feature = "emu")] +pub(crate) mod sd_provision; pub(crate) mod session; diff --git a/api/tests/src/algo/sealing/provision.rs b/api/tests/src/utils/sd_provision.rs similarity index 51% rename from api/tests/src/algo/sealing/provision.rs rename to api/tests/src/utils/sd_provision.rs index f9b67b05b..533b42410 100644 --- a/api/tests/src/algo/sealing/provision.rs +++ b/api/tests/src/utils/sd_provision.rs @@ -1,14 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Security-domain provisioning fixture for the api-level sealing round -//! trip. +//! Security-domain provisioning fixture shared by the api-level sealing +//! and backup tests. //! -//! Drives the full flow through the public `azihsm_api` surface — rotate -//! the CO PSK, `part_init_ex`, build a POTA-anchored PTA chain, -//! `part_final_ex` — to reach the `Initialized` state `SdSealingKeyGen` -//! requires. The chain is built on the host with [`azihsm_crypto`], -//! mirroring the wire-level `ddi/tbor/types/tests/harness/x509_fixture.rs`. +//! Drives the full flow through the public `azihsm_api` surface (rotate +//! the CO PSK, `part_init_ex`, POTA-anchored PTA chain, `part_final_ex`) +//! to reach the `Initialized` state, plus the evidence-chain and +//! sealing-key helpers the backup tests need. Certificates are built on +//! the host with [`azihsm_crypto`], mirroring the wire-level +//! `ddi/tbor/types/tests/harness/x509_fixture.rs`. use azihsm_api::*; use azihsm_crypto::EccCurve; @@ -21,9 +22,13 @@ use azihsm_crypto::SignOp; use azihsm_crypto::x509_builder::cert_builder; use azihsm_crypto::x509_builder::cert_builder::CN_LEN; use azihsm_crypto::x509_builder::cert_builder::IntermediateCertParams; +use azihsm_crypto::x509_builder::cert_builder::KeyUsage; +use azihsm_crypto::x509_builder::cert_builder::LeafCertParams; use azihsm_crypto::x509_builder::cert_builder::RootCertParams; use azihsm_crypto::x509_builder::cert_builder::SN_LEN; +use azihsm_ddi_tbor_types::KEY_REPORT_DATA_LEN; use azihsm_ddi_tbor_types::MACH_SEED_LEN; +use azihsm_ddi_tbor_types::PART_POLICY_LEN; use azihsm_ddi_tbor_types::POLICY_INFO_LEN; use azihsm_ddi_tbor_types::POLICY_MAX_KEY_LEN; use azihsm_ddi_tbor_types::POTA_THUMBPRINT_LEN; @@ -34,16 +39,29 @@ use azihsm_ddi_tbor_types::PolicyVer; use azihsm_ddi_tbor_types::SATA_THUMBPRINT_LEN; use zerocopy::IntoBytes; -use crate::emu_helpers::fresh_emu_partition; +use crate::utils::emu_helpers::fresh_emu_partition; const SEC1_PUB_LEN: usize = 97; -const RAW_PUB_LEN: usize = 96; +pub(crate) const RAW_PUB_LEN: usize = 96; const NOT_BEFORE: &[u8; 15] = b"20250101000000Z"; const NOT_AFTER: &[u8; 15] = b"20350101000000Z"; const ROOT_CN: &str = "AZIHSM POTA Root CA"; const ROOT_SN: &str = "POTAROOT1"; const PTA_CN: &str = "AZIHSM PTA Intermediate CA"; const PTA_SN: &str = "PTAINT001"; +const LEAF_CN: &str = "AZIHSM Evidence Leaf"; +const LEAF_SN: &str = "EVLEAF001"; + +/// Byte offset of the SATA public-key **data** inside the `PartPolicy` +/// image: `sata_pub_key` starts at 102 (`kind(2) ‖ len(2) ‖ data(96)`), +/// so the raw `X ‖ Y` coordinates begin at 106. +const OFF_SATA_PUB_KEY_DATA: usize = 106; + +/// Byte offsets of the backing-partition fields inside the `PartPolicy` +/// image (mirror of `fw/core/ddi/tbor/types/src/policy.rs`). +const OFF_BACKUP_PART_ID: usize = 302; +const OFF_BACKUP_PART_PUB_KEY: usize = 318; +const BACKUP_PART_ID_LEN: usize = 16; /// A fixed non-default CO PSK used to clear the default-PSK gate. const ROTATED_CO_PSK: [u8; PSK_LEN] = [ @@ -53,14 +71,14 @@ const ROTATED_CO_PSK: [u8; PSK_LEN] = [ /// A synthetic P-384 CA key (the policy POTA trust anchor) that signs /// certificates and exposes its public key. -struct CaKey { +pub(crate) struct CaKey { private_key: EccPrivateKey, pub_sec1: [u8; SEC1_PUB_LEN], } impl CaKey { /// Generate a fresh P-384 CA key. - fn generate() -> Self { + pub(crate) fn generate() -> Self { let private_key = EccPrivateKey::from_curve(EccCurve::P384).expect("P-384 key"); let (x, y) = private_key.coord_vec().expect("coords"); let mut pub_sec1 = [0u8; SEC1_PUB_LEN]; @@ -75,7 +93,7 @@ impl CaKey { /// Raw `X ‖ Y` (96-byte) public coordinates — the policy `POTAPubKey` /// form. - fn raw_pub(&self) -> [u8; RAW_PUB_LEN] { + pub(crate) fn raw_pub(&self) -> [u8; RAW_PUB_LEN] { self.pub_sec1[1..].try_into().expect("raw pub") } @@ -194,6 +212,81 @@ fn make_pta_chain(pota_ca: &CaKey, pta_pub_sec1: &[u8; SEC1_PUB_LEN]) -> PtaChai } } +/// Build an **end-entity** leaf certificate whose subject public key is +/// `leaf_pub_sec1` (an attestation-report signer's key), signed by +/// `issuer` (a self-signed CA). Unlike [`build_pta_intermediate`], the +/// leaf is `cA=false` with `digitalSignature` key usage. +fn build_leaf(leaf_pub_sec1: &[u8; SEC1_PUB_LEN], issuer: &CaKey) -> Vec { + let params = LeafCertParams { + public_key: leaf_pub_sec1, + serial_number: &serial(3), + not_before: NOT_BEFORE, + not_after: NOT_AFTER, + subject_cn: LEAF_CN, + subject_sn: LEAF_SN, + issuer_cn: ROOT_CN, + issuer_sn: ROOT_SN, + subject_key_id: &sha1_ski(leaf_pub_sec1), + authority_key_id: &issuer.ski(), + key_usage: KeyUsage::DIGITAL_SIGNATURE, + }; + let mut tbs = azihsm_crypto::x509_builder::leaf_cert::TBS_TEMPLATE; + patch_tbs_leaf(&mut tbs, ¶ms); + let (r, s) = issuer.sign(&tbs); + let mut out = vec![0u8; 1024]; + let len = cert_builder::build_leaf_cert(¶ms, &r, &s, &mut out).expect("leaf cert"); + out.truncate(len); + out +} + +/// A generated root -> leaf attestation-evidence chain, DER-encoded. +/// +/// `root_der` is a self-signed CA certificate; `leaf_der` is an +/// end-entity certificate signed by the root whose subject public key is +/// the caller-supplied report-signer key. +pub(crate) struct GeneratedChain { + root_der: Vec, + leaf_der: Vec, +} + +/// Build a root -> leaf chain: a self-signed root CA (`ca`) certifying an +/// end-entity leaf that carries `leaf_pub_raw` (raw `X ‖ Y`, the report +/// signer's public key). +/// +/// Pass a caller-controlled `ca` (e.g. the SATA anchor key) when the chain +/// must be anchored to a known public key; otherwise use a fresh +/// [`CaKey::generate`]. +fn make_chain(ca: &CaKey, leaf_pub_raw: &[u8; RAW_PUB_LEN]) -> GeneratedChain { + let mut leaf_sec1 = [0u8; SEC1_PUB_LEN]; + leaf_sec1[0] = 0x04; + leaf_sec1[1..].copy_from_slice(leaf_pub_raw); + GeneratedChain { + root_der: build_root(ca), + leaf_der: build_leaf(&leaf_sec1, ca), + } +} + +/// Patch a leaf-cert TBS template with the variable field values. +fn patch_tbs_leaf(tbs: &mut [u8], params: &LeafCertParams<'_>) { + use azihsm_crypto::x509_builder::leaf_cert::*; + let s_cn = pad_cn(params.subject_cn); + let i_cn = pad_cn(params.issuer_cn); + let s_sn = pad_sn(params.subject_sn); + let i_sn = pad_sn(params.issuer_sn); + tbs[PUBLIC_KEY_OFFSET..PUBLIC_KEY_OFFSET + 97].copy_from_slice(params.public_key); + tbs[SERIAL_NUMBER_OFFSET..SERIAL_NUMBER_OFFSET + 20].copy_from_slice(params.serial_number); + tbs[NOT_BEFORE_OFFSET..NOT_BEFORE_OFFSET + 15].copy_from_slice(params.not_before); + tbs[NOT_AFTER_OFFSET..NOT_AFTER_OFFSET + 15].copy_from_slice(params.not_after); + tbs[ISSUER_CN_OFFSET..ISSUER_CN_OFFSET + CN_LEN].copy_from_slice(&i_cn); + tbs[SUBJECT_CN_OFFSET..SUBJECT_CN_OFFSET + CN_LEN].copy_from_slice(&s_cn); + tbs[ISSUER_SN_OFFSET..ISSUER_SN_OFFSET + SN_LEN].copy_from_slice(&i_sn); + tbs[SUBJECT_SN_OFFSET..SUBJECT_SN_OFFSET + SN_LEN].copy_from_slice(&s_sn); + tbs[SUBJECT_KEY_ID_OFFSET..SUBJECT_KEY_ID_OFFSET + 20].copy_from_slice(params.subject_key_id); + tbs[AUTHORITY_KEY_ID_OFFSET..AUTHORITY_KEY_ID_OFFSET + 20] + .copy_from_slice(params.authority_key_id); + tbs[KEY_USAGE_OFFSET..KEY_USAGE_OFFSET + 2].copy_from_slice(¶ms.key_usage.to_bytes()); +} + /// Extract the SEC1 uncompressed public key (`0x04 ‖ X ‖ Y`) from a DER /// PKCS#10 CSR. fn pta_pub_from_csr(csr: &[u8]) -> [u8; SEC1_PUB_LEN] { @@ -377,3 +470,235 @@ pub(crate) fn finalized_co_session() -> HsmSession { session } + +/// Build a policy naming **this** partition as the backing partition +/// (`backup_part_id = PID`, `backup_part_pub_key = PID public key`) and +/// anchoring the security domain to `sata_pub` (raw `X ‖ Y`). +/// +/// The caller learns the PID / PID public key from `PartInfo` before +/// `part_init_ex`; the SATA key is the trust anchor the test also uses to +/// sign the partition-owner certificate chain. +fn backing_part_policy( + pid: &[u8], + pid_pub: &[u8], + sata_pub: &[u8; RAW_PUB_LEN], + pota_pub: &[u8; RAW_PUB_LEN], +) -> [u8; PART_POLICY_LEN] { + // Anchor the policy to a real POTA key so `part_final_ex` can validate + // a PTA certificate chain against it. + let policy = part_policy_with_pota(pota_pub); + let mut bytes = [0u8; PART_POLICY_LEN]; + bytes.copy_from_slice(policy.as_bytes()); + + // Overwrite the placeholder SATA key with the anchor's real P-384 + // coordinates (kind / len already Ecc384 / 96). + bytes[OFF_SATA_PUB_KEY_DATA..OFF_SATA_PUB_KEY_DATA + RAW_PUB_LEN].copy_from_slice(sata_pub); + + bytes[OFF_BACKUP_PART_ID..OFF_BACKUP_PART_ID + BACKUP_PART_ID_LEN].copy_from_slice(pid); + + // backup_part_pub_key = { kind: Ecc384 (LE), len: 96 (LE), data }. + let off = OFF_BACKUP_PART_PUB_KEY; + bytes[off..off + 2].copy_from_slice(&PolicyKeyKind::Ecc384.0.to_le_bytes()); + bytes[off + 2..off + 4].copy_from_slice(&(POLICY_MAX_KEY_LEN as u16).to_le_bytes()); + bytes[off + 4..off + 4 + POLICY_MAX_KEY_LEN].copy_from_slice(pid_pub); + + bytes +} + +/// Owns the DER bytes for the receiver's three evidence chains and the +/// attestation report, so a borrowed [`HsmSdEvidence`] can reference them. +pub(crate) struct SdEvidence { + mfgr: GeneratedChain, + owner: GeneratedChain, + part_owner: GeneratedChain, + report: Vec, +} + +impl SdEvidence { + /// Manufacturer chain as an ordered `[root, leaf]` cert list. + pub(crate) fn mfgr_certs(&self) -> [HsmCert<'_>; 2] { + [ + HsmCert { + cert: &self.mfgr.root_der, + }, + HsmCert { + cert: &self.mfgr.leaf_der, + }, + ] + } + + /// Owner chain as an ordered `[root, leaf]` cert list. + pub(crate) fn owner_certs(&self) -> [HsmCert<'_>; 2] { + [ + HsmCert { + cert: &self.owner.root_der, + }, + HsmCert { + cert: &self.owner.leaf_der, + }, + ] + } + + /// Partition-owner chain as an ordered `[root, leaf]` cert list. + pub(crate) fn part_owner_certs(&self) -> [HsmCert<'_>; 2] { + [ + HsmCert { + cert: &self.part_owner.root_der, + }, + HsmCert { + cert: &self.part_owner.leaf_der, + }, + ] + } + + /// The COSE_Sign1 attestation-report DER bytes. + pub(crate) fn report(&self) -> &[u8] { + &self.report + } + + /// Build a borrowed [`HsmSdEvidence`] over this party's three cert + /// chains and report and pass it to `f`. The cert arrays live only for + /// the call, so the evidence is delivered through a closure. + pub(crate) fn with_hsm_evidence(&self, f: impl FnOnce(&HsmSdEvidence<'_>) -> R) -> R { + let mfgr = self.mfgr_certs(); + let owner = self.owner_certs(); + let part_owner = self.part_owner_certs(); + f(&HsmSdEvidence { + mfgr_cert_chain: &mfgr, + owner_cert_chain: &owner, + part_owner_cert_chain: &part_owner, + report: self.report(), + }) + } +} + +/// Build the receiver's three-chain evidence for `pid_pub`: manufacturer +/// and owner chains rooted at fresh CAs, and a partition-owner chain rooted +/// at the policy `sata_key`. Every leaf certifies `pid_pub` (the report +/// signer), so all three share one leaf key. +pub(crate) fn build_receiver_evidence( + pid_pub: &[u8; RAW_PUB_LEN], + sata_key: &CaKey, + report: &[u8], +) -> SdEvidence { + SdEvidence { + mfgr: make_chain(&CaKey::generate(), pid_pub), + owner: make_chain(&CaKey::generate(), pid_pub), + part_owner: make_chain(sata_key, pid_pub), + report: report.to_vec(), + } +} + +/// Provision a fresh partition with a **backing-partition policy** — one +/// that names this partition (via `PartInfo`) as the backup backing +/// partition and anchors the security domain to `sata_key` — and return +/// the live CO session, the exact policy image (needed verbatim by +/// `sd_create_remote_backup`), and the partition-identity public key that +/// every evidence leaf certificate must carry. +pub(crate) fn finalized_backing_session( + sata_key: &CaKey, +) -> (HsmSession, [u8; PART_POLICY_LEN], [u8; RAW_PUB_LEN]) { + let (part, rev) = fresh_emu_partition(); + + // Bootstrap the CO session under the default PSK and rotate it; the + // bootstrap session closes on drop at the end of this block. + { + let bootstrap = part + .open_session_ex( + rev, + HsmSessionPsk::new(HsmPskId::CO), + HsmSessionExType::Authenticated, + ) + .expect("open bootstrap CO session"); + bootstrap + .change_psk(&ROTATED_CO_PSK) + .expect("rotate CO PSK"); + } + + let session = part + .open_session_ex( + rev, + HsmSessionPsk::with_psk(HsmPskId::CO, &ROTATED_CO_PSK), + HsmSessionExType::Authenticated, + ) + .expect("open rotated CO session"); + + // PID / PID public key are materialized before part_init_ex. + let pid = part.pid().expect("PartInfo PID"); + let pid_pub_vec = part.ex_pub_key().expect("PartInfo PID public key"); + assert_eq!( + pid.len(), + BACKUP_PART_ID_LEN, + "PartInfo PID must be BACKUP_PART_ID_LEN bytes", + ); + assert_eq!( + pid_pub_vec.len(), + RAW_PUB_LEN, + "PartInfo PID public key must be RAW_PUB_LEN bytes", + ); + let mut pid_pub = [0u8; RAW_PUB_LEN]; + pid_pub.copy_from_slice(&pid_pub_vec); + + // POTA anchor for the PTA certificate chain part_final_ex validates. + let pota = CaKey::generate(); + let policy = backing_part_policy(&pid, &pid_pub_vec, &sata_key.raw_pub(), &pota.raw_pub()); + + let init = session + .part_init_ex( + &mach_seed(), + &policy, + &pota_thumbprint(), + &sata_thumbprint(), + None, + ) + .expect("part_init_ex"); + + let chain = make_pta_chain(&pota, &pta_pub_from_csr(&init.pta_csr)); + let certs = [ + HsmCert { + cert: &chain.root_der, + }, + HsmCert { + cert: &chain.pta_der, + }, + ]; + session + .part_final_ex(&policy, &certs, None) + .expect("part_final_ex"); + + (session, policy, pid_pub) +} + +/// Well-formed sealing key props: a `Sealing`-kind P-384 secret key +/// permitted for derivation only. +pub(crate) fn sealing_props() -> HsmKeyProps { + HsmKeyPropsBuilder::default() + .class(HsmKeyClass::Secret) + .key_kind(HsmKeyKind::Sealing) + .bits(384) + .can_derive(true) + .build() + .expect("build sealing props") +} + +/// Mint an SD sealing key on `session` and return its masked blob and a +/// COSE_Sign1 `KeyReport` attesting it (signed by the PID key). +pub(crate) fn masked_key_and_report(session: &HsmSession) -> (Vec, Vec) { + let mut algo = HsmSealingKeyGenAlgo::default(); + let key = HsmKeyManager::generate_key(session, &mut algo, sealing_props()) + .expect("generate sealing key"); + + let masked = key.masked_key_vec().expect("masked key"); + + let report_data = [0u8; KEY_REPORT_DATA_LEN]; + let report_len = key + .generate_key_report(&report_data, None) + .expect("key report size"); + let mut report = vec![0u8; report_len]; + let written = key + .generate_key_report(&report_data, Some(&mut report)) + .expect("key report"); + report.truncate(written); + + (masked, report) +} diff --git a/ddi/interface/src/error.rs b/ddi/interface/src/error.rs index 044f4e663..589a828b3 100644 --- a/ddi/interface/src/error.rs +++ b/ddi/interface/src/error.rs @@ -7,6 +7,7 @@ use std::convert::Infallible; use azihsm_ddi_mbor_types::DdiStatus; use azihsm_ddi_mbor_types::MborError; +use azihsm_ddi_tbor_types::TborStatus; use thiserror::Error; use crate::*; @@ -58,6 +59,11 @@ pub enum DdiError { #[error("Manticore device error")] DdiStatus(DdiStatus), + /// Firmware-signalled TBOR command rejection, carrying the typed + /// [`TborStatus`]. + #[error("TBOR device error")] + TborStatus(TborStatus), + /// Linux error #[cfg(target_os = "linux")] #[error("nix error")] @@ -147,11 +153,11 @@ impl From for DdiError { #[inline] fn from(e: azihsm_ddi_tbor_codec::DecodeError) -> Self { match e { - // FW-signalled error: surface the typed HsmError discriminant - // so callers can match on specific codes (InvalidSessionType, - // AeadEnvelopeAuthFailed, etc.) instead of losing the detail to a - // generic `TborDecodeError`. - azihsm_ddi_tbor_codec::DecodeError::FwError(status) => Self::DdiError(status), + // FW-signalled error: surface the typed TBOR status so callers + // can match on specific codes. + azihsm_ddi_tbor_codec::DecodeError::FwError(status) => { + Self::TborStatus(TborStatus(status)) + } _ => Self::TborDecodeError, } } diff --git a/ddi/tbor/types/tests/commands/open_session.rs b/ddi/tbor/types/tests/commands/open_session.rs index 3aa1851ef..93a531823 100644 --- a/ddi/tbor/types/tests/commands/open_session.rs +++ b/ddi/tbor/types/tests/commands/open_session.rs @@ -186,7 +186,7 @@ fn session_open_finish_unknown_session_id_emu() { .tbor(&req) .expect_err("finish against unknown session_id must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection, got {err:?}", ); } @@ -205,7 +205,7 @@ fn open_session_double_finish_emu() { .tbor(&req) .expect_err("second finish against the same slot must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection, got {err:?}", ); } diff --git a/ddi/tbor/types/tests/commands/session_close.rs b/ddi/tbor/types/tests/commands/session_close.rs index 620e6c5fc..efbce5b50 100644 --- a/ddi/tbor/types/tests/commands/session_close.rs +++ b/ddi/tbor/types/tests/commands/session_close.rs @@ -74,7 +74,7 @@ fn session_close_unknown_id_emu() { .session_close(0xFFFF) .expect_err("close of unknown id must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection, got {err:?}", ); } @@ -92,7 +92,7 @@ fn session_close_double_close_emu() { .session_close(session_id) .expect_err("second close against the same id must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection on double-close, got {err:?}", ); } diff --git a/ddi/tbor/types/tests/harness/assertions.rs b/ddi/tbor/types/tests/harness/assertions.rs index 83cc8a9b7..08619b22a 100644 --- a/ddi/tbor/types/tests/harness/assertions.rs +++ b/ddi/tbor/types/tests/harness/assertions.rs @@ -45,19 +45,17 @@ pub fn assert_tbor_decode_error(err: &DdiError) { /// response header `status` field. The host-side `decode_response` /// short-circuits on `status != 0` and the conversion in /// `azihsm_ddi_interface::error` maps that to -/// [`DdiError::DdiError(status)`]. If the contract ever changes, this -/// is the single site that needs updating. +/// [`DdiError::TborStatus`]. If the contract ever changes, this is the +/// single site that needs updating. #[track_caller] pub fn assert_fw_rejects(err: &DdiError, expected: TborStatus) { - let expected_code = expected.0; match err { - DdiError::DdiError(code) => assert_eq!( - *code, expected_code, - "FW rejected with wrong TborStatus: expected {expected:?} (0x{expected_code:08X}), \ - got 0x{code:08X}", - ), - other => panic!( - "expected DdiError::DdiError(0x{expected_code:08X}) for {expected:?}, got {other:?}", + DdiError::TborStatus(status) => assert_eq!( + *status, expected, + "FW rejected with wrong TborStatus: expected {expected:?} (0x{:08X}), \ + got {status:?} (0x{:08X})", + expected.0, status.0, ), + other => panic!("expected DdiError::TborStatus({expected:?}), got {other:?}"), } } diff --git a/ddi/tbor/types/tests/hw/open_session.rs b/ddi/tbor/types/tests/hw/open_session.rs index 91bb3eed8..dfbf94f04 100644 --- a/ddi/tbor/types/tests/hw/open_session.rs +++ b/ddi/tbor/types/tests/hw/open_session.rs @@ -314,7 +314,7 @@ fn finish_unknown_session_id_rejected() { assert!( matches!( err, - azihsm_ddi_interface::DdiError::DdiError(_) + azihsm_ddi_interface::DdiError::TborStatus(_) | azihsm_ddi_interface::DdiError::DdiStatus(_) ), "expected FW or driver rejection, got {err:?}", @@ -347,7 +347,7 @@ fn double_finish_rejected() { .exec_op_tbor::(&req, None, &mut cookie) .expect_err("second finish against the same slot must fail"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection on double-finish, got {err:?}", ); // Close the Active slot we established up front so this test @@ -400,7 +400,7 @@ fn session_close_unknown_session_id_rejected() { assert!( matches!( err, - azihsm_ddi_interface::DdiError::DdiError(_) + azihsm_ddi_interface::DdiError::TborStatus(_) | azihsm_ddi_interface::DdiError::DdiStatus(_) ), "expected FW or driver rejection on close-unknown, got {err:?}", @@ -438,7 +438,7 @@ fn pk_init_all_zero_rejected() { .exec_op_tbor::(&req, None, &mut cookie) .expect_err("all-zero pk_init must be rejected"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection for all-zero pk_init, got {err:?}", ); } @@ -462,7 +462,7 @@ fn pk_init_not_on_curve_rejected() { .exec_op_tbor::(&req, None, &mut cookie) .expect_err("off-curve pk_init must be rejected"); assert!( - matches!(err, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(err, azihsm_ddi_interface::DdiError::TborStatus(_)), "expected FW-side rejection for off-curve pk_init, got {err:?}", ); } @@ -501,7 +501,7 @@ fn open_session_fills_table_then_recovers() { // an FW-side rejection (not a driver / decode fault) // before ending the ramp-up. assert!( - matches!(e, azihsm_ddi_interface::DdiError::DdiError(_)), + matches!(e, azihsm_ddi_interface::DdiError::TborStatus(_)), "table-full rejection must be FW-side, got {e:?}", ); rejection_seen = true;