Skip to content

Commit 73b26e9

Browse files
committed
feat(guest): structure GPU policy settings
1 parent e9b3158 commit 73b26e9

5 files changed

Lines changed: 127 additions & 48 deletions

File tree

docs/security/security-model.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,14 @@ dstack supports NVIDIA H100, H200, and B200 GPUs in confidential compute mode fo
5757

5858
GPUs are passed through via VFIO to the TEE-protected CVM. Before key provisioning, `dstack-util setup` inventories every VGA/3D-controller PCI function, compares that count with `vm_config.num_gpus`, and runs NVIDIA's local `nvattest` verifier over every NVIDIA-driver-visible GPU with a fresh nonce. dstack does not ship or pass a custom relying-party policy to this command; nvattest's built-in appraisal still applies. The complete JSON result (`result_code`, `result_message`, `claims`, and `detached_eat`) is saved at `/run/nvidia-gpu-attestation/attestation.out`.
5959

60-
An application may additionally set `requirements.gpu_policy` to a Rego v0 script. Its input is the nvattest output's `claims` array, and it must define the boolean entrypoint `data.policy.nv_match` in `package policy`. Immediately after measuring `compose-hash`, dstack measures `sha256(gpu_policy UTF-8 bytes)` in a `gpu-policy` event and then evaluates the policy. A false, undefined, malformed, or non-boolean result stops boot before key provisioning. If no GPU is configured, the policy receives an empty array. `gpu_policy` cannot be combined with `attest_gpu: false`.
60+
An application may additionally set `requirements.gpu_policy` to an object with the following deny-unknown-fields schema:
6161

62-
Separate fail-closed `nvidia-smi` queries require the current CC feature to be ON and DevTools mode to be OFF. Only after the default appraisal, optional application policy, and these checks succeed does dstack set the GPU ready state. The dstack CPU/guest boot chain is verified independently through measured boot; a GPU claim named `secboot` refers to the GPU appraisal, not UEFI Secure Boot in the CVM.
62+
- `rego` (optional string): a Rego v0 script evaluated with the nvattest output's `claims` array as `input`. It must define the boolean entrypoint `data.policy.nv_match` in `package policy`.
63+
- `allow_devtools` (boolean, default `false`): permit NVIDIA DevTools mode. Production applications should leave this disabled because DevTools removes the expected GPU memory-confidentiality guarantee.
64+
65+
Immediately after measuring `compose-hash`, dstack applies field defaults, JCS-canonicalizes the complete `gpu_policy` structure, and measures its SHA-256 digest in a `gpu-policy` event. It then applies the basic settings and optional Rego policy. A false, undefined, malformed, or non-boolean Rego result stops boot before key provisioning. If no GPU is configured, Rego receives an empty array. `gpu_policy` cannot be combined with `attest_gpu: false`.
66+
67+
Separate fail-closed `nvidia-smi` queries always require the current CC feature to be ON and require DevTools mode to be OFF unless the measured policy explicitly permits it. Only after the default appraisal, optional application policy, and these checks succeed does dstack set the GPU ready state. The dstack CPU/guest boot chain is verified independently through measured boot; a GPU claim named `secboot` refers to the GPU appraisal, not UEFI Secure Boot in the CVM.
6368

6469
### Dual Attestation
6570

