Skip to content

Commit 4897fde

Browse files
committed
Merge remote-tracking branch 'origin/master' into feat/shared-api-auth
# Conflicts: # dstack/vmm/src/main.rs
2 parents 8c81a88 + efdaf39 commit 4897fde

32 files changed

Lines changed: 2997 additions & 2706 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: 3 additions & 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.
@@ -74,6 +74,7 @@ This file contains system configuration in JSON format:
7474
| kms_urls | array of string | List of KMS service URLs |
7575
| gateway_urls | array of string | List of gateway service URLs |
7676
| pccs_url | string | URL of the PCCS service (used when dstack components need to verify a remote TD CVM or SGX enclave) |
77+
| nvidia_attestation_proxy_url | string | Optional persistent OCSP and RIM cache used by NVIDIA local GPU attestation |
7778
| docker_registry | string | URL of the docker registry |
7879
| host_api_url | string | VSOCK URL of host API |
7980
| vm_config | string | JSON string of VM configuration (os_image_hash, cpu_count, memory_size) |
@@ -85,6 +86,7 @@ The hash of this file is not extended to any RTMR because each field has its own
8586
| kms_urls | URLs themselves aren't security-critical. The trust anchor is the KMS root public key, which is extended as the `key-provider` launch event. On TDX-family platforms this is RTMR3; on AWS NitroTPM this is PCR14. Keys obtained from KMS will either successfully decrypt/encrypt the disk or fail-and-abort. |
8687
| gateway_urls | URLs aren't security-critical. Trust is established through CA certificates from KMS. App CVM and dstack-gateway CVM verify each other's CA certificates to ensure they're under the same KMS authority. |
8788
| pccs_url | URL isn't security-critical. Trust is anchored by the root public key pinned in the attestation verification program. |
89+
| nvidia_attestation_proxy_url | The URL is not a collateral trust anchor. The measured guest verifies NVIDIA signatures and the signed OCSP validity window, and continues to require a fresh GPU evidence nonce. A bad endpoint can withhold collateral and cause a denial of service, but cannot forge a successful attestation or replay an expired `good` response. |
8890
| docker_registry | Docker daemon verifies image integrity using the pinned image hashes in the docker-compose file. |
8991
| host_api_url | Used only for reporting or encrypted sealing key transport. An incorrect URL doesn't create security vulnerabilities. |
9092
| vm_config | Informs the CVM to report virtual hardware info to KMS when requesting keys. KMS uses this info to calculate expected RTMRs and verify image hash. If tampered with, image hash verification would fail and no keys would be distributed. |

docs/tutorials/vmm-configuration.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ qemu_path = ""
105105
kms_urls = ["http://127.0.0.1:8081"]
106106
gateway_urls = ["http://127.0.0.1:8082"]
107107
pccs_url = "https://pccs.phala.network/sgx/certification/v4"
108+
# Optional. See "NVIDIA GPU attestation cache" below.
109+
# nvidia_attestation_proxy_url = "http://10.0.2.2:8090"
108110
docker_registry = ""
109111
cid_start = 1000
110112
cid_pool_size = 1000
@@ -212,6 +214,24 @@ Verify QGS is running:
212214
systemctl status qgsd
213215
```
214216

217+
### Optional: NVIDIA GPU attestation cache
218+
219+
GPU images perform local NVIDIA attestation before app keys are provisioned.
220+
Without a cache this contacts NVIDIA's OCSP and RIM services during every cold
221+
boot. A fleet can run the persistent
222+
[`dstack-nvidia-attest-proxy`](../../dstack/nvidia-attest-proxy/README.md) and pass its
223+
URL to guests through sys-config:
224+
225+
```toml
226+
[cvm]
227+
nvidia_attestation_proxy_url = "http://10.0.2.2:8090"
228+
```
229+
230+
The example address is the host as seen from QEMU user-mode networking. The
231+
proxy must be reachable during `dstack-prepare`. It stores only NVIDIA-signed
232+
collateral and never becomes a signing trust anchor. OCSP entries are not
233+
served after their signed validity window.
234+
215235
### Step 7: Create Runtime Directories
216236

217237
```bash

dstack/Cargo.lock

