Skip to content

Commit 08e4440

Browse files
authored
feat: add native SEV-SNP hardware key provider (#1526)
AMD hosts cannot use dstack's SGX local key provider. Add `key_provider: sev-snp` to derive a hardware-bound seed inside the SNP guest and reuse the existing app-key and disk-unlock flow, so a KMS CVM can recover its encrypted state and stored root keys after restart. Signed-off-by: Hang Yin <h4x3rotab@gmail.com>
1 parent 7a83895 commit 08e4440

26 files changed

Lines changed: 781 additions & 115 deletions

File tree

‎docs/amd-sev-snp.md‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,55 @@ preflight does not replace firmware, kernel, QEMU, or OVMF validation.
5050

5151
## Install dstack on an SNP host
5252

53-
Pull a current unified image and select the platform explicitly:
53+
Use a release containing the `sev-snp` key provider in the guest OS and VMM. The
54+
KMS container and client attestation verifiers must recognize the new provider
55+
metadata. Set `VERSION` to the OS release version and `KMS_IMAGE` to the KMS
56+
container reference, then install:
5457

5558
```bash
56-
VERSION=0.6.0
5759
sudo dstackup image pull --version "$VERSION"
58-
sudo dstackup install --platform amd-sev-snp --image "dstack-$VERSION"
60+
sudo dstackup install --platform amd-sev-snp \
61+
--image "dstack-$VERSION" --kms-image "$KMS_IMAGE"
5962
sudo dstackup status
6063
```
6164

62-
The default `--platform auto` mode also selects SEV-SNP when the host CPU flags
63-
advertise `sev_snp`. Explicit selection is preferable while commissioning a
64-
host because it fails immediately when the required SNP device is absent.
65+
Confirm that installation reports `booted` for the KMS CVM. `dstackup` selects
66+
the `sev-snp` provider for the KMS CVM on AMD hosts. The default
67+
`--platform auto` also detects SEV-SNP from the host CPU flags. Explicit
68+
selection checks for the required SNP device before installation. Review the
69+
[KMS key-release policy](#kms-key-release-policy) before serving applications.
6570

66-
Unlike the TDX path, SEV-SNP does not use the local SGX key provider.
71+
## Run KMS with persistent local keys
72+
73+
The `sev-snp` provider lets a KMS CVM recover its encrypted disk and stored KMS
74+
roots after restart on the same AMD hardware. For a manually managed KMS CVM,
75+
set this field in its app-compose document:
76+
77+
```json
78+
{"key_provider": "sev-snp"}
79+
```
80+
81+
The deployment CLI also accepts `dstack deploy --key-provider sev-snp`. Application
82+
CVMs continue using `key_provider: kms` and the existing KMS API.
83+
84+
The guest derives a seed through `SNP_GET_DERIVED_KEY` using legacy VCEK at
85+
VMPL0. The seed binds to launch measurement, guest policy, HOST_DATA, launch
86+
identity, and a pinned TCB. The guest requires debugging disabled and encrypted
87+
storage, and verifies the configuration binding before deriving the seed.
88+
89+
Before initializing a new disk, the guest persists public derivation parameters
90+
in `.instance_info` through the host API. Preserve this file, the encrypted
91+
disk, and the original launch configuration together. Missing parameters for
92+
an existing disk, unknown versions, or derivation/unlock failures stop startup.
93+
94+
Recovery requires the original hardware and bound launch inputs. For OS or
95+
compose changes, or hardware replacement, preserve KMS roots through the
96+
existing onboarding/replication flow. Firmware updates must support the stored
97+
derivation TCB and satisfy attestation policy.
98+
99+
The entire KMS CVM, including its OS, is trusted. Protect KMS root and sealing
100+
keys by keeping the KMS OS and workload under operator control. Application
101+
key release continues to follow the existing KMS authorization policy.
67102

68103
## Attestation and image identity
69104

‎dstack/Cargo.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dstack/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ schnorrkel = "0.11.4"
253253
sha2 = { version = "0.10.8", default-features = false }
254254
sha3 = "0.10.8"
255255
subtle = "2"
256+
zeroize = "1.8"
256257
blake2 = "0.10.6"
257258
tokio-rustls = { version = "0.26.2", features = ["ring"] }
258259
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }

‎dstack/cert-client/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ impl CertRequestClient {
213213
match &keys.key_provider {
214214
KeyProvider::None { key }
215215
| KeyProvider::Local { key, .. }
216+
| KeyProvider::SevSnp { key }
216217
| KeyProvider::Tpm { key, .. } => {
217218
let ca = CaCert::new(keys.ca_cert.clone(), key.clone())
218219
.context("Failed to create CA")?;

‎dstack/crates/dstack-cli-core/src/compose.rs‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! build the app-compose manifest — the JSON document the VMM hashes (to derive
66
//! the app id) and deploys. The raw docker-compose YAML is embedded as a string.
77
8+
use dstack_types::KeyProviderKind;
89
use serde_json::json;
910

1011
/// build a minimal app-compose manifest from a docker-compose YAML body
@@ -34,7 +35,11 @@ pub fn build_app_compose_with_runtime(
3435
build_app_compose_with_runtime_and_volumes(
3536
name,
3637
docker_compose_yaml,
37-
kms_enabled,
38+
if kms_enabled {
39+
KeyProviderKind::Kms
40+
} else {
41+
KeyProviderKind::None
42+
},
3843
runner,
3944
snapshotter,
4045
&[],
@@ -45,7 +50,7 @@ pub fn build_app_compose_with_runtime(
4550
pub fn build_app_compose_with_runtime_and_volumes(
4651
name: &str,
4752
docker_compose_yaml: &str,
48-
kms_enabled: bool,
53+
key_provider: KeyProviderKind,
4954
runner: &str,
5055
snapshotter: Option<&str>,
5156
verity_volumes: &[dstack_types::VerityVolume],
@@ -55,7 +60,7 @@ pub fn build_app_compose_with_runtime_and_volumes(
5560
"name": name,
5661
"runner": runner,
5762
"docker_compose_file": docker_compose_yaml,
58-
"kms_enabled": kms_enabled,
63+
"kms_enabled": key_provider.is_kms(),
5964
"gateway_enabled": false,
6065
"local_key_provider_enabled": false,
6166
"public_logs": true,
@@ -74,7 +79,48 @@ pub fn build_app_compose_with_runtime_and_volumes(
7479
if !verity_volumes.is_empty() {
7580
manifest["verity_volumes"] = json!(verity_volumes);
7681
}
82+
// Preserve legacy KMS/None manifest bytes, which determine app identity.
83+
if !matches!(key_provider, KeyProviderKind::Kms | KeyProviderKind::None) {
84+
manifest["key_provider"] = json!(key_provider);
85+
}
7786
// pretty-print via Value's Display (`{:#}`) — infallible, and byte-identical
7887
// to serde_json::to_string_pretty (avoids an expect on an unfailable Result).
7988
format!("{manifest:#}")
8089
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
95+
#[test]
96+
fn typed_provider_matches_previous_cli_manifest_encoding() {
97+
let volumes = [dstack_types::VerityVolume {
98+
source: "test.img".into(),
99+
verity_root: [1; 32],
100+
target: "/test".into(),
101+
}];
102+
for volumes in [&[][..], &volumes[..]] {
103+
let build = |provider| {
104+
build_app_compose_with_runtime_and_volumes(
105+
"test",
106+
"",
107+
provider,
108+
"nerdctl-compose",
109+
Some("soci"),
110+
volumes,
111+
)
112+
};
113+
for provider in [
114+
KeyProviderKind::SevSnp,
115+
KeyProviderKind::Local,
116+
KeyProviderKind::Tpm,
117+
] {
118+
// The old CLI serialized a legacy manifest, then inserted the provider.
119+
let mut legacy: serde_json::Value =
120+
serde_json::from_str(&build(KeyProviderKind::None)).unwrap();
121+
legacy["key_provider"] = json!(provider);
122+
assert_eq!(build(provider), format!("{legacy:#}"));
123+
}
124+
}
125+
}
126+
}

‎dstack/crates/dstack-cli-core/src/config.rs‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,8 @@ pub fn auth_allowlist_json(cfg: &HostConfig) -> String {
210210
/// compose hash.
211211
pub const DEFAULT_KMS_IMAGE: &str = "dstacktee/dstack-kms:0.5.11@sha256:84b793feed825a5b5e70d04386e931e0e110461492793f17ab2128e39808d989";
212212

213-
/// build the KMS-in-CVM app-compose manifest. An init script writes the
214-
/// rendered `kms.toml` into the guest and the KMS container mounts it. On TDX
215-
/// the CVM uses the SGX local key provider to seal the KMS root key; AMD
216-
/// SEV-SNP has no such provider, so it's disabled there.
213+
/// Build the KMS app-compose manifest with an embedded `kms.toml` init script.
214+
/// Select SGX local provisioning on TDX and native SNP provisioning on AMD.
217215
pub fn kms_app_compose(kms_toml: &str, kms_image: &str, platform: Platform) -> String {
218216
let docker_compose = format!(
219217
r#"services:
@@ -234,7 +232,7 @@ volumes:
234232
let init_script = format!(
235233
"mkdir -p /dstack/kms-config\ncat > /dstack/kms-config/kms.toml <<'KMSTOML'\n{kms_toml}\nKMSTOML\ntrue\n"
236234
);
237-
let manifest = json!({
235+
let mut manifest = json!({
238236
"manifest_version": 2,
239237
"name": "dstack-kms",
240238
"runner": "docker-compose",
@@ -250,6 +248,9 @@ volumes:
250248
"secure_time": false,
251249
"allowed_envs": []
252250
});
251+
if platform == Platform::AmdSevSnp {
252+
manifest["key_provider"] = json!("sev-snp");
253+
}
253254
// infallible pretty-print via Value's Display; see compose::build_app_compose.
254255
format!("{manifest:#}")
255256
}
@@ -419,7 +420,7 @@ address = "vsock:2"
419420
port = {host_api_port}
420421
421422
[key_provider]
422-
enabled = true
423+
enabled = {use_sgx_key_provider}
423424
address = "{kp_addr}"
424425
port = {kp_port}
425426
"#,
@@ -448,6 +449,7 @@ port = {kp_port}
448449
host_api_port = r.host_api_port,
449450
kp_addr = r.key_provider_addr,
450451
kp_port = r.key_provider_port,
452+
use_sgx_key_provider = r.platform == Platform::Tdx,
451453
auth_enabled = r.auth_enabled,
452454
auth_tokens = if r.auth_token.is_empty() {
453455
String::new()
@@ -525,11 +527,12 @@ mod tests {
525527
assert!(tdx_vmm.contains(r#"platform = "tdx""#));
526528
assert!(tdx_vmm.contains(r#"host_share_mode = "9p""#));
527529
assert!(tdx_vmm.contains("use_mrconfigid = false"));
530+
assert!(tdx_vmm.contains("[key_provider]\nenabled = true"));
528531
toml::from_str::<toml::Value>(&tdx_vmm).expect("tdx vmm.toml valid");
529532
assert!(kms_app_compose("x", "img", Platform::Tdx)
530533
.contains(r#""local_key_provider_enabled": true"#));
531534

532-
// SNP: key-release gate set, vhd share, mrconfigid on, no local provider.
535+
// SNP: key-release gate set, vhd share, mrconfigid on, native sealing.
533536
let snp = kms_toml(&HostConfig {
534537
platform: Platform::AmdSevSnp,
535538
..Default::default()
@@ -543,9 +546,14 @@ mod tests {
543546
assert!(snp_vmm.contains(r#"platform = "amd-sev-snp""#));
544547
assert!(snp_vmm.contains(r#"host_share_mode = "vhd""#));
545548
assert!(snp_vmm.contains("use_mrconfigid = true"));
549+
assert!(snp_vmm.contains("[key_provider]\nenabled = false"));
546550
toml::from_str::<toml::Value>(&snp_vmm).expect("snp vmm.toml valid");
547551
assert!(kms_app_compose("x", "img", Platform::AmdSevSnp)
548552
.contains(r#""local_key_provider_enabled": false"#));
553+
let snp_compose: serde_json::Value =
554+
serde_json::from_str(&kms_app_compose("x", "img", Platform::AmdSevSnp)).unwrap();
555+
assert_eq!(snp_compose["key_provider"], "sev-snp");
556+
assert_eq!(snp_compose["kms_enabled"], false);
549557
}
550558

551559
#[test]

0 commit comments

Comments
 (0)