Skip to content

Commit 5c1daaa

Browse files
committed
feat(vmm): add swtpm key provider for no-TEE dev VMs
1 parent 68332fa commit 5c1daaa

7 files changed

Lines changed: 208 additions & 50 deletions

File tree

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ dstack deploy ./docker-compose.yml \
6262
--no-tee
6363
```
6464

65+
To exercise persistent TPM-backed app keys as well, install `swtpm` on the VMM
66+
host and select `key_provider=tpm` in the VMM console (or pass `--key-provider
67+
tpm` in tooling that exposes the compose option). The VMM keeps the software
68+
TPM state in the VM work directory so that seal/unseal survives guest restarts.
69+
This development provider is available only to no-TEE development images.
70+
6571
The simulator package is installed only in development images. This mode
6672
provides no hardware isolation and must never be used with production
6773
workloads or secrets. Real quote generation, hardware isolation, and KMS

docs/security/cvm-boundaries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ This is the main configuration file for the application in JSON format:
4444
| init_script | 0.5.5 | string | Bash script that executed prior to dockerd startup |
4545
| storage_fs | 0.5.5 | string | Filesystem type for the data disk of the CVM. Supported values: "zfs", "ext4". default to "zfs". **ZFS:** Ensures filesystem integrity with built-in data protection features. **ext4:** Provides better performance for database applications with lower overhead and faster I/O operations, but no strong integrity protection. |
4646
| swap_size | 0.5.5 | string/integer | The linux swap size. default to 0. Can be in byte or human-readable format (e.g., "1G", "256M"). |
47-
| key_provider | 0.5.6 | string | Key provider type. Supported values: "none", "kms", "local", "tpm". `"tpm"` is only supported on platforms whose TPM is part of the platform trust model (GCP vTPM, AWS EC2 NitroTPM); on other platforms guest setup fails closed, since sealing to a host-provided software TPM (e.g. swtpm under bare QEMU) offers no protection against the host. |
47+
| key_provider | 0.5.6 | string | Key provider type. Supported values: "none", "kms", "local", "tpm". GCP vTPM and AWS EC2 NitroTPM are part of their platform trust models. The Dstack development platform can use VMM-managed swtpm to exercise seal/unseal and restart persistence, but it offers no protection against the host and is intentionally not accepted by remote verifiers. |
4848

4949

5050
The hash of this file content is extended as the dstack `compose-hash` launch event. On TDX-family platforms the launch event is measured into RTMR3. On AWS NitroTPM it is measured into non-resettable SHA384 PCR14 before the `system-ready` launch boundary. Remote verifiers extract and replay this event during attestation.

dstack/tpm-attest/src/lib.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const AWS_NITRO_PCRS: [u32; 5] = [4, 7, 8, 12, 14];
4848

4949
pub fn dstack_pcr_policy_for_platform(platform: Platform) -> Result<PcrSelection> {
5050
match platform {
51-
Platform::Gcp => Ok(dstack_pcr_policy()),
51+
Platform::Dstack | Platform::Gcp => Ok(dstack_pcr_policy()),
5252
Platform::AwsEc2 => Ok(PcrSelection::sha384(&AWS_NITRO_PCRS)),
5353
_ => bail!("TPM local key provider is not supported on {platform:?}"),
5454
}
@@ -367,6 +367,16 @@ mod tests {
367367
let policy = dstack_pcr_policy_for_platform(Platform::AwsEc2).unwrap();
368368
assert_eq!(policy.to_arg(), "sha384:4,7,8,12,14");
369369
}
370+
371+
#[test]
372+
fn dstack_platform_uses_development_tpm_policy() {
373+
assert_eq!(
374+
dstack_pcr_policy_for_platform(Platform::Dstack)
375+
.unwrap()
376+
.to_arg(),
377+
"sha256:0,2,14"
378+
);
379+
}
370380
}
371381

372382
mod gcp_ak;

dstack/vmm/src/app.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ impl App {
399399
}
400400
for process in processes {
401401
if let Err(err) = self.supervisor.deploy(&process).await {
402+
self.stop_dependent_processes(id).await;
402403
if let Err(clear_err) = work_dir.clear_runtime_networks() {
403404
warn!(
404405
id,
@@ -430,8 +431,24 @@ impl App {
430431
pub async fn stop_vm(&self, id: &str) -> Result<()> {
431432
self.set_started(id, false)?;
432433
self.cleanup_port_forward(id).await;
433-
self.supervisor.stop(id).await?;
434-
Ok(())
434+
let result = self.supervisor.stop(id).await;
435+
self.stop_dependent_processes(id).await;
436+
result
437+
}
438+
439+
async fn stop_dependent_processes(&self, id: &str) {
440+
for process in self.supervisor.list().await.unwrap_or_default() {
441+
let note =
442+
serde_json::from_str::<ProcessAnnotation>(&process.config.note).unwrap_or_default();
443+
if note.live_for.as_deref() == Some(id) {
444+
if let Err(err) = self.supervisor.stop(&process.config.id).await {
445+
debug!(
446+
process_id = process.config.id,
447+
"failed to stop dependent process: {err:?}"
448+
);
449+
}
450+
}
451+
}
435452
}
436453

437454
pub async fn remove_vm(&self, id: &str) -> Result<()> {
@@ -475,6 +492,7 @@ impl App {
475492
if let Err(err) = self.supervisor.stop(id).await {
476493
debug!("supervisor.stop({id}) during removal: {err:?}");
477494
}
495+
self.stop_dependent_processes(id).await;
478496

479497
// Poll until the process is no longer running, then remove it.
480498
// Some VMs take a long time to stop (e.g. 2+ hours), so we wait indefinitely.
@@ -509,6 +527,31 @@ impl App {
509527
}
510528
}
511529

530+
// Auxiliary processes (currently swtpm) are owned by the VM through
531+
// ProcessAnnotation::live_for. Remove their stopped supervisor entries
532+
// before deleting the VM workdir.
533+
for process in self.supervisor.list().await.unwrap_or_default() {
534+
let note =
535+
serde_json::from_str::<ProcessAnnotation>(&process.config.note).unwrap_or_default();
536+
if note.live_for.as_deref() != Some(id) {
537+
continue;
538+
}
539+
for _ in 0..50 {
540+
match self.supervisor.info(&process.config.id).await {
541+
Ok(Some(info)) if info.state.status.is_running() => {
542+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
543+
}
544+
_ => break,
545+
}
546+
}
547+
if let Err(err) = self.supervisor.remove(&process.config.id).await {
548+
warn!(
549+
process_id = process.config.id,
550+
"failed to remove dependent process: {err:?}"
551+
);
552+
}
553+
}
554+
512555
// Only delete the workdir for user-initiated removal or if .removing marker exists.
513556
// Orphaned supervisor processes without the marker keep their data intact.
514557
let vm_path = self.work_dir(id);
@@ -748,13 +791,14 @@ impl App {
748791

749792
// Clean up orphaned supervisor processes (in supervisor but not loaded as VMs)
750793
let loaded_vm_ids: HashSet<String> = self.lock().vms.keys().cloned().collect();
751-
for (_, process) in &running_vms {
752-
if !loaded_vm_ids.contains(&process.config.id) {
794+
for (note, process) in &running_vms {
795+
let owner = note.live_for.as_deref().unwrap_or(&process.config.id);
796+
if !loaded_vm_ids.contains(owner) {
753797
info!(
754798
"Cleaning up orphaned supervisor process: {}",
755799
process.config.id
756800
);
757-
self.spawn_finish_remove(&process.config.id);
801+
self.spawn_finish_remove(owner);
758802
}
759803
}
760804

dstack/vmm/src/app/qemu.rs

Lines changed: 112 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ struct PreparedQemuLaunch {
179179
hugepage_numa_nodes: Option<HashMap<String, u32>>,
180180
gpu_numa_nodes: HashMap<String, String>,
181181
numa_cpus: Option<String>,
182-
tpm_path: Option<&'static str>,
182+
swtpm_socket: Option<PathBuf>,
183+
swtpm_path: Option<PathBuf>,
183184
tdx_mr_config_id: Option<String>,
184185
snp_host_data: Option<String>,
185186
snp_launch_params: Option<AmdSevSnpLaunchParams>,
@@ -219,11 +220,23 @@ impl PreparedQemuLaunch {
219220
} else {
220221
None
221222
};
222-
let tpm_path = if matches!(app_compose.key_provider(), KeyProviderKind::Tpm) {
223-
Some(detect_tpm_device()?)
224-
} else {
225-
None
226-
};
223+
let (swtpm_socket, swtpm_path) =
224+
if matches!(app_compose.key_provider(), KeyProviderKind::Tpm) {
225+
if !vm.manifest.no_tee || !vm.image.info.is_dev {
226+
bail!("swtpm key provider is only available to no-TEE development VMs");
227+
}
228+
let swtpm_path = which::which("swtpm")
229+
.context("tpm key provider requested but swtpm is not installed")?;
230+
let state_dir = workdir.swtpm_state_dir();
231+
fs::create_dir_all(&state_dir).context("failed to create swtpm state directory")?;
232+
let socket = workdir.swtpm_socket();
233+
if socket.exists() {
234+
fs::remove_file(&socket).context("failed to remove stale swtpm socket")?;
235+
}
236+
(Some(socket), Some(swtpm_path))
237+
} else {
238+
(None, None)
239+
};
227240
prepare_shared_disk(&workdir, cfg)?;
228241

229242
let tee_enabled = !vm.manifest.no_tee;
@@ -257,7 +270,8 @@ impl PreparedQemuLaunch {
257270
hugepage_numa_nodes,
258271
gpu_numa_nodes,
259272
numa_cpus,
260-
tpm_path,
273+
swtpm_socket,
274+
swtpm_path,
261275
tdx_mr_config_id,
262276
snp_host_data,
263277
snp_launch_params,
@@ -301,16 +315,6 @@ fn prepare_shared_disk(workdir: &VmWorkDir, cfg: &CvmConfig) -> Result<()> {
301315
create_shared_disk(&shared_disk_path, shared_dir).context("failed to create shared disk")
302316
}
303317

304-
fn detect_tpm_device() -> Result<&'static str> {
305-
if Path::new("/dev/tpmrm0").exists() {
306-
Ok("/dev/tpmrm0")
307-
} else if Path::new("/dev/tpm0").exists() {
308-
Ok("/dev/tpm0")
309-
} else {
310-
bail!("tpm key provider requested but no TPM device found on host")
311-
}
312-
}
313-
314318
struct QemuCommandBuilder<'a> {
315319
vm: &'a VmConfig,
316320
cfg: &'a CvmConfig,
@@ -326,14 +330,76 @@ impl VmConfig {
326330
gpus: &GpuConfig,
327331
) -> Result<Vec<ProcessConfig>> {
328332
let prepared = PreparedQemuLaunch::prepare(self, workdir, cfg, gpus)?;
329-
let process = QemuCommandBuilder {
333+
let mut process = QemuCommandBuilder {
330334
vm: self,
331335
cfg,
332336
gpus,
333337
prepared: &prepared,
334338
}
335339
.build()?;
336-
Ok(vec![process])
340+
let Some(socket) = prepared.swtpm_socket.as_deref() else {
341+
return Ok(vec![process]);
342+
};
343+
let swtpm_path = prepared
344+
.swtpm_path
345+
.as_ref()
346+
.context("missing swtpm executable for configured socket")?;
347+
348+
// The supervisor starts process configs in order, but swtpm may need a
349+
// moment to create its socket. Make QEMU wait without shell-quoting any
350+
// of its arguments: they are passed as positional parameters.
351+
let original_command = std::mem::replace(&mut process.command, "/bin/sh".into());
352+
let original_args = std::mem::take(&mut process.args);
353+
process.args = vec![
354+
"-c".into(),
355+
"for i in $(seq 1 100); do [ -S \"$1\" ] && shift && exec \"$@\"; sleep 0.05; done; echo 'timed out waiting for swtpm socket' >&2; exit 1".into(),
356+
"dstack-qemu-wait-swtpm".into(),
357+
socket.to_string_lossy().into_owned(),
358+
original_command,
359+
];
360+
process.args.extend(original_args);
361+
362+
let note = serde_json::to_string(&ProcessAnnotation {
363+
kind: "swtpm".into(),
364+
live_for: Some(self.manifest.id.clone()),
365+
})?;
366+
let swtpm_args = vec![
367+
"socket".into(),
368+
"--tpm2".into(),
369+
"--tpmstate".into(),
370+
format!("dir={}", prepared.workdir.swtpm_state_dir().display()),
371+
"--ctrl".into(),
372+
format!("type=unixio,path={},mode=0666", socket.display()),
373+
"--flags".into(),
374+
"not-need-init,startup-clear".into(),
375+
];
376+
let swtpm = ProcessConfig {
377+
id: format!("{}-swtpm", self.manifest.id),
378+
name: format!("{} swtpm", self.manifest.name),
379+
command: swtpm_path.to_string_lossy().into_owned(),
380+
args: swtpm_args,
381+
env: Default::default(),
382+
cwd: prepared.workdir.path().to_string_lossy().into_owned(),
383+
stdout: prepared
384+
.workdir
385+
.stdout_file()
386+
.to_string_lossy()
387+
.into_owned(),
388+
stderr: prepared
389+
.workdir
390+
.stderr_file()
391+
.to_string_lossy()
392+
.into_owned(),
393+
pidfile: prepared
394+
.workdir
395+
.swtpm_state_dir()
396+
.join("supervisor.pid")
397+
.to_string_lossy()
398+
.into_owned(),
399+
cid: None,
400+
note,
401+
};
402+
Ok(vec![swtpm, process])
337403
}
338404
}
339405

@@ -518,10 +584,12 @@ impl QemuCommandBuilder<'_> {
518584
}
519585

520586
fn configure_tpm_and_vsock(&self, command: &mut Command) {
521-
if let Some(tpm_path) = self.prepared.tpm_path {
587+
if let Some(socket) = &self.prepared.swtpm_socket {
522588
command
589+
.arg("-chardev")
590+
.arg(format!("socket,id=chrtpm,path={}", socket.display()))
523591
.arg("-tpmdev")
524-
.arg(format!("passthrough,id=tpm0,path={tpm_path}"))
592+
.arg("emulator,id=tpm0,chardev=chrtpm")
525593
.arg("-device")
526594
.arg("tpm-tis,tpmdev=tpm0");
527595
}
@@ -970,14 +1038,15 @@ mod tests {
9701038
workdir: PathBuf::from("/does-not-exist/vm-1"),
9711039
gateway_enabled: false,
9721040
};
973-
let prepared = PreparedQemuLaunch {
1041+
let mut prepared = PreparedQemuLaunch {
9741042
workdir: VmWorkDir::new("/does-not-exist/vm-1"),
9751043
platform: TeePlatform::Tdx,
9761044
networks: vec![config.cvm.networking.clone(), config.cvm.networking.clone()],
9771045
hugepage_numa_nodes: None,
9781046
gpu_numa_nodes: HashMap::new(),
9791047
numa_cpus: None,
980-
tpm_path: None,
1048+
swtpm_socket: None,
1049+
swtpm_path: None,
9811050
tdx_mr_config_id: None,
9821051
snp_host_data: None,
9831052
snp_launch_params: None,
@@ -1024,5 +1093,25 @@ mod tests {
10241093
.args
10251094
.iter()
10261095
.any(|arg| arg.contains("virtio-net-pci,netdev=net1")));
1096+
1097+
prepared.swtpm_socket = Some(PathBuf::from("/does-not-exist/vm-1/swtpm/swtpm.sock"));
1098+
let process = QemuCommandBuilder {
1099+
vm: &vm,
1100+
cfg: &config.cvm,
1101+
gpus: &GpuConfig::default(),
1102+
prepared: &prepared,
1103+
}
1104+
.build()
1105+
.unwrap();
1106+
assert!(process.args.windows(2).any(|args| {
1107+
args == [
1108+
"-chardev",
1109+
"socket,id=chrtpm,path=/does-not-exist/vm-1/swtpm/swtpm.sock",
1110+
]
1111+
}));
1112+
assert!(process
1113+
.args
1114+
.windows(2)
1115+
.any(|args| args == ["-tpmdev", "emulator,id=tpm0,chardev=chrtpm"]));
10271116
}
10281117
}

dstack/vmm/src/app/workdir.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ impl VmWorkDir {
104104
self.workdir.join("shared")
105105
}
106106

107+
pub fn swtpm_state_dir(&self) -> PathBuf {
108+
self.workdir.join("swtpm")
109+
}
110+
111+
pub fn swtpm_socket(&self) -> PathBuf {
112+
self.swtpm_state_dir().join("swtpm.sock")
113+
}
114+
107115
pub fn app_compose_path(&self) -> PathBuf {
108116
self.shared_dir().join(APP_COMPOSE)
109117
}

0 commit comments

Comments
 (0)