diff --git a/docs/amd-sev-snp.md b/docs/amd-sev-snp.md index d2ebe8139..557dab045 100644 --- a/docs/amd-sev-snp.md +++ b/docs/amd-sev-snp.md @@ -50,20 +50,55 @@ preflight does not replace firmware, kernel, QEMU, or OVMF validation. ## Install dstack on an SNP host -Pull a current unified image and select the platform explicitly: +Use a release containing the `sev-snp` key provider in the guest OS and VMM. The +KMS container and client attestation verifiers must recognize the new provider +metadata. Set `VERSION` to the OS release version and `KMS_IMAGE` to the KMS +container reference, then install: ```bash -VERSION=0.6.0 sudo dstackup image pull --version "$VERSION" -sudo dstackup install --platform amd-sev-snp --image "dstack-$VERSION" +sudo dstackup install --platform amd-sev-snp \ + --image "dstack-$VERSION" --kms-image "$KMS_IMAGE" sudo dstackup status ``` -The default `--platform auto` mode also selects SEV-SNP when the host CPU flags -advertise `sev_snp`. Explicit selection is preferable while commissioning a -host because it fails immediately when the required SNP device is absent. +Confirm that installation reports `booted` for the KMS CVM. `dstackup` selects +the `sev-snp` provider for the KMS CVM on AMD hosts. The default +`--platform auto` also detects SEV-SNP from the host CPU flags. Explicit +selection checks for the required SNP device before installation. Review the +[KMS key-release policy](#kms-key-release-policy) before serving applications. -Unlike the TDX path, SEV-SNP does not use the local SGX key provider. +## Run KMS with persistent local keys + +The `sev-snp` provider lets a KMS CVM recover its encrypted disk and stored KMS +roots after restart on the same AMD hardware. For a manually managed KMS CVM, +set this field in its app-compose document: + +```json +{"key_provider": "sev-snp"} +``` + +The deployment CLI also accepts `dstack deploy --key-provider sev-snp`. Application +CVMs continue using `key_provider: kms` and the existing KMS API. + +The guest derives a seed through `SNP_GET_DERIVED_KEY` using legacy VCEK at +VMPL0. The seed binds to launch measurement, guest policy, HOST_DATA, launch +identity, and a pinned TCB. The guest requires debugging disabled and encrypted +storage, and verifies the configuration binding before deriving the seed. + +Before initializing a new disk, the guest persists public derivation parameters +in `.instance_info` through the host API. Preserve this file, the encrypted +disk, and the original launch configuration together. Missing parameters for +an existing disk, unknown versions, or derivation/unlock failures stop startup. + +Recovery requires the original hardware and bound launch inputs. For OS or +compose changes, or hardware replacement, preserve KMS roots through the +existing onboarding/replication flow. Firmware updates must support the stored +derivation TCB and satisfy attestation policy. + +The entire KMS CVM, including its OS, is trusted. Protect KMS root and sealing +keys by keeping the KMS OS and workload under operator control. Application +key release continues to follow the existing KMS authorization policy. ## Attestation and image identity diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index ca00bfe48..2c7a9270a 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -2438,6 +2438,7 @@ dependencies = [ "serde-human-bytes", "serde_json", "serde_with", + "sev-snp-attest", "sha2 0.10.9", "sha3", "sodiumbox", @@ -2454,6 +2455,7 @@ dependencies = [ "x25519-dalek 2.0.1", "x509-parser 0.16.0", "yaml-rust2", + "zeroize", ] [[package]] @@ -7361,8 +7363,10 @@ dependencies = [ "anyhow", "fs-err", "hex", + "libc", "sev", "tracing", + "zeroize", ] [[package]] diff --git a/dstack/Cargo.toml b/dstack/Cargo.toml index 7a98c2471..cad831b12 100644 --- a/dstack/Cargo.toml +++ b/dstack/Cargo.toml @@ -253,6 +253,7 @@ schnorrkel = "0.11.4" sha2 = { version = "0.10.8", default-features = false } sha3 = "0.10.8" subtle = "2" +zeroize = "1.8" blake2 = "0.10.6" tokio-rustls = { version = "0.26.2", features = ["ring"] } x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } diff --git a/dstack/cert-client/src/lib.rs b/dstack/cert-client/src/lib.rs index 3082ee9ce..b96968fec 100644 --- a/dstack/cert-client/src/lib.rs +++ b/dstack/cert-client/src/lib.rs @@ -213,6 +213,7 @@ impl CertRequestClient { match &keys.key_provider { KeyProvider::None { key } | KeyProvider::Local { key, .. } + | KeyProvider::SevSnp { key } | KeyProvider::Tpm { key, .. } => { let ca = CaCert::new(keys.ca_cert.clone(), key.clone()) .context("Failed to create CA")?; diff --git a/dstack/crates/dstack-cli-core/src/compose.rs b/dstack/crates/dstack-cli-core/src/compose.rs index 4db4f53a6..34d4871b9 100644 --- a/dstack/crates/dstack-cli-core/src/compose.rs +++ b/dstack/crates/dstack-cli-core/src/compose.rs @@ -5,6 +5,7 @@ //! build the app-compose manifest — the JSON document the VMM hashes (to derive //! the app id) and deploys. The raw docker-compose YAML is embedded as a string. +use dstack_types::KeyProviderKind; use serde_json::json; /// build a minimal app-compose manifest from a docker-compose YAML body @@ -34,7 +35,11 @@ pub fn build_app_compose_with_runtime( build_app_compose_with_runtime_and_volumes( name, docker_compose_yaml, - kms_enabled, + if kms_enabled { + KeyProviderKind::Kms + } else { + KeyProviderKind::None + }, runner, snapshotter, &[], @@ -45,7 +50,7 @@ pub fn build_app_compose_with_runtime( pub fn build_app_compose_with_runtime_and_volumes( name: &str, docker_compose_yaml: &str, - kms_enabled: bool, + key_provider: KeyProviderKind, runner: &str, snapshotter: Option<&str>, verity_volumes: &[dstack_types::VerityVolume], @@ -55,7 +60,7 @@ pub fn build_app_compose_with_runtime_and_volumes( "name": name, "runner": runner, "docker_compose_file": docker_compose_yaml, - "kms_enabled": kms_enabled, + "kms_enabled": key_provider.is_kms(), "gateway_enabled": false, "local_key_provider_enabled": false, "public_logs": true, @@ -74,7 +79,48 @@ pub fn build_app_compose_with_runtime_and_volumes( if !verity_volumes.is_empty() { manifest["verity_volumes"] = json!(verity_volumes); } + // Preserve legacy KMS/None manifest bytes, which determine app identity. + if !matches!(key_provider, KeyProviderKind::Kms | KeyProviderKind::None) { + manifest["key_provider"] = json!(key_provider); + } // pretty-print via Value's Display (`{:#}`) — infallible, and byte-identical // to serde_json::to_string_pretty (avoids an expect on an unfailable Result). format!("{manifest:#}") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_provider_matches_previous_cli_manifest_encoding() { + let volumes = [dstack_types::VerityVolume { + source: "test.img".into(), + verity_root: [1; 32], + target: "/test".into(), + }]; + for volumes in [&[][..], &volumes[..]] { + let build = |provider| { + build_app_compose_with_runtime_and_volumes( + "test", + "", + provider, + "nerdctl-compose", + Some("soci"), + volumes, + ) + }; + for provider in [ + KeyProviderKind::SevSnp, + KeyProviderKind::Local, + KeyProviderKind::Tpm, + ] { + // The old CLI serialized a legacy manifest, then inserted the provider. + let mut legacy: serde_json::Value = + serde_json::from_str(&build(KeyProviderKind::None)).unwrap(); + legacy["key_provider"] = json!(provider); + assert_eq!(build(provider), format!("{legacy:#}")); + } + } + } +} diff --git a/dstack/crates/dstack-cli-core/src/config.rs b/dstack/crates/dstack-cli-core/src/config.rs index 03aaee22c..d2f832bce 100644 --- a/dstack/crates/dstack-cli-core/src/config.rs +++ b/dstack/crates/dstack-cli-core/src/config.rs @@ -210,10 +210,8 @@ pub fn auth_allowlist_json(cfg: &HostConfig) -> String { /// compose hash. pub const DEFAULT_KMS_IMAGE: &str = "dstacktee/dstack-kms:0.5.11@sha256:84b793feed825a5b5e70d04386e931e0e110461492793f17ab2128e39808d989"; -/// build the KMS-in-CVM app-compose manifest. An init script writes the -/// rendered `kms.toml` into the guest and the KMS container mounts it. On TDX -/// the CVM uses the SGX local key provider to seal the KMS root key; AMD -/// SEV-SNP has no such provider, so it's disabled there. +/// Build the KMS app-compose manifest with an embedded `kms.toml` init script. +/// Select SGX local provisioning on TDX and native SNP provisioning on AMD. pub fn kms_app_compose(kms_toml: &str, kms_image: &str, platform: Platform) -> String { let docker_compose = format!( r#"services: @@ -234,7 +232,7 @@ volumes: let init_script = format!( "mkdir -p /dstack/kms-config\ncat > /dstack/kms-config/kms.toml <<'KMSTOML'\n{kms_toml}\nKMSTOML\ntrue\n" ); - let manifest = json!({ + let mut manifest = json!({ "manifest_version": 2, "name": "dstack-kms", "runner": "docker-compose", @@ -250,6 +248,9 @@ volumes: "secure_time": false, "allowed_envs": [] }); + if platform == Platform::AmdSevSnp { + manifest["key_provider"] = json!("sev-snp"); + } // infallible pretty-print via Value's Display; see compose::build_app_compose. format!("{manifest:#}") } @@ -419,7 +420,7 @@ address = "vsock:2" port = {host_api_port} [key_provider] -enabled = true +enabled = {use_sgx_key_provider} address = "{kp_addr}" port = {kp_port} "#, @@ -448,6 +449,7 @@ port = {kp_port} host_api_port = r.host_api_port, kp_addr = r.key_provider_addr, kp_port = r.key_provider_port, + use_sgx_key_provider = r.platform == Platform::Tdx, auth_enabled = r.auth_enabled, auth_tokens = if r.auth_token.is_empty() { String::new() @@ -525,11 +527,12 @@ mod tests { assert!(tdx_vmm.contains(r#"platform = "tdx""#)); assert!(tdx_vmm.contains(r#"host_share_mode = "9p""#)); assert!(tdx_vmm.contains("use_mrconfigid = false")); + assert!(tdx_vmm.contains("[key_provider]\nenabled = true")); toml::from_str::(&tdx_vmm).expect("tdx vmm.toml valid"); assert!(kms_app_compose("x", "img", Platform::Tdx) .contains(r#""local_key_provider_enabled": true"#)); - // SNP: key-release gate set, vhd share, mrconfigid on, no local provider. + // SNP: key-release gate set, vhd share, mrconfigid on, native sealing. let snp = kms_toml(&HostConfig { platform: Platform::AmdSevSnp, ..Default::default() @@ -543,9 +546,14 @@ mod tests { assert!(snp_vmm.contains(r#"platform = "amd-sev-snp""#)); assert!(snp_vmm.contains(r#"host_share_mode = "vhd""#)); assert!(snp_vmm.contains("use_mrconfigid = true")); + assert!(snp_vmm.contains("[key_provider]\nenabled = false")); toml::from_str::(&snp_vmm).expect("snp vmm.toml valid"); assert!(kms_app_compose("x", "img", Platform::AmdSevSnp) .contains(r#""local_key_provider_enabled": false"#)); + let snp_compose: serde_json::Value = + serde_json::from_str(&kms_app_compose("x", "img", Platform::AmdSevSnp)).unwrap(); + assert_eq!(snp_compose["key_provider"], "sev-snp"); + assert_eq!(snp_compose["kms_enabled"], false); } #[test] diff --git a/dstack/crates/dstack-cli/src/main.rs b/dstack/crates/dstack-cli/src/main.rs index 8a70141e9..683b8381d 100644 --- a/dstack/crates/dstack-cli/src/main.rs +++ b/dstack/crates/dstack-cli/src/main.rs @@ -15,8 +15,14 @@ use clap::{Parser, Subcommand, ValueEnum}; use dstack_cli_core::layout::InstallLayout; use dstack_cli_core::vmm::{Vmm, DEFAULT_HOST}; use dstack_cli_core::{compose, ports, rpc}; +use dstack_types::KeyProviderKind; use fs_err as fs; +fn parse_key_provider(value: &str) -> std::result::Result { + serde_json::from_value(serde_json::Value::String(value.to_string())) + .map_err(|_| "expected kms, local, sev-snp, tpm, or none".to_string()) +} + #[derive(Parser)] #[command( name = "dstack", @@ -115,6 +121,9 @@ enum Command { /// deploy in non-KMS mode (ephemeral keys; no KMS required). #[arg(long)] no_kms: bool, + /// key provider: kms (default), local (SGX), sev-snp, tpm, or none. + #[arg(long, value_parser = parse_key_provider, conflicts_with = "no_kms")] + key_provider: Option, /// register the app's compose hash in this auth-allowlist.json. Defaults /// to the local allowlist from `dstackup install`. #[arg(long, value_name = "PATH")] @@ -236,11 +245,17 @@ async fn main() -> Result<()> { ports, volumes, no_kms, + key_provider, allowlist, dry_run, runner, snapshotter, } => { + let key_provider = key_provider.unwrap_or(if no_kms { + KeyProviderKind::None + } else { + KeyProviderKind::Kms + }); let compose = resolve_compose_arg(compose, compose_file)?; let image = if use_local_defaults { image.or_else(|| defaults.as_ref().and_then(|d| d.image.clone())) @@ -249,7 +264,8 @@ async fn main() -> Result<()> { }; let allowlist = if use_local_defaults { allowlist.or_else(|| { - (!no_kms) + key_provider + .is_kms() .then(|| defaults.as_ref().and_then(LocalDefaults::allowlist_path)) .flatten() }) @@ -267,7 +283,7 @@ async fn main() -> Result<()> { disk, &ports, &volumes, - no_kms, + key_provider, allowlist.as_deref(), dry_run, json, @@ -446,7 +462,7 @@ async fn cmd_deploy( disk: u32, port_specs: &[String], volume_specs: &[String], - no_kms: bool, + key_provider: KeyProviderKind, allowlist: Option<&str>, dry_run: bool, json: bool, @@ -474,7 +490,7 @@ async fn cmd_deploy( let app_compose = compose::build_app_compose_with_runtime_and_volumes( name, &yaml, - !no_kms, + key_provider, runner.as_str(), snapshotter.map(Snapshotter::as_str), &parsed_volumes, @@ -534,7 +550,7 @@ async fn cmd_deploy( " (the KMS issues keys only if this is the allowlist its auth webhook serves)" ); } - } else if !no_kms && !json { + } else if key_provider.is_kms() && !json { println!("note: no --allowlist given; a KMS-mode app needs its compose hash registered to get keys"); } @@ -763,6 +779,44 @@ mod tests { } } + #[test] + fn sev_snp_provider_is_explicit_and_conflicts_with_no_kms_alias() { + let cli = Cli::try_parse_from([ + "dstack", + "deploy", + "compose.yaml", + "--key-provider", + "sev-snp", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Command::Deploy { + key_provider: Some(KeyProviderKind::SevSnp), + .. + } + )); + assert!(Cli::try_parse_from([ + "dstack", + "deploy", + "compose.yaml", + "--key-provider", + "sev-snp", + "--no-kms" + ]) + .is_err()); + for invalid in ["snp", "sev_snp", "unknown"] { + assert!(Cli::try_parse_from([ + "dstack", + "deploy", + "compose.yaml", + "--key-provider", + invalid + ]) + .is_err()); + } + } + #[test] fn parses_verity_fs_image_flag() { let cli = Cli::parse_from(["dstack", "verity", "--fs-image", "rootfs.ext4"]); diff --git a/dstack/crates/dstackup/src/install.rs b/dstack/crates/dstackup/src/install.rs index 14158c60d..1dc6fd0b9 100644 --- a/dstack/crates/dstackup/src/install.rs +++ b/dstack/crates/dstackup/src/install.rs @@ -851,11 +851,10 @@ fn resolve_key_provider( println!(" [ok] using existing key provider at {addr}:{port}"); return Ok((addr, port, None)); } - // AMD SEV-SNP has no SGX local key provider; the rendered [key_provider] - // block is unused (the KMS-in-CVM runs with local_key_provider_enabled = - // false), so don't require or start one. + // AMD SEV-SNP derives its sealing seed inside the KMS CVM. The SGX + // service's [key_provider] block is unused, so don't start that service. if platform == Platform::AmdSevSnp { - println!(" [ok] no local key provider (sev-snp)"); + println!(" [ok] native guest key provider (sev-snp)"); return Ok(("127.0.0.1".to_string(), o.key_provider_port, None)); } // TDX: run our in-tree provider under Gramine from the installed assets unless diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index f1496c502..2108fc8fc 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -765,9 +765,47 @@ pub enum KeyProviderKind { Kms, Local, Tpm, + #[serde(rename = "sev-snp")] + SevSnp, +} + +/// Instance identity and public recovery metadata shared by the VMM and guest. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct InstanceInfo { + #[serde(default, with = "hex_bytes")] + pub instance_id_seed: Vec, + #[serde(default, with = "hex_bytes")] + pub instance_id: Vec, + #[serde(default, with = "hex_bytes")] + pub app_id: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snp_sealing: Option, +} + +/// Public parameters needed to reproduce the SNP sealing seed after a restart. +/// Version 1 fixes the root, guest fields, VMPL, and guest SVN in the provider. +#[serde_with::serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SnpSealingParameters { + pub version: u32, + // A string preserves all 64 bits through JSON consumers using IEEE doubles. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub tcb_version: u64, } impl KeyProviderKind { + /// Stable name used in measurements and key-provider events. + pub fn measurement_name(self) -> &'static str { + match self { + Self::None => "none", + Self::Local => "local-sgx", + Self::Kms => "kms", + Self::Tpm => "tpm", + Self::SevSnp => "sev-snp", + } + } + pub fn is_none(&self) -> bool { matches!(self, KeyProviderKind::None) } @@ -2447,6 +2485,9 @@ pub enum KeyProvider { #[serde(with = "hex_bytes")] pubkey: Vec, }, + SevSnp { + key: String, + }, Kms { url: String, #[serde(with = "hex_bytes")] @@ -2465,6 +2506,7 @@ impl KeyProvider { KeyProvider::None { .. } => KeyProviderKind::None, KeyProvider::Local { .. } => KeyProviderKind::Local, KeyProvider::Tpm { .. } => KeyProviderKind::Tpm, + KeyProvider::SevSnp { .. } => KeyProviderKind::SevSnp, KeyProvider::Kms { .. } => KeyProviderKind::Kms, } } @@ -2477,11 +2519,13 @@ impl KeyProvider { /// (from a TPM-sealed seed) and must not be treated as a stable provider id /// or measured as one. Mode is already carried by [`Self::kind`]. /// - None: empty + /// - SNP: empty; the fixed hardware policy is identified by the provider kind pub fn id(&self) -> &[u8] { match self { KeyProvider::None { .. } => &[], KeyProvider::Local { mr, .. } => mr, KeyProvider::Tpm { .. } => &[], + KeyProvider::SevSnp { .. } => &[], KeyProvider::Kms { pubkey, .. } => pubkey, } } @@ -2503,6 +2547,64 @@ impl KeyProviderInfo { mod key_provider_tests { use super::*; + #[test] + fn sev_snp_provider_has_no_instance_dependent_id() { + let provider: KeyProvider = + serde_json::from_str(r#"{"SevSnp":{"key":"test-only"}}"#).unwrap(); + assert_eq!(provider.kind(), KeyProviderKind::SevSnp); + assert!(provider.id().is_empty()); + assert_eq!( + serde_json::to_string(&provider.kind()).unwrap(), + r#""sev-snp""# + ); + assert_eq!(provider.kind().measurement_name(), "sev-snp"); + for obsolete_name in ["snp", "sev_snp"] { + assert!( + serde_json::from_value::(serde_json::json!(obsolete_name)) + .is_err() + ); + } + let compose: AppCompose = serde_json::from_value(serde_json::json!({ + "name": "snp-test", "runner": "docker-compose", "manifest_version": "3", "docker_compose_file": "", "key_provider": "sev-snp" + })) + .unwrap(); + assert_eq!(compose.key_provider(), KeyProviderKind::SevSnp); + assert!(!compose.kms_enabled()); + } + + #[test] + fn legacy_instance_metadata_encoding_is_unchanged() { + let raw = r#"{"instance_id_seed":"0102","instance_id":"03","app_id":"04"}"#; + let info: InstanceInfo = serde_json::from_str(raw).unwrap(); + assert_eq!(info.instance_id_seed, [1, 2]); + assert!(info.snp_sealing.is_none()); + assert_eq!(serde_json::to_string(&info).unwrap(), raw); + let empty: InstanceInfo = serde_json::from_str("{}").unwrap(); + assert!(empty.app_id.is_empty()); + } + + #[test] + fn snp_metadata_preserves_full_tcb_precision() { + let metadata = SnpSealingParameters { + version: 1, + tcb_version: u64::MAX - 1, + }; + let encoded = serde_json::to_value(metadata).unwrap(); + assert_eq!(encoded["tcb_version"], (u64::MAX - 1).to_string()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + metadata + ); + assert!( + serde_json::from_str::(r#"{"version":1,"tcb_version":1}"#) + .is_err() + ); + assert!(serde_json::from_str::( + r#"{"version":1,"tcb_version":"1","root_key_select":0}"# + ) + .is_err()); + } + #[test] fn legacy_kms_provider_drops_temp_ca_material_when_reserialized() { let provider: KeyProvider = serde_json::from_value(serde_json::json!({ diff --git a/dstack/dstack-types/src/mr_config.rs b/dstack/dstack-types/src/mr_config.rs index d11c3a5aa..6b0024a3d 100644 --- a/dstack/dstack-types/src/mr_config.rs +++ b/dstack/dstack-types/src/mr_config.rs @@ -32,6 +32,7 @@ fn key_provider_kind_byte(key_provider: KeyProviderKind) -> u8 { KeyProviderKind::Local => 1, KeyProviderKind::Kms => 2, KeyProviderKind::Tpm => 3, + KeyProviderKind::SevSnp => 4, } } @@ -194,12 +195,7 @@ impl MrConfigV3 { } pub fn key_provider_name(&self) -> &'static str { - match self.key_provider { - KeyProviderKind::None => "none", - KeyProviderKind::Local => "local-sgx", - KeyProviderKind::Kms => "kms", - KeyProviderKind::Tpm => "tpm", - } + self.key_provider.measurement_name() } } @@ -207,6 +203,38 @@ impl MrConfigV3 { mod tests { use super::*; + #[test] + fn sev_snp_provider_is_distinct_without_changing_legacy_codes() { + let providers = [ + KeyProviderKind::None, + KeyProviderKind::Local, + KeyProviderKind::Kms, + KeyProviderKind::Tpm, + KeyProviderKind::SevSnp, + ]; + for (code, provider) in providers.into_iter().enumerate() { + assert_eq!(key_provider_kind_byte(provider), code as u8); + } + let snp = MrConfigV3::new( + vec![1; 20], + vec![2; 32], + None, + KeyProviderKind::SevSnp, + vec![], + vec![3; 20], + ); + let mut other = snp.clone(); + other.key_provider = KeyProviderKind::None; + assert_eq!(snp.key_provider_name(), "sev-snp"); + let document = snp.to_canonical_json(); + assert_eq!( + serde_json::from_str::(&document).unwrap()["key_provider"], + "sev-snp" + ); + assert_eq!(MrConfigV3::from_document(&document).unwrap(), snp); + assert_ne!(snp.to_snp_host_data(), other.to_snp_host_data()); + } + #[test] fn mr_config_v3_hash_changes_with_app_identity() { let config = MrConfigV3::new( diff --git a/dstack/dstack-util/Cargo.toml b/dstack/dstack-util/Cargo.toml index f2401bc40..29a785b65 100644 --- a/dstack/dstack-util/Cargo.toml +++ b/dstack/dstack-util/Cargo.toml @@ -33,6 +33,7 @@ tokio = { workspace = true, features = ["full"] } tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true +zeroize.workspace = true x25519-dalek.workspace = true dstack-kms-rpc.workspace = true @@ -41,6 +42,7 @@ ra-tls = { workspace = true, features = ["quote"] } dstack-gateway-rpc.workspace = true tdx-attest.workspace = true tpm-attest.workspace = true +sev-snp-attest.workspace = true tpm2.workspace = true tpm-qvl = { workspace = true, features = ["crl-download"] } host-api = { workspace = true, features = ["client"] } diff --git a/dstack/dstack-util/src/main.rs b/dstack/dstack-util/src/main.rs index e6c49f271..c8dca2d13 100644 --- a/dstack/dstack-util/src/main.rs +++ b/dstack/dstack-util/src/main.rs @@ -1161,6 +1161,9 @@ fn gen_app_keys_from_seed( key: key.serialize_pem(), pubkey: key.public_key_der(), }, + KeyProviderKind::SevSnp => KeyProvider::SevSnp { + key: key.serialize_pem(), + }, KeyProviderKind::Kms => { anyhow::bail!("KMS keys must be fetched from the KMS server") } diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 6b224cdf8..1f8bcf4c5 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -24,7 +24,7 @@ use dstack_types::{ APP_COMPOSE, APP_KEYS, DECRYPTED_ENV, DECRYPTED_ENV_JSON, ENCRYPTED_ENV, HOST_SHARED_DIR_NAME, INSTANCE_INFO, SEALED_ENV, SYS_CONFIG, USER_CONFIG, }, - GpuPolicy, KeyProvider, KeyProviderInfo, GPU_ATTESTATION_OUTPUT, + GpuPolicy, InstanceInfo, KeyProvider, KeyProviderInfo, GPU_ATTESTATION_OUTPUT, }; use fs_err as fs; use luks::{ @@ -43,6 +43,7 @@ use safe_write::{safe_write, safe_write_with_mode}; use scopeguard::defer; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; +use zeroize::Zeroizing; use crate::{ cmd_show_mrs, @@ -62,7 +63,6 @@ use dstack_gateway_rpc::{ RegisterCvmRequest, RegisterCvmResponse, WireGuardPeer, }; use ra_tls::rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256}; -use serde_human_bytes as hex_bytes; use serde_json::Value; use tpm_attest::{self as tpm, TpmContext}; @@ -96,6 +96,7 @@ async fn sign_cert_request( mod config_id_verifier; mod luks; mod sealed_env; +mod snp; #[cfg(test)] use sealed_env::seal_env; @@ -126,16 +127,6 @@ pub struct GatewayRefreshArgs { force: bool, } -#[derive(Deserialize, Serialize, Clone, Default)] -struct InstanceInfo { - #[serde(with = "hex_bytes", default)] - instance_id_seed: Vec, - #[serde(with = "hex_bytes", default)] - instance_id: Vec, - #[serde(with = "hex_bytes", default)] - app_id: Vec, -} - #[derive(Debug, Clone, Copy, PartialEq, Default)] enum FsType { #[default] @@ -2584,24 +2575,7 @@ impl<'a> Stage0<'a> { } Ok(()) } - async fn get_keys_from_local_key_provider(&self) -> Result { - info!("Getting keys from local key provider"); - let provision = self - .vmm - .get_sealing_key() - .await - .context("Failed to get sealing key")?; - // write to fs - let app_keys = gen_app_keys_from_seed( - &provision.sk, - KeyProviderKind::Local, - Some(provision.mr.to_vec()), - ) - .context("Failed to generate app keys")?; - Ok(app_keys) - } - - fn generate_tpm_app_keys(&self) -> Result { + fn get_tpm_seed(&self) -> Result> { let tpm = TpmContext::detect().context("failed to detect TPM context")?; // PCR policy: platform-specific (AWS: sha384 PCR4/7/8/12/14) @@ -2618,43 +2592,46 @@ impl<'a> Stage0<'a> { "unsealed root key seed from TPM (PCR policy: {})", pcr_policy.to_arg() ); - return gen_app_keys_from_seed(&seed, KeyProviderKind::Tpm, None) - .context("failed to generate TPM app keys"); + return Ok(Zeroizing::new(seed)); } // No sealed seed exists, generate new one info!("no sealed seed found, generating new seed..."); - let seed: [u8; 32] = tpm.get_random().context("TPM RNG unavailable")?; + let seed = Zeroizing::new(tpm.get_random::<32>().context("TPM RNG unavailable")?); // Seal the new seed under the platform PCR policy tpm.seal( - &seed, + seed.as_ref(), tpm::SEALED_NV_INDEX, tpm::PRIMARY_KEY_HANDLE, &pcr_policy, ) .context("failed to seal seed to TPM")?; - gen_app_keys_from_seed(&seed, KeyProviderKind::Tpm, None) - .context("failed to generate TPM app keys") + Ok(seed) } - async fn request_app_keys(&self) -> Result { + async fn request_app_keys(&self, app_info: &mut AppInfo) -> Result { let key_provider = self.shared.app_compose.key_provider(); validate_key_provider_inputs(key_provider, &self.shared.sys_config.kms_urls)?; - match key_provider { - KeyProviderKind::Kms => self.request_app_keys_from_kms().await, - KeyProviderKind::Local => self.get_keys_from_local_key_provider().await, - KeyProviderKind::None => { - info!("No key provider is enabled, generating temporary app keys"); - let seed: [u8; 32] = rand::thread_rng().gen(); - gen_app_keys_from_seed(&seed, KeyProviderKind::None, None) - .context("Failed to generate app keys") + let (seed, provider_id) = match key_provider { + KeyProviderKind::Kms => return self.request_app_keys_from_kms().await, + KeyProviderKind::Local => { + let provision = self + .vmm + .get_sealing_key() + .await + .context("failed to get SGX sealing key")?; + (Zeroizing::new(provision.sk), Some(provision.mr.to_vec())) } - KeyProviderKind::Tpm => { - info!("Generating app keys from TPM"); - self.generate_tpm_app_keys() + KeyProviderKind::SevSnp => (self.get_snp_seed(app_info).await?, None), + KeyProviderKind::Tpm => (self.get_tpm_seed()?, None), + KeyProviderKind::None => { + info!("no key provider is enabled, generating temporary app keys"); + (Zeroizing::new(rand::thread_rng().gen::<[u8; 32]>()), None) } - } + }; + gen_app_keys_from_seed(seed.as_ref(), key_provider, provider_id) + .context("failed to generate app keys") } async fn setup_swap(&self, swap_size: u64, opts: &DstackOptions) -> Result<()> { @@ -3207,7 +3184,12 @@ impl<'a> Stage0<'a> { }) } - fn verify_app(&self, app_info: &AppInfo, keys: &AppKeys) -> Result<()> { + fn verify_app_config( + &self, + app_info: &AppInfo, + provider: KeyProviderKind, + provider_id: &[u8], + ) -> Result<()> { config_id_verifier::verify_mr_config_id( &app_info.compose_hash, &app_info.gpu_policy_hash, @@ -3220,28 +3202,24 @@ impl<'a> Stage0<'a> { .ok() .context("Invalid app id")?, &app_info.instance_info.instance_id, - keys.key_provider.kind(), - keys.key_provider.id(), + provider, + provider_id, )?; - self.verify_key_provider_id(keys.key_provider.id())?; - // TPM uses an empty id: the instance app-root pubkey is not a stable - // provider identity and must not enter the launch measurement chain. - let kp_info = match &keys.key_provider { - KeyProvider::None { .. } => KeyProviderInfo::new("none".into(), "".into()), - KeyProvider::Local { .. } => { - KeyProviderInfo::new("local-sgx".into(), hex::encode(keys.key_provider.id())) - } - KeyProvider::Tpm { .. } => KeyProviderInfo::new("tpm".into(), "".into()), - KeyProvider::Kms { .. } => { - KeyProviderInfo::new("kms".into(), hex::encode(keys.key_provider.id())) - } - }; + self.verify_key_provider_id(provider_id) + } + + fn verify_app(&self, app_info: &AppInfo, keys: &AppKeys) -> Result<()> { + self.verify_app_config(app_info, keys.key_provider.kind(), keys.key_provider.id())?; + let kp_info = KeyProviderInfo::new( + keys.key_provider.kind().measurement_name().into(), + hex::encode(keys.key_provider.id()), + ); emit_key_provider_info(&kp_info)?; Ok(()) } async fn setup_fs(self) -> Result> { - let app_info = self + let mut app_info = self .measure_app_info() .await .context("Failed to measure app info")?; @@ -3252,7 +3230,7 @@ impl<'a> Stage0<'a> { .notify_q("boot.progress", "requesting app keys") .await; let app_keys = self - .request_app_keys() + .request_app_keys(&mut app_info) .await .context("Failed to request app keys")?; if app_keys.disk_crypt_key.is_empty() { diff --git a/dstack/dstack-util/src/system_setup/snp.rs b/dstack/dstack-util/src/system_setup/snp.rs new file mode 100644 index 000000000..daff534f4 --- /dev/null +++ b/dstack/dstack-util/src/system_setup/snp.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use dstack_types::{KeyProviderKind, SnpSealingParameters}; +use zeroize::Zeroizing; + +use super::{detect_tee_variant, parse_dstack_options, AppInfo, Command, Stage0, TeeVariant}; + +const SEALING_VERSION: u32 = 1; + +fn parameters_for_boot( + stored: Option, + disk_initialized: bool, + initial_tcb: impl FnOnce() -> Result, +) -> Result { + if let Some(parameters) = stored { + if parameters.version != SEALING_VERSION { + bail!( + "unsupported SNP sealing parameter version: {}", + parameters.version + ); + } + return Ok(parameters); + } + if disk_initialized { + bail!("snp sealing parameters are missing for an existing encrypted disk"); + } + Ok(SnpSealingParameters { + version: SEALING_VERSION, + tcb_version: initial_tcb()?, + }) +} + +impl Stage0<'_> { + pub(super) async fn get_snp_seed(&self, app_info: &mut AppInfo) -> Result> { + if detect_tee_variant()? != TeeVariant::DstackAmdSevSnp { + bail!("the SEV-SNP key provider requires an AMD SEV-SNP guest"); + } + // HOST_DATA is host-supplied. The measured OS must validate its binding + // to the actual app/config before deriving or using a sealing seed. + self.verify_app_config(app_info, KeyProviderKind::SevSnp, &[])?; + if !parse_dstack_options(&self.shared)?.storage_encrypted { + bail!("the SEV-SNP key provider requires encrypted storage"); + } + let status = Command::new("cryptsetup") + .arg("isLuks") + .arg(&self.args.device) + .status() + .context("failed to inspect disk before SNP sealing initialization")?; + let disk_initialized = match status.code() { + Some(0) => true, + Some(1) => false, + _ => bail!("failed to inspect disk before SNP sealing initialization: {status}"), + }; + let parameters = parameters_for_boot( + app_info.instance_info.snp_sealing, + disk_initialized, + sev_snp_attest::sealing::initial_tcb_version, + )?; + app_info.instance_info.snp_sealing = Some(parameters); + + // The share is read-only in the guest. Use the existing per-VM, + // acknowledged persistence operation, and stop if it cannot persist. + // No key material is sent to the host; only public derivation metadata. + self.shared + .sys_config + .host_api_url + .as_ref() + .context("snp sealing requires host API persistence for instance metadata")?; + self.vmm + .notify( + "instance.info", + &serde_json::to_string(&app_info.instance_info)?, + ) + .await + .context("failed to persist SNP sealing parameters before disk unlock")?; + sev_snp_attest::sealing::derive_sealing_seed(parameters.tcb_version) + .context("failed to derive SNP sealing seed") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_initial_tcb_on_later_boots() { + let stored = SnpSealingParameters { + version: 1, + tcb_version: 0x0101, + }; + let actual = + parameters_for_boot(Some(stored), true, || panic!("must not select a new TCB")); + assert_eq!(actual.unwrap(), stored); + } + + #[test] + fn initializes_only_a_new_disk() { + let parameters = parameters_for_boot(None, false, || Ok(0x0202)).unwrap(); + assert_eq!( + parameters, + SnpSealingParameters { + version: 1, + tcb_version: 0x0202 + } + ); + assert!( + parameters_for_boot(None, true, || panic!("must not replace missing metadata")) + .is_err() + ); + } + + #[test] + fn rejects_unknown_policy_and_derivation_errors() { + let stored = SnpSealingParameters { + version: 2, + tcb_version: 1, + }; + assert!(parameters_for_boot(Some(stored), true, || panic!( + "must not replace unknown policy" + )) + .is_err()); + assert!(parameters_for_boot(None, false, || bail!("unsupported platform")).is_err()); + } +} diff --git a/dstack/scripts/bin/dstack-cloud b/dstack/scripts/bin/dstack-cloud index b481f8fc9..c15e61ee0 100755 --- a/dstack/scripts/bin/dstack-cloud +++ b/dstack/scripts/bin/dstack-cloud @@ -3397,7 +3397,7 @@ Examples: new_parser.add_argument("--gateway-enabled", "--gw", dest="gateway_enabled", action="store_true", help="Enable dstack-gateway") new_parser.add_argument("--no-gateway-enabled", "--no-gw", dest="gateway_enabled", action="store_false") new_parser.set_defaults(gateway_enabled=None) - new_parser.add_argument("--key-provider", "--kp", type=str, choices=["kms", "local", "tpm", "none"], help="Key provider type") + new_parser.add_argument("--key-provider", "--kp", type=str, choices=["kms", "local", "tpm", "none", "sev-snp"], help="Key provider type") new_parser.add_argument("--storage-fs", "--fs", dest="storage_fs", type=str, choices=["ext4", "zfs"], help="Storage filesystem") new_parser.add_argument("--secure-time", action="store_true", help="Enable secure time synchronization") new_parser.add_argument("--no-secure-time", dest="secure_time", action="store_false") diff --git a/dstack/sev-snp-attest/Cargo.toml b/dstack/sev-snp-attest/Cargo.toml index 9b2dc9f1b..45cdc24c0 100644 --- a/dstack/sev-snp-attest/Cargo.toml +++ b/dstack/sev-snp-attest/Cargo.toml @@ -16,3 +16,5 @@ fs-err.workspace = true hex.workspace = true sev.workspace = true tracing.workspace = true +libc.workspace = true +zeroize.workspace = true diff --git a/dstack/sev-snp-attest/src/lib.rs b/dstack/sev-snp-attest/src/lib.rs index 1226070b4..a95363cdb 100644 --- a/dstack/sev-snp-attest/src/lib.rs +++ b/dstack/sev-snp-attest/src/lib.rs @@ -4,6 +4,8 @@ //! Minimal AMD SEV-SNP guest report support. +pub mod sealing; + use std::path::Path; use anyhow::{bail, Context, Result}; diff --git a/dstack/sev-snp-attest/src/sealing.rs b/dstack/sev-snp-attest/src/sealing.rs new file mode 100644 index 000000000..74d589dd1 --- /dev/null +++ b/dstack/sev-snp-attest/src/sealing.rs @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Local sealing with an explicit legacy VCEK and a fixed VM identity policy. +//! +//! Linux's `root_key_select` contains firmware ROOT_KEY_SELECT in bit 0 and +//! KEY_SEL in bits 2:1. The pinned sev crate's Boolean constructor cannot +//! express KEY_SEL=1, so use the Linux UAPI directly. There is no alternate root. + +use std::{fs::OpenOptions, os::fd::AsRawFd}; + +use anyhow::{bail, Context, Result}; +use sev::firmware::guest::Firmware; +use zeroize::{Zeroize, Zeroizing}; + +use crate::{SEV_GUEST_DEVICE, SNP_REPORT_SIZE}; + +const EXPLICIT_VCEK: u32 = 2; +const GUEST_FIELDS: u64 = 0x29; // guest policy, launch measurement, selected TCB + +#[repr(C)] +#[derive(Default)] +struct GuestRequest { + msg_version: u8, + reserved: [u8; 7], + req_data: u64, + resp_data: u64, + exitinfo2: u64, +} + +#[repr(C)] +struct KeyRequest { + root_key_select: u32, + reserved: u32, + guest_field_select: u64, + vmpl: u32, + guest_svn: u32, + tcb_version: u64, +} + +impl KeyRequest { + fn new(tcb_version: u64) -> Self { + Self { + root_key_select: EXPLICIT_VCEK, + reserved: 0, + guest_field_select: GUEST_FIELDS, + vmpl: 0, + guest_svn: 0, + tcb_version, + } + } +} + +#[repr(C)] +#[derive(Default)] +struct KeyResponse { + status: u32, + reserved: [u8; 28], + key: [u8; 32], +} + +impl Drop for KeyResponse { + fn drop(&mut self) { + self.key.zeroize(); + } +} + +const SNP_GET_DERIVED_KEY: libc::Ioctl = libc::_IOWR::(b'S' as u32, 1); + +fn tcb_not_newer(requested: u64, available: u64) -> bool { + requested + .to_le_bytes() + .iter() + .zip(available.to_le_bytes()) + .all(|(requested, available)| *requested <= available) +} + +fn report_tcbs(report: &[u8]) -> Result<(u64, u64)> { + if report.len() != SNP_REPORT_SIZE { + bail!("invalid SNP sealing report length"); + } + let read_u64 = |offset| { + let mut bytes = [0; 8]; + bytes.copy_from_slice(&report[offset..offset + 8]); + u64::from_le_bytes(bytes) + }; + let version = u32::from_le_bytes([report[0], report[1], report[2], report[3]]); + if !(2..=6).contains(&version) { + bail!("unsupported SNP sealing report version: {version}"); + } + if report[0x30..0x34] != [0; 4] { + bail!("snp sealing requires VMPL0"); + } + if read_u64(8) & (1 << 19) != 0 { + bail!("snp sealing requires a guest with debugging disabled"); + } + Ok((read_u64(0x1e0), read_u64(0x1f0))) +} + +fn get_tcbs() -> Result<(u64, u64)> { + let mut firmware = Firmware::open().context("failed to open SNP guest device")?; + let report = firmware + .get_report(Some(1), Some([0; 64]), Some(0)) + .map_err(|err| anyhow::anyhow!("failed to get SNP sealing report: {err}"))?; + report_tcbs(&report) +} + +/// Choose a supported initial TCB. Persist it before using the sealing seed; +/// subsequent boots must reuse it, including after firmware updates. +pub fn initial_tcb_version() -> Result { + let (committed, launch) = get_tcbs()?; + if !tcb_not_newer(committed, launch) { + bail!("committed SNP TCB exceeds launch TCB"); + } + Ok(committed) +} + +fn check_status(exitinfo2: u64, status: u32) -> Result<()> { + if exitinfo2 != 0 || status != 0 { + bail!( + "snp key derivation failed: firmware={:#x}, VMM={:#x}, response={status:#x}", + exitinfo2 as u32, + exitinfo2 >> 32, + ); + } + Ok(()) +} + +/// Derive a sealing seed bound to policy, launch measurement, HOST_DATA, +/// launch identity, VMPL0, and the persisted TCB. Never logs the seed. +pub fn derive_sealing_seed(tcb_version: u64) -> Result> { + let (_, launch) = get_tcbs()?; + if !tcb_not_newer(tcb_version, launch) { + bail!("stored SNP sealing TCB exceeds launch TCB"); + } + let device = OpenOptions::new() + .read(true) + .write(true) + .open(SEV_GUEST_DEVICE) + .context("failed to open SNP guest device for derivation")?; + let request = KeyRequest::new(tcb_version); + let mut response = KeyResponse::default(); + let mut io = GuestRequest { + msg_version: 1, + req_data: (&request as *const KeyRequest) as u64, + resp_data: (&mut response as *mut KeyResponse) as u64, + ..Default::default() + }; + // SAFETY: the descriptor is /dev/sev-guest; all buffers use the Linux UAPI + // layout, remain alive for the ioctl, and have the required size/alignment. + let rc = unsafe { libc::ioctl(device.as_raw_fd(), SNP_GET_DERIVED_KEY, &mut io) }; + if rc != 0 { + return Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "snp key derivation ioctl failed (exitinfo2={:#x})", + io.exitinfo2 + ) + }); + } + check_status(io.exitinfo2, response.status)?; + Ok(Zeroizing::new(response.key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::{offset_of, size_of}; + + #[test] + fn linux_abi_and_sealing_policy() { + assert_eq!(size_of::(), 32); + assert_eq!(offset_of!(GuestRequest, req_data), 8); + assert_eq!(offset_of!(GuestRequest, resp_data), 16); + assert_eq!(offset_of!(GuestRequest, exitinfo2), 24); + assert_eq!(size_of::(), 32); + assert_eq!(offset_of!(KeyRequest, tcb_version), 24); + assert_eq!(size_of::(), 64); + assert_eq!(offset_of!(KeyResponse, key), 32); + assert_eq!(SNP_GET_DERIVED_KEY, 0xc0205301); + let request = KeyRequest::new(0x1234); + assert_eq!(request.root_key_select & 1, 0); + assert_eq!(request.root_key_select >> 1, 1); + assert_eq!(request.guest_field_select, (1 << 0) | (1 << 3) | (1 << 5)); + assert_eq!(request.vmpl, 0); + assert_eq!(request.guest_svn, 0); + } + + #[test] + fn rejects_firmware_vmm_and_response_failures() { + assert!(check_status(0, 0).is_ok()); + for (exitinfo2, status) in [(0x16, 0), (1 << 32, 0), (0, 0x27)] { + assert!(check_status(exitinfo2, status).is_err()); + } + } + + #[test] + fn tcb_comparison_is_componentwise() { + assert!(tcb_not_newer(0x0101, 0x0202)); + assert!(tcb_not_newer(0x0202, 0x0202)); + assert!(!tcb_not_newer(0x0103, 0x0202)); + assert!(!tcb_not_newer(0x0301, 0x0202)); + } + + #[test] + fn rejects_invalid_report_and_debug_guest() { + let mut report = vec![0; SNP_REPORT_SIZE]; + assert!(report_tcbs(&report).is_err()); + report[0] = 3; + report[0x1e0..0x1e8].copy_from_slice(&1u64.to_le_bytes()); + report[0x1f0..0x1f8].copy_from_slice(&2u64.to_le_bytes()); + assert_eq!(report_tcbs(&report).unwrap(), (1, 2)); + report[0x30] = 1; + assert!(report_tcbs(&report).is_err()); + report[0x30] = 0; + report[8..16].copy_from_slice(&(1u64 << 19).to_le_bytes()); + assert!(report_tcbs(&report).is_err()); + assert!(report_tcbs(&report[..100]).is_err()); + } +} diff --git a/dstack/vmm/src/app/mr_config.rs b/dstack/vmm/src/app/mr_config.rs index 926bcc675..c8b08170b 100644 --- a/dstack/vmm/src/app/mr_config.rs +++ b/dstack/vmm/src/app/mr_config.rs @@ -199,6 +199,32 @@ mod tests { use super::*; use dstack_types::KeyProviderKind; + #[test] + fn relaunch_preserves_snp_sealing_parameters() { + let temp = tempfile::tempdir().unwrap(); + let workdir = VmWorkDir::new(temp.path()); + fs::create_dir_all(workdir.shared_dir()).unwrap(); + let mut compose = app_compose(serde_json::json!("3")); + compose.key_provider = Some(KeyProviderKind::SevSnp); + fs::write( + workdir.app_compose_path(), + serde_json::to_vec(&compose).unwrap(), + ) + .unwrap(); + let original = serde_json::json!({ + "instance_id_seed": "11".repeat(20), + "instance_id": "22".repeat(20), + "app_id": "33".repeat(20), + "snp_sealing": {"version": 1, "tcb_version": "257"} + }); + fs::write(workdir.instance_info_path(), original.to_string()).unwrap(); + workdir.prepare_mr_config_v3(&compose, false).unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(workdir.instance_info_path()).unwrap()).unwrap(); + assert_eq!(saved["snp_sealing"], original["snp_sealing"]); + assert_eq!(saved["instance_id_seed"], original["instance_id_seed"]); + } + fn app_compose(manifest_version: serde_json::Value) -> AppCompose { serde_json::from_value(serde_json::json!({ "manifest_version": manifest_version, diff --git a/dstack/vmm/src/app/workdir.rs b/dstack/vmm/src/app/workdir.rs index 06ed7c139..c002bfc6a 100644 --- a/dstack/vmm/src/app/workdir.rs +++ b/dstack/vmm/src/app/workdir.rs @@ -16,19 +16,10 @@ use dstack_types::{ }; use fs_err as fs; use serde::{Deserialize, Serialize}; -use serde_human_bytes as hex_bytes; use crate::{app::Manifest, config::Networking}; -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct InstanceInfo { - #[serde(default, with = "hex_bytes")] - pub instance_id_seed: Vec, - #[serde(default, with = "hex_bytes")] - pub instance_id: Vec, - #[serde(default, with = "hex_bytes")] - pub app_id: Vec, -} +pub use dstack_types::InstanceInfo; #[derive(Deserialize, Serialize)] struct State { diff --git a/dstack/vmm/src/main_service.rs b/dstack/vmm/src/main_service.rs index df581f98e..c7b4e956f 100644 --- a/dstack/vmm/src/main_service.rs +++ b/dstack/vmm/src/main_service.rs @@ -359,6 +359,13 @@ pub fn create_manifest_from_vm_config( bail!("tee simulator credentials are not configured on this VMM"); } let key_provider = key_provider_from_compose(&request.compose_file)?; + if key_provider == dstack_types::KeyProviderKind::SevSnp + && (cvm_config.resolved_platform() != crate::config::CvmPlatform::AmdSevSnp + || simulated_tee.is_some() + || request.no_tee) + { + bail!("the SEV-SNP key provider requires a real AMD SEV-SNP platform"); + } let swtpm = needs_swtpm(key_provider, simulated_tee); let disk_prealloc = disk_prealloc_from_vm_config(&request, cvm_config)?; validate_disk_prealloc_against_compose(disk_prealloc, &request.compose_file)?; @@ -1822,6 +1829,35 @@ mod tests { } } + #[test] + fn snp_provider_requires_real_snp_hardware() { + let mut request = test_vm_configuration(); + request.compose_file = serde_json::json!({ "key_provider": "sev-snp" }).to_string(); + let mut config = test_cvm_config(); + config.platform = Some(crate::config::CvmPlatform::Tdx); + assert!(create_manifest_from_vm_config(request.clone(), &config) + .unwrap_err() + .to_string() + .contains("requires a real AMD SEV-SNP platform")); + config.platform = Some(crate::config::CvmPlatform::AmdSevSnp); + assert!(create_manifest_from_vm_config(request.clone(), &config).is_ok()); + request.no_tee = true; + assert!(create_manifest_from_vm_config(request.clone(), &config) + .unwrap_err() + .to_string() + .contains("requires a real AMD SEV-SNP platform")); + request.no_tee = false; + config.tee_simulator = Some(dstack_types::TeeSimulatorConfig { + mock_attestation_seed: Some("11".repeat(32)), + ..Default::default() + }); + request.simulated_tee = Some("dstack-amd-sev-snp".into()); + assert!(create_manifest_from_vm_config(request, &config) + .unwrap_err() + .to_string() + .contains("requires a real AMD SEV-SNP platform")); + } + #[test] fn simulated_tee_requires_node_credentials() { let mut request = test_vm_configuration(); diff --git a/dstack/vmm/src/vmm-cli.py b/dstack/vmm/src/vmm-cli.py index 31e48c23b..bfe06da15 100755 --- a/dstack/vmm/src/vmm-cli.py +++ b/dstack/vmm/src/vmm-cli.py @@ -2087,7 +2087,7 @@ def _patched_format_help(): ) compose_parser.add_argument( "--key-provider", - choices=["none", "kms", "local", "tpm"], + choices=["none", "kms", "local", "tpm", "sev-snp"], default=None, help="Override key provider type (none, kms, local, or tpm)", ) diff --git a/dstack/vmm/ui/src/components/CreateVmDialog.ts b/dstack/vmm/ui/src/components/CreateVmDialog.ts index 8f1ac9163..a12eb80cd 100644 --- a/dstack/vmm/ui/src/components/CreateVmDialog.ts +++ b/dstack/vmm/ui/src/components/CreateVmDialog.ts @@ -216,7 +216,8 @@ const CreateVmDialogComponent = { diff --git a/dstack/vmm/ui/src/composables/useVmManager.ts b/dstack/vmm/ui/src/composables/useVmManager.ts index bb4178a12..d19d183c1 100644 --- a/dstack/vmm/ui/src/composables/useVmManager.ts +++ b/dstack/vmm/ui/src/composables/useVmManager.ts @@ -78,7 +78,7 @@ function compatibleInitScripts(initScripts: string[]): string | string[] | undef return scripts.length === 0 ? undefined : scripts.length === 1 ? scripts[0] : scripts; } -type KeyProviderKind = 'none' | 'kms' | 'local' | 'tpm'; +type KeyProviderKind = 'none' | 'kms' | 'local' | 'tpm' | 'sev-snp'; type RequirementPlatform = | 'dstack-tdx' | 'dstack-gcp-tdx' diff --git a/sdk/js/src/get-compose-hash.ts b/sdk/js/src/get-compose-hash.ts index 4148433ba..938022988 100644 --- a/sdk/js/src/get-compose-hash.ts +++ b/sdk/js/src/get-compose-hash.ts @@ -35,7 +35,7 @@ function sortObject(obj: SortableValue): SortableValue { return obj; } -export type KeyProviderKind = "none" | "kms" | "local" | "tpm"; +export type KeyProviderKind = "none" | "kms" | "local" | "tpm" | "sev-snp"; export type RequirementPlatform = | "dstack-tdx" | "dstack-gcp-tdx" diff --git a/sdk/python/src/dstack_sdk/get_compose_hash.py b/sdk/python/src/dstack_sdk/get_compose_hash.py index 2a33e1776..5520205ba 100644 --- a/sdk/python/src/dstack_sdk/get_compose_hash.py +++ b/sdk/python/src/dstack_sdk/get_compose_hash.py @@ -17,7 +17,7 @@ from typing import Optional from typing import Union -KeyProviderKind = Literal["none", "kms", "local", "tpm"] +KeyProviderKind = Literal["none", "kms", "local", "tpm", "sev-snp"] class DockerConfig: