Skip to content

Commit a327752

Browse files
authored
Merge pull request #798 from Dstack-TEE/feat/dev-swtpm
vmm: add per-VM swtpm key provider
2 parents 02aeb89 + ba45ea0 commit a327752

16 files changed

Lines changed: 527 additions & 2683 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+
The software TPM is host-controlled and does not provide hardware isolation.
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 platform can use VMM-managed swtpm for 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/Cargo.lock

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

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/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ rocket-vsock-listener = { workspace = true }
1515
tracing.workspace = true
1616
tracing-subscriber = { workspace = true, features = ["env-filter"] }
1717
anyhow.workspace = true
18+
libc.workspace = true
1819
serde = { workspace = true, features = ["derive"] }
1920
serde_json.workspace = true
2021
shared_child.workspace = true
@@ -63,6 +64,7 @@ tar.workspace = true
6364

6465
[dev-dependencies]
6566
insta.workspace = true
67+
tempfile.workspace = true
6668

6769
[build-dependencies]
6870
or-panic.workspace = true

dstack/vmm/src/app.rs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ pub(crate) mod registry;
4747
mod vm_info;
4848
mod workdir;
4949

50+
fn signal_pidfd(pid: u32, signal: libc::c_int) -> std::io::Result<()> {
51+
// SAFETY: pidfd syscalls receive scalar arguments and a null siginfo pointer.
52+
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
53+
if fd < 0 {
54+
return Err(std::io::Error::last_os_error());
55+
}
56+
let result = unsafe {
57+
libc::syscall(
58+
libc::SYS_pidfd_send_signal,
59+
fd,
60+
signal,
61+
std::ptr::null::<libc::siginfo_t>(),
62+
0,
63+
)
64+
};
65+
// SAFETY: fd was returned by pidfd_open and is owned by this function.
66+
unsafe { libc::close(fd as libc::c_int) };
67+
if result < 0 {
68+
Err(std::io::Error::last_os_error())
69+
} else {
70+
Ok(())
71+
}
72+
}
73+
5074
#[derive(Deserialize, Serialize, Debug, Clone)]
5175
pub struct PortMapping {
5276
pub address: IpAddr,
@@ -425,10 +449,44 @@ impl App {
425449

426450
pub async fn stop_vm(&self, id: &str) -> Result<()> {
427451
self.set_started(id, false)?;
428-
self.supervisor.stop(id).await?;
452+
self.stop_vm_process(id).await?;
429453
Ok(())
430454
}
431455

456+
async fn stop_vm_process(&self, id: &str) -> Result<()> {
457+
let Some(info) = self.supervisor.info(id).await? else {
458+
return Ok(());
459+
};
460+
// Non-TPM VMs run QEMU directly and keep the existing Supervisor stop
461+
// path. Only the TPM launcher's hidden subcommand implements graceful
462+
// child-process shutdown.
463+
if info.config.args.first().map(String::as_str) != Some("vm-launcher") {
464+
return self.supervisor.stop(id).await;
465+
}
466+
if info.state.status.is_running() {
467+
let pid = info.state.pid.context("running VM launcher has no PID")?;
468+
if let Err(error) = signal_pidfd(pid, libc::SIGTERM) {
469+
warn!(id, %pid, %error, "failed to signal VM launcher gracefully; forcing shutdown");
470+
return self.supervisor.stop(id).await;
471+
}
472+
for _ in 0..150 {
473+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
474+
let running = self
475+
.supervisor
476+
.info(id)
477+
.await?
478+
.is_some_and(|info| info.state.status.is_running());
479+
if !running {
480+
// Synchronize Supervisor's `started` flag after the launcher
481+
// completed its graceful child cleanup.
482+
return self.supervisor.stop(id).await;
483+
}
484+
}
485+
warn!(id, "VM launcher did not stop gracefully; forcing shutdown");
486+
}
487+
self.supervisor.stop(id).await
488+
}
489+
432490
pub async fn remove_vm(&self, id: &str) -> Result<()> {
433491
{
434492
let mut state = self.lock();
@@ -464,8 +522,8 @@ impl App {
464522
/// `delete_workdir`: true for user-initiated removal, false for orphan cleanup.
465523
async fn finish_remove_vm(&self, id: &str, delete_workdir: bool) -> Result<()> {
466524
// Stop the supervisor process (idempotent if already stopped)
467-
if let Err(err) = self.supervisor.stop(id).await {
468-
debug!("supervisor.stop({id}) during removal: {err:?}");
525+
if let Err(err) = self.stop_vm_process(id).await {
526+
debug!("graceful VM stop during removal failed: {err:?}");
469527
}
470528

471529
// Poll until the process is no longer running, then remove it.

dstack/vmm/src/app/qemu.rs

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use crate::{
77
app::Manifest,
88
config::{CvmConfig, Networking, NetworkingMode, ProcessAnnotation, TeePlatform},
9+
vm_launcher::{ChildCommand, LaunchSpec},
910
};
1011
use std::collections::HashMap;
1112
use std::os::unix::fs::PermissionsExt;
@@ -29,6 +30,7 @@ use anyhow::{bail, Context, Result};
2930
use bon::Builder;
3031
use dstack_types::{shared_filenames::HOST_SHARED_DISK_LABEL, KeyProviderKind};
3132
use fs_err as fs;
33+
use nix::unistd::User;
3234
use serde::Serialize;
3335
use supervisor_client::supervisor::ProcessConfig;
3436

@@ -179,7 +181,8 @@ struct PreparedQemuLaunch {
179181
hugepage_numa_nodes: Option<HashMap<String, u32>>,
180182
gpu_numa_nodes: HashMap<String, String>,
181183
numa_cpus: Option<String>,
182-
tpm_path: Option<&'static str>,
184+
swtpm_socket: Option<PathBuf>,
185+
swtpm_path: Option<PathBuf>,
183186
tdx_mr_config_id: Option<String>,
184187
snp_host_data: Option<String>,
185188
snp_launch_params: Option<AmdSevSnpLaunchParams>,
@@ -219,11 +222,20 @@ impl PreparedQemuLaunch {
219222
} else {
220223
None
221224
};
222-
let tpm_path = if matches!(app_compose.key_provider(), KeyProviderKind::Tpm) {
223-
Some(detect_tpm_device()?)
224-
} else {
225-
None
226-
};
225+
let (swtpm_socket, swtpm_path) =
226+
if matches!(app_compose.key_provider(), KeyProviderKind::Tpm) {
227+
let swtpm_path = which::which("swtpm")
228+
.context("tpm key provider requested but swtpm is not installed")?;
229+
let state_dir = workdir.swtpm_state_dir();
230+
fs::create_dir_all(&state_dir).context("failed to create swtpm state directory")?;
231+
let socket = workdir.swtpm_socket();
232+
if socket.exists() {
233+
fs::remove_file(&socket).context("failed to remove stale swtpm socket")?;
234+
}
235+
(Some(socket), Some(swtpm_path))
236+
} else {
237+
(None, None)
238+
};
227239
prepare_shared_disk(&workdir, cfg)?;
228240

229241
let tee_enabled = !vm.manifest.no_tee;
@@ -257,7 +269,8 @@ impl PreparedQemuLaunch {
257269
hugepage_numa_nodes,
258270
gpu_numa_nodes,
259271
numa_cpus,
260-
tpm_path,
272+
swtpm_socket,
273+
swtpm_path,
261274
tdx_mr_config_id,
262275
snp_host_data,
263276
snp_launch_params,
@@ -301,16 +314,6 @@ fn prepare_shared_disk(workdir: &VmWorkDir, cfg: &CvmConfig) -> Result<()> {
301314
create_shared_disk(&shared_disk_path, shared_dir).context("failed to create shared disk")
302315
}
303316

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-
314317
struct QemuCommandBuilder<'a> {
315318
vm: &'a VmConfig,
316319
cfg: &'a CvmConfig,
@@ -333,7 +336,71 @@ impl VmConfig {
333336
prepared: &prepared,
334337
}
335338
.build()?;
336-
Ok(vec![process])
339+
let Some(socket) = prepared.swtpm_socket.as_deref() else {
340+
return Ok(vec![process]);
341+
};
342+
let swtpm_path = prepared
343+
.swtpm_path
344+
.as_ref()
345+
.context("missing swtpm executable for configured socket")?;
346+
let (socket_uid, socket_gid) = if cfg.user.is_empty() {
347+
(unsafe { libc::geteuid() }, unsafe { libc::getegid() })
348+
} else {
349+
let user = User::from_name(&cfg.user)
350+
.context("failed to resolve QEMU user")?
351+
.with_context(|| format!("QEMU user {} does not exist", cfg.user))?;
352+
(user.uid.as_raw(), user.gid.as_raw())
353+
};
354+
355+
let swtpm_args = vec![
356+
"socket".into(),
357+
"--tpm2".into(),
358+
"--tpmstate".into(),
359+
format!("dir={}", prepared.workdir.swtpm_state_dir().display()),
360+
"--ctrl".into(),
361+
format!(
362+
"type=unixio,path={},mode=0600,uid={socket_uid},gid={socket_gid}",
363+
socket.display()
364+
),
365+
"--flags".into(),
366+
"not-need-init,startup-clear".into(),
367+
];
368+
let spec = LaunchSpec {
369+
qemu: ChildCommand {
370+
command: process.command,
371+
args: process.args,
372+
},
373+
swtpm: ChildCommand {
374+
command: swtpm_path.to_string_lossy().into_owned(),
375+
args: swtpm_args,
376+
},
377+
swtpm_socket: socket.to_path_buf(),
378+
startup_timeout_ms: 5_000,
379+
shutdown_timeout_ms: 10_000,
380+
};
381+
let spec_path = prepared.workdir.launch_spec_path();
382+
safe_write::safe_write(&spec_path, serde_json::to_vec_pretty(&spec)?)
383+
.context("failed to write VM launch specification")?;
384+
let executable =
385+
std::env::current_exe().context("failed to locate dstack-vmm executable")?;
386+
let launcher = ProcessConfig {
387+
id: self.manifest.id.clone(),
388+
name: self.manifest.name.clone(),
389+
command: executable.to_string_lossy().into_owned(),
390+
args: vec![
391+
"vm-launcher".into(),
392+
"--spec".into(),
393+
spec_path.to_string_lossy().into_owned(),
394+
],
395+
env: process.env,
396+
cwd: process.cwd,
397+
stdout: process.stdout,
398+
stderr: process.stderr,
399+
pidfile: process.pidfile,
400+
cid: process.cid,
401+
note: process.note,
402+
};
403+
Ok(vec![launcher])
337404
}
338405
}
339406

@@ -518,10 +585,12 @@ impl QemuCommandBuilder<'_> {
518585
}
519586

520587
fn configure_tpm_and_vsock(&self, command: &mut Command) {
521-
if let Some(tpm_path) = self.prepared.tpm_path {
588+
if let Some(socket) = &self.prepared.swtpm_socket {
522589
command
590+
.arg("-chardev")
591+
.arg(format!("socket,id=chrtpm,path={}", socket.display()))
523592
.arg("-tpmdev")
524-
.arg(format!("passthrough,id=tpm0,path={tpm_path}"))
593+
.arg("emulator,id=tpm0,chardev=chrtpm")
525594
.arg("-device")
526595
.arg("tpm-tis,tpmdev=tpm0");
527596
}
@@ -970,14 +1039,15 @@ mod tests {
9701039
workdir: PathBuf::from("/does-not-exist/vm-1"),
9711040
gateway_enabled: false,
9721041
};
973-
let prepared = PreparedQemuLaunch {
1042+
let mut prepared = PreparedQemuLaunch {
9741043
workdir: VmWorkDir::new("/does-not-exist/vm-1"),
9751044
platform: TeePlatform::Tdx,
9761045
networks: vec![config.cvm.networking.clone(), config.cvm.networking.clone()],
9771046
hugepage_numa_nodes: None,
9781047
gpu_numa_nodes: HashMap::new(),
9791048
numa_cpus: None,
980-
tpm_path: None,
1049+
swtpm_socket: None,
1050+
swtpm_path: None,
9811051
tdx_mr_config_id: None,
9821052
snp_host_data: None,
9831053
snp_launch_params: None,
@@ -1024,5 +1094,25 @@ mod tests {
10241094
.args
10251095
.iter()
10261096
.any(|arg| arg.contains("virtio-net-pci,netdev=net1")));
1097+
1098+
prepared.swtpm_socket = Some(PathBuf::from("/does-not-exist/vm-1/swtpm/swtpm.sock"));
1099+
let process = QemuCommandBuilder {
1100+
vm: &vm,
1101+
cfg: &config.cvm,
1102+
gpus: &GpuConfig::default(),
1103+
prepared: &prepared,
1104+
}
1105+
.build()
1106+
.unwrap();
1107+
assert!(process.args.windows(2).any(|args| {
1108+
args == [
1109+
"-chardev",
1110+
"socket,id=chrtpm,path=/does-not-exist/vm-1/swtpm/swtpm.sock",
1111+
]
1112+
}));
1113+
assert!(process
1114+
.args
1115+
.windows(2)
1116+
.any(|args| args == ["-tpmdev", "emulator,id=tpm0,chardev=chrtpm"]));
10271117
}
10281118
}

dstack/vmm/src/app/workdir.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ 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+
115+
pub fn launch_spec_path(&self) -> PathBuf {
116+
self.workdir.join("launch.json")
117+
}
118+
107119
pub fn app_compose_path(&self) -> PathBuf {
108120
self.shared_dir().join(APP_COMPOSE)
109121
}

0 commit comments

Comments
 (0)