@@ -71,7 +76,7 @@ A verifier must replay the measured event log, require exactly one pre-`system-r
7176

7277
The GPU gate assumes a malicious host/VMM and untrusted host-provided PCI topology, while trusting the CPU TEE, the measured dstack guest/kernel, NVIDIA hardware/firmware roots, and the cryptography used by both attestation chains. Availability is out of scope.
7378

74-
The events make the following **boot-time** statement: immediately before key provisioning, all attached VGA/3D PCI functions were NVIDIA devices, their count matched the quote-bound VM configuration, nvattest returned one fresh successfully appraised claim for each device, the measured application policy accepted those claims when present, CC was ON, DevTools was OFF, and setting the GPU ready state succeeded. This closes these cases:
79+
The events make the following **boot-time** statement: immediately before key provisioning, all attached VGA/3D PCI functions were NVIDIA devices, their count matched the quote-bound VM configuration, nvattest returned one fresh successfully appraised claim for each device, the measured application policy accepted those claims and GPU state when present, CC was ON, DevTools complied with that policy (OFF by default), and setting the GPU ready state succeeded. This closes these cases:
7580

7681
- A GPU-less launch cannot be presented as a GPU-verified launch: it has `num_gpus == 0` and no `gpu-attestation` event.
7782
- A mixed launch cannot attest only its TEE-capable subset. Non-NVIDIA display GPUs are rejected, and the sysfs, `vm_config`, and nvattest claim counts must all agree. A non-CC NVIDIA GPU either prevents evidence collection/appraisal or causes the default appraisal, application policy, or CC-state check to fail.
@@ -180,7 +185,7 @@ Use this checklist to verify a workload running in a dstack CVM.
180185
- [ ] `vm_config.num_gpus` is greater than zero and matches the expected deployment
181186
- [ ] If `requirements.gpu_policy` is required, exactly one `gpu-policy` event follows `compose-hash`, and its payload matches the expected SHA-256 digest
182187
- [ ] Exactly one `gpu-attestation` event appears before `system-ready`
183-
- [ ] The event device count matches `vm_config.num_gpus`, and its CC/DevTools fields indicate production mode
188+
- [ ] The event device count matches `vm_config.num_gpus`, CC is ON, and its DevTools field complies with the measured policy
184189
- [ ] The platform binds the event log to a quoted RTMR/PCR (do not accept it from current SEV-SNP evidence)
185190

186191
**Key management verification:**

dstack/Cargo.lock

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

dstack/dstack-types/src/lib.rs

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ pub struct AppCompose {
121121
pub requirements: Option<Requirements>,
122122
}
123123

124+
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
125+
#[serde(default, deny_unknown_fields)]
126+
pub struct GpuPolicy {
127+
/// Optional Rego v0 policy evaluated against NVIDIA nvattest's `claims`
128+
/// array. It must define the boolean rule `data.policy.nv_match`.
129+
#[serde(skip_serializing_if = "Option::is_none")]
130+
pub rego: Option<String>,
131+
/// Permit NVIDIA DevTools mode. This defaults to false because DevTools
132+
/// disables the GPU memory-confidentiality guarantees expected in
133+
/// production.
134+
pub allow_devtools: bool,
135+
}
136+
124137
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
125138
#[serde(default, deny_unknown_fields)]
126139
pub struct Requirements {
@@ -159,15 +172,12 @@ pub struct Requirements {
159172
/// are unaffected either way.
160173
#[serde(skip_serializing_if = "Option::is_none")]
161174
pub attest_gpu: Option<bool>,
162-
/// Optional application-supplied Rego policy evaluated against the
163-
/// `claims` array in NVIDIA nvattest's JSON output before key
164-
/// provisioning. The policy must define the boolean rule
165-
/// `data.policy.nv_match`.
175+
/// Optional application GPU policy applied before key provisioning.
166176
///
167-
/// The SHA-256 digest of the exact UTF-8 policy bytes is emitted as the
168-
/// `gpu-policy` launch event immediately after `compose-hash`.
177+
/// Its default-expanded, JCS-canonicalized JSON SHA-256 digest is emitted
178+
/// as the `gpu-policy` launch event immediately after `compose-hash`.
169179
#[serde(skip_serializing_if = "Option::is_none")]
170-
pub gpu_policy: Option<String>,
180+
pub gpu_policy: Option<GpuPolicy>,
171181
}
172182

173183
impl Requirements {
@@ -466,7 +476,10 @@ mod app_compose_tests {
466476
"platforms": ["dstack-gcp-tdx", "dstack-tdx"],
467477
"tdx_measure_acpi_tables": true,
468478
"launch_token_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
469-
"gpu_policy": "package policy\n\ndefault nv_match = false\n"
479+
"gpu_policy": {
480+
"rego": "package policy\n\ndefault nv_match = false\n",
481+
"allow_devtools": true
482+
}
470483
}
471484
}))
472485
.unwrap();
@@ -481,10 +494,12 @@ mod app_compose_tests {
481494
requirements.launch_token_hash.as_deref(),
482495
Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
483496
);
497+
let gpu_policy = requirements.gpu_policy.as_ref().unwrap();
484498
assert_eq!(
485-
requirements.gpu_policy.as_deref(),
499+
gpu_policy.rego.as_deref(),
486500
Some("package policy\n\ndefault nv_match = false\n")
487501
);
502+
assert!(gpu_policy.allow_devtools);
488503

489504
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
490505
"manifest_version": "3",
@@ -555,13 +570,31 @@ mod app_compose_tests {
555570
"name": "test",
556571
"runner": "docker-compose",
557572
"requirements": {
558-
"gpu_policy": "package policy\n\ndefault nv_match = false\n"
573+
"gpu_policy": {
574+
"rego": "package policy\n\ndefault nv_match = false\n"
575+
}
559576
}
560577
}))
561578
.unwrap();
562579
let requirements = gpu_policy.requirements.as_ref().unwrap();
563-
assert!(requirements.gpu_policy.is_some());
580+
let gpu_policy = requirements.gpu_policy.as_ref().unwrap();
581+
assert!(gpu_policy.rego.is_some());
582+
assert!(!gpu_policy.allow_devtools);
564583
assert!(!requirements.is_empty());
584+
585+
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
586+
"manifest_version": "3",
587+
"name": "test",
588+
"runner": "docker-compose",
589+
"requirements": {
590+
"gpu_policy": {
591+
"rego": "package policy",
592+
"allow_debug": true
593+
}
594+
}
595+
}))
596+
.unwrap_err();
597+
assert!(err.to_string().contains("unknown field"));
565598
}
566599

567600
#[test]

dstack/dstack-util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ serde.workspace = true
2525
serde-human-bytes.workspace = true
2626
semver.workspace = true
2727
serde_json.workspace = true
28+
serde_jcs.workspace = true
2829
sha2.workspace = true
2930
tokio = { workspace = true, features = ["full"] }
3031
tracing.workspace = true

dstack/dstack-util/src/system_setup.rs

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use dstack_types::{
2020
APP_COMPOSE, APP_KEYS, DECRYPTED_ENV, DECRYPTED_ENV_JSON, ENCRYPTED_ENV,
2121
HOST_SHARED_DIR_NAME, HOST_SHARED_DISK_LABEL, INSTANCE_INFO, SYS_CONFIG, USER_CONFIG,
2222
},
23-
KeyProvider, KeyProviderInfo,
23+
GpuPolicy, KeyProvider, KeyProviderInfo,
2424
};
2525
use fs_err as fs;
2626
use luks2::{
@@ -1114,16 +1114,17 @@ mod gpu {
11141114

11151115
pub(super) struct GpuAttestationResult {
11161116
claims: Vec<Value>,
1117-
event: Vec<u8>,
1117+
output: Vec<u8>,
1118+
devices: u32,
11181119
}
11191120

11201121
impl GpuAttestationResult {
11211122
pub(super) fn claims(&self) -> &[Value] {
11221123
&self.claims
11231124
}
11241125

1125-
pub(super) fn event(&self) -> &[u8] {
1126-
&self.event
1126+
pub(super) fn event(&self, devtools: bool) -> Result<Vec<u8>> {
1127+
attestation_event(&self.output, self.devices, devtools)
11271128
}
11281129
}
11291130

@@ -1236,20 +1237,28 @@ mod gpu {
12361237
parse_nvidia_smi_state(&output.stdout, name)
12371238
}
12381239

1239-
/// Require the current system-wide GPU state to be production CC. NVAT's
1240-
/// NVML collector accepts protected-PCIe as well as CC and its default
1241-
/// appraisal does not reject DevTools, so a successful appraisal alone is
1242-
/// not a sufficient confidentiality check.
1243-
async fn verify_production_cc_mode() -> Result<()> {
1244-
if !query_nvidia_smi_state("-f", "CC feature state").await? {
1240+
fn validate_cc_state(cc_enabled: bool, devtools: bool, allow_devtools: bool) -> Result<()> {
1241+
if !cc_enabled {
12451242
bail!("nvidia confidential compute mode is not enabled");
12461243
}
1247-
if query_nvidia_smi_state("-d", "DevTools mode").await? {
1244+
if devtools && !allow_devtools {
12481245
bail!("nvidia DevTools mode is enabled");
12491246
}
12501247
Ok(())
12511248
}
12521249

1250+
/// Require the current system-wide GPU state to use CC. NVAT's NVML
1251+
/// collector accepts protected-PCIe as well as CC, so a successful
1252+
/// appraisal alone is not a sufficient confidentiality check. DevTools is
1253+
/// rejected by default but can be explicitly allowed by the measured app
1254+
/// policy.
1255+
pub(super) async fn verify_cc_mode(allow_devtools: bool) -> Result<bool> {
1256+
let cc_enabled = query_nvidia_smi_state("-f", "CC feature state").await?;
1257+
let devtools = query_nvidia_smi_state("-d", "DevTools mode").await?;
1258+
validate_cc_state(cc_enabled, devtools, allow_devtools)?;
1259+
Ok(devtools)
1260+
}
1261+
12531262
/// Mark the GPU as ready to accept work. Only meaningful (and only
12541263
/// succeeds) when the GPU runs in CC mode.
12551264
pub(super) async fn set_gpu_ready_state() -> Result<()> {
@@ -1306,22 +1315,22 @@ mod gpu {
13061315
Ok(output.claims)
13071316
}
13081317

1309-
fn attestation_event(stdout: &[u8], devices: u32) -> Result<Vec<u8>> {
1318+
fn attestation_event(stdout: &[u8], devices: u32, devtools: bool) -> Result<Vec<u8>> {
13101319
let event = GpuAttestationEvent {
13111320
version: EVENT_VERSION,
13121321
provider: "nvidia",
13131322
devices,
13141323
cc_mode: "on",
1315-
devtools: false,
1324+
devtools,
13161325
evidence_sha256: hex::encode(sha256(stdout)),
13171326
};
13181327
serde_json::to_vec(&event).context("failed to serialize GPU attestation event")
13191328
}
13201329

13211330
/// Run local GPU attestation via nvattest with a fresh nonce and no custom
13221331
/// relying-party policy. nvattest's built-in appraisal still applies. The
1323-
/// complete JSON output is preserved under /run, while its claims and a
1324-
/// versioned summary are returned for the application policy and RTMR3.
1332+
/// complete JSON output is preserved under /run and retained with its
1333+
/// claims for the application policy and versioned RTMR3 summary.
13251334
pub(super) async fn attest_gpu(expected_devices: u32) -> Result<GpuAttestationResult> {
13261335
if !Path::new(NVATTEST).exists() {
13271336
bail!("nvattest is not available in this image");
@@ -1360,15 +1369,16 @@ mod gpu {
13601369
);
13611370
}
13621371
let claims = validate_attestation_output(&output.stdout, &nonce, expected_devices)?;
1363-
verify_production_cc_mode().await?;
13641372
Ok(GpuAttestationResult {
13651373
claims,
1366-
event: attestation_event(&output.stdout, expected_devices)?,
1374+
output: output.stdout,
1375+
devices: expected_devices,
13671376
})
13681377
}
13691378

1370-
pub(super) fn policy_digest(policy: &str) -> [u8; 32] {
1371-
sha256(policy.as_bytes())
1379+
pub(super) fn policy_digest(policy: &GpuPolicy) -> Result<[u8; 32]> {
1380+
let canonical = serde_jcs::to_vec(policy).context("failed to canonicalize GPU policy")?;
1381+
Ok(sha256(&canonical))
13721382
}
13731383

13741384
/// Evaluate the app-provided Rego v0 policy using the same input shape as
@@ -1483,6 +1493,14 @@ mod gpu {
14831493
assert!(parse_nvidia_smi_state(b"current: ON, pending: OFF\n", "CC").is_err());
14841494
}
14851495

1496+
#[test]
1497+
fn basic_policy_requires_cc_and_rejects_devtools_by_default() {
1498+
validate_cc_state(true, false, false).unwrap();
1499+
assert!(validate_cc_state(false, false, false).is_err());
1500+
assert!(validate_cc_state(true, true, false).is_err());
1501+
validate_cc_state(true, true, true).unwrap();
1502+
}
1503+
14861504
#[test]
14871505
fn nvattest_output_requires_every_expected_gpu_and_fresh_nonce() {
14881506
let nonce = "11".repeat(32);
@@ -1510,12 +1528,12 @@ mod gpu {
15101528
let nonce = "22".repeat(32);
15111529
let output = nvattest_output(&nonce, 1);
15121530
let event: Value =
1513-
serde_json::from_slice(&attestation_event(&output, 1).unwrap()).unwrap();
1531+
serde_json::from_slice(&attestation_event(&output, 1, true).unwrap()).unwrap();
15141532
assert_eq!(event["version"], EVENT_VERSION);
15151533
assert_eq!(event["devices"], 1);
15161534
assert!(event.get("policy").is_none());
15171535
assert_eq!(event["cc_mode"], "on");
1518-
assert_eq!(event["devtools"], false);
1536+
assert_eq!(event["devtools"], true);
15191537
assert_eq!(event["evidence_sha256"], hex::encode(sha256(&output)));
15201538
}
15211539

@@ -1539,10 +1557,22 @@ mod gpu {
15391557
}
15401558

15411559
#[test]
1542-
fn policy_digest_uses_exact_utf8_bytes() {
1543-
let policy = "package policy\n\ndefault nv_match = true\n";
1544-
assert_eq!(policy_digest(policy), sha256(policy.as_bytes()));
1545-
assert_ne!(policy_digest(policy), policy_digest(policy.trim()));
1560+
fn policy_digest_commits_to_the_canonical_policy_structure() {
1561+
let policy = GpuPolicy {
1562+
rego: Some("package policy\n\ndefault nv_match = true\n".to_string()),
1563+
allow_devtools: true,
1564+
};
1565+
let canonical = serde_jcs::to_vec(&policy).unwrap();
1566+
assert_eq!(policy_digest(&policy).unwrap(), sha256(&canonical));
1567+
1568+
let production_policy = GpuPolicy {
1569+
allow_devtools: false,
1570+
..policy.clone()
1571+
};
1572+
assert_ne!(
1573+
policy_digest(&policy).unwrap(),
1574+
policy_digest(&production_policy).unwrap()
1575+
);
15461576
}
15471577
}
15481578
}
@@ -2240,27 +2270,34 @@ impl<'a> Stage0<'a> {
22402270
emit_runtime_event("app-id", &instance_info.app_id)?;
22412271
emit_runtime_event("compose-hash", &compose_hash)?;
22422272

2243-
if let Some(policy) = self
2273+
let gpu_policy = self
22442274
.shared
22452275
.app_compose
22462276
.requirements
22472277
.as_ref()
2248-
.and_then(|requirements| requirements.gpu_policy.as_deref())
2249-
{
2250-
emit_runtime_event("gpu-policy", &gpu::policy_digest(policy))?;
2278+
.and_then(|requirements| requirements.gpu_policy.as_ref());
2279+
if let Some(policy) = gpu_policy {
2280+
emit_runtime_event("gpu-policy", &gpu::policy_digest(policy)?)?;
22512281
let claims = gpu_attestation
22522282
.map(gpu::GpuAttestationResult::claims)
22532283
.unwrap_or(&[]);
2254-
gpu::evaluate_policy(policy, claims).context("failed to apply GPU policy")?;
2255-
info!("application GPU policy accepted the attestation claims");
2284+
if let Some(rego) = policy.rego.as_deref() {
2285+
gpu::evaluate_policy(rego, claims).context("failed to apply GPU Rego policy")?;
2286+
}
22562287
}
22572288

22582289
if let Some(attestation) = gpu_attestation {
2290+
let allow_devtools = gpu_policy.is_some_and(|policy| policy.allow_devtools);
2291+
let devtools = gpu::verify_cc_mode(allow_devtools).await?;
22592292
gpu::set_gpu_ready_state().await?;
2260-
emit_runtime_event("gpu-attestation", attestation.event())
2293+
let event = attestation.event(devtools)?;
2294+
emit_runtime_event("gpu-attestation", &event)
22612295
.context("failed to emit GPU attestation event")?;
22622296
info!("GPU TEE attestation succeeded");
22632297
}
2298+
if gpu_policy.is_some() {
2299+
info!("application GPU policy accepted the attestation claims and state");
2300+
}
22642301

22652302
emit_runtime_event("instance-id", &instance_id)?;
22662303
emit_runtime_event("boot-mr-done", &[])?;
@@ -2865,7 +2902,9 @@ fn test_gpu_policy_requires_gpu_attestation() {
28652902
"runner": "docker-compose",
28662903
"requirements": {
28672904
"attest_gpu": false,
2868-
"gpu_policy": "package policy\n\ndefault nv_match = true\n"
2905+
"gpu_policy": {
2906+
"rego": "package policy\n\ndefault nv_match = true\n"
2907+
}
28692908
}
28702909
}))
28712910
.unwrap();

0 commit comments

Comments
 (0)