Skip to content

Commit f8b4691

Browse files
committed
guest: gate boot on GPU TEE attestation via requirements.verify_gpu
1 parent e5f138c commit f8b4691

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

dstack-types/src/lib.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,16 @@ pub struct Requirements {
143143
/// (e.g. 32 random alphanumeric characters).
144144
#[serde(skip_serializing_if = "Option::is_none")]
145145
pub launch_token_hash: Option<String>,
146+
/// GPU TEE attestation requirement, defaults to `true` when omitted.
147+
///
148+
/// On guests with an NVIDIA GPU attached, `true` means the guest runs
149+
/// local GPU attestation (nvattest) during system setup and refuses to
150+
/// boot — before key provisioning — if the GPU fails to attest (e.g. a
151+
/// non-CC GPU or CC mode disabled by the host). `false` skips attestation
152+
/// and sets the GPU ready state directly. Guests without a GPU attached
153+
/// are unaffected either way.
154+
#[serde(skip_serializing_if = "Option::is_none")]
155+
pub verify_gpu: Option<bool>,
146156
}
147157

148158
impl Requirements {
@@ -151,6 +161,7 @@ impl Requirements {
151161
&& self.platforms.is_none()
152162
&& self.tdx_measure_acpi_tables.is_none()
153163
&& self.launch_token_hash.is_none()
164+
&& self.verify_gpu.is_none()
154165
}
155166
}
156167

@@ -345,6 +356,16 @@ impl AppCompose {
345356
}
346357
}
347358
}
359+
360+
/// Whether an attached GPU must pass local TEE attestation before the
361+
/// guest continues booting. Defaults to `true` when
362+
/// `requirements.verify_gpu` is omitted.
363+
pub fn verify_gpu(&self) -> bool {
364+
self.requirements
365+
.as_ref()
366+
.and_then(|r| r.verify_gpu)
367+
.unwrap_or(true)
368+
}
348369
}
349370

350371
#[cfg(test)]
@@ -501,6 +522,41 @@ mod app_compose_tests {
501522
assert!(!requirements.is_empty());
502523
}
503524

