diff --git a/Cargo.lock b/Cargo.lock index e7485c1e..bf47b6f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "aes-siv" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e08d0cdb774acd1e4dac11478b1a0c0d203134b2aab0ba25eb430de9b18f8b9" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "cmac", + "ctr", + "dbl", + "digest 0.10.7", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1058,6 +1074,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitfield" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c821a6e124197eb56d907ccc2188eab1038fb919c914f47976e64dd8dbc855d1" + [[package]] name = "bitflags" version = "1.3.2" @@ -1478,6 +1500,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmake" version = "0.1.57" @@ -1513,6 +1546,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "codicon" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12170080f3533d6f09a19f81596f836854d0fa4867dc32c8172b8474b4e9de61" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1938,6 +1977,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "dcap-qvl" version = "0.3.12" @@ -2168,13 +2216,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -2185,7 +2254,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2421,7 +2490,9 @@ dependencies = [ name = "dstack-kms" version = "0.5.8" dependencies = [ + "aes-siv", "anyhow", + "base64 0.22.1", "chrono", "clap", "dstack-guest-agent-rpc", @@ -2448,6 +2519,7 @@ dependencies = [ "serde-duration", "serde-human-bytes", "serde_json", + "sev", "sha2 0.10.9", "sha3", "tempfile", @@ -2657,7 +2729,7 @@ dependencies = [ "base64 0.22.1", "bon", "clap", - "dirs", + "dirs 6.0.0", "dstack-kms-rpc", "dstack-port-forward", "dstack-types", @@ -2802,6 +2874,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -4115,6 +4188,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "iocuddle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8972d5be69940353d5347a1344cb375d9b457d6809b428b05bb1ca2fb9ce007" + [[package]] name = "iohash" version = "0.5.8" @@ -4465,7 +4544,7 @@ dependencies = [ "rust-argon2", "secrecy", "serde", - "serde-big-array", + "serde-big-array 0.3.3", "serde_json", "sha2 0.9.9", "thiserror 1.0.69", @@ -5991,6 +6070,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -6905,6 +6995,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde-duration" version = "0.5.8" @@ -7062,6 +7161,34 @@ dependencies = [ "serde", ] +[[package]] +name = "sev" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ac277517d8fffdf3c41096323ed705b3a7c75e397129c072fb448339839d0f" +dependencies = [ + "base64 0.22.1", + "bincode 1.3.3", + "bitfield", + "bitflags 1.3.2", + "byteorder", + "codicon", + "dirs 5.0.1", + "hex", + "iocuddle", + "lazy_static", + "libc", + "p384", + "rsa", + "serde", + "serde-big-array 0.5.1", + "serde_bytes", + "sha2 0.10.9", + "static_assertions", + "uuid", + "x509-cert", +] + [[package]] name = "sha1" version = "0.10.6" @@ -7704,6 +7831,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.50.0" @@ -8113,6 +8261,7 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -8960,6 +9109,7 @@ dependencies = [ "const-oid", "der", "spki", + "tls_codec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 982b892a..41c673d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,8 @@ hex = { version = "0.4.3", default-features = false } hex_fmt = "0.3.0" hex-literal = "1.0.0" prost = "0.13.5" +# AMD SEV-SNP attestation verification +sev = { version = "=6.0.0", default-features = false, features = ["snp", "crypto_nossl"] } prost-types = "0.13.5" scale = { version = "3.7.4", package = "parity-scale-codec", features = [ "derive", @@ -177,6 +179,7 @@ url = "2.5" # Cryptography/Security aes-gcm = "0.10.3" +aes-siv = "0.7.0" curve25519-dalek = "4.1.3" dcap-qvl = "0.3.10" elliptic-curve = { version = "0.13.8", features = ["pkcs8"] } diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index ed5d9623..124a3b40 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -30,6 +30,7 @@ use sha2::Digest as _; const DSTACK_TDX: &str = "dstack-tdx"; const DSTACK_GCP_TDX: &str = "dstack-gcp-tdx"; const DSTACK_NITRO_ENCLAVE: &str = "dstack-nitro-enclave"; +const DSTACK_SEV_SNP: &str = "dstack-sev-snp"; #[cfg(feature = "quote")] const SYS_CONFIG_PATH: &str = "/dstack/.host-shared/.sys-config.json"; @@ -63,6 +64,9 @@ pub enum AttestationMode { /// Dstack attestation SDK in AWS Nitro Enclave #[serde(rename = "dstack-nitro-enclave")] DstackNitroEnclave, + /// AMD SEV-SNP attestation + #[serde(rename = "dstack-sev-snp")] + DstackSevSnp, } impl AttestationMode { @@ -96,6 +100,7 @@ impl AttestationMode { Self::DstackTdx => true, Self::DstackGcpTdx => true, Self::DstackNitroEnclave => false, + Self::DstackSevSnp => false, } } @@ -105,6 +110,7 @@ impl AttestationMode { Self::DstackGcpTdx => Some(14), Self::DstackTdx => None, Self::DstackNitroEnclave => None, + Self::DstackSevSnp => None, } } @@ -114,6 +120,7 @@ impl AttestationMode { Self::DstackTdx => DSTACK_TDX, Self::DstackGcpTdx => DSTACK_GCP_TDX, Self::DstackNitroEnclave => DSTACK_NITRO_ENCLAVE, + Self::DstackSevSnp => DSTACK_SEV_SNP, } } @@ -123,6 +130,8 @@ impl AttestationMode { Self::DstackTdx => true, Self::DstackGcpTdx => true, Self::DstackNitroEnclave => false, + // SEV-SNP: compose_hash and rootfs_hash are separate fields in the request + Self::DstackSevSnp => true, } } } @@ -615,7 +624,9 @@ impl Attestation { cc_eventlog::tdx::read_event_log().context("Failed to read event log")?; AttestationQuote::DstackTdx(TdxQuote { quote, event_log }) } - AttestationMode::DstackGcpTdx | AttestationMode::DstackNitroEnclave => { + AttestationMode::DstackGcpTdx + | AttestationMode::DstackNitroEnclave + | AttestationMode::DstackSevSnp => { bail!("Unsupported attestation mode: {mode:?}"); } }; diff --git a/kms/Cargo.toml b/kms/Cargo.toml index bc33bc6a..c7aafc0f 100644 --- a/kms/Cargo.toml +++ b/kms/Cargo.toml @@ -23,6 +23,7 @@ tracing.workspace = true tracing-subscriber.workspace = true x25519-dalek.workspace = true yasna.workspace = true +aes-siv.workspace = true dstack-kms-rpc.workspace = true ra-rpc = { workspace = true, features = ["client", "rocket"] } @@ -47,6 +48,9 @@ tempfile.workspace = true serde-duration.workspace = true dstack-verifier = { workspace = true, default-features = false } dstack-mr.workspace = true +# AMD SEV-SNP attestation verification (cert chain + report signature) +sev = { workspace = true } +base64.workspace = true [features] default = [] diff --git a/kms/kms.toml b/kms/kms.toml index 70b2b717..aaa31f08 100644 --- a/kms/kms.toml +++ b/kms/kms.toml @@ -47,3 +47,20 @@ enabled = true auto_bootstrap_domain = "" address = "0.0.0.0" port = 8000 + +# AMD SEV-SNP measurement verification (optional). +# When configured, the KMS recomputes the expected MEASUREMENT from the image +# fingerprints in the request and rejects requests where they do not match. +# Omit this section to skip measurement recomputation (dev / non-AMD deployments). +# +# ovmf_path is OPTIONAL when the VM sends ovmf_sections in the request (i.e. +# the launcher extracted OVMF metadata before starting the VM). In that case +# the KMS does not need the OVMF file on disk at all. +# ovmf_path is needed only as a fallback for older VMs that do not send sections. +# +# [core.sev_snp] +# # Optional fallback: path to the AMD SEV-SNP OVMF binary. +# # Not needed when the VM provides ovmf_sections in the request. +# ovmf_path = "/usr/share/dstack/ovmf-amd-sev.fd" +# # SNP guest features bitmask (0x1 = kernel hashes enabled). +# guest_features = 1 diff --git a/kms/rpc/proto/kms_rpc.proto b/kms/rpc/proto/kms_rpc.proto index adaaa5f5..59e0adbe 100644 --- a/kms/rpc/proto/kms_rpc.proto +++ b/kms/rpc/proto/kms_rpc.proto @@ -44,10 +44,33 @@ message AppKeyResponse { string tproxy_app_id = 6; // Reverse proxy app ID from DstackKms contract. string gateway_app_id = 7; - // OS Image hash + // OS Image hash bytes os_image_hash = 8; } +// Response for GetAppKeyAmd. Keys are encrypted with the VM's X25519 public key. +// Decrypt using: crypt-tool decrypt -s $SEED -d -p +message AppKeyAmdResponse { + // TLS CA certificate which is used as the trust anchor for all HTTPS RPCs in the system. + string ca_cert = 1; + // Disk encryption key encrypted with the VM's X25519 pubkey (AES-128-SIV). + bytes disk_crypt_key = 2; + // X25519 env encryption key encrypted with the VM's X25519 pubkey (AES-128-SIV). + bytes env_crypt_key = 3; + // ECDSA k256 key encrypted with the VM's X25519 pubkey (AES-128-SIV). + bytes k256_key = 4; + // Signature of the k256 key signed by the root k256 key (plaintext). + bytes k256_signature = 5; + // Reverse proxy app ID from DstackKms contract. (Deprecated. For backward compatibility) + string tproxy_app_id = 6; + // Reverse proxy app ID from DstackKms contract. + string gateway_app_id = 7; + // OS Image hash (plaintext). + bytes os_image_hash = 8; + // Ephemeral KMS X25519 public key used to encrypt disk_crypt_key, env_crypt_key, and k256_key. + bytes key_provider_pubkey = 9; +} + message GetMetaResponse { string ca_cert = 1; bool allow_any_upgrade = 2; @@ -95,6 +118,8 @@ message SignCertResponse { service KMS { // Request the app key given the app id and tdx quote rpc GetAppKey(GetAppKeyRequest) returns (AppKeyResponse); + // Request the app key using AMD SEV-SNP attestation + rpc GetAppKeyAmd(GetAppKeyAmdRequest) returns (AppKeyAmdResponse); // KMS key handover rpc GetKmsKey(GetKmsKeyRequest) returns (KmsKeyResponse); // Request the app environment encryption public key given the app id @@ -115,6 +140,74 @@ message ClearImageCacheRequest { string config_hash = 3; } +// One OVMF SEV metadata section descriptor. +// Extracted from the OVMF binary by the launcher before VM launch +// and sent inside the VM so the KMS does not need the OVMF file. +// section_type values: 1=SNP_SEC_MEMORY, 2=SNP_SECRETS, 3=CPUID, +// 4=SVSM_CAA, 0x10=SNP_KERNEL_HASHES. +message OvmfSection { + uint32 gpa = 1; + uint32 size = 2; + uint32 section_type = 3; +} + +// Request message for AMD SEV-SNP attestation-based app key retrieval. +message GetAppKeyAmdRequest { + // Raw 1184-byte SNP attestation report binary. + bytes snp_report = 1; + // ASK (AMD SEV Key) certificate in PEM format. + bytes ask_pem = 2; + // VCEK (Versioned Chip Endorsement Key) certificate in PEM format. + bytes vcek_pem = 3; + + // Application identifier (hex-encoded, e.g. 20-byte Ethereum address). + // Determines which derived keys are returned. + string app_id = 4; + + // SHA-256 of docker-compose.yaml as a hex string (64 chars). + // This is embedded in the kernel cmdline, so it is covered by MEASUREMENT. + string compose_hash = 5; + + // SHA-256 of the rootfs ISO image as a hex string (64 chars). + // Also embedded in the kernel cmdline and covered by MEASUREMENT. + string rootfs_hash = 6; + + // Image fingerprints — needed by KMS to recompute and verify MEASUREMENT. + // If these fields are present the KMS MUST verify that + // sev-snp-measure(ovmf_hash, kernel_hash, initrd_hash, cmdline, vcpus, vcpu_type) + // == report.MEASUREMENT. + + // GCTX-based hash of the OVMF binary (hex, 96 chars = 48 bytes). + // Compute with: sev-snp-measure --mode snp:ovmf-hash --ovmf ovmf.fd + string ovmf_hash = 7; + + // SHA-256 of the kernel binary (bzImage) as a hex string (64 chars). + string kernel_hash = 8; + + // SHA-256 of the initrd binary (initramfs.cpio.gz) as a hex string. + string initrd_hash = 9; + + // Number of vCPUs the VM was launched with (affects VMSA and MEASUREMENT). + uint32 vcpus = 10; + + // vCPU type string matching sev-snp-measure --vcpu-type (e.g. "EPYC-v4"). + string vcpu_type = 11; + + // Optional: SHA-256 of docker-files.tar (hex) for docker_additional_files_hash cmdline param. + string docker_files_hash = 12; + + // OVMF-derived metadata extracted by the launcher before VM launch. + // When ovmf_sections is non-empty the KMS does NOT need an OVMF file on disk. + // Any lie about these values causes MEASUREMENT mismatch → request rejected. + + // GPA of the SEV hashes table within the SNP_KERNEL_HASHES page. + uint64 sev_hashes_table_gpa = 13; + // AP reset EIP (entry point for all APs, from SEV_ES_RESET_BLOCK). + uint32 sev_es_reset_eip = 14; + // Ordered list of OVMF SEV metadata sections (determines GCTX page sequence). + repeated OvmfSection ovmf_sections = 15; +} + message BootstrapRequest { string domain = 1; } diff --git a/kms/src/config.rs b/kms/src/config.rs index 3eaa2d11..c39d24e3 100644 --- a/kms/src/config.rs +++ b/kms/src/config.rs @@ -31,6 +31,32 @@ pub(crate) struct ImageConfig { pub download_timeout: Duration, } +/// Configuration for AMD SEV-SNP measurement verification. +/// When present, the KMS recomputes the expected SNP MEASUREMENT in pure Rust +/// (no external tooling required) and compares it against the hardware-attested +/// value in the SNP attestation report. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SevSnpMeasureConfig { + /// Path to the AMD SEV-SNP OVMF binary (ovmf.fd) used for this VM image. + /// + /// **Optional** when the VM sends `ovmf_sections` in the request (metadata + /// extracted by the launcher from the OVMF binary before launch). In that + /// case the KMS never reads the OVMF file and this field may be omitted. + /// + /// Required only as a fallback for older VM images that do not send + /// `ovmf_sections`. The file is parsed for GPA metadata only + /// (section descriptors, reset EIP, sev_hashes_table_gpa). + pub ovmf_path: Option, + /// SNP guest features bitmask (0x1 = SNP with kernel hashes enabled). + /// Must match the value used when the VM was launched. + #[serde(default = "default_guest_features")] + pub guest_features: u64, +} + +fn default_guest_features() -> u64 { + 0x1 +} + #[derive(Debug, Clone, Deserialize)] pub(crate) struct KmsConfig { pub cert_dir: PathBuf, @@ -40,6 +66,9 @@ pub(crate) struct KmsConfig { pub image: ImageConfig, #[serde(with = "serde_human_bytes")] pub admin_token_hash: Vec, + /// AMD SEV-SNP measurement verification. Optional: if absent, measurement + /// recomputation is skipped (useful for dev environments without OVMF). + pub sev_snp: Option, } impl KmsConfig { diff --git a/kms/src/main_service.rs b/kms/src/main_service.rs index 9965fc8d..d36cc1cd 100644 --- a/kms/src/main_service.rs +++ b/kms/src/main_service.rs @@ -4,12 +4,13 @@ use std::{path::PathBuf, sync::Arc}; +use aes_siv::KeyInit; use anyhow::{bail, Context, Result}; use dstack_kms_rpc::{ kms_server::{KmsRpc, KmsServer}, - AppId, AppKeyResponse, ClearImageCacheRequest, GetAppKeyRequest, GetKmsKeyRequest, - GetMetaResponse, GetTempCaCertResponse, KmsKeyResponse, KmsKeys, PublicKeyResponse, - SignCertRequest, SignCertResponse, + AppId, AppKeyAmdResponse, AppKeyResponse, ClearImageCacheRequest, GetAppKeyAmdRequest, + GetAppKeyRequest, GetKmsKeyRequest, GetMetaResponse, GetTempCaCertResponse, KmsKeyResponse, + KmsKeys, PublicKeyResponse, SignCertRequest, SignCertResponse, }; use dstack_verifier::{CvmVerifier, VerificationDetails}; use fs_err as fs; @@ -31,6 +32,7 @@ use crate::{ crypto::{derive_k256_key, sign_message, sign_message_with_timestamp}, }; +mod amd_attest; pub(crate) mod upgrade_authority; #[derive(Clone)] @@ -427,6 +429,195 @@ impl KmsRpc for RpcHandler { .context("Failed to clear measurement cache")?; Ok(()) } + + async fn get_app_key_amd(self, request: GetAppKeyAmdRequest) -> Result { + use amd_attest::{ + compute_expected_measurement, validate_app_id, verify_amd_attestation, AmdAttestInput, + OvmfSectionParam, + }; + use ra_tls::attestation::AttestationMode; + + // 1. Decode hex app_id → bytes and validate. + let app_id = hex::decode(&request.app_id).context("app_id is not valid hex")?; + validate_app_id(&app_id).context("Invalid app_id")?; + + // 2. Verify AMD cert chain + SNP report signature. + // This proves the MEASUREMENT in the report is hardware-attested. + let verified = verify_amd_attestation(&AmdAttestInput { + report: &request.snp_report, + ask_pem: &request.ask_pem, + vcek_pem: &request.vcek_pem, + }) + .context("AMD attestation verification failed")?; + + // 3. Verify that compose_hash / rootfs_hash match the attested MEASUREMENT. + // Without this check a malicious VM could send a genuine SNP report + // but lie about compose_hash/rootfs_hash to get keys for a different app. + // + // We recompute the expected MEASUREMENT from the image fingerprints + // (kernel/initrd hashes + cmdline containing compose_hash + rootfs_hash) + // and compare byte-for-byte with the hardware-attested value. + // + // If [core.sev_snp] is absent the check is skipped (dev/non-AMD deployments). + if !request.kernel_hash.is_empty() && request.vcpus > 0 { + if let Some(cfg) = &self.state.config.sev_snp { + // Convert proto OvmfSection list to the internal param type. + let ovmf_sections: Vec = request + .ovmf_sections + .iter() + .map(|s| OvmfSectionParam { + gpa: s.gpa, + size: s.size, + section_type: s.section_type, + }) + .collect(); + + let expected = compute_expected_measurement( + cfg, + &amd_attest::MeasurementInput { + ovmf_hash: &request.ovmf_hash, + sev_hashes_table_gpa: request.sev_hashes_table_gpa, + sev_es_reset_eip: request.sev_es_reset_eip, + ovmf_sections: &ovmf_sections, + kernel_hash: &request.kernel_hash, + initrd_hash: &request.initrd_hash, + vcpus: request.vcpus, + vcpu_type: &request.vcpu_type, + compose_hash: &request.compose_hash, + rootfs_hash: &request.rootfs_hash, + docker_files_hash: if request.docker_files_hash.is_empty() { + None + } else { + Some(&request.docker_files_hash) + }, + }, + ) + .context("Failed to recompute expected SNP MEASUREMENT")?; + if expected != verified.measurement { + bail!( + "MEASUREMENT mismatch: compose_hash/rootfs_hash do not match the \ + hardware-attested measurement (expected={}, got={})", + hex::encode(expected), + hex::encode(verified.measurement), + ); + } + } else { + tracing::warn!( + "AMD measurement verification skipped: [core.sev_snp] not configured" + ); + } + } + + // 3. Build BootInfo. + // mr_aggregated = the hardware-attested SNP MEASUREMENT (48 bytes). + // It covers OVMF + kernel + initrd + cmdline (which includes + // compose_hash and rootfs_hash), so the auth webhook can verify + // whether this measurement is in its allowlist. + // + // instance_id = first 20 bytes of report_data. + // The auth-eth contract represents instanceId as `address` (20 bytes), + // so AMD path must provide a 20-byte value here. + let instance_id = verified.report_data[..20].to_vec(); + // chip_id is 64 bytes; hash to bytes32 for contract compatibility. + let device_id = sha2::Sha256::digest(verified.chip_id).to_vec(); + // measurement is 48 bytes (SNP GCTX); hash to bytes32 for contract compatibility. + let mr_aggregated = sha2::Sha256::digest(verified.measurement).to_vec(); + + // Hex-decode hash fields for BootInfo (consistent with how TDX stores them). + let os_image_hash = + hex::decode(&request.rootfs_hash).context("rootfs_hash is not valid hex")?; + let compose_hash = + hex::decode(&request.compose_hash).context("compose_hash is not valid hex")?; + + let boot_info = BootInfo { + attestation_mode: AttestationMode::DstackSevSnp, + mr_aggregated, + os_image_hash, + mr_system: vec![0u8; 32], + app_id: app_id.clone(), + compose_hash, + instance_id, + device_id, + key_provider_info: vec![], + tcb_status: String::new(), + advisory_ids: vec![], + }; + + // 4. Ask the auth API whether this app is allowed to boot. + let response = self + .state + .config + .auth_api + .is_app_allowed(&boot_info, false) + .await + .context("Auth API request failed")?; + if !response.is_allowed { + bail!("Boot denied: {}", response.reason); + } + + // 5. Derive keys deterministically from app_id (same derivation as TDX). + let instance_id_bytes = &boot_info.instance_id; + + let context_data = vec![&app_id[..], &instance_id_bytes[..], b"app-disk-crypt-key"]; + let app_disk_key = kdf::derive_dh_secret(&self.state.root_ca.key, &context_data) + .context("Failed to derive app disk key")?; + + let env_crypt_key = { + let secret = + kdf::derive_dh_secret(&self.state.root_ca.key, &[&app_id[..], b"env-encrypt-key"]) + .context("Failed to derive env encrypt key")?; + x25519_dalek::StaticSecret::from(secret).to_bytes() + }; + + let (k256_app_key, k256_signature) = + derive_k256_key(&self.state.k256_key, &app_id).context("Failed to derive k256 key")?; + + // 6. Encrypt the derived keys with the VM's X25519 public key embedded in report_data[0..32]. + // This ensures only the attested VM (which holds the SEED) can decrypt the keys. + // The VM decrypts with: crypt-tool decrypt -s $SEED -d -p + if verified.report_data.len() < 32 { + bail!("report_data too short to contain VM public key (need >=32 bytes)"); + } + let vm_pk_bytes: [u8; 32] = verified.report_data[..32] + .try_into() + .context("Failed to extract VM public key from report_data")?; + if vm_pk_bytes == [0u8; 32] { + bail!("VM public key in report_data is all-zeros; VM must embed its X25519 pubkey"); + } + let vm_pk = x25519_dalek::PublicKey::from(vm_pk_bytes); + + // Generate an ephemeral KMS keypair for this response. + let kms_ephem_sk = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let kms_ephem_pk = x25519_dalek::PublicKey::from(&kms_ephem_sk); + let shared_secret = kms_ephem_sk.diffie_hellman(&vm_pk); + + // Encrypt using AES-128-SIV (same scheme as crypt-tool decrypt: ECDH → Aes128Siv key). + let mut cipher = aes_siv::siv::Aes128Siv::new(shared_secret.as_bytes().into()); + let encrypted_disk_key = cipher + .encrypt([&[]], &app_disk_key) + .map_err(|_| anyhow::anyhow!("Failed to encrypt disk_crypt_key"))?; + let mut cipher = aes_siv::siv::Aes128Siv::new(shared_secret.as_bytes().into()); + let encrypted_env_key = cipher + .encrypt([&[]], &env_crypt_key) + .map_err(|_| anyhow::anyhow!("Failed to encrypt env_crypt_key"))?; + let mut cipher = aes_siv::siv::Aes128Siv::new(shared_secret.as_bytes().into()); + let k256_key_bytes = k256_app_key.to_bytes(); + let encrypted_k256_key = cipher + .encrypt([&[]], &k256_key_bytes) + .map_err(|_| anyhow::anyhow!("Failed to encrypt k256_key"))?; + + Ok(AppKeyAmdResponse { + ca_cert: self.state.root_ca.pem_cert.clone(), + disk_crypt_key: encrypted_disk_key, + env_crypt_key: encrypted_env_key, + k256_key: encrypted_k256_key, + k256_signature, + tproxy_app_id: response.gateway_app_id.clone(), + gateway_app_id: response.gateway_app_id, + os_image_hash: boot_info.os_image_hash, + key_provider_pubkey: kms_ephem_pk.as_bytes().to_vec(), + }) + } } impl RpcCall for RpcHandler { diff --git a/kms/src/main_service/amd_attest.rs b/kms/src/main_service/amd_attest.rs new file mode 100644 index 00000000..63ccbec2 --- /dev/null +++ b/kms/src/main_service/amd_attest.rs @@ -0,0 +1,972 @@ +// SPDX-FileCopyrightText: © 2024-2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 +// +// AMD SEV-SNP attestation verification. + +use anyhow::{bail, Context, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use sev::certs::snp::{ca, Certificate, Chain, Verifiable}; +use sev::firmware::guest::AttestationReport; +use sha2::{Digest, Sha256, Sha384}; +use std::fs; + +use crate::config::SevSnpMeasureConfig; + +// ============================================================================= +// HARDCODED ROOT OF TRUST (ARK) +// ============================================================================= + +// AMD Genoa ARK certificate (DER, base64-encoded). +// This is the absolute Root of Trust for AMD EPYC Genoa (4th gen) processors. +// Source: https://kdsintf.amd.com/vcek/v1/Genoa/cert_chain +const GENOA_ARK_DER_B64: &str = "MIIGYzCCBBKgAwIBAgIDAgAAMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDASBgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZpY2VzMRIwEAYDVQQDDAlBUkstR2Vub2EwHhcNMjIwMTI2MTUzNDM3WhcNNDcwMTI2MTUzNDM3WjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDASBgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5jZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJQVJLLUdlbm9hMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3Cd95S/uFOuRIskW9vz9VDBF69NDQF79oRhL/L2PVQGhK3YdfEBgpF/JiwWFBsT/fXDhzA01p3LkcT/7LdjcRfKXjHl+0Qq/M4dZkh6QDoUeKzNBLDcBKDDGWo3v35NyrxbA1DnkYwUKU5AAk4P94tKXLp80oxt84ahyHoLmc/LqsGsp+oq1Bz4PPsYLwTG4iMKVaaT90/oZ4I8oibSru92vJhlqWO27d/Rxc3iUMyhNeGToOvgx/iUo4gGpG61NDpkEUvIzuKcaMx8IdTpWg2DF6SwF0IgVMffnvtJmA68BwJNWo1E4PLJdaPfBifcJpuBFwNVQIPQEVX3aP89HJSp8YbY9lySS6PlVEqTBBtaQmi4ATGmMR+n2K/e+JAhU2Gj7jIpJhOkdH9firQDnmlA2SFfJ/Cc0mGNzW9RmIhyOUnNFoclmkRhl3/AQU5Ys9Qsan1jT/EiyT+pCpmnA+y9edvhDCbOG8F2oxHGRdTBkylungrkXJGYiwGrR8kaiqv7NN8QhOBMqYjcbrkEr0f8QMKklIS5ruOfqlLMCBw8JLB3LkjpWgtD7OpxkzSsohN47Uom86RY6lp72g8eXHP1qYrnvhzaG1S70vw6OkbaaC9EjiH/uHgAJQGxon7u0Q7xgoREWA/e7JcBQwLg80Hq/sbRuqesxz7wBWSY254cCAwEAAaN+MHwwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSfXfn+DdjzWtAzGiXvgSlPvjGoWzAPBgNVHRMBAf8EBTADAQH/MDoGA1UdHwQzMDEwL6AtoCuGKWh0dHBzOi8va2RzaW50Zi5hbWQuY29tL3ZjZWsvdjEvR2Vub2EvY3JsMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBA4ICAQAdIlPBC7DQmvH7kjlOznFx3i21SzOPDs5L7SgFjMC9rR07292GQCA7Z7Ulq97JQaWeD2ofGGse5swj4OQfKfVv/zaJUFjvosZOnfZ63epu8MjWgBSXJg5QE/Al0zRsZsp53DBTdA+Uv/s33fexdenT1mpKYzhIg/cKtz4oMxq8JKWJ8Po1CXLzKcfrTphjlbkh8AVKMXeBd2SpM33B1YP4g1BOdk013kqb7bRHZ1iB2JHG5cMKKbwRCSAAGHLTzASgDcXr9Fp7Z3liDhGu/ci1opGmkp12QNiJuBbkTU+xDZHm5X8Jm99BX7NEpzlOwIVR8ClgBDyuBkBC2ljtr3ZSaUIYj2xuyWN95KFY49nWxcz90CFa3Hzmy4zMQmBe9dVyls5eL5p9bkXcgRMDTbgmVZiAf4afe8DLdmQcYcMFQbHhgVzMiyZHGJgcCrQmA7MkTwEIds1wx/HzMcwU4qqNBAoZV7oeIIPxdqFXfPqHqiRlEbRDfX1TG5NFVaeByX0GyH6jzYVuezETzruaky6fp2bl2bczxPE8HdS38ijiJmm9vl50RGUeOAXjSuInGR4bsRufeGPB9peTa9BcBOeTWzstqTUB/F/qaZCIZKr4X6TyfUuSDz/1JDAGl+lxdM0P9+lLaP9NahQjHCVf0zf1c1salVuGFk2w/wMz1R1BHg=="; + +/// Result of a successfully verified AMD SNP attestation. +#[derive(Debug, Clone)] +pub struct VerifiedAmdReport { + /// 48-byte SNP MEASUREMENT (GCTX launch digest, hardware-attested). + pub measurement: [u8; 48], + /// 64-byte report_data field set by the VM when calling snpguest report. + pub report_data: [u8; 64], + /// 64-byte chip_id: unique identifier of the AMD processor. + pub chip_id: [u8; 64], +} + +/// Input required for AMD attestation verification. +pub struct AmdAttestInput<'a> { + /// Raw 1184-byte SNP attestation report binary. + pub report: &'a [u8], + /// ASK (AMD SEV Key) certificate in PEM format. + pub ask_pem: &'a [u8], + /// VCEK (Versioned Chip Endorsement Key) certificate in PEM format. + pub vcek_pem: &'a [u8], +} + +/// Verify AMD SEV-SNP attestation. +/// +/// Workflow: +/// 1. Parse the SNP attestation report. +/// 2. Load the hardcoded ARK (Genoa Root of Trust). +/// 3. Parse the provided ASK and VCEK certificates. +/// 4. Verify the certificate chain: ARK → ASK → VCEK. +/// 5. Verify the report signature using the VCEK public key. +/// 6. Return the verified measurement, report_data, and chip_id. +/// +/// The returned `measurement` is the hardware-attested GCTX launch digest +/// that covers OVMF, kernel, initrd, and cmdline (including compose_hash + +/// rootfs_hash). The auth webhook can use this for its allowlist checks. +pub fn verify_amd_attestation(input: &AmdAttestInput<'_>) -> Result { + // 1. Parse the attestation report (1184 bytes). + let report = AttestationReport::from_bytes(input.report) + .map_err(|e| anyhow::anyhow!("SNP report parse failed: {e}"))?; + + // 2. Load hardcoded Genoa ARK (Root of Trust). + let ark_der = STANDARD + .decode(GENOA_ARK_DER_B64) + .context("Failed to base64-decode hardcoded ARK")?; + let ark = Certificate::from_der(&ark_der) + .map_err(|e| anyhow::anyhow!("ARK DER parse failed: {e:?}"))?; + + // 3. Parse the provided ASK and VCEK from PEM. + let ask = Certificate::from_pem(input.ask_pem) + .map_err(|e| anyhow::anyhow!("ASK PEM parse failed: {e:?}"))?; + let vcek = Certificate::from_pem(input.vcek_pem) + .map_err(|e| anyhow::anyhow!("VCEK PEM parse failed: {e:?}"))?; + + // 4. Verify certificate chain: ARK → ASK → VCEK. + let ca_chain = ca::Chain { ark, ask }; + let chain = Chain { + ca: ca_chain, + vek: vcek.clone(), + }; + chain + .verify() + .map_err(|e| anyhow::anyhow!("AMD cert chain verification failed: {e:?}"))?; + + // 5. Verify report signature using the VCEK public key. + (&vcek, &report) + .verify() + .map_err(|e| anyhow::anyhow!("SNP report signature verification failed: {e:?}"))?; + + // 6. Extract verified fields. + let mut measurement = [0u8; 48]; + measurement.copy_from_slice( + report + .measurement + .as_ref() + .get(..48) + .context("measurement too short")?, + ); + + let mut report_data = [0u8; 64]; + report_data.copy_from_slice( + report + .report_data + .as_ref() + .get(..64) + .context("report_data too short")?, + ); + + let mut chip_id = [0u8; 64]; + chip_id.copy_from_slice( + report + .chip_id + .as_ref() + .get(..64) + .context("chip_id too short")?, + ); + + Ok(VerifiedAmdReport { + measurement, + report_data, + chip_id, + }) +} + +/// Verify that `app_id` is non-empty (prevents deriving keys for a zero app_id). +pub fn validate_app_id(app_id: &[u8]) -> Result<()> { + if app_id.is_empty() { + bail!("app_id must not be empty"); + } + if app_id.iter().all(|&b| b == 0) { + bail!("app_id must not be all-zeros"); + } + Ok(()) +} + +// ============================================================================= +// Pure-Rust SNP MEASUREMENT recomputation +// ============================================================================= +// +// Ports the sev-snp-measure Python algorithm to Rust without any additional +// dependencies: SHA-384 (GCTX) and SHA-256 (SevHashes) use `sha2` which is +// already a direct dep; OVMF parsing and VMSA page construction are implemented +// below from the AMD spec and the sev-snp-measure source. +// +// References: +// AMD SNP spec §8.17.2 – PAGE_INFO / GCTX +// https://github.com/IBM/sev-snp-measure +// https://github.com/virtee/sev/tree/main/src/measurement + +// -------- GCTX (SHA-384 launch digest accumulator) --------------------------- + +const LD_BYTES: usize = 48; +const ZEROS_LD: [u8; LD_BYTES] = [0u8; LD_BYTES]; +// VMSA page GPA: (u64)(-1) page-aligned, bits >51 cleared. +const VMSA_GPA: u64 = 0x0000_FFFF_FFFF_F000; + +struct Gctx { + ld: [u8; LD_BYTES], +} + +impl Gctx { + fn new() -> Self { + Self { ld: ZEROS_LD } + } + + fn from_ovmf_hash(hex: &str) -> Result { + let raw = hex::decode(hex).context("ovmf_hash is not valid hex")?; + let ld: [u8; LD_BYTES] = raw + .try_into() + .map_err(|_| anyhow::anyhow!("ovmf_hash must be 48 bytes (96 hex chars)"))?; + Ok(Self { ld }) + } + + /// SNP spec §8.17.2 Table 67 – PAGE_INFO layout (0x70 = 112 bytes total): + /// [ 0..48) – current launch digest + /// [48..96) – contents (SHA-384 of page data, or all-zeros) + /// [96..98) – length u16 LE = 0x70 + /// [98] – page_type + /// [99..104) – is_imi(1) + vmpl3/2/1_perms(3) + reserved(1) = 0 + /// [104..112) – gpa u64 LE + fn update(&mut self, page_type: u8, gpa: u64, contents: &[u8; LD_BYTES]) { + let mut buf = [0u8; 0x70]; + buf[..LD_BYTES].copy_from_slice(&self.ld); + buf[48..96].copy_from_slice(contents); + buf[96..98].copy_from_slice(&0x70u16.to_le_bytes()); + buf[98] = page_type; + // buf[99..104] stay 0 + buf[104..112].copy_from_slice(&gpa.to_le_bytes()); + let mut digest = [0u8; LD_BYTES]; + digest.copy_from_slice(&Sha384::digest(buf)); + self.ld = digest; + } + + fn sha384(data: &[u8]) -> [u8; LD_BYTES] { + let mut out = [0u8; LD_BYTES]; + out.copy_from_slice(&Sha384::digest(data)); + out + } + + fn update_normal_pages(&mut self, start_gpa: u64, data: &[u8]) { + for (i, chunk) in data.chunks(4096).enumerate() { + self.update(0x01, start_gpa + (i * 4096) as u64, &Self::sha384(chunk)); + } + } + + fn update_zero_pages(&mut self, gpa: u64, len: usize) { + for i in (0..len).step_by(4096) { + self.update(0x03, gpa + i as u64, &ZEROS_LD); + } + } + + fn update_secrets_page(&mut self, gpa: u64) { + self.update(0x05, gpa, &ZEROS_LD); + } + + fn update_cpuid_page(&mut self, gpa: u64) { + self.update(0x06, gpa, &ZEROS_LD); + } + + fn update_vmsa_page(&mut self, page: &[u8]) { + self.update(0x02, VMSA_GPA, &Self::sha384(page)); + } +} + +// -------- SevHashes page construction ---------------------------------------- +// +// GUID values in little-endian (Windows / mixed-endian RFC 4122) byte order. +// Computed as: time_low(LE u32) + time_mid(LE u16) + time_hi(LE u16) + +// clock_seq_hi(u8) + clock_seq_lo(u8) + node(6 u8, unchanged). +// +// 9438d606-4f22-4cc9-b479-a793d411fd21 → table header +// 4de79437-abd2-427f-b835-d5b172d2045b → kernel entry +// 44baf731-3a2f-4bd7-9af1-41e29169781d → initrd entry +// 97d02dd8-bd20-4c94-aa78-e7714d36ab2a → cmdline entry + +const GUID_LE_HASH_TABLE_HEADER: [u8; 16] = [ + 0x06, 0xd6, 0x38, 0x94, 0x22, 0x4f, 0xc9, 0x4c, 0xb4, 0x79, 0xa7, 0x93, 0xd4, 0x11, 0xfd, 0x21, +]; +const GUID_LE_KERNEL_ENTRY: [u8; 16] = [ + 0x37, 0x94, 0xe7, 0x4d, 0xd2, 0xab, 0x7f, 0x42, 0xb8, 0x35, 0xd5, 0xb1, 0x72, 0xd2, 0x04, 0x5b, +]; +const GUID_LE_INITRD_ENTRY: [u8; 16] = [ + 0x31, 0xf7, 0xba, 0x44, 0x2f, 0x3a, 0xd7, 0x4b, 0x9a, 0xf1, 0x41, 0xe2, 0x91, 0x69, 0x78, 0x1d, +]; +const GUID_LE_CMDLINE_ENTRY: [u8; 16] = [ + 0xd8, 0x2d, 0xd0, 0x97, 0x20, 0xbd, 0x94, 0x4c, 0xaa, 0x78, 0xe7, 0x71, 0x4d, 0x36, 0xab, 0x2a, +]; + +/// One `SevHashTableEntry`: guid(16) + length_u16(2) + sha256_hash(32) = 50 bytes. +fn sev_entry(guid: &[u8; 16], hash: &[u8; 32]) -> [u8; 50] { + let mut e = [0u8; 50]; + e[..16].copy_from_slice(guid); + e[16..18].copy_from_slice(&50u16.to_le_bytes()); + e[18..].copy_from_slice(hash); + e +} + +/// Build the 4096-byte SNP kernel-hashes page from pre-computed SHA-256 hashes. +/// +/// Replicates the binary layout that QEMU places in the `SNP_KERNEL_HASHES` +/// OVMF metadata section so the GCTX measurement matches exactly: +/// +/// SevHashTable (168 bytes): +/// guid(16) + length_u16(2) + cmdline_entry(50) + initrd_entry(50) + kernel_entry(50) +/// Padded to next multiple of 16 → 176 bytes +/// Placed at `page_offset` within a 4096-byte zero page. +/// +/// * `kernel_hash_hex` – 64-char hex (SHA-256 of the kernel bzImage) +/// * `initrd_hash_hex` – 64-char hex (SHA-256 of the initrd); `""` → hash of empty bytes +/// * `append` – kernel cmdline WITHOUT trailing `\0` +/// * `page_offset` – byte offset within the page (`ovmf.sev_hashes_table_gpa() & 0xfff`) +fn build_sev_hashes_page( + kernel_hash_hex: &str, + initrd_hash_hex: &str, + append: &str, + page_offset: usize, +) -> Result<[u8; 4096]> { + let kernel_hash: [u8; 32] = hex::decode(kernel_hash_hex) + .context("kernel_hash_hex: not valid hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("kernel_hash must be 32 bytes (64 hex chars)"))?; + + let initrd_hash: [u8; 32] = if initrd_hash_hex.is_empty() { + let mut h = [0u8; 32]; + h.copy_from_slice(&Sha256::digest(b"")); + h + } else { + hex::decode(initrd_hash_hex) + .context("initrd_hash_hex: not valid hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("initrd_hash must be 32 bytes (64 hex chars)"))? + }; + + let mut cmdline_bytes = append.as_bytes().to_vec(); + cmdline_bytes.push(0); // NUL terminator, same as QEMU + let mut cmdline_hash = [0u8; 32]; + cmdline_hash.copy_from_slice(&Sha256::digest(&cmdline_bytes)); + + let cmdline_entry = sev_entry(&GUID_LE_CMDLINE_ENTRY, &cmdline_hash); + let initrd_entry = sev_entry(&GUID_LE_INITRD_ENTRY, &initrd_hash); + let kernel_entry = sev_entry(&GUID_LE_KERNEL_ENTRY, &kernel_hash); + + // SevHashTable: guid(16) + length(2) + cmdline(50) + initrd(50) + kernel(50) = 168 bytes + const TABLE_SIZE: usize = 16 + 2 + 50 + 50 + 50; // 168 + let mut table = [0u8; TABLE_SIZE]; + table[..16].copy_from_slice(&GUID_LE_HASH_TABLE_HEADER); + table[16..18].copy_from_slice(&(TABLE_SIZE as u16).to_le_bytes()); + table[18..68].copy_from_slice(&cmdline_entry); + table[68..118].copy_from_slice(&initrd_entry); + table[118..168].copy_from_slice(&kernel_entry); + + // Pad to next multiple of 16: (168 + 15) & !15 = 176 → 8 bytes padding. + const PADDED: usize = (TABLE_SIZE + 15) & !(15usize); + let mut padded = [0u8; PADDED]; + padded[..TABLE_SIZE].copy_from_slice(&table); + + if page_offset + PADDED > 4096 { + bail!("SevHashTable (offset={page_offset}, size={PADDED}) overflows 4096-byte page"); + } + let mut page = [0u8; 4096]; + page[page_offset..page_offset + PADDED].copy_from_slice(&padded); + Ok(page) +} + +// -------- OVMF binary parser ------------------------------------------------- +// +// Parses the OVMF footer table (at the end of the binary) and the SEV metadata +// section to extract the information needed for GCTX computation. +// Translated directly from sev-snp-measure/sevsnpmeasure/ovmf.py. + +/// Sections declared by the OVMF SEV Metadata. +#[derive(Debug, PartialEq)] +enum SectionType { + SnpSecMemory = 1, + SnpSecrets = 2, + Cpuid = 3, + SvsmCaa = 4, + SnpKernelHashes = 0x10, +} + +impl SectionType { + fn from_u32(v: u32) -> Option { + match v { + 1 => Some(Self::SnpSecMemory), + 2 => Some(Self::SnpSecrets), + 3 => Some(Self::Cpuid), + 4 => Some(Self::SvsmCaa), + 0x10 => Some(Self::SnpKernelHashes), + _ => None, + } + } +} + +struct MetadataSection { + gpa: u32, + size: u32, + section_type: SectionType, +} + +struct OvmfInfo { + data: Vec, + gpa: u64, // = 4 GiB - data.len() + sections: Vec, + sev_hashes_table_gpa: u64, + sev_es_reset_eip: u32, +} + +// GUIDs stored as little-endian bytes (Windows / mixed-endian RFC 4122 layout). +// Format: time_low(4 LE) + time_mid(2 LE) + time_hi(2 LE) + clock_hi + clock_lo + node(6). +const GUID_FOOTER_TABLE: [u8; 16] = [ + 0xde, 0x82, 0xb5, 0x96, 0xb2, 0x1f, 0xf7, 0x45, 0xba, 0xea, 0xa3, 0x66, 0xc5, 0x5a, 0x08, 0x2d, +]; +const GUID_SEV_HASH_TABLE_RV: [u8; 16] = [ + 0x1f, 0x37, 0x55, 0x72, 0x3b, 0x3a, 0x04, 0x4b, 0x92, 0x7b, 0x1d, 0xa6, 0xef, 0xa8, 0xd4, 0x54, +]; +const GUID_SEV_ES_RESET_BLK: [u8; 16] = [ + 0xde, 0x71, 0xf7, 0x00, 0x7e, 0x1a, 0xcb, 0x4f, 0x89, 0x0e, 0x68, 0xc7, 0x7e, 0x2f, 0xb4, 0x4e, +]; +const GUID_SEV_META_DATA: [u8; 16] = [ + 0x66, 0x65, 0x88, 0xdc, 0x4a, 0x98, 0x98, 0x47, 0xa7, 0x5e, 0x55, 0x85, 0xa7, 0xbf, 0x67, 0xcc, +]; + +fn read_u16_le(buf: &[u8], off: usize) -> u16 { + u16::from_le_bytes([buf[off], buf[off + 1]]) +} +fn read_u32_le(buf: &[u8], off: usize) -> u32 { + u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) +} + +impl OvmfInfo { + fn load(path: &str) -> Result { + let data = fs::read(path).with_context(|| format!("Cannot read OVMF binary '{path}'"))?; + let size = data.len(); + // 4 GiB – size gives us the GPA base where OVMF is mapped. + let gpa = (0x1_0000_0000u64) + .checked_sub(size as u64) + .context("OVMF binary is larger than 4 GiB")?; + + // --- parse footer table --- + // The OVMF table footer entry is at: data[size-32-18 .. size-32] + // Entry layout: size_u16(2) + guid_le(16) + const ENTRY_HDR: usize = 18; // sizeof(OvmfFooterTableEntry) + let footer_off = size.saturating_sub(32 + ENTRY_HDR); + if footer_off + ENTRY_HDR > size { + bail!("OVMF binary too small to contain footer table"); + } + if data[footer_off + 2..footer_off + 18] != GUID_FOOTER_TABLE { + bail!("OVMF footer GUID not found – is this an AMD SEV OVMF file?"); + } + let footer_total_size = read_u16_le(&data, footer_off) as usize; + if footer_total_size < ENTRY_HDR { + bail!("OVMF footer table: invalid total size {footer_total_size}"); + } + let table_size = footer_total_size - ENTRY_HDR; + // table_bytes = data[footer_off - table_size .. footer_off] + let table_start = footer_off.saturating_sub(table_size); + let table_bytes = &data[table_start..footer_off]; + + let mut sev_hashes_table_gpa: u64 = 0; + let mut sev_es_reset_eip: u32 = 0; + let mut meta_offset_from_end: Option = None; + + let mut pos = table_bytes.len(); + while pos >= ENTRY_HDR { + let entry_off = pos - ENTRY_HDR; + let entry_size = read_u16_le(table_bytes, entry_off) as usize; + if entry_size < ENTRY_HDR || entry_size > pos { + bail!("OVMF footer table: invalid entry size {entry_size} at pos {pos}"); + } + let guid = &table_bytes[entry_off + 2..entry_off + 18]; + let data_start = pos - entry_size; + let data_end = pos - ENTRY_HDR; + let entry_data = &table_bytes[data_start..data_end]; + + if guid == GUID_SEV_HASH_TABLE_RV && entry_data.len() >= 4 { + sev_hashes_table_gpa = read_u32_le(entry_data, 0) as u64; + } else if guid == GUID_SEV_ES_RESET_BLK && entry_data.len() >= 4 { + sev_es_reset_eip = read_u32_le(entry_data, 0); + } else if guid == GUID_SEV_META_DATA && entry_data.len() >= 4 { + meta_offset_from_end = Some(read_u32_le(entry_data, 0) as usize); + } + pos -= entry_size; + } + + if sev_es_reset_eip == 0 { + bail!("OVMF: SEV_ES_RESET_BLOCK entry not found in footer table"); + } + + // --- parse SEV metadata sections --- + let mut sections = Vec::new(); + if let Some(off_from_end) = meta_offset_from_end { + if off_from_end > size { + bail!("OVMF SEV metadata offset {off_from_end} exceeds file size {size}"); + } + let meta_start = size - off_from_end; + // Header: signature[4] + size(u32) + version(u32) + num_items(u32) = 16 bytes + if meta_start + 16 > size { + bail!("OVMF: SEV metadata header out of bounds"); + } + if &data[meta_start..meta_start + 4] != b"ASEV" { + bail!("OVMF: bad SEV metadata signature (expected 'ASEV')"); + } + let meta_version = read_u32_le(&data, meta_start + 8); + if meta_version != 1 { + bail!("OVMF: unsupported SEV metadata version {meta_version}"); + } + let num_items = read_u32_le(&data, meta_start + 12) as usize; + // Each section desc: gpa(u32) + size(u32) + type(u32) = 12 bytes + let items_start = meta_start + 16; + if items_start + num_items * 12 > size { + bail!("OVMF: SEV metadata sections out of bounds"); + } + for i in 0..num_items { + let off = items_start + i * 12; + let sec_gpa = read_u32_le(&data, off); + let sec_size = read_u32_le(&data, off + 4); + let sec_type = read_u32_le(&data, off + 8); + let section_type = SectionType::from_u32(sec_type).with_context(|| { + format!("OVMF: unknown section type {sec_type:#x} in metadata item {i}") + })?; + sections.push(MetadataSection { + gpa: sec_gpa, + size: sec_size, + section_type, + }); + } + }; + + Ok(OvmfInfo { + data, + gpa, + sections, + sev_hashes_table_gpa, + sev_es_reset_eip, + }) + } +} + +// -------- VMSA page builder (QEMU mode, SNP) --------------------------------- +// +// Builds the 4 KiB VMSA page (SevEsSaveArea) for one vCPU at a given EIP, +// matching QEMU's initial register state. +// Translated from sev-snp-measure/sevsnpmeasure/vmsa.py :: VMSA.build_save_area. +// +// The struct layout (pack=1, all little-endian) is fixed by the AMD APM Volume 2 +// Table B-4 and the Linux kernel struct sev_es_work_area. +// +// Offset constants below are the byte offsets of each field within the 4096-byte +// SevEsSaveArea structure (verified against the Python ctypes struct definition). +// +// VmcbSeg layout (16 bytes each): +// +0 selector u16 +// +2 attrib u16 +// +4 limit u32 +// +8 base u64 +fn write_u16_le_at(buf: &mut [u8], off: usize, v: u16) { + buf[off..off + 2].copy_from_slice(&v.to_le_bytes()); +} +fn write_u32_le_at(buf: &mut [u8], off: usize, v: u32) { + buf[off..off + 4].copy_from_slice(&v.to_le_bytes()); +} +fn write_u64_le_at(buf: &mut [u8], off: usize, v: u64) { + buf[off..off + 8].copy_from_slice(&v.to_le_bytes()); +} +fn write_vmcb_seg(buf: &mut [u8], off: usize, selector: u16, attrib: u16, limit: u32, base: u64) { + write_u16_le_at(buf, off, selector); + write_u16_le_at(buf, off + 2, attrib); + write_u32_le_at(buf, off + 4, limit); + write_u64_le_at(buf, off + 8, base); +} + +/// Compute the 32-bit AMD CPUID signature from (family, model, stepping). +/// AMD CPUID Specification #25481, Section: Fn0000_0001_EAX. +fn amd_cpu_sig(family: u32, model: u32, stepping: u32) -> u32 { + let (family_low, family_high) = if family > 0xf { + (0xf, (family - 0xf) & 0xff) + } else { + (family, 0) + }; + let model_low = model & 0xf; + let model_high = (model >> 4) & 0xf; + (family_high << 20) + | (model_high << 16) + | (family_low << 8) + | (model_low << 4) + | (stepping & 0xf) +} + +/// Map a QEMU vcpu-type string (case-insensitive) to its AMD CPUID signature. +fn vcpu_sig_from_type(vcpu_type: &str) -> Result { + match vcpu_type.to_lowercase().as_str() { + "epyc" | "epyc-v1" | "epyc-v2" | "epyc-ibpb" | "epyc-v3" | "epyc-v4" + => Ok(amd_cpu_sig(23, 1, 2)), + "epyc-rome" | "epyc-rome-v1" | "epyc-rome-v2" | "epyc-rome-v3" + => Ok(amd_cpu_sig(23, 49, 0)), + "epyc-milan" | "epyc-milan-v1" | "epyc-milan-v2" + => Ok(amd_cpu_sig(25, 1, 1)), + "epyc-genoa" | "epyc-genoa-v1" + => Ok(amd_cpu_sig(25, 17, 0)), + other => bail!("Unknown vcpu_type: {other:?}. Supported: EPYC, EPYC-v4, EPYC-Rome, EPYC-Milan, EPYC-Genoa (and -v1/-v2/-v3 variants)"), + } +} + +/// Build a 4096-byte VMSA page for QEMU/SNP at the given EIP. +/// +/// * `eip` – reset vector EIP (0xfffffff0 for BSP, or sev_es_reset_eip for AP) +/// * `vcpu_sig` – AMD CPUID signature (placed in RDX) +/// * `sev_features` – guest features bitmask (= cfg.guest_features) +fn build_vmsa_page(eip: u32, vcpu_sig: u32, sev_features: u64) -> Box<[u8; 4096]> { + let mut page = Box::new([0u8; 4096]); + let p = page.as_mut_slice(); + + // QEMU initial segment state (from vmsa.py build_save_area for VMMType.QEMU) + let cs_base = (eip as u64) & 0xffff_0000; + let rip = (eip as u64) & 0x0000_ffff; + + // Segment registers (VmcbSeg: selector,attrib,limit,base) + write_vmcb_seg(p, 0x000, 0, 0x0093, 0xffff, 0); // es + write_vmcb_seg(p, 0x010, 0xf000, 0x009b, 0xffff, cs_base); // cs + write_vmcb_seg(p, 0x020, 0, 0x0093, 0xffff, 0); // ss + write_vmcb_seg(p, 0x030, 0, 0x0093, 0xffff, 0); // ds + write_vmcb_seg(p, 0x040, 0, 0x0093, 0xffff, 0); // fs + write_vmcb_seg(p, 0x050, 0, 0x0093, 0xffff, 0); // gs + write_vmcb_seg(p, 0x060, 0, 0x0000, 0xffff, 0); // gdtr + write_vmcb_seg(p, 0x070, 0, 0x0082, 0xffff, 0); // ldtr + write_vmcb_seg(p, 0x080, 0, 0x0000, 0xffff, 0); // idtr + write_vmcb_seg(p, 0x090, 0, 0x008b, 0xffff, 0); // tr + + // Control / status registers + write_u64_le_at(p, 0x0D0, 0x1000); // efer (SVME) + write_u64_le_at(p, 0x148, 0x40); // cr4 (MCE) + write_u64_le_at(p, 0x158, 0x10); // cr0 (PE) + write_u64_le_at(p, 0x160, 0x400); // dr7 + write_u64_le_at(p, 0x168, 0xffff_0ff0); // dr6 + write_u64_le_at(p, 0x170, 0x2); // rflags + write_u64_le_at(p, 0x178, rip); // rip + write_u64_le_at(p, 0x268, 0x0007_0406_0007_0406); // g_pat (PAT MSR) + write_u64_le_at(p, 0x310, vcpu_sig as u64); // rdx (CPUID sig) + write_u64_le_at(p, 0x3B0, sev_features); // sev_features + write_u64_le_at(p, 0x3E8, 0x1); // xcr0 + write_u32_le_at(p, 0x408, 0x1f80); // mxcsr + write_u16_le_at(p, 0x410, 0x037f); // x87_fcw + + page +} + +// -------- Top-level measurement entry point ---------------------------------- + +/// One OVMF SEV metadata section descriptor supplied from the VM request. +/// +/// The launcher extracts these values from the OVMF binary before VM launch +/// and passes them to the guest. Any lie about these values causes MEASUREMENT +/// mismatch ⇒ the KMS rejects the request. +pub struct OvmfSectionParam { + pub gpa: u32, + pub size: u32, + /// Raw `section_type` value: 1=SNP_SEC_MEMORY, 2=SNP_SECRETS, 3=CPUID, + /// 4=SVSM_CAA, 0x10=SNP_KERNEL_HASHES. + pub section_type: u32, +} + +/// Per-request inputs for [`compute_expected_measurement`]. +/// +/// Separate from [`SevSnpMeasureConfig`] (KMS host config) so the function +/// stays within the clippy argument-count limit. +pub struct MeasurementInput<'a> { + /// 96-char hex GCTX seed for OVMF pages; `""` → compute from file. + pub ovmf_hash: &'a str, + /// GPA of the SevHashTable (from request; ignored when loading OVMF file). + pub sev_hashes_table_gpa: u64, + /// AP reset EIP (from request; ignored when loading OVMF file). + pub sev_es_reset_eip: u32, + /// Sections from request; empty → fall back to loading file. + pub ovmf_sections: &'a [OvmfSectionParam], + /// 64-char hex SHA-256 of the kernel. + pub kernel_hash: &'a str, + /// 64-char hex SHA-256 of the initrd (`""` = empty initrd). + pub initrd_hash: &'a str, + /// vCPU count (must match the VM launch value). + pub vcpus: u32, + /// QEMU CPU model string, e.g. `"EPYC-v4"`. + pub vcpu_type: &'a str, + /// 64-char hex placed in `docker_compose_hash=` cmdline arg. + pub compose_hash: &'a str, + /// 64-char hex placed in `rootfs_hash=` cmdline arg. + pub rootfs_hash: &'a str, + /// Optional 64-char hex placed in `docker_additional_files_hash=`. + pub docker_files_hash: Option<&'a str>, +} + +/// Recompute the expected SNP MEASUREMENT in pure Rust. +/// +/// Two code paths: +/// +/// 1. **VM-provided OVMF metadata** (`input.ovmf_sections` is non-empty): +/// The KMS never needs the OVMF file on disk. `input.ovmf_hash` *must* +/// be provided. +/// +/// 2. **OVMF file on KMS disk** (`input.ovmf_sections` is empty): +/// The KMS reads `cfg.ovmf_path` (which must be `Some`). +/// +/// Returns the expected 48-byte GCTX launch digest. +pub fn compute_expected_measurement( + cfg: &SevSnpMeasureConfig, + input: &MeasurementInput<'_>, +) -> Result<[u8; 48]> { + let ovmf_hash = input.ovmf_hash; + let sev_hashes_table_gpa = input.sev_hashes_table_gpa; + let sev_es_reset_eip = input.sev_es_reset_eip; + let ovmf_sections = input.ovmf_sections; + let kernel_hash = input.kernel_hash; + let initrd_hash = input.initrd_hash; + let vcpus = input.vcpus; + let vcpu_type_str = input.vcpu_type; + let compose_hash = input.compose_hash; + let rootfs_hash = input.rootfs_hash; + let docker_files_hash = input.docker_files_hash; + // Reconstruct the kernel cmdline exactly as produced by the VM launcher. + let mut cmdline = format!( + "console=ttyS0 loglevel=7 docker_compose_hash={compose_hash} rootfs_hash={rootfs_hash}" + ); + if let Some(dh) = docker_files_hash { + if !dh.is_empty() { + cmdline.push_str(&format!(" docker_additional_files_hash={dh}")); + } + } + + // Determine the GCTX initial state, the effective sev_hashes_table_gpa, + // the effective sev_es_reset_eip, and the ordered section list. + let (mut gctx, eff_hashes_gpa, eff_reset_eip, resolved_sections); + if !ovmf_sections.is_empty() { + // Path 1: VM provided all OVMF metadata. No OVMF file needed. + if ovmf_hash.is_empty() { + bail!( + "ovmf_hash must be provided in the request when ovmf_sections \ + are given (KMS does not have the OVMF file on disk)" + ); + } + gctx = Gctx::from_ovmf_hash(ovmf_hash)?; + eff_hashes_gpa = sev_hashes_table_gpa; + eff_reset_eip = sev_es_reset_eip; + resolved_sections = ovmf_sections + .iter() + .enumerate() + .map(|(i, s)| { + let section_type = SectionType::from_u32(s.section_type).ok_or_else(|| { + anyhow::anyhow!( + "Unknown section_type {:#x} in ovmf_sections[{i}]", + s.section_type + ) + })?; + Ok(MetadataSection { + gpa: s.gpa, + size: s.size, + section_type, + }) + }) + .collect::>>()?; + } else { + // Path 2: Load OVMF binary from KMS disk. + let path = cfg.ovmf_path.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SNP MEASUREMENT verification requires either ovmf_sections in the \ + request or ovmf_path \ + configured in [core.sev_snp] on the KMS host" + ) + })?; + let ovmf = OvmfInfo::load(path)?; + gctx = if ovmf_hash.is_empty() { + let mut g = Gctx::new(); + g.update_normal_pages(ovmf.gpa, &ovmf.data); + g + } else { + Gctx::from_ovmf_hash(ovmf_hash)? + }; + eff_hashes_gpa = ovmf.sev_hashes_table_gpa; + eff_reset_eip = ovmf.sev_es_reset_eip; + resolved_sections = ovmf.sections; + } + + // Feed SEV metadata sections in order (mirrors snp_update_metadata_pages). + let mut has_kernel_hashes_section = false; + for sec in &resolved_sections { + let gpa = sec.gpa as u64; + let size = sec.size as usize; + match sec.section_type { + SectionType::SnpSecMemory => gctx.update_zero_pages(gpa, size), + SectionType::SnpSecrets => gctx.update_secrets_page(gpa), + SectionType::Cpuid => gctx.update_cpuid_page(gpa), + SectionType::SvsmCaa => gctx.update_zero_pages(gpa, size), + SectionType::SnpKernelHashes => { + has_kernel_hashes_section = true; + if eff_hashes_gpa == 0 { + bail!("SNP_KERNEL_HASHES section present but sev_hashes_table_gpa is 0"); + } + let page_offset = (eff_hashes_gpa & 0xfff) as usize; + let page = build_sev_hashes_page(kernel_hash, initrd_hash, &cmdline, page_offset)?; + gctx.update_normal_pages(gpa, &page); + } + } + } + if !has_kernel_hashes_section { + bail!( + "OVMF metadata does not include a SNP_KERNEL_HASHES section — \ + kernel/initrd hashes cannot be incorporated into the measurement" + ); + } + + // Add one VMSA page per vCPU (BSP first, then APs). + let vcpu_sig = vcpu_sig_from_type(vcpu_type_str)?; + let bsp_vmsa = build_vmsa_page(0xffff_fff0, vcpu_sig, cfg.guest_features); + let ap_vmsa = build_vmsa_page(eff_reset_eip, vcpu_sig, cfg.guest_features); + + for i in 0..vcpus as usize { + let vmsa_page = if i == 0 { + bsp_vmsa.as_ref() + } else { + ap_vmsa.as_ref() + }; + gctx.update_vmsa_page(vmsa_page); + } + + Ok(gctx.ld) +} + +// ============================================================================= +// Manual integration test: compute MEASUREMENT from sev_image_fingerprints.json +// ============================================================================= +// +// Run with: +// FINGERPRINTS=/path/to/sev_image_fingerprints.json \ +// COMPOSE_HASH=<64-hex> \ +// ROOTFS_HASH=<64-hex> \ +// [DOCKER_FILES_HASH=<64-hex>] \ +// [OVMF_PATH=/path/to/ovmf.fd] \ +// cargo test -p dstack-kms -- --ignored amd::compute_measurement_from_fingerprints --nocapture +// +// FINGERPRINTS must be a JSON file with the following fields, e.g.: +// { +// "kernel_hash": "<64 hex>", +// "initrd_hash": "<64 hex>", +// "vcpus": 1, +// "vcpu_type": "EPYC", +// "ovmf_hash": "<96 hex>", +// "sev_hashes_table_gpa": , +// "sev_es_reset_eip": , +// "ovmf_sections": [{"gpa":,"size":,"section_type":}, ...] +// } +// +// COMPOSE_HASH and ROOTFS_HASH are the SHA-256 hashes used in the kernel +// cmdline (same values that snpguest / the VM inserts into the attestation +// report request). +// +// Compare the printed measurement against: +// snpguest display report att-report.bin → Measurement field +// or: +// sev-snp-measure --mode snp --vcpus 1 --vcpu-type EPYC \ +// --ovmf ovmf.fd --kernel bzImage --initrd initramfs.cpio.gz \ +// --append "console=ttyS0 loglevel=7 docker_compose_hash=... rootfs_hash=..." + +#[cfg(test)] +mod amd { + use super::*; + use serde::Deserialize; + use std::env; + + #[derive(Debug, Deserialize)] + struct FingerprintSection { + gpa: u32, + size: u32, + section_type: u32, + } + + #[derive(Debug, Deserialize)] + struct FingerprintsFile { + kernel_hash: String, + #[serde(default)] + initrd_hash: String, + #[serde(default = "default_vcpus")] + vcpus: u32, + #[serde(default = "default_vcpu_type")] + vcpu_type: String, + #[serde(default)] + ovmf_hash: String, + #[serde(default)] + ovmf_path: Option, + #[serde(default = "default_guest_features_test")] + guest_features: u64, + #[serde(default)] + sev_hashes_table_gpa: u64, + #[serde(default)] + sev_es_reset_eip: u32, + #[serde(default)] + ovmf_sections: Vec, + } + + fn default_vcpus() -> u32 { + 1 + } + fn default_vcpu_type() -> String { + "EPYC".to_string() + } + fn default_guest_features_test() -> u64 { + 1 + } + + /// Read `sev_image_fingerprints.json` and compute the SNP MEASUREMENT. + /// + /// Marked `#[ignore]` so it only runs when explicitly requested (see + /// the comment block above for the exact cargo test invocation). + #[test] + #[ignore] + fn compute_measurement_from_fingerprints() { + // ---- read env vars ------------------------------------------------ + let fp_path = env::var("FINGERPRINTS") + .expect("FINGERPRINTS env var must point to sev_image_fingerprints.json"); + let compose_hash = env::var("COMPOSE_HASH") + .expect("COMPOSE_HASH env var must be set (SHA-256 hex of docker-compose.yaml)"); + let rootfs_hash = env::var("ROOTFS_HASH") + .expect("ROOTFS_HASH env var must be set (SHA-256 hex of rootfs ISO)"); + let docker_files_hash = env::var("DOCKER_FILES_HASH").ok(); + let ovmf_path_override = env::var("OVMF_PATH").ok(); + + // ---- parse fingerprints file -------------------------------------- + let raw = std::fs::read_to_string(&fp_path) + .unwrap_or_else(|e| panic!("Cannot read {fp_path}: {e}")); + let fp: FingerprintsFile = + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("Cannot parse {fp_path}: {e}")); + + println!("--- sev_image_fingerprints ---"); + println!(" kernel_hash: {}", fp.kernel_hash); + println!(" initrd_hash: {}", fp.initrd_hash); + println!(" vcpus: {}", fp.vcpus); + println!(" vcpu_type: {}", fp.vcpu_type); + println!(" ovmf_hash: {}", fp.ovmf_hash); + println!(" sev_hashes_table_gpa: {:#x}", fp.sev_hashes_table_gpa); + println!(" sev_es_reset_eip: {:#x}", fp.sev_es_reset_eip); + println!(" guest_features: {:#x}", fp.guest_features); + println!(" ovmf_sections: {} entries", fp.ovmf_sections.len()); + for (i, s) in fp.ovmf_sections.iter().enumerate() { + println!( + " [{i}] gpa={:#x} size={:#x} type={}", + s.gpa, s.size, s.section_type + ); + } + println!("--- inputs ---"); + println!(" compose_hash: {compose_hash}"); + println!(" rootfs_hash: {rootfs_hash}"); + if let Some(dh) = &docker_files_hash { + println!(" docker_files_hash: {dh}"); + } + if let Some(op) = &ovmf_path_override { + println!(" OVMF_PATH override: {op}"); + } + + // ---- build config ------------------------------------------------ + let effective_ovmf_path = ovmf_path_override.or(fp.ovmf_path).or_else(|| { + // If sections are provided the path is optional + if !fp.ovmf_sections.is_empty() { + None + } else { + panic!( + "No ovmf_path in fingerprints and OVMF_PATH env var not set. \ + Either add ovmf_path to the JSON or set OVMF_PATH." + ) + } + }); + + let cfg = SevSnpMeasureConfig { + ovmf_path: effective_ovmf_path, + guest_features: fp.guest_features, + }; + + // ---- convert sections -------------------------------------------- + let sections: Vec = fp + .ovmf_sections + .iter() + .map(|s| OvmfSectionParam { + gpa: s.gpa, + size: s.size, + section_type: s.section_type, + }) + .collect(); + + // ---- compute ----------------------------------------------------- + let measurement = compute_expected_measurement( + &cfg, + &MeasurementInput { + ovmf_hash: &fp.ovmf_hash, + sev_hashes_table_gpa: fp.sev_hashes_table_gpa, + sev_es_reset_eip: fp.sev_es_reset_eip, + ovmf_sections: §ions, + kernel_hash: &fp.kernel_hash, + initrd_hash: &fp.initrd_hash, + vcpus: fp.vcpus, + vcpu_type: &fp.vcpu_type, + compose_hash: &compose_hash, + rootfs_hash: &rootfs_hash, + docker_files_hash: docker_files_hash.as_deref(), + }, + ) + .expect("compute_expected_measurement failed"); + + println!("--- result ---"); + println!("MEASUREMENT = {}", hex::encode(measurement)); + println!(); + println!("Compare with `snpguest display report att-report.bin` → Measurement field"); + } +}