Lines changed: 31 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
@@ -59,6 +59,7 @@ members = [
5959
"serde-duration",
6060
"dstack-mr",
6161
"dstack-mr/cli",
62+
"nvidia-attest-proxy",
6263
"verifier",
6364
"size-parser",
6465
"crates/dstack-cli-core",

dstack/dstack-types/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,10 @@ pub struct SysConfig {
710710
#[serde(default, alias = "tproxy_urls")]
711711
pub gateway_urls: Vec<String>,
712712
pub pccs_url: Option<String>,
713+
/// Optional NVIDIA attestation collateral proxy. When present, nvattest
714+
/// fetches both OCSP responses and RIM documents through this endpoint.
715+
#[serde(default, skip_serializing_if = "Option::is_none")]
716+
pub nvidia_attestation_proxy_url: Option<String>,
713717
pub docker_registry: Option<String>,
714718
pub host_api_url: Option<String>,
715719
/// MrConfigV3 document string for platform app/config binding.

dstack/dstack-util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ sha2.workspace = true
2929
tokio = { workspace = true, features = ["full"] }
3030
tracing.workspace = true
3131
tracing-subscriber.workspace = true
32+
url.workspace = true
3233
x25519-dalek.workspace = true
3334

3435
dstack-kms-rpc.workspace = true

dstack/dstack-util/src/system_setup.rs

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,7 @@ mod gpu {
10811081
const ATTESTATION_TIMEOUT: Duration = Duration::from_secs(300);
10821082
const EVENT_VERSION: u32 = 2;
10831083
const POLICY_ENTRYPOINT: &str = "data.policy.nv_match";
1084+
const TRUST_OUTPOST_POLICY: &str = "/usr/share/nvattest/policies/allow_trust_outpost_ocsp.rego";
10841085
/// Bound Rego evaluation so a runaway application policy cannot hang boot.
10851086
const POLICY_TIMEOUT: Duration = Duration::from_secs(10);
10861087

@@ -1368,11 +1369,61 @@ mod gpu {
13681369
serde_json::to_vec(&event).context("failed to serialize GPU attestation event")
13691370
}
13701371

1371-
/// Run local GPU attestation via nvattest with a fresh nonce and no custom
1372-
/// relying-party policy. nvattest's built-in appraisal still applies. The
1373-
/// complete JSON output is preserved under /run and retained with its
1374-
/// claims for the application policy and versioned RTMR3 summary.
1375-
pub(super) async fn attest_gpu(expected_devices: u32) -> Result<GpuAttestationResult> {
1372+
fn normalize_proxy_url(proxy_url: Option<&str>) -> Result<Option<String>> {
1373+
let Some(proxy_url) = proxy_url.map(str::trim).filter(|url| !url.is_empty()) else {
1374+
return Ok(None);
1375+
};
1376+
let parsed = url::Url::parse(proxy_url).context("invalid NVIDIA attestation proxy URL")?;
1377+
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1378+
bail!("NVIDIA attestation proxy must be an absolute HTTP(S) URL");
1379+
}
1380+
if parsed.query().is_some()
1381+
|| parsed.fragment().is_some()
1382+
|| !parsed.username().is_empty()
1383+
|| parsed.password().is_some()
1384+
|| parsed.path() != "/"
1385+
{
1386+
bail!(
1387+
"NVIDIA attestation proxy URL must not contain credentials, path, query, or fragment"
1388+
);
1389+
}
1390+
Ok(Some(parsed.as_str().trim_end_matches('/').to_string()))
1391+
}
1392+
1393+
fn nvattest_args(nonce: &str, proxy_url: Option<&str>) -> Result<Vec<String>> {
1394+
let mut args = vec![
1395+
"attest".to_string(),
1396+
"--device".to_string(),
1397+
"gpu".to_string(),
1398+
"--verifier".to_string(),
1399+
"local".to_string(),
1400+
"--nonce".to_string(),
1401+
nonce.to_string(),
1402+
"--format".to_string(),
1403+
"json".to_string(),
1404+
];
1405+
if let Some(proxy_url) = normalize_proxy_url(proxy_url)? {
1406+
args.extend([
1407+
"--ocsp-url".to_string(),
1408+
format!("{proxy_url}/ocsp"),
1409+
"--rim-url".to_string(),
1410+
proxy_url,
1411+
"--relying-party-policy".to_string(),
1412+
TRUST_OUTPOST_POLICY.to_string(),
1413+
]);
1414+
}
1415+
Ok(args)
1416+
}
1417+
1418+
/// Run local GPU attestation via nvattest with a fresh evidence nonce. If
1419+
/// sys-config selects a collateral proxy, both RIM and OCSP traffic is
1420+
/// routed through it and NVIDIA's Trust Outpost policy accepts cached OCSP
1421+
/// responses whose responder nonce no longer matches. The independent GPU
1422+
/// evidence nonce remains mandatory and is checked below.
1423+
pub(super) async fn attest_gpu(
1424+
expected_devices: u32,
1425+
proxy_url: Option<&str>,
1426+
) -> Result<GpuAttestationResult> {
13761427
if !Path::new(NVATTEST).exists() {
13771428
bail!("nvattest is not available in this image");
13781429
}
@@ -1382,22 +1433,14 @@ mod gpu {
13821433
warn!("failed to step system clock: {err:?}");
13831434
}
13841435
let nonce = hex::encode(rand::thread_rng().gen::<[u8; 32]>());
1385-
let output = run_command(
1386-
NVATTEST,
1387-
&[
1388-
"attest",
1389-
"--device",
1390-
"gpu",
1391-
"--verifier",
1392-
"local",
1393-
"--nonce",
1394-
&nonce,
1395-
"--format",
1396-
"json",
1397-
],
1398-
ATTESTATION_TIMEOUT,
1399-
)
1400-
.await?;
1436+
let args = nvattest_args(&nonce, proxy_url)?;
1437+
if args.iter().any(|arg| arg == "--relying-party-policy")
1438+
&& !Path::new(TRUST_OUTPOST_POLICY).is_file()
1439+
{
1440+
bail!("NVIDIA attestation proxy is configured but {TRUST_OUTPOST_POLICY} is missing");
1441+
}
1442+
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
1443+
let output = run_command(NVATTEST, &args, ATTESTATION_TIMEOUT).await?;
14011444
if !output.stderr.is_empty() {
14021445
info!("nvattest: {}", truncated_lossy(&output.stderr, 2048));
14031446
}
@@ -1556,6 +1599,35 @@ mod gpu {
15561599
assert_eq!(nvidia_gpu_count(nvidia).unwrap(), 2);
15571600
}
15581601

1602+
#[test]
1603+
fn proxy_routes_ocsp_and_rim_and_selects_outpost_policy() {
1604+
let nonce = format!("test-nonce-{}", std::process::id());
1605+
let args = nvattest_args(&nonce, Some("http://10.0.2.2:8090/")).unwrap();
1606+
assert!(args
1607+
.windows(2)
1608+
.any(|args| args == ["--ocsp-url", "http://10.0.2.2:8090/ocsp"]));
1609+
assert!(args
1610+
.windows(2)
1611+
.any(|args| args == ["--rim-url", "http://10.0.2.2:8090"]));
1612+
assert!(args
1613+
.windows(2)
1614+
.any(|args| args == ["--relying-party-policy", TRUST_OUTPOST_POLICY]));
1615+
1616+
let direct = nvattest_args(&nonce, None).unwrap();
1617+
assert!(!direct.iter().any(|arg| arg == "--ocsp-url"));
1618+
assert!(!direct.iter().any(|arg| arg == "--relying-party-policy"));
1619+
}
1620+
1621+
#[test]
1622+
fn proxy_url_validation_is_fail_closed() {
1623+
let nonce = format!("test-nonce-{}", std::process::id());
1624+
assert!(nvattest_args(&nonce, Some("file:///tmp/proxy")).is_err());
1625+
assert!(nvattest_args(&nonce, Some("https://user@example.com")).is_err());
1626+
assert!(nvattest_args(&nonce, Some("https://example.com?q=1")).is_err());
1627+
assert!(nvattest_args(&nonce, Some("https://example.com/base")).is_err());
1628+
assert!(normalize_proxy_url(Some(" ")).unwrap().is_none());
1629+
}
1630+
15591631
#[test]
15601632
fn basic_policy_requires_cc_and_rejects_devtools_by_default() {
15611633
let nonce = "44".repeat(32);
@@ -1874,7 +1946,14 @@ impl Stage0<'_> {
18741946
}
18751947
self.vmm.notify_q("boot.progress", "attesting GPU").await;
18761948
info!("verifying GPU TEE attestation");
1877-
let attestation = gpu::attest_gpu(expected_devices).await?;
1949+
let attestation = gpu::attest_gpu(
1950+
expected_devices,
1951+
self.shared
1952+
.sys_config
1953+
.nvidia_attestation_proxy_url
1954+
.as_deref(),
1955+
)
1956+
.await?;
18781957

18791958
let gpu_state = gpu::query_gpu_state(expected_devices)?;
18801959
attestation
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
[package]
6+
name = "dstack-nvidia-attest-proxy"
7+
version.workspace = true
8+
authors.workspace = true
9+
edition.workspace = true
10+
license.workspace = true
11+
12+
[dependencies]
13+
anyhow.workspace = true
14+
base64.workspace = true
15+
bytes.workspace = true
16+
futures.workspace = true
17+
chrono.workspace = true
18+
clap = { workspace = true, features = ["env"] }
19+
dashmap.workspace = true
20+
hex = { workspace = true, features = ["alloc"] }
21+
http.workspace = true
22+
http-body-util.workspace = true
23+
humantime.workspace = true
24+
hyper = { workspace = true, features = ["server"] }
25+
hyper-util = { workspace = true, features = ["tokio"] }
26+
reqwest.workspace = true
27+
serde.workspace = true
28+
serde_json.workspace = true
29+
sha2.workspace = true
30+
tokio = { workspace = true, features = ["full"] }
31+
tracing.workspace = true
32+
tracing-subscriber.workspace = true
33+
url.workspace = true
34+
35+
[dev-dependencies]
36+
tempfile.workspace = true

0 commit comments

Comments
 (0)