525+
#[test]
526+
fn verify_gpu_defaults_to_true() {
527+
let no_requirements: AppCompose = serde_json::from_value(serde_json::json!({
528+
"manifest_version": 2,
529+
"name": "test",
530+
"runner": "docker-compose"
531+
}))
532+
.unwrap();
533+
assert!(no_requirements.verify_gpu());
534+
535+
let omitted: AppCompose = serde_json::from_value(serde_json::json!({
536+
"manifest_version": "3",
537+
"name": "test",
538+
"runner": "docker-compose",
539+
"requirements": {}
540+
}))
541+
.unwrap();
542+
assert!(omitted.verify_gpu());
543+
assert!(omitted.requirements.as_ref().unwrap().is_empty());
544+
545+
let disabled: AppCompose = serde_json::from_value(serde_json::json!({
546+
"manifest_version": "3",
547+
"name": "test",
548+
"runner": "docker-compose",
549+
"requirements": {
550+
"verify_gpu": false
551+
}
552+
}))
553+
.unwrap();
554+
assert!(!disabled.verify_gpu());
555+
let requirements = disabled.requirements.as_ref().unwrap();
556+
assert_eq!(requirements.verify_gpu, Some(false));
557+
assert!(!requirements.is_empty());
558+
}
559+
504560
#[test]
505561
fn launch_token_hash_is_domain_separated() {
506562
assert_eq!(

dstack-util/src/system_setup.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,10 +1047,153 @@ async fn do_sys_setup(stage0: Stage0<'_>) -> Result<()> {
10471047
} else {
10481048
info!("System time will be synchronized by chronyd in background");
10491049
}
1050+
stage0
1051+
.setup_gpu()
1052+
.await
1053+
.context("Failed to verify GPU TEE attestation")?;
10501054
let stage1 = stage0.setup_fs().await?;
10511055
stage1.setup().await
10521056
}
10531057

1058+
/// GPU TEE attestation gate (`requirements.verify_gpu`, defaults to true).
1059+
///
1060+
/// Runs before key provisioning so a CVM whose GPU cannot prove it is a
1061+
/// genuine, CC-enabled NVIDIA TEE never gets its app keys. The GPU
1062+
/// "ready" state (`nvidia-smi conf-compute -srs 1`) is only set from here —
1063+
/// nvidia-persistenced deliberately does not set it — so CUDA work cannot be
1064+
/// submitted to an unverified GPU either.
1065+
mod gpu {
1066+
use super::*;
1067+
1068+
const NVATTEST: &str = "/usr/bin/nvattest";
1069+
const ATTESTATION_OUTPUT: &str = "/run/nvidia-gpu-attestation/attestation.out";
1070+
const ATTESTATION_TIMEOUT: Duration = Duration::from_secs(300);
1071+
1072+
/// True if a passed-through NVIDIA GPU is present, detected via sysfs PCI
1073+
/// (vendor 0x10de, class VGA 0x0300xx or 3D controller 0x0302xx) so it
1074+
/// works even before the nvidia driver is loaded.
1075+
pub(super) fn nvidia_gpu_present() -> bool {
1076+
let Ok(entries) = fs::read_dir("/sys/bus/pci/devices") else {
1077+
return false;
1078+
};
1079+
entries.filter_map(|e| e.ok()).any(|dev| {
1080+
let read = |name: &str| {
1081+
fs::read_to_string(dev.path().join(name))
1082+
.unwrap_or_default()
1083+
.trim()
1084+
.to_string()
1085+
};
1086+
read("vendor") == "0x10de"
1087+
&& matches!(read("class").get(..6), Some("0x0300") | Some("0x0302"))
1088+
})
1089+
}
1090+
1091+
/// Mark the GPU as ready to accept work. Only meaningful (and only
1092+
/// succeeds) when the GPU runs in CC mode.
1093+
pub(super) fn set_gpu_ready_state() -> Result<()> {
1094+
let output = Command::new("nvidia-smi")
1095+
.args(["conf-compute", "-srs", "1"])
1096+
.output()
1097+
.context("failed to run nvidia-smi conf-compute")?;
1098+
if !output.status.success() {
1099+
bail!(
1100+
"nvidia-smi conf-compute -srs 1 failed ({}): {}",
1101+
output.status,
1102+
truncated_lossy(&output.stderr, 512),
1103+
);
1104+
}
1105+
info!("GPU ready state set");
1106+
Ok(())
1107+
}
1108+
1109+
/// Run local GPU attestation via nvattest with a fresh nonce, keeping the
1110+
/// verifier output in /run for debugging. Fails on any non-zero exit —
1111+
/// including a GPU that cannot produce an attestation report at all (a
1112+
/// non-CC GPU, or CC mode left off by the host).
1113+
pub(super) async fn attest_gpu() -> Result<()> {
1114+
if !Path::new(NVATTEST).exists() {
1115+
bail!("nvattest is not available in this image");
1116+
}
1117+
// Certificate/OCSP validation needs a sane clock even when
1118+
// secure_time is off; best-effort step chrony before attesting.
1119+
if let Err(err) = cmd!(chronyc makestep) {
1120+
warn!("failed to step system clock: {err:?}");
1121+
}
1122+
let nonce = hex::encode(rand::thread_rng().gen::<[u8; 32]>());
1123+
let output = tokio::time::timeout(
1124+
ATTESTATION_TIMEOUT,
1125+
tokio::process::Command::new(NVATTEST)
1126+
.args(["attest", "--device", "gpu", "--verifier", "local"])
1127+
.args(["--nonce", &nonce])
1128+
.output(),
1129+
)
1130+
.await
1131+
.context("GPU attestation timed out")?
1132+
.context("failed to run nvattest")?;
1133+
if !output.stderr.is_empty() {
1134+
info!("nvattest: {}", truncated_lossy(&output.stderr, 2048));
1135+
}
1136+
if !output.status.success() {
1137+
bail!(
1138+
"nvattest exited with {}: {}",
1139+
output.status,
1140+
truncated_lossy(&output.stderr, 512),
1141+
);
1142+
}
1143+
if let Err(err) = save_attestation_output(&output.stdout) {
1144+
warn!("failed to save GPU attestation output: {err:?}");
1145+
}
1146+
Ok(())
1147+
}
1148+
1149+
fn save_attestation_output(stdout: &[u8]) -> Result<()> {
1150+
let output_path = Path::new(ATTESTATION_OUTPUT);
1151+
if let Some(parent) = output_path.parent() {
1152+
fs::create_dir_all(parent)?;
1153+
}
1154+
safe_write(output_path, stdout)?;
1155+
fs::set_permissions(
1156+
output_path,
1157+
std::os::unix::fs::PermissionsExt::from_mode(0o600),
1158+
)?;
1159+
Ok(())
1160+
}
1161+
1162+
fn truncated_lossy(bytes: &[u8], limit: usize) -> String {
1163+
let text = String::from_utf8_lossy(bytes);
1164+
let text = text.trim();
1165+
match text.char_indices().nth(limit) {
1166+
Some((idx, _)) => format!("{}...", &text[..idx]),
1167+
None => text.to_string(),
1168+
}
1169+
}
1170+
}
1171+
1172+
impl Stage0<'_> {
1173+
/// Enforce `requirements.verify_gpu` (default true): attest an attached
1174+
/// NVIDIA GPU before continuing to key provisioning, or — when explicitly
1175+
/// disabled — set the GPU ready state without verification.
1176+
async fn setup_gpu(&self) -> Result<()> {
1177+
if !gpu::nvidia_gpu_present() {
1178+
return Ok(());
1179+
}
1180+
if !self.shared.app_compose.verify_gpu() {
1181+
warn!("requirements.verify_gpu is false; setting GPU ready state without attestation");
1182+
// Best-effort: a GPU with CC mode off has no ready state to set.
1183+
if let Err(err) = gpu::set_gpu_ready_state() {
1184+
warn!("failed to set GPU ready state: {err:?}");
1185+
}
1186+
return Ok(());
1187+
}
1188+
self.vmm.notify_q("boot.progress", "attesting GPU").await;
1189+
info!("verifying GPU TEE attestation");
1190+
gpu::attest_gpu().await?;
1191+
gpu::set_gpu_ready_state()?;
1192+
info!("GPU TEE attestation succeeded");
1193+
Ok(())
1194+
}
1195+
}
1196+
10541197
pub async fn cmd_gateway_refresh(args: GatewayRefreshArgs) -> Result<()> {
10551198
let host_shared_dir = args.work_dir.join(HOST_SHARED_DIR_NAME);
10561199
let shared = HostShared::load(host_shared_dir.as_path()).with_context(|| {

0 commit comments

Comments
 (0)