Skip to content

Commit efdaf39

Browse files
authored
Merge pull request #791 from Dstack-TEE/hapi-gpu-ocsp-cache
feat: cache NVIDIA GPU attestation collateral
2 parents a327752 + 3afdc16 commit efdaf39

19 files changed

Lines changed: 2470 additions & 23 deletions

File tree

docs/security/cvm-boundaries.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 29 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
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# NVIDIA GPU Attestation Proxy
2+
3+
`dstack-nvidia-attest-proxy` is a persistent, PCCS-like cache for the two NVIDIA
4+
services used by local GPU attestation:
5+
6+
- `POST /ocsp` caches DER OCSP responses by the request's certificate IDs. The
7+
per-request OCSP nonce is deliberately excluded from the key.
8+
- `GET /v1/rim/<id>` caches version-addressed NVIDIA RIM documents.
9+
- `GET /healthz` reports process health.
10+
- `GET /info` reports fresh/stale entry counts per cache kind.
11+
12+
The proxy never signs or rewrites collateral. `nvattest` in the CVM still
13+
verifies NVIDIA's certificate chains, OCSP signatures, response validity
14+
window, RIM signatures, measurements, and the fresh GPU evidence nonce. Cache
15+
entries are persisted so a warm cache survives both proxy and VM restarts.
16+
17+
## Run
18+
19+
```bash
20+
cargo build --release -p dstack-nvidia-attest-proxy
21+
sudo install -m 0755 target/release/dstack-nvidia-attest-proxy /usr/local/bin/
22+
sudo install -d -m 0750 /var/cache/dstack/nvidia-attest-proxy
23+
24+
sudo /usr/local/bin/dstack-nvidia-attest-proxy \
25+
--listen 0.0.0.0:8090 \
26+
--cache-dir /var/cache/dstack/nvidia-attest-proxy
27+
```
28+
29+
All options have environment-variable equivalents; see `--help`. In
30+
particular, `NV_ATTESTATION_SERVICE_KEY` supplies an optional NVIDIA bearer
31+
token without placing it on the command line.
32+
33+
Configure the VMM with a URL reachable during early guest boot:
34+
35+
```toml
36+
[cvm]
37+
nvidia_attestation_proxy_url = "http://10.0.2.2:8090"
38+
```
39+
40+
For QEMU user-mode networking, `10.0.2.2` is normally the host address. Other
41+
networking modes should use the corresponding host or fleet service address.
42+
43+
## Cache behavior
44+
45+
On a cold miss the original request is forwarded to NVIDIA. A successful OCSP
46+
response is cached no later than its signed `nextUpdate` and no longer than
47+
`--ocsp-max-ttl` (24 hours by default). Responses without `nextUpdate` use one
48+
hour from `thisUpdate`, matching the pinned NVIDIA SDK. RIM documents default
49+
to a 30-day TTL.
50+
51+
Two mechanisms keep entries warm, and they compose: a background sweep (every
52+
`--refresh-interval`, 10 minutes by default) renews entries that have
53+
consumed half their lifetime, so a warm cache rides through an NVIDIA outage
54+
with close to a full validity window instead of only the remainder; entries
55+
past their usefulness are dropped, not retried. As a synchronous fallback, an
56+
OCSP response with less than `--ocsp-refresh-before` validity remaining
57+
(five minutes by default) is refreshed in-line on the next request before it
58+
is served — concurrent refreshes for the same entry are coalesced. If an
59+
in-line refresh fails, the proxy keeps serving the old response only until
60+
its existing signed expiry; it never extends or serves an expired one.
61+
62+
Expired OCSP entries are never served. Therefore a warm cache removes the
63+
NVIDIA service from the boot path only for the signed validity period; it does
64+
not turn revocation checking into an indefinite fail-open. RIM documents are
65+
signed and version-addressed, so an expired RIM entry — unlike OCSP — may
66+
still be served for up to `--rim-max-stale` (7 days by default) when the
67+
upstream is unreachable or failing; the guest verifies its signature either
68+
way. Response headers expose `X-Dstack-Cache: HIT|MISS|REFRESH|STALE`, `Age`,
69+
and `X-Dstack-Cache-Expires` for operations.
70+
Each cache kind is capped at 10,000 entries by default; the oldest entry is
71+
evicted when the limit is reached. Use `--max-cache-entries-per-kind` to tune
72+
the bound for a deployment.
73+
74+
Because a cached response contains the nonce from the request that populated
75+
the cache, `nvattest` reports `x-nvidia-cert-ocsp-nonce-matches = false` on a
76+
hit. When the proxy URL is configured, dstack selects NVIDIA's packaged
77+
`allow_trust_outpost_ocsp.rego` policy. This relaxes only the OCSP nonce check;
78+
the GPU attestation report's independent nonce remains required by dstack.

0 commit comments

Comments
 (0)