diff --git a/openhcl/openhcl_attestation_protocol/src/igvm_attest/get.rs b/openhcl/openhcl_attestation_protocol/src/igvm_attest/get.rs index 09aff9b890..08f6ab0169 100644 --- a/openhcl/openhcl_attestation_protocol/src/igvm_attest/get.rs +++ b/openhcl/openhcl_attestation_protocol/src/igvm_attest/get.rs @@ -167,12 +167,14 @@ impl IgvmAttestRequestHeader { /// Bitmap of additional Igvm request attributes. /// 0 - error_code: Requesting IGVM Agent Error code /// 1 - retry: Retry preference +/// 2 - skip_hw_unsealing: Skip hardware unsealing in case key release request fails #[bitfield(u32)] #[derive(IntoBytes, FromBytes, Immutable, KnownLayout)] pub struct IgvmCapabilityBitMap { pub error_code: bool, pub retry: bool, - #[bits(30)] + pub skip_hw_unsealing: bool, + #[bits(29)] _reserved: u32, } @@ -228,12 +230,14 @@ impl IgvmAttestRequestDataExt { } /// Bitmap indicates a signal to requestor -/// 0 - IGVM_SIGNAL_RETRY_RCOMMENDED_BIT: Retry recommendation +/// 0 - IGVM_SIGNAL_RETRY_RECOMMENDED_BIT: Retry recommendation +/// 1 - IGVM_SIGNAL_SKIP_HW_UNSEALING_RECOMMENDED_BIT: Skip hardware unsealing #[bitfield(u32)] #[derive(IntoBytes, Immutable, KnownLayout, FromBytes)] pub struct IgvmSignal { pub retry: bool, - #[bits(31)] + pub skip_hw_unsealing: bool, + #[bits(30)] _reserved: u32, } diff --git a/openhcl/underhill_attestation/src/igvm_attest/mod.rs b/openhcl/underhill_attestation/src/igvm_attest/mod.rs index 5ccc6ac1e4..132a84b53f 100644 --- a/openhcl/underhill_attestation/src/igvm_attest/mod.rs +++ b/openhcl/underhill_attestation/src/igvm_attest/mod.rs @@ -54,12 +54,13 @@ pub enum Error { latest_version: IgvmAttestResponseVersion, }, #[error( - "attest failed ({igvm_error_code}-{http_status_code}), retry recommendation ({retry_signal})" + "attest failed ({igvm_error_code}-{http_status_code}), retry recommendation ({retry_signal}), skip hw unsealing ({skip_hw_unsealing})" )] Attestation { igvm_error_code: u32, http_status_code: u32, retry_signal: bool, + skip_hw_unsealing: bool, }, } @@ -247,6 +248,7 @@ pub fn parse_response_header(response: &[u8]) -> Result false, - Err((_, r)) => r, + Err((_, r)) => *r, }; + let skip_hw_unsealing = matches!( + &skr_response, + Err(( + secure_key_release::RequestVmgsEncryptionKeysError::ParseIgvmAttestKeyReleaseResponse( + igvm_attest::key_release::KeyReleaseError::ParseHeader( + igvm_attest::Error::Attestation { + skip_hw_unsealing: true, + .. + }, + ), + ), + _, + )) + ); + let VmgsEncryptionKeys { ingress_rsa_kek, wrapped_des_key, @@ -342,6 +359,7 @@ async fn try_unlock_vmgs( tcb_version, guest_state_encryption_policy, strict_encryption_policy, + skip_hw_unsealing, ) .await .map_err(|e| { @@ -683,6 +701,7 @@ async fn get_derived_keys( tcb_version: Option, guest_state_encryption_policy: GuestStateEncryptionPolicy, strict_encryption_policy: bool, + skip_hw_unsealing: bool, ) -> Result { tracing::info!( CVM_ALLOWED, @@ -866,7 +885,18 @@ async fn get_derived_keys( // If sources of encryption used last are missing, attempt to unseal VMGS key with hardware key if (no_kek && found_dek) || (no_gsp && requires_gsp) || (no_gsp_by_id && requires_gsp_by_id) { // If possible, get ingressKey from hardware sealed data - let (hardware_key_protector, hardware_derived_keys) = if let Some(tee_call) = tee_call { + let (hardware_key_protector, hardware_derived_keys) = if skip_hw_unsealing { + tracing::warn!( + CVM_ALLOWED, + "Skipping hardware unsealing of VMGS DEK as signaled by IGVM agent" + ); + get.event_log_fatal( + guest_emulation_transport::api::EventLogId::DEK_HARDWARE_UNSEALING_SKIPPED, + ) + .await; + + return Err(GetDerivedKeysError::HardwareUnsealingSkipped); + } else if let Some(tee_call) = tee_call { let hardware_key_protector = match vmgs::read_hardware_key_protector(vmgs).await { Ok(hardware_key_protector) => Some(hardware_key_protector), Err(e) => { @@ -932,12 +962,12 @@ async fn get_derived_keys( }); } else { if no_kek && found_dek { - Err(GetDerivedKeysError::GetIngressKeyFromKpFailed)? + return Err(GetDerivedKeysError::GetIngressKeyFromKpFailed); } else if no_gsp && requires_gsp { - Err(GetDerivedKeysError::GetIngressKeyFromKGspFailed)? + return Err(GetDerivedKeysError::GetIngressKeyFromKGspFailed); } else { // no_gsp_by_id && requires_gsp_by_id - Err(GetDerivedKeysError::GetIngressKeyFromKGspByIdFailed)? + return Err(GetDerivedKeysError::GetIngressKeyFromKGspByIdFailed); } } } @@ -2563,6 +2593,90 @@ mod tests { assert!(vmgs.encrypted()); } + #[async_test] + async fn init_sec_secure_key_release_skip_hw_unsealing(driver: DefaultDriver) { + let mut vmgs = new_formatted_vmgs().await; + + // IGVM attest is required + // KEY_RELEASE succeeds on first boot, fails with skip_hw_unsealing on second boot. + // WRAPPED_KEY is not in the plan, so it falls back to default (success) every time. + let mut plan = IgvmAgentTestPlan::default(); + plan.insert( + IgvmAttestRequestType::KEY_RELEASE_REQUEST, + VecDeque::from([ + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondFailureSkipHwUnsealing, + ]), + ); + + let get_pair = new_test_get(driver, true, Some(plan)).await; + + let bios_guid = Guid::new_random(); + let att_cfg = new_attestation_vm_config(); + + // Ensure VMGS is not encrypted and agent data is empty before the call + assert!(!vmgs.encrypted()); + + // Obtain a LocalDriver briefly, then run the async flow under the pool executor + let tee = MockTeeCall::new(0x1234); + let ldriver = pal_async::local::block_with_io(|ld| async move { ld }); + let res = initialize_platform_security( + &get_pair.client, + bios_guid, + &att_cfg, + &mut vmgs, + Some(&tee), + false, + ldriver.clone(), + GuestStateEncryptionPolicy::Auto, + true, + ) + .await + .unwrap(); + + // VMGS is now encrypted and HWKP is updated. + assert!(vmgs.encrypted()); + assert!(!hardware_key_protector_is_empty(&mut vmgs).await); + // Agent data should be the same as `key_reference` in the WRAPPED_KEY response. + let key_reference = serde_json::json!({ + "key_info": { + "host": "name" + }, + "attestation_info": { + "host": "attestation_name" + } + }); + let key_reference = serde_json::to_string(&key_reference).unwrap(); + let key_reference = key_reference.as_bytes(); + let mut expected_agent_data = [0u8; AGENT_DATA_MAX_SIZE]; + expected_agent_data[..key_reference.len()].copy_from_slice(key_reference); + assert_eq!(res.agent_data.unwrap(), expected_agent_data.to_vec()); + // Secure key should be None without pre-provisioning + assert!(res.guest_secret_key.is_none()); + + // Second call: KEY_RELEASE fails with skip_hw_unsealing signal. + // The skip_hw_unsealing signal causes the hardware unsealing fallback to be + // skipped, so VMGS unlock should fail. + // NOTE: The test relies on the test GED to return failing KEY_RELEASE response + // with retry recommendation as false so the retry loop terminates immediately. + // Otherwise, the test will get stuck on timer.sleep() as the driver is not + // progressed. + let result = initialize_platform_security( + &get_pair.client, + bios_guid, + &att_cfg, + &mut vmgs, + Some(&tee), + false, + ldriver, + GuestStateEncryptionPolicy::Auto, + true, + ) + .await; + + assert!(result.is_err()); + } + #[async_test] async fn init_sec_secure_key_release_no_hw_sealing_backup(driver: DefaultDriver) { let mut vmgs = new_formatted_vmgs().await; diff --git a/openhcl/underhill_attestation/src/secure_key_release.rs b/openhcl/underhill_attestation/src/secure_key_release.rs index e3dcac2b24..df8618b71f 100644 --- a/openhcl/underhill_attestation/src/secure_key_release.rs +++ b/openhcl/underhill_attestation/src/secure_key_release.rs @@ -155,6 +155,7 @@ pub async fn request_vmgs_encryption_keys( igvm_error_code, http_status_code, retry_signal, + .. }, ), ), @@ -176,6 +177,7 @@ pub async fn request_vmgs_encryption_keys( igvm_error_code, http_status_code, retry_signal, + skip_hw_unsealing, }, ), ), @@ -185,6 +187,7 @@ pub async fn request_vmgs_encryption_keys( igvm_error_code = &igvm_error_code, igvm_http_status_code = &http_status_code, retry_signal = &retry_signal, + skip_hw_unsealing = &skip_hw_unsealing, error = &key_release_attest_error as &dyn std::error::Error, "VMGS key-encryption failed due to igvm attest error" ); diff --git a/vm/devices/get/get_protocol/src/lib.rs b/vm/devices/get/get_protocol/src/lib.rs index 89e5c0d3cf..86f7dcd267 100644 --- a/vm/devices/get/get_protocol/src/lib.rs +++ b/vm/devices/get/get_protocol/src/lib.rs @@ -350,6 +350,7 @@ open_enum! { TPM_INVALID_STATE = 17, TPM_IDENTITY_CHANGE_FAILED = 18, WRAPPED_KEY_REQUIRED_BUT_INVALID = 19, + DEK_HARDWARE_UNSEALING_SKIPPED = 23, } } diff --git a/vm/devices/get/get_resources/src/lib.rs b/vm/devices/get/get_resources/src/lib.rs index 1c66d12b3a..f217d26781 100644 --- a/vm/devices/get/get_resources/src/lib.rs +++ b/vm/devices/get/get_resources/src/lib.rs @@ -250,7 +250,28 @@ pub mod ged { pub enum IgvmAttestTestConfig { /// Config for testing AK cert retry after failure. AkCertRequestFailureAndRetry, + /// Config for testing AK cert retry after failure — extended plan. + /// + /// Windows guests with CVM isolation (SNP, TDX) or VBS generate + /// additional boot-time AK cert requests (background retries + /// during the initial boot and the initial_reboot) that consume + /// plan actions before the test code runs. This plan has extra + /// failure actions to absorb those requests. + AkCertRequestFailureAndRetryExtended, /// Config for testing AK cert persistency across boots. AkCertPersistentAcrossBoot, + /// Config for testing AK cert persistency across boots — extended + /// plan. + /// + /// VBS guests send a background AK cert request during the initial + /// boot whose response may be lost when the VM resets for the + /// `initial_reboot`. The extra `RespondSuccess` absorbs that + /// request so the second boot still gets a cert provisioned. + AkCertPersistentAcrossBootExtended, + /// Config for testing skip hardware unsealing signal from IGVMAgent. + KeyReleaseFailureSkipHwUnsealing, + /// Config for testing key release failure without skip_hw_unsealing + /// signal — hardware unsealing fallback should be attempted. + KeyReleaseFailure, } } diff --git a/vm/devices/get/guest_emulation_device/src/lib.rs b/vm/devices/get/guest_emulation_device/src/lib.rs index 4075990cce..756ff24ef4 100644 --- a/vm/devices/get/guest_emulation_device/src/lib.rs +++ b/vm/devices/get/guest_emulation_device/src/lib.rs @@ -271,7 +271,7 @@ impl GuestEmulationDevice { waiting_for_vtl0_start: Vec::new(), last_save_restore_buf_len: 0, igvm_agent_setting, - igvm_agent: TestIgvmAgent::new(), + igvm_agent: TestIgvmAgent::new("openvmm"), test_gsp_by_id, } } diff --git a/vm/devices/get/test_igvm_agent_lib/src/lib.rs b/vm/devices/get/test_igvm_agent_lib/src/lib.rs index 4b45e6d546..d65031a254 100644 --- a/vm/devices/get/test_igvm_agent_lib/src/lib.rs +++ b/vm/devices/get/test_igvm_agent_lib/src/lib.rs @@ -98,6 +98,8 @@ pub enum KeyReleaseError { /// Test IGVM agent includes states that need to be persisted. #[derive(Debug, Clone, Default)] pub struct TestIgvmAgent { + /// VM name for log correlation. + vm_name: String, /// Optional RSA private key used for attestation. secret_key: Option, /// Optional DES key @@ -115,8 +117,14 @@ pub enum IgvmAgentAction { RespondSuccess, /// Emit a response that indicates a protocol error. RespondFailure, - /// Skip responding to simulate a timeout. + /// Emit a response that indicates a protocol error with skip_hw_unsealing signal. + RespondFailureSkipHwUnsealing, + /// Skip responding to simulate a timeout (consumed once). NoResponse, + /// Skip responding for this and all subsequent requests of the same type. + /// Unlike [`NoResponse`](Self::NoResponse), this action is never consumed + /// from the queue. + AlwaysNoResponse, } /// IGVM Agent test plan specifying scripted actions for a request type. @@ -160,10 +168,76 @@ fn test_config_to_plan(test_config: &IgvmAttestTestConfig) -> IgvmAgentTestPlan ]), ); } + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended => { + // CVM guests (SNP, TDX, VBS) with Hyper-V can generate multiple + // boot-time AK_CERT_REQUEST calls (background retries during + // the initial boot and the initial_reboot). Six failures + // ensure the SUCCESS action is never consumed during boot, + // so it remains available for the guest test. + plan.insert( + IgvmAttestRequestType::AK_CERT_REQUEST, + VecDeque::from([ + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::RespondSuccess, + ]), + ); + } IgvmAttestTestConfig::AkCertPersistentAcrossBoot => { plan.insert( IgvmAttestRequestType::AK_CERT_REQUEST, - VecDeque::from([IgvmAgentAction::RespondSuccess, IgvmAgentAction::NoResponse]), + VecDeque::from([ + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::AlwaysNoResponse, + ]), + ); + } + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended => { + // VBS guests generate a background AK cert request during the + // initial boot. The response is often lost when the VM resets + // for the `initial_reboot`, consuming the first action. The + // extra RespondSuccess ensures the second boot can still + // provision the cert via RPC, and the third boot validates that + // the cert is cached. + plan.insert( + IgvmAttestRequestType::AK_CERT_REQUEST, + VecDeque::from([ + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::AlwaysNoResponse, + ]), + ); + } + IgvmAttestTestConfig::KeyReleaseFailureSkipHwUnsealing => { + // Two RespondSuccess entries to cover the initial boot and + // the initial_reboot (guest quirk) — both consume a + // KEY_RELEASE during run() before the test code starts. + plan.insert( + IgvmAttestRequestType::KEY_RELEASE_REQUEST, + VecDeque::from([ + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondFailureSkipHwUnsealing, + IgvmAgentAction::AlwaysNoResponse, + ]), + ); + } + IgvmAttestTestConfig::KeyReleaseFailure => { + // Two RespondSuccess entries to cover the initial boot and + // the initial_reboot (guest quirk) — both consume a + // KEY_RELEASE during run() before the test code starts. + plan.insert( + IgvmAttestRequestType::KEY_RELEASE_REQUEST, + VecDeque::from([ + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondSuccess, + IgvmAgentAction::RespondFailure, + IgvmAgentAction::AlwaysNoResponse, + ]), ); } } @@ -172,9 +246,13 @@ fn test_config_to_plan(test_config: &IgvmAttestTestConfig) -> IgvmAgentTestPlan } impl TestIgvmAgent { - /// Create an instance. - pub fn new() -> Self { + /// Create an instance associated with the given VM name. + /// + /// The `vm_name` is included in all tracing output so that log + /// messages from the library can be correlated with a specific VM. + pub fn new(vm_name: impl Into) -> Self { Self { + vm_name: vm_name.into(), secret_key: None, des_key: None, plan: None, @@ -190,7 +268,7 @@ impl TestIgvmAgent { return; } - tracing::info!("install the scripted plan for test IGVM Agent"); + tracing::info!(vm_name = %self.vm_name, "install the scripted plan for test IGVM Agent"); match setting { IgvmAgentTestSetting::TestPlan(plan) => { @@ -205,17 +283,27 @@ impl TestIgvmAgent { } /// Take the next scripted action for the given request type, if any. + /// + /// [`IgvmAgentAction::AlwaysNoResponse`] is sticky: it is returned but + /// never removed from the queue, so every subsequent call for the same + /// request type will keep returning it. pub fn take_next_action( &mut self, request_type: IgvmAttestRequestType, ) -> Option { // Fast path: no plan installed. let plan = self.plan.as_mut()?; - plan.get_mut(&request_type)?.pop_front() + let queue = plan.get_mut(&request_type)?; + match queue.front()? { + IgvmAgentAction::AlwaysNoResponse => Some(IgvmAgentAction::AlwaysNoResponse), + _ => queue.pop_front(), + } } /// Request handler. pub fn handle_request(&mut self, request_bytes: &[u8]) -> Result<(Vec, u32), Error> { + let _span = tracing::info_span!("igvm_agent", vm_name = %self.vm_name).entered(); + let request = IgvmAttestRequestBase::read_from_prefix(request_bytes) .map_err(|_| Error::InvalidIgvmAttestRequest)? .0; // TODO: zerocopy: map_err (https://github.com/microsoft/openvmm/issues/759) @@ -247,10 +335,10 @@ impl TestIgvmAgent { let (response, length) = if let Some(action) = self.take_next_action(request.header.request_type) { - // If a plan is provided and has a queued action for this request type, + // If a plan is installed and has a queued action for this request type, // execute it. This allows tests to force success/no-response, etc. match action { - IgvmAgentAction::NoResponse => { + IgvmAgentAction::NoResponse | IgvmAgentAction::AlwaysNoResponse => { tracing::info!(?request.header.request_type, "Test plan: NoResponse"); (vec![], 0) } @@ -366,10 +454,72 @@ impl TestIgvmAgent { ty => return Err(Error::UnsupportedIgvmAttestRequestType(ty.0)), } } + IgvmAgentAction::RespondFailureSkipHwUnsealing => { + tracing::info!(?request.header.request_type, "Test plan: RespondFailureSkipHwUnsealing"); + match request.header.request_type { + IgvmAttestRequestType::WRAPPED_KEY_REQUEST => { + let header = IgvmAttestWrappedKeyResponseHeader { + data_size: size_of::() as u32, + version: IGVM_ATTEST_RESPONSE_CURRENT_VERSION, + error_info: IgvmErrorInfo { + error_code: 0x5678, + http_status_code: 400, + igvm_signal: IgvmSignal::default() + .with_retry(false) + .with_skip_hw_unsealing(true), + reserved: [0; 3], + }, + }; + let payload = header.as_bytes().to_vec(); + let payload_len = payload.len() as u32; + + (payload, payload_len) + } + IgvmAttestRequestType::KEY_RELEASE_REQUEST => { + let header = IgvmAttestKeyReleaseResponseHeader { + data_size: size_of::() as u32, + version: IGVM_ATTEST_RESPONSE_CURRENT_VERSION, + error_info: IgvmErrorInfo { + error_code: 0x5678, + http_status_code: 400, + igvm_signal: IgvmSignal::default() + .with_retry(false) + .with_skip_hw_unsealing(true), + reserved: [0; 3], + }, + }; + let payload = header.as_bytes().to_vec(); + let payload_len = payload.len() as u32; + + (payload, payload_len) + } + IgvmAttestRequestType::AK_CERT_REQUEST => { + let header = IgvmAttestAkCertResponseHeader { + data_size: size_of::() as u32, + version: IGVM_ATTEST_RESPONSE_CURRENT_VERSION, + error_info: IgvmErrorInfo { + error_code: 0x5678, + http_status_code: 400, + igvm_signal: IgvmSignal::default() + .with_retry(false) + .with_skip_hw_unsealing(true), + reserved: [0; 3], + }, + }; + let payload = header.as_bytes().to_vec(); + let payload_len = payload.len() as u32; + + (payload, payload_len) + } + ty => return Err(Error::UnsupportedIgvmAttestRequestType(ty.0)), + } + } } } else { - // If no plan is provided, fall back to the default behavior that - // always return valid responses. + // No scripted action for this request type (either no plan is + // installed, or the plan does not cover this request type / is + // exhausted). Fall back to the default behavior that always + // returns valid responses. match request.header.request_type { IgvmAttestRequestType::AK_CERT_REQUEST => { tracing::info!("Send a response for AK_CERT_REQUEST"); diff --git a/vm/devices/get/test_igvm_agent_rpc_server/src/main.rs b/vm/devices/get/test_igvm_agent_rpc_server/src/main.rs index f4f98cf153..aafabf32f4 100644 --- a/vm/devices/get/test_igvm_agent_rpc_server/src/main.rs +++ b/vm/devices/get/test_igvm_agent_rpc_server/src/main.rs @@ -28,6 +28,10 @@ enum TestConfig { AkCertRequestFailureAndRetry, /// Test AK cert persistency across boots AkCertPersistentAcrossBoot, + /// Test skip hardware unsealing signal from IGVM Agent + KeyReleaseFailureSkipHwUnsealing, + /// Test key release failure without skip_hw_unsealing signal + KeyReleaseFailure, } impl From for IgvmAttestTestConfig { @@ -39,6 +43,10 @@ impl From for IgvmAttestTestConfig { TestConfig::AkCertPersistentAcrossBoot => { IgvmAttestTestConfig::AkCertPersistentAcrossBoot } + TestConfig::KeyReleaseFailureSkipHwUnsealing => { + IgvmAttestTestConfig::KeyReleaseFailureSkipHwUnsealing + } + TestConfig::KeyReleaseFailure => IgvmAttestTestConfig::KeyReleaseFailure, } } } @@ -53,7 +61,7 @@ fn main() -> ExitCode { let args = Args::parse(); let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("test_igvm_agent_rpc_server=info")); + .unwrap_or_else(|_| EnvFilter::new("test_igvm_agent_rpc_server=info,test_igvm_agent_lib=info")); let _ = fmt() .with_env_filter(filter) @@ -66,8 +74,8 @@ fn main() -> ExitCode { if let Some(test_config) = args.test_config { let igvm_config: IgvmAttestTestConfig = test_config.into(); let setting = IgvmAgentTestSetting::TestConfig(igvm_config); - rpc::igvm_agent::install_plan(&setting); - tracing::info!(?test_config, "installed test configuration"); + rpc::igvm_agent::install_default_plan(&setting); + tracing::info!(?test_config, "installed default test configuration"); } else { tracing::info!("no test configuration provided, using default behavior"); } diff --git a/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/handlers.rs b/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/handlers.rs index cae47b764f..c4feaa1ca2 100644 --- a/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/handlers.rs +++ b/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/handlers.rs @@ -218,7 +218,8 @@ pub extern "system" fn rpc_igvm_attest( "invoking attest igvm_agent" ); - let payload = match igvm_agent::process_igvm_attest(report_slice) { + let vm_name_ref = vm_name_str.as_deref().unwrap_or(""); + let payload = match igvm_agent::process_igvm_attest(vm_name_ref, report_slice) { Ok(payload) => payload, Err(err) => { tracing::error!(?err, "igvm_agent::process_igvm_attest failed"); diff --git a/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/igvm_agent.rs b/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/igvm_agent.rs index 0c7ad429c5..1dabf07456 100644 --- a/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/igvm_agent.rs +++ b/vm/devices/get/test_igvm_agent_rpc_server/src/rpc/igvm_agent.rs @@ -1,9 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Shared façade that exposes the test IGVM agent through a singleton instance. +//! Per-VM test IGVM agent façade. +//! +//! Each VM gets its own [`TestIgvmAgent`] keyed by VM name. The test plan +//! for a given VM is resolved by matching its name against a hardcoded +//! mapping (see [`config_for_vm_name`]). A default plan can also be +//! installed via the CLI `--test_config` flag; it applies to VMs whose +//! names do not match any known pattern. +//! +//! **Naming convention** – Hyper-V VM names are capped at 100 characters +//! (see `petri::vm::make_vm_safe_name`). The prefix added by the test +//! macro can consume ~85 characters on the worst-case image name, leaving +//! only ~15 characters for the test function name. Keep test function +//! names short (≤ 15 chars) so the distinctive part is never truncated. +use get_resources::ged::IgvmAttestTestConfig; use parking_lot::Mutex; +use std::collections::HashMap; use std::sync::OnceLock; use test_igvm_agent_lib::Error; use test_igvm_agent_lib::IgvmAgentTestSetting; @@ -21,25 +35,161 @@ pub enum TestAgentFacadeError { /// Convenience result type for façade invocations. pub type TestAgentResult = Result; -static TEST_AGENT: OnceLock> = OnceLock::new(); +/// Per-VM agent registry. +struct AgentRegistry { + /// Default setting (from CLI `--test_config`), applied to VMs that + /// don't match any hardcoded pattern. + default_setting: Option, + /// Live per-VM agents, lazily created on first request. + agents: HashMap, +} + +static REGISTRY: OnceLock> = OnceLock::new(); -fn global_agent() -> &'static Mutex { - TEST_AGENT.get_or_init(|| Mutex::new(TestIgvmAgent::new())) +fn registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + Mutex::new(AgentRegistry { + default_setting: None, + agents: HashMap::new(), + }) + }) } -fn guard_agent() -> parking_lot::MutexGuard<'static, TestIgvmAgent> { - global_agent().lock() +/// Hardcoded mapping from VM name substrings to test configurations. +/// +/// The substring must be short enough to survive Hyper-V's 100-char name +/// limit even on the longest image prefixes (~85 chars). Keep patterns +/// ≤ 15 characters. +/// +/// Hyper-V VM names are built as: +/// `{module}::{vmm}_{firmware}_{arch}_{image}_{isolation}_{test_fn}` +/// We match by `contains` on the test function name portion. +fn config_for_vm_name(vm_name: &str) -> Option { + /// (substring, config) pairs – order does not matter since each + /// pattern is unique. + const KNOWN_TEST_CONFIGS: &[(&str, IgvmAttestTestConfig)] = &[ + ( + "ubuntu_2504_server_x64_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "windows_datacenter_core_2022_x64_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "ubuntu_2504_server_x64_vbs_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_vbs_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "ubuntu_2504_server_x64_snp_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_snp_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "ubuntu_2504_server_x64_tdx_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_tdx_ak_cert_retry", + IgvmAttestTestConfig::AkCertRequestFailureAndRetryExtended, + ), + ( + "ubuntu_2504_server_x64_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "windows_datacenter_core_2022_x64_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "ubuntu_2504_server_x64_vbs_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_vbs_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "ubuntu_2504_server_x64_snp_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_snp_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "ubuntu_2504_server_x64_tdx_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "windows_datacenter_core_2025_x64_prepped_tdx_ak_cert_cache", + IgvmAttestTestConfig::AkCertPersistentAcrossBootExtended, + ), + ( + "ubuntu_2504_server_x64_snp_skip_hw_unseal", + IgvmAttestTestConfig::KeyReleaseFailureSkipHwUnsealing, + ), + ( + "windows_datacenter_core_2025_x64_prepped_snp_skip_hw_unseal", + IgvmAttestTestConfig::KeyReleaseFailureSkipHwUnsealing, + ), + ( + "ubuntu_2504_server_x64_snp_use_hw_unseal", + IgvmAttestTestConfig::KeyReleaseFailure, + ), + ( + "windows_datacenter_core_2025_x64_prepped_snp_use_hw_unseal", + IgvmAttestTestConfig::KeyReleaseFailure, + ), + ]; + + for &(pattern, config) in KNOWN_TEST_CONFIGS { + if vm_name.contains(pattern) { + tracing::info!(vm_name, pattern, "matched test config for VM"); + return Some(IgvmAgentTestSetting::TestConfig(config)); + } + } + + None } -/// Install a scripted test plan for the shared test agent instance. -pub fn install_plan(setting: &IgvmAgentTestSetting) { - let mut agent = guard_agent(); - agent.install_plan_from_setting(setting); +/// Install a default test plan used as a fallback for VMs that don't +/// match any hardcoded pattern. +pub fn install_default_plan(setting: &IgvmAgentTestSetting) { + let mut reg = registry().lock(); + reg.default_setting = Some(setting.clone()); } -/// Process an attestation request payload using the shared test agent. -pub fn process_igvm_attest(report: &[u8]) -> TestAgentResult> { - let mut agent = guard_agent(); +/// Process an attestation request payload for the given VM. +/// +/// On first contact the VM's agent is created and configured: +/// 1. If the VM name matches a hardcoded pattern, that config is used. +/// 2. Otherwise the default plan (if any) is installed. +pub fn process_igvm_attest(vm_name: &str, report: &[u8]) -> TestAgentResult> { + let mut reg = registry().lock(); + + // Clone the default setting before entering the entry API so the + // borrow checker is happy. + let default_setting = reg.default_setting.clone(); + + let agent = reg.agents.entry(vm_name.to_owned()).or_insert_with(|| { + let mut agent = TestIgvmAgent::new(vm_name); + if let Some(setting) = config_for_vm_name(vm_name) { + agent.install_plan_from_setting(&setting); + } else if let Some(ref default) = default_setting { + agent.install_plan_from_setting(default); + } + tracing::info!(vm_name, "created per-VM test agent"); + agent + }); + let (payload, expected_len) = agent.handle_request(report).map_err(|err| match err { Error::InvalidIgvmAttestRequest => TestAgentFacadeError::InvalidRequest, _ => TestAgentFacadeError::AgentFailure, diff --git a/vm/devices/tpm/tpm_guest_tests/src/main.rs b/vm/devices/tpm/tpm_guest_tests/src/main.rs index 6fb46a1a35..e03b505454 100644 --- a/vm/devices/tpm/tpm_guest_tests/src/main.rs +++ b/vm/devices/tpm/tpm_guest_tests/src/main.rs @@ -44,7 +44,7 @@ const MAX_NV_READ_SIZE: usize = 4096; const MAX_ATTESTATION_READ_SIZE: usize = 2600; const GUEST_INPUT_SIZE: u16 = 64; const GUEST_INPUT_AUTH: u64 = 0; -const AK_CERT_RETRY_DELAY_MS: u64 = 200; +const AK_CERT_RETRY_DELAY_MS: u64 = 1000; #[derive(Debug, Default)] struct Config { diff --git a/vmm_tests/vmm_tests/tests/tests/multiarch/tpm.rs b/vmm_tests/vmm_tests/tests/tests/multiarch/tpm.rs index 56a779ced0..39874766bb 100644 --- a/vmm_tests/vmm_tests/tests/tests/multiarch/tpm.rs +++ b/vmm_tests/vmm_tests/tests/tests/multiarch/tpm.rs @@ -4,6 +4,8 @@ use anyhow::Context; use anyhow::ensure; use petri::PetriGuestStateLifetime; +#[cfg(windows)] +use petri::PetriHaltReason; use petri::PetriVmBuilder; use petri::PetriVmmBackend; use petri::ResolvedArtifact; @@ -144,7 +146,7 @@ impl<'a> TpmGuestTests<'a> { "--expected-data-hex", expected_hex, "--retry", - "3", + "10", ]) .read() .await @@ -157,7 +159,7 @@ impl<'a> TpmGuestTests<'a> { "--expected-data-hex", expected_hex, "--retry", - "3", + "10", ]) .read() .await @@ -354,6 +356,144 @@ async fn tpm_ak_cert_retry( Ok(()) } +/// Hyper-V variant of TPM AK cert persisted test. +/// +/// First boot: AK cert request is served by the RPC agent. +/// Second boot: AK cert is served from the persistent cache. +/// +/// The test function name contains `ak_cert_cache` so the per-VM agent +/// registry in test_igvm_agent_rpc_server matches it to the +/// `AkCertPersistentAcrossBoot` configuration. +#[cfg(windows)] +#[vmm_test( + hyperv_openhcl_uefi_x64(vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64(vhd(windows_datacenter_core_2022_x64))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[vbs](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[vbs](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[tdx](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[tdx](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], +)] +async fn ak_cert_cache( + config: PetriVmBuilder, + extra_deps: (ResolvedArtifact, ResolvedArtifact), +) -> anyhow::Result<()> { + let os_flavor = config.os_flavor(); + let (tpm_guest_tests_artifact, rpc_server_artifact) = extra_deps; + + let rpc_server_path = rpc_server_artifact.get(); + let _rpc_guard = ensure_rpc_server_running(rpc_server_path)?; + + let (mut vm, mut agent) = config + .with_tpm(true) + .with_tpm_state_persistence(true) + .with_guest_state_lifetime(PetriGuestStateLifetime::Disk) + .run() + .await?; + + let guest_binary_path = match os_flavor { + OsFlavor::Linux => TPM_GUEST_TESTS_LINUX_GUEST_PATH, + OsFlavor::Windows => TPM_GUEST_TESTS_WINDOWS_GUEST_PATH, + _ => unreachable!(), + }; + + agent.reboot().await?; + agent = vm.wait_for_reset().await?; + + let host_binary_path = tpm_guest_tests_artifact.get(); + let tpm_guest_tests = + TpmGuestTests::send_tpm_guest_tests(&agent, host_binary_path, guest_binary_path, os_flavor) + .await?; + + let expected_hex = expected_ak_cert_hex(); + let output = tpm_guest_tests + .read_ak_cert_with_expected_hex(expected_hex.as_str()) + .await?; + + ensure!( + output.contains("AK certificate matches expected value"), + format!("{output}") + ); + + agent.power_off().await?; + vm.wait_for_clean_teardown().await?; + + Ok(()) +} + +/// Hyper-V variant of TPM AK cert retry test. +/// +/// The RPC agent is configured to fail the first AK cert request and +/// succeed on retry. The guest-side `tpm_guest_tests` binary verifies +/// that the first read fails and the second (retry) read succeeds with +/// the expected certificate data. +/// +/// The test function name contains `ak_cert_retry` so the per-VM agent +/// registry in test_igvm_agent_rpc_server matches it to the +/// `AkCertRequestFailureAndRetry` configuration. +#[cfg(windows)] +#[vmm_test( + hyperv_openhcl_uefi_x64(vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64(vhd(windows_datacenter_core_2022_x64))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[vbs](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[vbs](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[tdx](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[tdx](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], +)] +async fn ak_cert_retry( + config: PetriVmBuilder, + extra_deps: (ResolvedArtifact, ResolvedArtifact), +) -> anyhow::Result<()> { + let os_flavor = config.os_flavor(); + let (tpm_guest_tests_artifact, rpc_server_artifact) = extra_deps; + + let rpc_server_path = rpc_server_artifact.get(); + let _rpc_guard = ensure_rpc_server_running(rpc_server_path)?; + + let (vm, agent) = config + .with_tpm(true) + .with_tpm_state_persistence(true) + .with_guest_state_lifetime(PetriGuestStateLifetime::Disk) + .run() + .await?; + + let guest_binary_path = match os_flavor { + OsFlavor::Linux => TPM_GUEST_TESTS_LINUX_GUEST_PATH, + OsFlavor::Windows => TPM_GUEST_TESTS_WINDOWS_GUEST_PATH, + _ => unreachable!(), + }; + + let host_binary_path = tpm_guest_tests_artifact.get(); + let tpm_guest_tests = + TpmGuestTests::send_tpm_guest_tests(&agent, host_binary_path, guest_binary_path, os_flavor) + .await?; + + // The read attempt is expected to fail and trigger an AK cert renewal request. + let attempt = tpm_guest_tests.read_ak_cert().await; + assert!( + attempt.is_err(), + "AK certificate read unexpectedly succeeded" + ); + + let expected_hex = expected_ak_cert_hex(); + let output = tpm_guest_tests + .read_ak_cert_with_expected_hex(expected_hex.as_str()) + .await?; + + ensure!( + output.contains("AK certificate matches expected value"), + format!("{output}") + ); + + agent.power_off().await?; + vm.wait_for_clean_teardown().await?; + + Ok(()) +} + /// VBS boot test with attestation enabled #[openvmm_test( openhcl_uefi_x64[vbs](vhd(windows_datacenter_core_2025_x64_prepped)), @@ -547,6 +687,180 @@ async fn cvm_tpm_guest_tests( Ok(()) } +/// Test that skip_hw_unsealing signal from IGVM agent causes VMGS +/// unlock to fail on second boot. +/// +/// First boot: KEY_RELEASE succeeds, VMGS is encrypted with hardware +/// key protector, TPM state is sealed. +/// Second boot: KEY_RELEASE fails with skip_hw_unsealing signal, hardware +/// unsealing fallback is skipped, VMGS cannot be unlocked. +/// `initialize_platform_security` returns an error, underhill reports the +/// failure to the host via `complete_start_vtl0`, and the host terminates +/// the VM. +/// +/// The test function name contains `skip_hw_unseal` so the per-VM agent +/// registry in test_igvm_agent_rpc_server matches it to the +/// `KeyReleaseFailureSkipHwUnsealing` configuration. +#[cfg(windows)] +#[vmm_test( + hyperv_openhcl_uefi_x64[snp](vhd(ubuntu_2504_server_x64))[TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(windows_datacenter_core_2025_x64_prepped))[TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], +)] +async fn skip_hw_unseal( + config: PetriVmBuilder, + extra_deps: (ResolvedArtifact,), +) -> anyhow::Result<()> { + let (rpc_server_artifact,) = extra_deps; + + let rpc_server_path = rpc_server_artifact.get(); + let _rpc_guard = ensure_rpc_server_running(rpc_server_path)?; + + let (mut vm, agent) = config + .with_tpm(true) + .with_tpm_state_persistence(true) + .with_guest_state_lifetime(PetriGuestStateLifetime::Disk) + .run() + .await?; + + // First boot: KEY_RELEASE succeeds. TPM state is sealed with hardware + // key protector. No guest-side verification needed — just let the boot + // complete so the VMGS state is populated. + + // Reboot: triggers second KEY_RELEASE which fails with skip_hw_unsealing. + // VMGS unlock will fail because hardware unsealing fallback is skipped. + // initialize_platform_security returns an error, underhill reports the + // failure to the host via complete_start_vtl0, and the host terminates + // the VM. + agent.reboot().await?; + + // Wait for the VM to reset and then fail on the second boot. + // + // Depending on timing, two outcomes are possible: + // + // 1. wait_for_halt() returns Reset (the CVM restart check saw the VM + // briefly reach Running state before underhill failed), and then the + // subsequent wait_for_teardown() fails because the VM termination + // does not produce a recognized halt event. + // + // 2. wait_for_halt() itself fails because the VM never reached Running + // state within the allowed CVM restart timeout (underhill failed + // before Hyper-V reported the VM as Running). + // + // Both outcomes confirm the expected behavior: the VM cannot boot after + // hardware unsealing is skipped. + match vm.wait_for_halt().await { + Ok(PetriHaltReason::Reset) => { + tracing::info!("Got reset event; waiting for second boot termination..."); + // The VM termination after second boot failure may not produce + // a recognized Hyper-V halt event (e.g., event 18620 from + // Hyper-V-Chipset is not in the standard halt event filter). + // Ignore the error — the VM going off IS the expected outcome. + match vm.wait_for_teardown().await { + Ok(halt_reason) => { + tracing::info!("Second boot halt reason: {halt_reason:?}"); + } + Err(e) => { + tracing::info!("Second boot terminated as expected: {e:#}"); + } + } + } + Ok(other) => { + anyhow::bail!("Expected Reset or VM start failure, got {other:?}"); + } + Err(e) => { + // The VM failed to restart within the allowed time, which is + // the expected behavior when underhill cannot unlock VMGS. + tracing::info!("VM failed to restart as expected: {e:#}"); + vm.teardown().await?; + } + } + + Ok(()) +} + +/// Test that KEY_RELEASE failure without skip_hw_unsealing signal allows +/// hardware unsealing fallback to succeed. +/// +/// First boot: KEY_RELEASE succeeds, VMGS is encrypted with hardware +/// key protector, TPM state is sealed. AK cert is verified. +/// Second boot: KEY_RELEASE fails (plain failure, no skip_hw_unsealing +/// signal), hardware unsealing fallback is attempted and succeeds because +/// the hardware key protector was saved on first boot. The VM boots +/// normally and the AK cert remains accessible. +/// +/// The test function name contains `use_hw_unseal` so the per-VM agent +/// registry in test_igvm_agent_rpc_server matches it to the +/// `KeyReleaseFailure` configuration. +#[cfg(windows)] +#[vmm_test( + hyperv_openhcl_uefi_x64[snp](vhd(ubuntu_2504_server_x64))[TPM_GUEST_TESTS_LINUX_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], + hyperv_openhcl_uefi_x64[snp](vhd(windows_datacenter_core_2025_x64_prepped))[TPM_GUEST_TESTS_WINDOWS_X64, TEST_IGVM_AGENT_RPC_SERVER_WINDOWS_X64], +)] +async fn use_hw_unseal( + config: PetriVmBuilder, + extra_deps: (ResolvedArtifact, ResolvedArtifact), +) -> anyhow::Result<()> { + let os_flavor = config.os_flavor(); + let (tpm_guest_tests_artifact, rpc_server_artifact) = extra_deps; + + let rpc_server_path = rpc_server_artifact.get(); + let _rpc_guard = ensure_rpc_server_running(rpc_server_path)?; + + let (mut vm, agent) = config + .with_tpm(true) + .with_tpm_state_persistence(true) + .with_guest_state_lifetime(PetriGuestStateLifetime::Disk) + .run() + .await?; + + let guest_binary_path = match os_flavor { + OsFlavor::Linux => TPM_GUEST_TESTS_LINUX_GUEST_PATH, + OsFlavor::Windows => TPM_GUEST_TESTS_WINDOWS_GUEST_PATH, + _ => unreachable!(), + }; + let host_binary_path = tpm_guest_tests_artifact.get(); + let tpm_guest_tests = + TpmGuestTests::send_tpm_guest_tests(&agent, host_binary_path, guest_binary_path, os_flavor) + .await?; + + // First boot: KEY_RELEASE succeeds. Verify AK cert is present. + let expected_hex = expected_ak_cert_hex(); + let ak_cert_output = tpm_guest_tests + .read_ak_cert_with_expected_hex(expected_hex.as_str()) + .await?; + + ensure!( + ak_cert_output.contains("AK certificate matches expected value"), + format!("{ak_cert_output}") + ); + + // Reboot: triggers second KEY_RELEASE which fails (plain failure, + // no skip_hw_unsealing signal). Hardware unsealing fallback kicks + // in and succeeds — the VM boots normally. + agent.reboot().await?; + let agent = vm.wait_for_reset().await?; + + // Verify AK cert is still accessible after the hw unsealing fallback. + let host_binary_path = tpm_guest_tests_artifact.get(); + let tpm_guest_tests = + TpmGuestTests::send_tpm_guest_tests(&agent, host_binary_path, guest_binary_path, os_flavor) + .await?; + + let ak_cert_output = tpm_guest_tests + .read_ak_cert_with_expected_hex(expected_hex.as_str()) + .await?; + + ensure!( + ak_cert_output.contains("AK certificate matches expected value"), + "AK cert should still be accessible after hw unsealing fallback: {ak_cert_output}" + ); + + agent.power_off().await?; + vm.wait_for_clean_teardown().await?; + + Ok(()) +} + /// Test that TPM NVRAM size persists across servicing. #[vmm_test( openvmm_openhcl_uefi_x64(vhd(ubuntu_2504_server_x64))[LATEST_STANDARD_X64, VMGS_WITH_16K_TPM],