From 8286e6220983ccadd8b712242db222a6c058c290 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:39:20 -0700 Subject: [PATCH 1/7] feat(guest-agent): return GPU evidence from Attest on request `Attest` takes a new `AttestArgs` with `include_gpu_evidence`, and `AttestResponse` gains `gpu_evidence` carrying the same boot-time nvattest bytes `GpuInfo` serves. A verifier that wants to check a GPU launch needs the quote, the runtime event log and that evidence together; it can now get all three in one round trip instead of pairing `Attest` with a second `GpuInfo` call. `Attest` gets its own request message rather than a flag on the shared `RawQuoteArgs`, which `GetQuote` and the legacy `Tappd.RawQuote` also use and which have no business growing a GPU option. `AttestArgs` keeps `report_data` at field 1 so the wire format is unchanged for existing callers, and reserves 2 and 3: those numbers carried `include_ccel` and `include_preimages` while this RPC took a `RawQuoteArgs`, and both were bools, so a new bool there would read a pre-0.6.0 client's `include_ccel = true` as something else entirely. The flag is handled in the RPC layer rather than the platform backend because the evidence is a file the boot wrote, not a platform quote. It stays opt-in so callers that do not care about GPUs neither pay the disk read nor carry the payload. --- .../src/bin/dstack-kms-sign-cert-fixture.rs | 5 ++- dstack/gateway/src/distributed_certbot.rs | 18 +++++++-- dstack/guest-agent-simulator/src/simulator.rs | 1 + dstack/guest-agent/rpc/proto/agent_rpc.proto | 25 +++++++++++- dstack/guest-agent/src/backend.rs | 3 ++ dstack/guest-agent/src/rpc_service.rs | 39 ++++++++++++++++--- .../kms/src/main_service/upgrade_authority.rs | 11 ++++-- 7 files changed, 86 insertions(+), 16 deletions(-) diff --git a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs index 3451c5535..714f02602 100644 --- a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs +++ b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs @@ -3,7 +3,7 @@ //! Generate a v2 KMS CSR whose key is bound to fresh guest attestation. use anyhow::{Context, Result}; -use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, RawQuoteArgs}; +use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, AttestArgs}; use http_client::prpc::PrpcClient; use ra_tls::{ attestation::{PlatformEvidence, QuoteContentType, VersionedAttestation}, @@ -31,8 +31,9 @@ async fn main() -> Result<()> { let address = dstack_types::dstack_agent_address(); let client = DstackGuestClient::new(PrpcClient::new(address)); let response = client - .attest(RawQuoteArgs { + .attest(AttestArgs { report_data: report_data.to_vec(), + include_gpu_evidence: false, }) .await .context("failed to obtain key-bound guest attestation")?; diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index b9193691d..46cf53b51 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use certbot::{AcmeClient, Dns01Client}; -use dstack_guest_agent_rpc::RawQuoteArgs; +use dstack_guest_agent_rpc::{AttestArgs, RawQuoteArgs}; use ra_tls::attestation::QuoteContentType; use ra_tls::rcgen::KeyPair; use tokio::sync::Mutex; @@ -658,7 +658,13 @@ impl DistributedCertBot { }; // Get attestation - let attestation_str = match agent.attest(RawQuoteArgs { report_data }).await { + let attestation_str = match agent + .attest(AttestArgs { + report_data, + include_gpu_evidence: false, + }) + .await + { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!("failed to get attestation for ACME account: {err:?}"); @@ -734,7 +740,13 @@ impl DistributedCertBot { }; // Get attestation - let attestation = match agent.attest(RawQuoteArgs { report_data }).await { + let attestation = match agent + .attest(AttestArgs { + report_data, + include_gpu_evidence: false, + }) + .await + { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!(domain, "failed to get attestation: {err:?}"); diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 92df0950b..9c643048b 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -73,6 +73,7 @@ pub fn simulated_attest_response( }; Ok(AttestResponse { attestation: attestation.to_bytes()?, + gpu_evidence: String::new(), }) } diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 1a3552c5c..f8980664c 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -53,7 +53,7 @@ service DstackGuest { // Generates a versioned attestation with the given report data. // Returns a dstack-defined attestation format that supports different attestation modes across platforms. - rpc Attest(RawQuoteArgs) returns (AttestResponse) {} + rpc Attest(AttestArgs) returns (AttestResponse) {} // Get app info rpc Info(google.protobuf.Empty) returns (AppInfo) {} @@ -180,6 +180,20 @@ message RawQuoteArgs { bytes report_data = 1; } +// The request to get a versioned attestation +message AttestArgs { + // 64 bytes of report data + bytes report_data = 1; + // Field 2 and 3 carried `include_ccel` and `include_preimages` while this RPC + // took a `RawQuoteArgs`. Both were bools, so reusing either number here would + // make a pre-0.6.0 client's `include_ccel = true` arrive as a request for + // something else entirely. + reserved 2, 3; + reserved "include_ccel", "include_preimages"; + // Also return the boot-time GPU attestation evidence in `gpu_evidence`. + bool include_gpu_evidence = 4; +} + message TdxQuoteResponse { // TDX quote bytes quote = 1; @@ -196,6 +210,15 @@ message TdxQuoteResponse { message AttestResponse { // The attestation bytes attestation = 1; + // Complete JSON output produced by nvattest at boot, the same bytes `GpuInfo` + // serves. Only `DstackGuest.Attest` populates it, and only when the request set + // `include_gpu_evidence` and boot-time GPU attestation output exists. + // + // Not bound to `report_data`: nvattest ran at boot against its own nonce, so a + // fresh `report_data` says nothing about it. Bind it by replaying the runtime + // event log and comparing sha256 of these exact UTF-8 bytes against the + // `evidence_sha256` field of the measured `gpu-attestation` event. + string gpu_evidence = 2; } message GpuInfoResponse { diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index d2347cdef..00d96e722 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -50,6 +50,9 @@ impl PlatformBackend for RealPlatform { attestation.fill_event_preimages(); Ok(AttestResponse { attestation: attestation.into_versioned().to_bytes()?, + // Filled in by the RPC layer when the caller asks for it: GPU + // evidence is a file the boot wrote, not a platform quote. + gpu_evidence: String::new(), }) } } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index e3ee8fc35..8a9d15103 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -15,10 +15,10 @@ use dstack_guest_agent_rpc::{ dstack_guest_server::{DstackGuestRpc, DstackGuestServer}, tappd_server::{TappdRpc, TappdServer}, worker_server::{WorkerRpc, WorkerServer}, - AppInfo, AttestAppKeyRequest, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, GetKeyArgs, - GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, GpuInfoResponse, - HealthResponse, RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, - WorkerVersion, + AppInfo, AttestAppKeyRequest, AttestArgs, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, + GetKeyArgs, GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, + GpuInfoResponse, HealthResponse, RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, + TdxQuoteResponse, WorkerVersion, }; use dstack_types::{AppKeys, SysConfig, GPU_ATTESTATION_OUTPUT}; use ed25519_dalek::ed25519::signature::hazmat::PrehashSigner; @@ -65,6 +65,16 @@ fn read_gpu_attestation(path: &Path) -> String { } } +/// GPU evidence to return alongside an attestation. Opt-in, so a caller that +/// does not care about GPUs neither pays the disk read nor carries the payload. +fn attest_gpu_evidence(include: bool, path: &Path) -> String { + if include { + read_gpu_attestation(path) + } else { + String::new() + } +} + #[derive(Clone)] pub struct AppState { inner: Arc, @@ -443,9 +453,14 @@ impl DstackGuestRpc for InternalRpcHandler { }) } - async fn attest(self, request: RawQuoteArgs) -> Result { + async fn attest(self, request: AttestArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; - self.state.attest_response(report_data) + let mut response = self.state.attest_response(report_data)?; + response.gpu_evidence = attest_gpu_evidence( + request.include_gpu_evidence, + Path::new(GPU_ATTESTATION_OUTPUT), + ); + Ok(response) } async fn version(self) -> Result { @@ -748,6 +763,17 @@ mod tests { assert_eq!(read_gpu_attestation(output.path()), attestation); } + #[test] + fn attest_returns_gpu_evidence_only_when_requested() { + let mut output = tempfile::NamedTempFile::new().unwrap(); + let evidence = r#"{"result_code":0,"claims":[]}"#; + output.write_all(evidence.as_bytes()).unwrap(); + output.flush().unwrap(); + + assert_eq!(attest_gpu_evidence(true, output.path()), evidence); + assert_eq!(attest_gpu_evidence(false, output.path()), ""); + } + #[test] fn missing_gpu_attestation_output_reads_as_empty() { let dir = tempfile::tempdir().unwrap(); @@ -955,6 +981,7 @@ pNs85uhOZE8z2jr8Pg== let attestation = patch_report_data(&self.attestation, report_data); Ok(AttestResponse { attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, + gpu_evidence: String::new(), }) } } diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index 9340dd650..a4f4d8730 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -5,9 +5,7 @@ use super::build_boot_info_for_attestation; use crate::config::{AuthApi, KmsConfig}; use anyhow::{bail, Context, Result}; -use dstack_guest_agent_rpc::{ - dstack_guest_client::DstackGuestClient, AttestResponse, RawQuoteArgs, -}; +use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, AttestArgs, AttestResponse}; use http_client::prpc::PrpcClient; use ra_tls::attestation::{AttestationVerifier, VerifiedAttestation, VersionedAttestation}; use serde::de::DeserializeOwned; @@ -182,7 +180,12 @@ pub(crate) fn dstack_client() -> DstackGuestClient { } pub(crate) async fn app_attest(report_data: Vec) -> Result { - dstack_client().attest(RawQuoteArgs { report_data }).await + dstack_client() + .attest(AttestArgs { + report_data, + include_gpu_evidence: false, + }) + .await } pub(crate) fn pad64(hash: [u8; 32]) -> Vec { From 5a56f1f43ff5425be07cbfd2d19a96408bf2b119 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:39:26 -0700 Subject: [PATCH 2/7] feat(sdk): expose the Attest GPU evidence option in all four SDKs Each SDK grows the request flag and the response field without breaking an existing call site, using whatever that language already does for optional arguments: Rust adds `attest_with(AttestConfig)` alongside `attest`, built with the same `bon` builder `get_tls_key` uses; Go adds `AttestWithOptions` and `Attest` delegates to it with a zero-value `AttestOptions`; Python and JS take a defaulted parameter. All four default `gpu_evidence` to an empty string when the server omits it, so a new SDK still works against an agent that predates the field. --- sdk/curl/api.md | 15 ++++++++--- sdk/go/README.md | 17 ++++++++++++ sdk/go/dstack/client.go | 26 ++++++++++++++++-- sdk/go/dstack/client_test.go | 31 ++++++++++++++++++++++ sdk/js/README.md | 13 ++++++++- sdk/js/src/__tests__/index.test.ts | 10 +++++++ sdk/js/src/index.ts | 24 ++++++++++++++--- sdk/python/README.md | 12 +++++++++ sdk/python/src/dstack_sdk/dstack_client.py | 25 ++++++++++++++--- sdk/python/tests/test_client.py | 16 +++++++++++ sdk/rust/README.md | 18 +++++++++++++ sdk/rust/src/dstack_client.rs | 20 +++++++++++--- sdk/rust/tests/test_client.rs | 25 ++++++++++++++++- sdk/rust/types/src/dstack.rs | 22 +++++++++++++++ 14 files changed, 257 insertions(+), 17 deletions(-) diff --git a/sdk/curl/api.md b/sdk/curl/api.md index be19180f7..02f86b00e 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -238,6 +238,7 @@ You can submit the returned `attestation` directly to the verifier `/verify` end | Field | Type | Description | Example | |-------|------|-------------|----------| | `report_data` | string | Report data of max length 64 bytes. Padding with 0s if less than 64 bytes. | `"1234deadbeaf"` | +| `include_gpu_evidence` | boolean | Optional, defaults to `false`. Also returns the boot-time GPU attestation evidence in `gpu_evidence`. | `true` | **Example:** ```bash @@ -245,21 +246,29 @@ curl --unix-socket /var/run/dstack.sock -X POST \ http://dstack/Attest \ -H 'Content-Type: application/json' \ -d '{ - "report_data": "1234deadbeaf" + "report_data": "1234deadbeaf", + "include_gpu_evidence": true }' ``` Or ```bash -curl --unix-socket /var/run/dstack.sock http://dstack/Attest?report_data=00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +curl --unix-socket /var/run/dstack.sock 'http://dstack/Attest?report_data=1234deadbeaf&include_gpu_evidence=true' ``` **Response:** ```json { - "attestation": "" + "attestation": "", + "gpu_evidence": "{\"result_code\": 0, \"claims\": [...]}" } ``` +`gpu_evidence` carries the same bytes [`GpuInfo`](#7-gpu-info) serves, so one call +returns both the quote and the GPU evidence a verifier needs. It is empty unless +`include_gpu_evidence` was set and boot-time GPU attestation output exists. It is +**not** bound to `report_data` — authenticate it with the `evidence_sha256` +procedure documented under `GpuInfo` below. + ### 7. GPU Info Returns GPU information collected during boot. Currently, this includes the diff --git a/sdk/go/README.md b/sdk/go/README.md index bda275b24..9140d6bc5 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -575,6 +575,23 @@ instead. - Cryptographic proof of execution environment - Audit trail generation +##### `AttestWithOptions(ctx context.Context, reportData []byte, opts AttestOptions) (*AttestResponse, error)` + +Same as `Attest`, with options. Set `IncludeGpuEvidence` to also return the boot-time +GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. + +```go +resp, err := client.AttestWithOptions(ctx, reportData, dstack.AttestOptions{IncludeGpuEvidence: true}) +if err != nil { + log.Fatal(err) +} +fmt.Println(resp.GpuEvidence) +``` + +The evidence is the same bytes ``GpuInfo`` serves and is empty unless the flag was set +and boot-time GPU attestation output exists. It is not bound to `report_data`; verify +it with the measured `gpu-attestation` event digest as described under ``GpuInfo``. + ##### `GpuInfo(ctx context.Context) (*GpuInfoResponse, error)` Returns GPU information collected during boot. Currently, this includes the diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 8985cae34..6b2e6ee5e 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -111,6 +111,20 @@ func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { // Represents the response from an attestation request. type AttestResponse struct { Attestation []byte + // GpuEvidence is the complete JSON output produced by nvattest during guest + // boot. Empty unless the request set IncludeGpuEvidence and the guest has + // boot-time GPU attestation output. + // + // It is not bound to reportData: verify it by replaying the runtime event log + // and comparing sha256 of these exact UTF-8 bytes against evidence_sha256 in + // the `gpu-attestation` event. + GpuEvidence string +} + +// AttestOptions tunes what an Attest call returns. +type AttestOptions struct { + // IncludeGpuEvidence also returns the boot-time GPU attestation evidence. + IncludeGpuEvidence bool } // GpuInfoResponse contains GPU information collected during boot. @@ -503,12 +517,19 @@ func (c *DstackClient) GetQuote(ctx context.Context, reportData []byte) (*GetQuo // Gets a versioned attestation from the dstack service. func (c *DstackClient) Attest(ctx context.Context, reportData []byte) (*AttestResponse, error) { + return c.AttestWithOptions(ctx, reportData, AttestOptions{}) +} + +// Gets a versioned attestation from the dstack service, optionally bundling the +// boot-time GPU attestation evidence so a verifier can check both in one round trip. +func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, opts AttestOptions) (*AttestResponse, error) { if len(reportData) > 64 { return nil, fmt.Errorf("report data is too large, it should be at most 64 bytes") } payload := map[string]interface{}{ - "report_data": hex.EncodeToString(reportData), + "report_data": hex.EncodeToString(reportData), + "include_gpu_evidence": opts.IncludeGpuEvidence, } data, err := c.sendRPCRequest(ctx, "/Attest", payload) @@ -518,6 +539,7 @@ func (c *DstackClient) Attest(ctx context.Context, reportData []byte) (*AttestRe var response struct { Attestation string `json:"attestation"` + GpuEvidence string `json:"gpu_evidence"` } if err := json.Unmarshal(data, &response); err != nil { return nil, err @@ -528,7 +550,7 @@ func (c *DstackClient) Attest(ctx context.Context, reportData []byte) (*AttestRe return nil, err } - return &AttestResponse{Attestation: attestation}, nil + return &AttestResponse{Attestation: attestation, GpuEvidence: response.GpuEvidence}, nil } // GpuInfo returns GPU information collected during boot. diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 0ca8d1185..4010fadc5 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -78,6 +78,37 @@ func TestAttest(t *testing.T) { } } +func TestAttestWithGpuEvidence(t *testing.T) { + const evidence = `{"result_code":0,"claims":[]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/Attest" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + if payload["include_gpu_evidence"] != true { + t.Fatalf("expected include_gpu_evidence to be forwarded, got: %v", payload["include_gpu_evidence"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "attestation": "deadbeef", + "gpu_evidence": evidence, + }) + })) + defer server.Close() + + client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) + resp, err := client.AttestWithOptions(context.Background(), []byte("test"), dstack.AttestOptions{IncludeGpuEvidence: true}) + if err != nil { + t.Fatal(err) + } + if resp.GpuEvidence != evidence { + t.Fatalf("unexpected gpu evidence: %s", resp.GpuEvidence) + } +} + func TestGpuInfo(t *testing.T) { const attestation = `{"result_code":0,"claims":[]}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/sdk/js/README.md b/sdk/js/README.md index 7594e3eb1..62f979e21 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -87,7 +87,7 @@ quote.quote // hex-encoded TDX quote quote.event_log // JSON string of measured events ``` -### `attest(reportData)` +### `attest(reportData, includeGpuEvidence?)` Versioned dstack attestation that works across TDX / GCP / Nitro providers. Preferred for cross-platform verifiers. @@ -95,6 +95,17 @@ Versioned dstack attestation that works across TDX / GCP / Nitro providers. Pref const { attestation } = await client.attest('app-state-snapshot') ``` +Pass `true` as the second argument to also return the boot-time GPU attestation +evidence, so a verifier gets the quote and the GPU evidence in one round trip. + +```typescript +const { attestation, gpu_evidence } = await client.attest('app-state-snapshot', true) +``` + +The evidence is the same bytes ``gpuInfo()`` serves and is empty unless the flag was set +and boot-time GPU attestation output exists. It is not bound to `report_data`; verify +it with the measured `gpu-attestation` event digest as described under ``gpuInfo()``. + ### `gpuInfo()` Returns GPU information collected during boot. Currently, this includes the diff --git a/sdk/js/src/__tests__/index.test.ts b/sdk/js/src/__tests__/index.test.ts index c5cc3fa6d..232fe3169 100644 --- a/sdk/js/src/__tests__/index.test.ts +++ b/sdk/js/src/__tests__/index.test.ts @@ -53,6 +53,16 @@ describe('DstackClient', () => { const result = await client.attest('test') expect(result).toHaveProperty('attestation') expect(result.attestation).not.toBe('') + expect(result.gpu_evidence).toBe('') + }) + + it('should be able to attest with gpu evidence', async () => { + const client = new DstackClient() + const result = await client.attest('test', true) + expect(result).toHaveProperty('attestation') + expect(result.attestation).not.toBe('') + // Whether evidence exists depends on the host; assert the field is present. + expect(result).toHaveProperty('gpu_evidence') }) it('should able to get derive key result as uint8array', async () => { diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index dc9f64e8e..168a838fb 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -101,6 +101,17 @@ export interface AttestResponse { __name__: Readonly<'AttestResponse'> attestation: Hex + + /** + * Complete JSON output produced by nvattest during guest boot. Empty unless the + * request set `include_gpu_evidence` and the guest has boot-time GPU attestation + * output. + * + * Not bound to `report_data`: verify it by replaying the runtime event log and + * comparing sha256 of these exact UTF-8 bytes against `evidence_sha256` in the + * `gpu-attestation` event. + */ + gpu_evidence: string } export interface GpuInfoResponse { @@ -285,13 +296,19 @@ export class DstackClient { return Object.freeze(result) } - async attest(report_data: string | Buffer | Uint8Array): Promise { + /** + * Requests a versioned attestation for the given report data. + * + * Pass `include_gpu_evidence` to also return the boot-time GPU attestation + * evidence in `gpu_evidence`, so a verifier can check both in one round trip. + */ + async attest(report_data: string | Buffer | Uint8Array, include_gpu_evidence: boolean = false): Promise { let hex = to_hex(report_data) if (hex.length > 128) { throw new Error(`Report data is too large, it should be less than 64 bytes.`) } - const payload = JSON.stringify({ report_data: hex }) - const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) + const payload = JSON.stringify({ report_data: hex, include_gpu_evidence }) + const result = await send_rpc_request<{ attestation: string, gpu_evidence?: string }>(this.endpoint, '/Attest', payload) if ('error' in (result as any)) { const err = (result as any)['error'] as string throw new Error(err) @@ -299,6 +316,7 @@ export class DstackClient { return Object.freeze({ __name__: 'AttestResponse', attestation: result.attestation as Hex, + gpu_evidence: result.gpu_evidence ?? '', }) } diff --git a/sdk/python/README.md b/sdk/python/README.md index 8788a7269..d1e1cab3d 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -100,6 +100,18 @@ print(result.attestation) # hex string print(result.decode_attestation()) # bytes ``` +Pass `include_gpu_evidence=True` to also return the boot-time GPU attestation +evidence, so a verifier gets the quote and the GPU evidence in one round trip. + +```python +result = client.attest(b'user:alice:nonce123', include_gpu_evidence=True) +print(result.gpu_evidence) +``` + +The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set +and boot-time GPU attestation output exists. It is not bound to `report_data`; verify +it with the measured `gpu-attestation` event digest as described under ``gpu_info()``. + ### GPU Info `gpu_info()` returns GPU information collected during boot. Currently, this diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 96b40e577..5faf83050 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -152,6 +152,12 @@ def decode_event_log(self) -> "List[EventLog]": class AttestResponse(BaseModel): attestation: str + # Complete JSON output produced by nvattest during guest boot. Empty unless + # the request set include_gpu_evidence and the guest has boot-time GPU + # attestation output. Not bound to report_data: verify it by replaying the + # runtime event log and comparing sha256 of these exact UTF-8 bytes against + # evidence_sha256 in the `gpu-attestation` event. + gpu_evidence: str = "" def decode_attestation(self) -> bytes: return bytes.fromhex(self.attestation) @@ -427,8 +433,13 @@ async def get_quote( async def attest( self, report_data: str | bytes, + include_gpu_evidence: bool = False, ) -> AttestResponse: - """Request a versioned attestation for the provided report data.""" + """Request a versioned attestation for the provided report data. + + Set include_gpu_evidence to also return the boot-time GPU attestation + evidence in AttestResponse.gpu_evidence. + """ if not report_data or not isinstance(report_data, (bytes, str)): raise ValueError("report_data can not be empty") report_bytes: bytes = ( @@ -437,7 +448,10 @@ async def attest( if len(report_bytes) > 64: raise ValueError("report_data must be less than 64 bytes") hex = binascii.hexlify(report_bytes).decode() - result = await self._send_rpc_request("Attest", {"report_data": hex}) + result = await self._send_rpc_request( + "Attest", + {"report_data": hex, "include_gpu_evidence": include_gpu_evidence}, + ) return AttestResponse(**result) async def gpu_info(self) -> GpuInfoResponse: @@ -569,8 +583,13 @@ def get_quote( def attest( self, report_data: str | bytes, + include_gpu_evidence: bool = False, ) -> AttestResponse: - """Request a versioned attestation for the provided report data.""" + """Request a versioned attestation for the provided report data. + + Set include_gpu_evidence to also return the boot-time GPU attestation + evidence in AttestResponse.gpu_evidence. + """ raise NotImplementedError @call_async diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 80724a22c..705c1a6fe 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -121,6 +121,22 @@ async def test_async_client_attest(): assert len(result.attestation) > 0 +@pytest.mark.asyncio +async def test_async_client_attest_gpu_evidence(monkeypatch): + evidence = '{"result_code":0,"claims":[]}' + + async def fake_send(self, method, payload): + assert method == "Attest" + assert payload["include_gpu_evidence"] is True + return {"attestation": "deadbeef", "gpu_evidence": evidence} + + monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") + monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) + result = await AsyncDstackClient().attest("test", include_gpu_evidence=True) + assert isinstance(result, AttestResponse) + assert result.gpu_evidence == evidence + + @pytest.mark.asyncio async def test_async_client_gpu_info(monkeypatch): attestation = '{"result_code":0,"claims":[]}' diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 2f00b0cec..2014e4770 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -103,6 +103,24 @@ println!("{}", info.tcb_info); #### `attest(report_data: Vec) -> AttestResponse` Generates a versioned attestation with a custom 64-byte payload. - `attestation`: Hex-encoded attestation +- `gpu_evidence`: Boot-time GPU attestation evidence, empty unless requested + +#### `attest_with(config: AttestConfig) -> AttestResponse` +Same, with options. Set `include_gpu_evidence` to also return the boot-time GPU +attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. + +```rust +let config = AttestConfig::builder() + .report_data(hex::encode(b"user:alice:nonce123")) + .include_gpu_evidence(true) + .build(); +let result = client.attest_with(config).await?; +println!("{}", result.gpu_evidence); +``` + +The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set +and boot-time GPU attestation output exists. It is not bound to `report_data`; verify +it with the measured `gpu-attestation` event digest as described under ``gpu_info()``. #### `gpu_info() -> GpuInfoResponse` diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client.rs index c6f824eeb..eb8e60200 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -4,7 +4,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use anyhow::Result; +use anyhow::{Context, Result}; use hex::encode as hex_encode; use http_client_unix_domain_socket::{ClientUnix, Method}; use reqwest::Client; @@ -152,13 +152,25 @@ impl DstackClient { Ok(response) } + /// Requests a versioned attestation for the given report data. pub async fn attest(&self, report_data: Vec) -> Result { + self.attest_with( + AttestConfig::builder() + .report_data(hex_encode(&report_data)) + .build(), + ) + .await + } + + /// Requests a versioned attestation, optionally bundling the boot-time GPU + /// attestation evidence so a verifier can check both in one round trip. + pub async fn attest_with(&self, config: AttestConfig) -> Result { + let report_data = + hex::decode(&config.report_data).context("Invalid report data encoding")?; if report_data.is_empty() || report_data.len() > 64 { anyhow::bail!("Invalid report data length") } - let hex_data = hex_encode(report_data); - let data = json!({ "report_data": hex_data }); - let response = self.send_rpc_request("/Attest", &data).await?; + let response = self.send_rpc_request("/Attest", &config).await?; let response = serde_json::from_value::(response)?; Ok(response) diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 6bd636c0d..b230e75a1 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -6,7 +6,7 @@ // SPDX-License-Identifier: Apache-2.0 use dcap_qvl::quote::Quote; -use dstack_sdk::dstack_client::DstackClient as AsyncDstackClient; +use dstack_sdk::dstack_client::{AttestConfig, DstackClient as AsyncDstackClient}; use dstack_sdk::verify::verify_signature; use sha2::{Digest, Sha256}; @@ -31,11 +31,34 @@ async fn test_async_client_attest() { let result = client.attest(b"test".to_vec()).await.unwrap(); let attestation = result.decode_attestation().unwrap(); assert!(!attestation.is_empty()); + assert!(result.gpu_evidence.is_empty()); let too_large = client.attest(vec![0_u8; 65]).await; assert!(too_large.is_err()); } +#[tokio::test] +async fn test_async_client_attest_with_gpu_evidence() { + let client = AsyncDstackClient::new(None); + let config = AttestConfig::builder() + .report_data(hex::encode(b"test")) + .include_gpu_evidence(true) + .build(); + let result = client.attest_with(config).await.unwrap(); + assert!(!result.decode_attestation().unwrap().is_empty()); + // Whether evidence exists depends on the host, so assert the request + // round-trips and the field is populated from the same source as GpuInfo. + assert_eq!( + result.gpu_evidence, + client.gpu_info().await.unwrap().attestation + ); + + let too_large = AttestConfig::builder() + .report_data(hex::encode([0_u8; 65])) + .build(); + assert!(client.attest_with(too_large).await.is_err()); +} + #[tokio::test] async fn test_async_client_get_tls_key() { let client = AsyncDstackClient::new(None); diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index 18144e08b..d404f1679 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -96,6 +96,28 @@ pub struct GetQuoteResponse { pub struct AttestResponse { /// The attestation in hexadecimal format pub attestation: String, + /// Complete JSON output produced by nvattest during guest boot. Empty + /// unless the request set `include_gpu_evidence` and the guest has + /// boot-time GPU attestation output. + /// + /// Not bound to `report_data`: verify it by replaying the runtime event log + /// and comparing sha256 of these exact UTF-8 bytes against + /// `evidence_sha256` in the `gpu-attestation` event. + #[serde(default)] + pub gpu_evidence: String, +} + +/// Configuration for a versioned attestation request +#[derive(Debug, bon::Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct AttestConfig { + /// The report data in hexadecimal format, at most 64 bytes once decoded + #[builder(into)] + pub report_data: String, + /// Also return the boot-time GPU attestation evidence in `gpu_evidence` + #[builder(default = false)] + pub include_gpu_evidence: bool, } /// Response containing the complete NVIDIA GPU attestation output. From dd58608b4ed0ab20bd742f3049d85ea2ba6bbee7 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:39:31 -0700 Subject: [PATCH 3/7] docs: note that Attest can carry the GPU evidence The attestation docs told verifiers to fetch GPU evidence from `GpuInfo`. `Attest` now serves the same bytes, so point at both and keep the binding procedure unchanged: the evidence is authenticated by `evidence_sha256` in the measured `gpu-attestation` event either way, never by `report_data`. --- CHANGELOG.md | 1 + docs/attestation-tdx.md | 2 +- docs/security/security-model.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45aa253bb..7bd41100e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - sdk: `verify_signature` and `verify_signature_chain` in all four SDKs, replacing the removed guest-agent `Verify` RPC. `verify_signature_chain` is new capability rather than a port: it walks all three links of a `Sign` signature chain -- payload signature, the app root key attesting `"{purpose}:{hex(pubkey)}"`, and the KMS root attesting that app root for this `app_id` -- and requires the chain to anchor at a KMS root public key **the caller supplies**. That anchor has to come from somewhere independently trusted (the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a pinned value); read it from the KMS being checked and an attacker who can answer that query can also mint a self-consistent chain. The four ports are pinned against one committed set of test vectors, `sdk/tests/vectors/signature_chain.json`, generated from the real KMS and guest-agent primitives -- this repo has shipped cross-language crypto drift twice already +- guest-agent: `Attest` accepts `include_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip instead of also calling `GpuInfo`. Exposed in the Rust, Python, Go and JS SDKs - sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs - shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796) - gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer diff --git a/docs/attestation-tdx.md b/docs/attestation-tdx.md index a71533ef1..bee9f0458 100644 --- a/docs/attestation-tdx.md +++ b/docs/attestation-tdx.md @@ -45,7 +45,7 @@ evaluation, `gpu-attestation`. The `gpu-policy-hash` payload is The `gpu-attestation` payload is JSON containing the verified device count, CC/DevTools state, and `evidence_sha256`. -The guest-agent `GpuInfo` API returns the complete `nvattest` JSON captured during boot. It is not trustworthy by itself. After verifying the TDX quote and replaying the event log to RTMR3, hash the exact UTF-8 bytes of `GpuInfo.attestation` and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. +The guest-agent `GpuInfo` API returns the complete `nvattest` JSON captured during boot; `Attest` returns the same bytes in `gpu_evidence` when called with `include_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip. It is not trustworthy by itself. After verifying the TDX quote and replaying the event log to RTMR3, hash the exact UTF-8 bytes of `GpuInfo.attestation` (or `Attest.gpu_evidence`) and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. ### 2.2. Determining expected MRs MRTD, RTMR0, RTMR1, and RTMR2 correspond to the image. dstack OS builds all related software from source. diff --git a/docs/security/security-model.md b/docs/security/security-model.md index 72791944e..2f3a269c7 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -144,7 +144,7 @@ boot-mr-done } ``` -`GpuInfo` returns that complete boot-time `nvattest` JSON in its `attestation` string; it does not run a new attestation. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, and compare `evidence_sha256` with `SHA-256(UTF-8(GpuInfo.attestation))`. Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the returned string. +`GpuInfo` returns that complete boot-time `nvattest` JSON in its `attestation` string, and `Attest` returns the same bytes in `gpu_evidence` when called with `include_gpu_evidence`; neither runs a new attestation. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, and compare `evidence_sha256` with `SHA-256(UTF-8(GpuInfo.attestation))` (equivalently `SHA-256(UTF-8(Attest.gpu_evidence))`). Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the returned string. A verifier must replay the measured event log, require exactly one `gpu-policy-hash` event immediately after `compose-hash`, and compare its 32-byte payload with the expected policy digest (`SHA-256(JCS({}))` for the omitted/default policy). When MrConfigV3 includes `gpu_policy_hash`, it must match the same digest. When GPU protection is required, the verifier must also require exactly one pre-`system-ready` `gpu-attestation` event with `devices > 0` and, when applicable, the expected deployment count. The raw `attestation.out` file is not trusted by itself; if it is supplied for inspection, its digest must match the `gpu-attestation` event. From 4a8b76e97a42c1eacd7386aaee74a3f840aa96ec Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 20:00:54 -0700 Subject: [PATCH 4/7] refactor: name the field boottime_gpu_evidence `Attest(report_data, include_gpu_evidence = true)` reads as though the GPU evidence answers the caller's challenge. It does not: nvattest ran at boot against its own nonce, and the response carries a historical record. That misreading is the specific hazard the GPU evidence design warns about -- a relying party believing a GPU figure says more than it does -- so put the answer in the name rather than only in a comment. The proto now also states why sampling the GPU at attestation time would not be an improvement: an NVIDIA report binds the device and a nonce but not the TD the device is attached to, so a fresh report can be relayed from a genuine remote GPU. --- CHANGELOG.md | 2 +- docs/attestation-tdx.md | 2 +- docs/security/security-model.md | 2 +- .../src/bin/dstack-kms-sign-cert-fixture.rs | 2 +- dstack/gateway/src/distributed_certbot.rs | 4 ++-- dstack/guest-agent-simulator/src/simulator.rs | 2 +- dstack/guest-agent/rpc/proto/agent_rpc.proto | 16 ++++++++++++---- dstack/guest-agent/src/backend.rs | 2 +- dstack/guest-agent/src/rpc_service.rs | 14 +++++++------- .../kms/src/main_service/upgrade_authority.rs | 2 +- sdk/curl/api.md | 12 ++++++------ sdk/go/README.md | 6 +++--- sdk/go/dstack/client.go | 16 ++++++++-------- sdk/go/dstack/client_test.go | 14 +++++++------- sdk/js/README.md | 4 ++-- sdk/js/src/__tests__/index.test.ts | 4 ++-- sdk/js/src/index.ts | 16 ++++++++-------- sdk/python/README.md | 6 +++--- sdk/python/src/dstack_sdk/dstack_client.py | 18 +++++++++--------- sdk/python/tests/test_client.py | 10 +++++----- sdk/rust/README.md | 8 ++++---- sdk/rust/tests/test_client.rs | 8 ++++---- sdk/rust/types/src/dstack.rs | 8 ++++---- 23 files changed, 93 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd41100e..8ba6ad3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - sdk: `verify_signature` and `verify_signature_chain` in all four SDKs, replacing the removed guest-agent `Verify` RPC. `verify_signature_chain` is new capability rather than a port: it walks all three links of a `Sign` signature chain -- payload signature, the app root key attesting `"{purpose}:{hex(pubkey)}"`, and the KMS root attesting that app root for this `app_id` -- and requires the chain to anchor at a KMS root public key **the caller supplies**. That anchor has to come from somewhere independently trusted (the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a pinned value); read it from the KMS being checked and an attacker who can answer that query can also mint a self-consistent chain. The four ports are pinned against one committed set of test vectors, `sdk/tests/vectors/signature_chain.json`, generated from the real KMS and guest-agent primitives -- this repo has shipped cross-language crypto drift twice already -- guest-agent: `Attest` accepts `include_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip instead of also calling `GpuInfo`. Exposed in the Rust, Python, Go and JS SDKs +- guest-agent: `Attest` accepts `include_boottime_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.boottime_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip instead of also calling `GpuInfo`. Exposed in the Rust, Python, Go and JS SDKs - sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs - shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796) - gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer diff --git a/docs/attestation-tdx.md b/docs/attestation-tdx.md index bee9f0458..bab973198 100644 --- a/docs/attestation-tdx.md +++ b/docs/attestation-tdx.md @@ -45,7 +45,7 @@ evaluation, `gpu-attestation`. The `gpu-policy-hash` payload is The `gpu-attestation` payload is JSON containing the verified device count, CC/DevTools state, and `evidence_sha256`. -The guest-agent `GpuInfo` API returns the complete `nvattest` JSON captured during boot; `Attest` returns the same bytes in `gpu_evidence` when called with `include_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip. It is not trustworthy by itself. After verifying the TDX quote and replaying the event log to RTMR3, hash the exact UTF-8 bytes of `GpuInfo.attestation` (or `Attest.gpu_evidence`) and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. +The guest-agent `GpuInfo` API returns the complete `nvattest` JSON captured during boot; `Attest` returns the same bytes in `boottime_gpu_evidence` when called with `include_boottime_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip. It is not trustworthy by itself. After verifying the TDX quote and replaying the event log to RTMR3, hash the exact UTF-8 bytes of `GpuInfo.attestation` (or `Attest.boottime_gpu_evidence`) and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. ### 2.2. Determining expected MRs MRTD, RTMR0, RTMR1, and RTMR2 correspond to the image. dstack OS builds all related software from source. diff --git a/docs/security/security-model.md b/docs/security/security-model.md index 2f3a269c7..5ba888705 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -144,7 +144,7 @@ boot-mr-done } ``` -`GpuInfo` returns that complete boot-time `nvattest` JSON in its `attestation` string, and `Attest` returns the same bytes in `gpu_evidence` when called with `include_gpu_evidence`; neither runs a new attestation. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, and compare `evidence_sha256` with `SHA-256(UTF-8(GpuInfo.attestation))` (equivalently `SHA-256(UTF-8(Attest.gpu_evidence))`). Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the returned string. +`GpuInfo` returns that complete boot-time `nvattest` JSON in its `attestation` string, and `Attest` returns the same bytes in `boottime_gpu_evidence` when called with `include_boottime_gpu_evidence`; neither runs a new attestation. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, and compare `evidence_sha256` with `SHA-256(UTF-8(GpuInfo.attestation))` (equivalently `SHA-256(UTF-8(Attest.boottime_gpu_evidence))`). Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the returned string. A verifier must replay the measured event log, require exactly one `gpu-policy-hash` event immediately after `compose-hash`, and compare its 32-byte payload with the expected policy digest (`SHA-256(JCS({}))` for the omitted/default policy). When MrConfigV3 includes `gpu_policy_hash`, it must match the same digest. When GPU protection is required, the verifier must also require exactly one pre-`system-ready` `gpu-attestation` event with `devices > 0` and, when applicable, the expected deployment count. The raw `attestation.out` file is not trusted by itself; if it is supplied for inspection, its digest must match the `gpu-attestation` event. diff --git a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs index 714f02602..e2a09767c 100644 --- a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs +++ b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { let response = client .attest(AttestArgs { report_data: report_data.to_vec(), - include_gpu_evidence: false, + include_boottime_gpu_evidence: false, }) .await .context("failed to obtain key-bound guest attestation")?; diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index 46cf53b51..734ab954c 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -661,7 +661,7 @@ impl DistributedCertBot { let attestation_str = match agent .attest(AttestArgs { report_data, - include_gpu_evidence: false, + include_boottime_gpu_evidence: false, }) .await { @@ -743,7 +743,7 @@ impl DistributedCertBot { let attestation = match agent .attest(AttestArgs { report_data, - include_gpu_evidence: false, + include_boottime_gpu_evidence: false, }) .await { diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 9c643048b..f26c8ffed 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -73,7 +73,7 @@ pub fn simulated_attest_response( }; Ok(AttestResponse { attestation: attestation.to_bytes()?, - gpu_evidence: String::new(), + boottime_gpu_evidence: String::new(), }) } diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index f8980664c..65c4442ae 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -190,8 +190,10 @@ message AttestArgs { // something else entirely. reserved 2, 3; reserved "include_ccel", "include_preimages"; - // Also return the boot-time GPU attestation evidence in `gpu_evidence`. - bool include_gpu_evidence = 4; + // Also return the boot-time GPU attestation evidence in + // `boottime_gpu_evidence`. This does not sample the GPU now and does not + // answer `report_data`; see that field. + bool include_boottime_gpu_evidence = 4; } message TdxQuoteResponse { @@ -212,13 +214,19 @@ message AttestResponse { bytes attestation = 1; // Complete JSON output produced by nvattest at boot, the same bytes `GpuInfo` // serves. Only `DstackGuest.Attest` populates it, and only when the request set - // `include_gpu_evidence` and boot-time GPU attestation output exists. + // `include_boottime_gpu_evidence` and boot-time GPU attestation output exists. // // Not bound to `report_data`: nvattest ran at boot against its own nonce, so a // fresh `report_data` says nothing about it. Bind it by replaying the runtime // event log and comparing sha256 of these exact UTF-8 bytes against the // `evidence_sha256` field of the measured `gpu-attestation` event. - string gpu_evidence = 2; + // + // This is a historical statement about the boot, not a live one: it does not + // prove the GPU is still attached. Sampling the GPU at attestation time would + // not fix that -- an NVIDIA report binds the device and a nonce but not the TD + // the device is attached to, so a fresh report can be relayed from a genuine + // remote GPU. Only TDISP/TEE-IO device binding closes that. + string boottime_gpu_evidence = 2; } message GpuInfoResponse { diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index 00d96e722..cdf6904d6 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -52,7 +52,7 @@ impl PlatformBackend for RealPlatform { attestation: attestation.into_versioned().to_bytes()?, // Filled in by the RPC layer when the caller asks for it: GPU // evidence is a file the boot wrote, not a platform quote. - gpu_evidence: String::new(), + boottime_gpu_evidence: String::new(), }) } } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 8a9d15103..15ee49d6f 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -67,7 +67,7 @@ fn read_gpu_attestation(path: &Path) -> String { /// GPU evidence to return alongside an attestation. Opt-in, so a caller that /// does not care about GPUs neither pays the disk read nor carries the payload. -fn attest_gpu_evidence(include: bool, path: &Path) -> String { +fn boottime_gpu_evidence(include: bool, path: &Path) -> String { if include { read_gpu_attestation(path) } else { @@ -456,8 +456,8 @@ impl DstackGuestRpc for InternalRpcHandler { async fn attest(self, request: AttestArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; let mut response = self.state.attest_response(report_data)?; - response.gpu_evidence = attest_gpu_evidence( - request.include_gpu_evidence, + response.boottime_gpu_evidence = boottime_gpu_evidence( + request.include_boottime_gpu_evidence, Path::new(GPU_ATTESTATION_OUTPUT), ); Ok(response) @@ -764,14 +764,14 @@ mod tests { } #[test] - fn attest_returns_gpu_evidence_only_when_requested() { + fn attest_returns_boottime_gpu_evidence_only_when_requested() { let mut output = tempfile::NamedTempFile::new().unwrap(); let evidence = r#"{"result_code":0,"claims":[]}"#; output.write_all(evidence.as_bytes()).unwrap(); output.flush().unwrap(); - assert_eq!(attest_gpu_evidence(true, output.path()), evidence); - assert_eq!(attest_gpu_evidence(false, output.path()), ""); + assert_eq!(boottime_gpu_evidence(true, output.path()), evidence); + assert_eq!(boottime_gpu_evidence(false, output.path()), ""); } #[test] @@ -981,7 +981,7 @@ pNs85uhOZE8z2jr8Pg== let attestation = patch_report_data(&self.attestation, report_data); Ok(AttestResponse { attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, - gpu_evidence: String::new(), + boottime_gpu_evidence: String::new(), }) } } diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index a4f4d8730..c315e0128 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -183,7 +183,7 @@ pub(crate) async fn app_attest(report_data: Vec) -> Result { dstack_client() .attest(AttestArgs { report_data, - include_gpu_evidence: false, + include_boottime_gpu_evidence: false, }) .await } diff --git a/sdk/curl/api.md b/sdk/curl/api.md index 02f86b00e..84bb3d345 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -238,7 +238,7 @@ You can submit the returned `attestation` directly to the verifier `/verify` end | Field | Type | Description | Example | |-------|------|-------------|----------| | `report_data` | string | Report data of max length 64 bytes. Padding with 0s if less than 64 bytes. | `"1234deadbeaf"` | -| `include_gpu_evidence` | boolean | Optional, defaults to `false`. Also returns the boot-time GPU attestation evidence in `gpu_evidence`. | `true` | +| `include_boottime_gpu_evidence` | boolean | Optional, defaults to `false`. Also returns the boot-time GPU attestation evidence in `boottime_gpu_evidence`. | `true` | **Example:** ```bash @@ -247,25 +247,25 @@ curl --unix-socket /var/run/dstack.sock -X POST \ -H 'Content-Type: application/json' \ -d '{ "report_data": "1234deadbeaf", - "include_gpu_evidence": true + "include_boottime_gpu_evidence": true }' ``` Or ```bash -curl --unix-socket /var/run/dstack.sock 'http://dstack/Attest?report_data=1234deadbeaf&include_gpu_evidence=true' +curl --unix-socket /var/run/dstack.sock 'http://dstack/Attest?report_data=1234deadbeaf&include_boottime_gpu_evidence=true' ``` **Response:** ```json { "attestation": "", - "gpu_evidence": "{\"result_code\": 0, \"claims\": [...]}" + "boottime_gpu_evidence": "{\"result_code\": 0, \"claims\": [...]}" } ``` -`gpu_evidence` carries the same bytes [`GpuInfo`](#7-gpu-info) serves, so one call +`boottime_gpu_evidence` carries the same bytes [`GpuInfo`](#7-gpu-info) serves, so one call returns both the quote and the GPU evidence a verifier needs. It is empty unless -`include_gpu_evidence` was set and boot-time GPU attestation output exists. It is +`include_boottime_gpu_evidence` was set and boot-time GPU attestation output exists. It is **not** bound to `report_data` — authenticate it with the `evidence_sha256` procedure documented under `GpuInfo` below. diff --git a/sdk/go/README.md b/sdk/go/README.md index 9140d6bc5..5b7c524de 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -577,15 +577,15 @@ instead. ##### `AttestWithOptions(ctx context.Context, reportData []byte, opts AttestOptions) (*AttestResponse, error)` -Same as `Attest`, with options. Set `IncludeGpuEvidence` to also return the boot-time +Same as `Attest`, with options. Set `IncludeBoottimeGpuEvidence` to also return the boot-time GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. ```go -resp, err := client.AttestWithOptions(ctx, reportData, dstack.AttestOptions{IncludeGpuEvidence: true}) +resp, err := client.AttestWithOptions(ctx, reportData, dstack.AttestOptions{IncludeBoottimeGpuEvidence: true}) if err != nil { log.Fatal(err) } -fmt.Println(resp.GpuEvidence) +fmt.Println(resp.BoottimeGpuEvidence) ``` The evidence is the same bytes ``GpuInfo`` serves and is empty unless the flag was set diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 6b2e6ee5e..b963deb1c 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -111,20 +111,20 @@ func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { // Represents the response from an attestation request. type AttestResponse struct { Attestation []byte - // GpuEvidence is the complete JSON output produced by nvattest during guest - // boot. Empty unless the request set IncludeGpuEvidence and the guest has + // BoottimeGpuEvidence is the complete JSON output produced by nvattest during guest + // boot. Empty unless the request set IncludeBoottimeGpuEvidence and the guest has // boot-time GPU attestation output. // // It is not bound to reportData: verify it by replaying the runtime event log // and comparing sha256 of these exact UTF-8 bytes against evidence_sha256 in // the `gpu-attestation` event. - GpuEvidence string + BoottimeGpuEvidence string } // AttestOptions tunes what an Attest call returns. type AttestOptions struct { - // IncludeGpuEvidence also returns the boot-time GPU attestation evidence. - IncludeGpuEvidence bool + // IncludeBoottimeGpuEvidence also returns the boot-time GPU attestation evidence. + IncludeBoottimeGpuEvidence bool } // GpuInfoResponse contains GPU information collected during boot. @@ -529,7 +529,7 @@ func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, payload := map[string]interface{}{ "report_data": hex.EncodeToString(reportData), - "include_gpu_evidence": opts.IncludeGpuEvidence, + "include_boottime_gpu_evidence": opts.IncludeBoottimeGpuEvidence, } data, err := c.sendRPCRequest(ctx, "/Attest", payload) @@ -539,7 +539,7 @@ func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, var response struct { Attestation string `json:"attestation"` - GpuEvidence string `json:"gpu_evidence"` + BoottimeGpuEvidence string `json:"boottime_gpu_evidence"` } if err := json.Unmarshal(data, &response); err != nil { return nil, err @@ -550,7 +550,7 @@ func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, return nil, err } - return &AttestResponse{Attestation: attestation, GpuEvidence: response.GpuEvidence}, nil + return &AttestResponse{Attestation: attestation, BoottimeGpuEvidence: response.BoottimeGpuEvidence}, nil } // GpuInfo returns GPU information collected during boot. diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 4010fadc5..8e82bbef7 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -78,7 +78,7 @@ func TestAttest(t *testing.T) { } } -func TestAttestWithGpuEvidence(t *testing.T) { +func TestAttestWithBoottimeGpuEvidence(t *testing.T) { const evidence = `{"result_code":0,"claims":[]}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/Attest" { @@ -88,24 +88,24 @@ func TestAttestWithGpuEvidence(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("failed to decode request: %v", err) } - if payload["include_gpu_evidence"] != true { - t.Fatalf("expected include_gpu_evidence to be forwarded, got: %v", payload["include_gpu_evidence"]) + if payload["include_boottime_gpu_evidence"] != true { + t.Fatalf("expected include_boottime_gpu_evidence to be forwarded, got: %v", payload["include_boottime_gpu_evidence"]) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "attestation": "deadbeef", - "gpu_evidence": evidence, + "boottime_gpu_evidence": evidence, }) })) defer server.Close() client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) - resp, err := client.AttestWithOptions(context.Background(), []byte("test"), dstack.AttestOptions{IncludeGpuEvidence: true}) + resp, err := client.AttestWithOptions(context.Background(), []byte("test"), dstack.AttestOptions{IncludeBoottimeGpuEvidence: true}) if err != nil { t.Fatal(err) } - if resp.GpuEvidence != evidence { - t.Fatalf("unexpected gpu evidence: %s", resp.GpuEvidence) + if resp.BoottimeGpuEvidence != evidence { + t.Fatalf("unexpected gpu evidence: %s", resp.BoottimeGpuEvidence) } } diff --git a/sdk/js/README.md b/sdk/js/README.md index 62f979e21..f289e6052 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -87,7 +87,7 @@ quote.quote // hex-encoded TDX quote quote.event_log // JSON string of measured events ``` -### `attest(reportData, includeGpuEvidence?)` +### `attest(reportData, includeBoottimeGpuEvidence?)` Versioned dstack attestation that works across TDX / GCP / Nitro providers. Preferred for cross-platform verifiers. @@ -99,7 +99,7 @@ Pass `true` as the second argument to also return the boot-time GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. ```typescript -const { attestation, gpu_evidence } = await client.attest('app-state-snapshot', true) +const { attestation, boottime_gpu_evidence } = await client.attest('app-state-snapshot', true) ``` The evidence is the same bytes ``gpuInfo()`` serves and is empty unless the flag was set diff --git a/sdk/js/src/__tests__/index.test.ts b/sdk/js/src/__tests__/index.test.ts index 232fe3169..6c0b5e8ce 100644 --- a/sdk/js/src/__tests__/index.test.ts +++ b/sdk/js/src/__tests__/index.test.ts @@ -53,7 +53,7 @@ describe('DstackClient', () => { const result = await client.attest('test') expect(result).toHaveProperty('attestation') expect(result.attestation).not.toBe('') - expect(result.gpu_evidence).toBe('') + expect(result.boottime_gpu_evidence).toBe('') }) it('should be able to attest with gpu evidence', async () => { @@ -62,7 +62,7 @@ describe('DstackClient', () => { expect(result).toHaveProperty('attestation') expect(result.attestation).not.toBe('') // Whether evidence exists depends on the host; assert the field is present. - expect(result).toHaveProperty('gpu_evidence') + expect(result).toHaveProperty('boottime_gpu_evidence') }) it('should able to get derive key result as uint8array', async () => { diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 168a838fb..7508c8f7a 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -104,14 +104,14 @@ export interface AttestResponse { /** * Complete JSON output produced by nvattest during guest boot. Empty unless the - * request set `include_gpu_evidence` and the guest has boot-time GPU attestation + * request set `include_boottime_gpu_evidence` and the guest has boot-time GPU attestation * output. * * Not bound to `report_data`: verify it by replaying the runtime event log and * comparing sha256 of these exact UTF-8 bytes against `evidence_sha256` in the * `gpu-attestation` event. */ - gpu_evidence: string + boottime_gpu_evidence: string } export interface GpuInfoResponse { @@ -299,16 +299,16 @@ export class DstackClient { /** * Requests a versioned attestation for the given report data. * - * Pass `include_gpu_evidence` to also return the boot-time GPU attestation - * evidence in `gpu_evidence`, so a verifier can check both in one round trip. + * Pass `include_boottime_gpu_evidence` to also return the boot-time GPU attestation + * evidence in `boottime_gpu_evidence`, so a verifier can check both in one round trip. */ - async attest(report_data: string | Buffer | Uint8Array, include_gpu_evidence: boolean = false): Promise { + async attest(report_data: string | Buffer | Uint8Array, include_boottime_gpu_evidence: boolean = false): Promise { let hex = to_hex(report_data) if (hex.length > 128) { throw new Error(`Report data is too large, it should be less than 64 bytes.`) } - const payload = JSON.stringify({ report_data: hex, include_gpu_evidence }) - const result = await send_rpc_request<{ attestation: string, gpu_evidence?: string }>(this.endpoint, '/Attest', payload) + const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) + const result = await send_rpc_request<{ attestation: string, boottime_gpu_evidence?: string }>(this.endpoint, '/Attest', payload) if ('error' in (result as any)) { const err = (result as any)['error'] as string throw new Error(err) @@ -316,7 +316,7 @@ export class DstackClient { return Object.freeze({ __name__: 'AttestResponse', attestation: result.attestation as Hex, - gpu_evidence: result.gpu_evidence ?? '', + boottime_gpu_evidence: result.boottime_gpu_evidence ?? '', }) } diff --git a/sdk/python/README.md b/sdk/python/README.md index d1e1cab3d..907145e3f 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -100,12 +100,12 @@ print(result.attestation) # hex string print(result.decode_attestation()) # bytes ``` -Pass `include_gpu_evidence=True` to also return the boot-time GPU attestation +Pass `include_boottime_gpu_evidence=True` to also return the boot-time GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. ```python -result = client.attest(b'user:alice:nonce123', include_gpu_evidence=True) -print(result.gpu_evidence) +result = client.attest(b'user:alice:nonce123', include_boottime_gpu_evidence=True) +print(result.boottime_gpu_evidence) ``` The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 5faf83050..6887917c0 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -153,11 +153,11 @@ def decode_event_log(self) -> "List[EventLog]": class AttestResponse(BaseModel): attestation: str # Complete JSON output produced by nvattest during guest boot. Empty unless - # the request set include_gpu_evidence and the guest has boot-time GPU + # the request set include_boottime_gpu_evidence and the guest has boot-time GPU # attestation output. Not bound to report_data: verify it by replaying the # runtime event log and comparing sha256 of these exact UTF-8 bytes against # evidence_sha256 in the `gpu-attestation` event. - gpu_evidence: str = "" + boottime_gpu_evidence: str = "" def decode_attestation(self) -> bytes: return bytes.fromhex(self.attestation) @@ -433,12 +433,12 @@ async def get_quote( async def attest( self, report_data: str | bytes, - include_gpu_evidence: bool = False, + include_boottime_gpu_evidence: bool = False, ) -> AttestResponse: """Request a versioned attestation for the provided report data. - Set include_gpu_evidence to also return the boot-time GPU attestation - evidence in AttestResponse.gpu_evidence. + Set include_boottime_gpu_evidence to also return the boot-time GPU attestation + evidence in AttestResponse.boottime_gpu_evidence. """ if not report_data or not isinstance(report_data, (bytes, str)): raise ValueError("report_data can not be empty") @@ -450,7 +450,7 @@ async def attest( hex = binascii.hexlify(report_bytes).decode() result = await self._send_rpc_request( "Attest", - {"report_data": hex, "include_gpu_evidence": include_gpu_evidence}, + {"report_data": hex, "include_boottime_gpu_evidence": include_boottime_gpu_evidence}, ) return AttestResponse(**result) @@ -583,12 +583,12 @@ def get_quote( def attest( self, report_data: str | bytes, - include_gpu_evidence: bool = False, + include_boottime_gpu_evidence: bool = False, ) -> AttestResponse: """Request a versioned attestation for the provided report data. - Set include_gpu_evidence to also return the boot-time GPU attestation - evidence in AttestResponse.gpu_evidence. + Set include_boottime_gpu_evidence to also return the boot-time GPU attestation + evidence in AttestResponse.boottime_gpu_evidence. """ raise NotImplementedError diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 705c1a6fe..09e9ad5dc 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -122,19 +122,19 @@ async def test_async_client_attest(): @pytest.mark.asyncio -async def test_async_client_attest_gpu_evidence(monkeypatch): +async def test_async_client_attest_boottime_gpu_evidence(monkeypatch): evidence = '{"result_code":0,"claims":[]}' async def fake_send(self, method, payload): assert method == "Attest" - assert payload["include_gpu_evidence"] is True - return {"attestation": "deadbeef", "gpu_evidence": evidence} + assert payload["include_boottime_gpu_evidence"] is True + return {"attestation": "deadbeef", "boottime_gpu_evidence": evidence} monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - result = await AsyncDstackClient().attest("test", include_gpu_evidence=True) + result = await AsyncDstackClient().attest("test", include_boottime_gpu_evidence=True) assert isinstance(result, AttestResponse) - assert result.gpu_evidence == evidence + assert result.boottime_gpu_evidence == evidence @pytest.mark.asyncio diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 2014e4770..4cae1a949 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -103,19 +103,19 @@ println!("{}", info.tcb_info); #### `attest(report_data: Vec) -> AttestResponse` Generates a versioned attestation with a custom 64-byte payload. - `attestation`: Hex-encoded attestation -- `gpu_evidence`: Boot-time GPU attestation evidence, empty unless requested +- `boottime_gpu_evidence`: Boot-time GPU attestation evidence, empty unless requested #### `attest_with(config: AttestConfig) -> AttestResponse` -Same, with options. Set `include_gpu_evidence` to also return the boot-time GPU +Same, with options. Set `include_boottime_gpu_evidence` to also return the boot-time GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. ```rust let config = AttestConfig::builder() .report_data(hex::encode(b"user:alice:nonce123")) - .include_gpu_evidence(true) + .include_boottime_gpu_evidence(true) .build(); let result = client.attest_with(config).await?; -println!("{}", result.gpu_evidence); +println!("{}", result.boottime_gpu_evidence); ``` The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index b230e75a1..73043099b 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -31,25 +31,25 @@ async fn test_async_client_attest() { let result = client.attest(b"test".to_vec()).await.unwrap(); let attestation = result.decode_attestation().unwrap(); assert!(!attestation.is_empty()); - assert!(result.gpu_evidence.is_empty()); + assert!(result.boottime_gpu_evidence.is_empty()); let too_large = client.attest(vec![0_u8; 65]).await; assert!(too_large.is_err()); } #[tokio::test] -async fn test_async_client_attest_with_gpu_evidence() { +async fn test_async_client_attest_with_boottime_gpu_evidence() { let client = AsyncDstackClient::new(None); let config = AttestConfig::builder() .report_data(hex::encode(b"test")) - .include_gpu_evidence(true) + .include_boottime_gpu_evidence(true) .build(); let result = client.attest_with(config).await.unwrap(); assert!(!result.decode_attestation().unwrap().is_empty()); // Whether evidence exists depends on the host, so assert the request // round-trips and the field is populated from the same source as GpuInfo. assert_eq!( - result.gpu_evidence, + result.boottime_gpu_evidence, client.gpu_info().await.unwrap().attestation ); diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index d404f1679..93789c24e 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -97,14 +97,14 @@ pub struct AttestResponse { /// The attestation in hexadecimal format pub attestation: String, /// Complete JSON output produced by nvattest during guest boot. Empty - /// unless the request set `include_gpu_evidence` and the guest has + /// unless the request set `include_boottime_gpu_evidence` and the guest has /// boot-time GPU attestation output. /// /// Not bound to `report_data`: verify it by replaying the runtime event log /// and comparing sha256 of these exact UTF-8 bytes against /// `evidence_sha256` in the `gpu-attestation` event. #[serde(default)] - pub gpu_evidence: String, + pub boottime_gpu_evidence: String, } /// Configuration for a versioned attestation request @@ -115,9 +115,9 @@ pub struct AttestConfig { /// The report data in hexadecimal format, at most 64 bytes once decoded #[builder(into)] pub report_data: String, - /// Also return the boot-time GPU attestation evidence in `gpu_evidence` + /// Also return the boot-time GPU attestation evidence in `boottime_gpu_evidence` #[builder(default = false)] - pub include_gpu_evidence: bool, + pub include_boottime_gpu_evidence: bool, } /// Response containing the complete NVIDIA GPU attestation output. From 66345d336117acb717d6aecbf164519e7c6ea4e8 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 20:07:03 -0700 Subject: [PATCH 5/7] refactor(guest-agent): return the attestation, not the RPC response, from the backend `PlatformBackend::attest_response` handed back an `AttestResponse` it could not fully populate: the GPU evidence is a file the boot wrote, so every backend wrote `boottime_gpu_evidence: String::new()` and the RPC layer patched the struct afterwards. Three implementations had to remember the blank field, and the platform abstraction knew about a field no platform supplies. `attestation_for_report_data` returns a `VersionedAttestation` instead, which is what the two neighbouring methods on the trait already do. Encoding it and assembling the response is the RPC layer's job, so `Attest` builds its own response in one expression and `AttestAppKey` states plainly that a key attestation carries no machine evidence. --- dstack/guest-agent-simulator/src/main.rs | 24 ++++++++---- dstack/guest-agent-simulator/src/simulator.rs | 10 ++--- dstack/guest-agent/src/backend.rs | 15 +++----- dstack/guest-agent/src/rpc_service.rs | 37 ++++++++++++------- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index 95db32586..e65e1715e 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -13,7 +13,7 @@ use dstack_guest_agent::{ config::{self, Config}, run_server, AppState, }; -use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use dstack_guest_agent_rpc::GetQuoteResponse; use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::VersionedAttestation; use serde::Deserialize; @@ -102,7 +102,7 @@ impl PlatformBackend for SimulatorPlatform { ) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { + fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result { simulator::simulated_attest_response( &self.attestation, report_data, @@ -183,9 +183,13 @@ mod tests { fn simulator_attest_response_preserves_legacy_wire_format() { let platform = load_fixture_platform(); let report_data = [0x5a; 64]; - let response = platform.attest_response(report_data).unwrap(); - assert_eq!(response.attestation.first(), Some(&0x00)); - let patched = VersionedAttestation::from_bytes(&response.attestation) + let encoded = platform + .attestation_for_report_data(report_data) + .unwrap() + .to_bytes() + .unwrap(); + assert_eq!(encoded.first(), Some(&0x00)); + let patched = VersionedAttestation::from_bytes(&encoded) .unwrap() .into_v1(); assert_eq!(patched.report_data().unwrap(), report_data); @@ -296,7 +300,7 @@ mod tests { // relying parties on GCP at Attest. let attested = simulator::simulated_attest_response(&gcp_tdx, report_data, true, None) .expect("Attest must work on GCP TDX too"); - let round_tripped = VersionedAttestation::from_bytes(&attested.attestation) + let round_tripped = VersionedAttestation::from_bytes(&attested.to_bytes().unwrap()) .unwrap() .into_v1(); assert!(round_tripped.platform.tpm_quote().is_some()); @@ -312,8 +316,12 @@ mod tests { let original = fixture.clone().into_v1().report_data().unwrap(); let platform = SimulatorPlatform::new(fixture, false, None).unwrap(); let report_data = [0x5a; 64]; - let response = platform.attest_response(report_data).unwrap(); - let patched = VersionedAttestation::from_bytes(&response.attestation) + let encoded = platform + .attestation_for_report_data(report_data) + .unwrap() + .to_bytes() + .unwrap(); + let patched = VersionedAttestation::from_bytes(&encoded) .unwrap() .into_v1(); assert_eq!(patched.report_data().unwrap(), original); diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index f26c8ffed..b6c731e2d 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -6,7 +6,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use dcap_qvl::quote::Quote; -use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use dstack_guest_agent_rpc::GetQuoteResponse; use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::{ AttestationV1, PlatformEvidence, QuoteContentType, TdxAttestationExt, VersionedAttestation, @@ -59,21 +59,17 @@ pub fn simulated_attest_response( report_data: [u8; 64], patch_report_data: bool, generator: Option<&TdxGenerator>, -) -> Result { +) -> Result { let preserve_legacy = matches!(source, VersionedAttestation::V0 { .. }); let mut attestation = prepare_attestation(source, report_data, patch_report_data, generator, "attest")?; if let Some(event_log) = attestation.platform.tdx_event_log_mut() { cc_eventlog::tdx::fill_v2_preimages(event_log); } - let attestation = if preserve_legacy { + Ok(if preserve_legacy { attestation.try_into_legacy()?.into_versioned() } else { VersionedAttestation::V1 { attestation } - }; - Ok(AttestResponse { - attestation: attestation.to_bytes()?, - boottime_gpu_evidence: String::new(), }) } diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index cdf6904d6..a7d662c31 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; -use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use dstack_guest_agent_rpc::GetQuoteResponse; use ra_tls::attestation::Attestation; use ra_tls::attestation::{QuoteContentType, VersionedAttestation}; @@ -11,7 +11,9 @@ pub trait PlatformBackend: Send + Sync { fn attestation_for_info(&self) -> Result; fn certificate_attestation(&self, pubkey: &[u8]) -> Result; fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result; - fn attest_response(&self, report_data: [u8; 64]) -> Result; + /// The attestation `Attest` and `AttestAppKey` return, with digest + /// preimages filled in. Encoding it is the RPC layer's job. + fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result; } #[derive(Debug, Default)] @@ -44,15 +46,10 @@ impl PlatformBackend for RealPlatform { }) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { + fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result { let mut attestation = Attestation::quote(&report_data).context("Failed to get attestation")?; attestation.fill_event_preimages(); - Ok(AttestResponse { - attestation: attestation.into_versioned().to_bytes()?, - // Filled in by the RPC layer when the caller asks for it: GPU - // evidence is a file the boot wrote, not a platform quote. - boottime_gpu_evidence: String::new(), - }) + Ok(attestation.into_versioned()) } } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 15ee49d6f..8e215349f 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -226,8 +226,11 @@ impl AppState { .quote_response(report_data, &self.inner.vm_config) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - self.inner.platform.attest_response(report_data) + fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result> { + self.inner + .platform + .attestation_for_report_data(report_data)? + .to_bytes() } } @@ -455,12 +458,13 @@ impl DstackGuestRpc for InternalRpcHandler { async fn attest(self, request: AttestArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; - let mut response = self.state.attest_response(report_data)?; - response.boottime_gpu_evidence = boottime_gpu_evidence( - request.include_boottime_gpu_evidence, - Path::new(GPU_ATTESTATION_OUTPUT), - ); - Ok(response) + Ok(AttestResponse { + attestation: self.state.attestation_for_report_data(report_data)?, + boottime_gpu_evidence: boottime_gpu_evidence( + request.include_boottime_gpu_evidence, + Path::new(GPU_ATTESTATION_OUTPUT), + ), + }) } async fn version(self) -> Result { @@ -656,7 +660,12 @@ impl WorkerRpc for ExternalRpcHandler { async fn attest_app_key(self, request: AttestAppKeyRequest) -> Result { let report_data = self.app_key_report_data(&request.algorithm).await?; - self.state.attest_response(report_data) + Ok(AttestResponse { + attestation: self.state.attestation_for_report_data(report_data)?, + // This method attests a key, not the machine. A caller that wants + // the boot-time GPU evidence asks `Attest` or `GpuInfo` for it. + boottime_gpu_evidence: String::new(), + }) } } @@ -977,12 +986,12 @@ pNs85uhOZE8z2jr8Pg== }) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { + fn attestation_for_report_data( + &self, + report_data: [u8; 64], + ) -> Result { let attestation = patch_report_data(&self.attestation, report_data); - Ok(AttestResponse { - attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, - boottime_gpu_evidence: String::new(), - }) + Ok(VersionedAttestation::V1 { attestation }) } } From 9b0c4f396f3c32b835db794204f79dd31de2ac33 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 20:26:14 -0700 Subject: [PATCH 6/7] refactor(guest-agent): name the backend method attest_cvm `attestation_for_report_data` described its argument, not its job. The method attests the CVM, which is the distinction that matters once attesting a GPU is also a thing the agent can be asked to do. --- dstack/guest-agent-simulator/src/main.rs | 6 +++--- dstack/guest-agent/src/backend.rs | 9 +++++---- dstack/guest-agent/src/rpc_service.rs | 16 +++++----------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index e65e1715e..fd29c43c8 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -102,7 +102,7 @@ impl PlatformBackend for SimulatorPlatform { ) } - fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result { + fn attest_cvm(&self, report_data: [u8; 64]) -> Result { simulator::simulated_attest_response( &self.attestation, report_data, @@ -184,7 +184,7 @@ mod tests { let platform = load_fixture_platform(); let report_data = [0x5a; 64]; let encoded = platform - .attestation_for_report_data(report_data) + .attest_cvm(report_data) .unwrap() .to_bytes() .unwrap(); @@ -317,7 +317,7 @@ mod tests { let platform = SimulatorPlatform::new(fixture, false, None).unwrap(); let report_data = [0x5a; 64]; let encoded = platform - .attestation_for_report_data(report_data) + .attest_cvm(report_data) .unwrap() .to_bytes() .unwrap(); diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index a7d662c31..1c6671454 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -11,9 +11,10 @@ pub trait PlatformBackend: Send + Sync { fn attestation_for_info(&self) -> Result; fn certificate_attestation(&self, pubkey: &[u8]) -> Result; fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result; - /// The attestation `Attest` and `AttestAppKey` return, with digest - /// preimages filled in. Encoding it is the RPC layer's job. - fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result; + /// Attest the CVM itself: the attestation `Attest` and `AttestAppKey` + /// return, with digest preimages filled in. Encoding it is the RPC + /// layer's job. + fn attest_cvm(&self, report_data: [u8; 64]) -> Result; } #[derive(Debug, Default)] @@ -46,7 +47,7 @@ impl PlatformBackend for RealPlatform { }) } - fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result { + fn attest_cvm(&self, report_data: [u8; 64]) -> Result { let mut attestation = Attestation::quote(&report_data).context("Failed to get attestation")?; attestation.fill_event_preimages(); diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 8e215349f..b594cee74 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -226,11 +226,8 @@ impl AppState { .quote_response(report_data, &self.inner.vm_config) } - fn attestation_for_report_data(&self, report_data: [u8; 64]) -> Result> { - self.inner - .platform - .attestation_for_report_data(report_data)? - .to_bytes() + fn attest_cvm(&self, report_data: [u8; 64]) -> Result> { + self.inner.platform.attest_cvm(report_data)?.to_bytes() } } @@ -459,7 +456,7 @@ impl DstackGuestRpc for InternalRpcHandler { async fn attest(self, request: AttestArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; Ok(AttestResponse { - attestation: self.state.attestation_for_report_data(report_data)?, + attestation: self.state.attest_cvm(report_data)?, boottime_gpu_evidence: boottime_gpu_evidence( request.include_boottime_gpu_evidence, Path::new(GPU_ATTESTATION_OUTPUT), @@ -661,7 +658,7 @@ impl WorkerRpc for ExternalRpcHandler { async fn attest_app_key(self, request: AttestAppKeyRequest) -> Result { let report_data = self.app_key_report_data(&request.algorithm).await?; Ok(AttestResponse { - attestation: self.state.attestation_for_report_data(report_data)?, + attestation: self.state.attest_cvm(report_data)?, // This method attests a key, not the machine. A caller that wants // the boot-time GPU evidence asks `Attest` or `GpuInfo` for it. boottime_gpu_evidence: String::new(), @@ -986,10 +983,7 @@ pNs85uhOZE8z2jr8Pg== }) } - fn attestation_for_report_data( - &self, - report_data: [u8; 64], - ) -> Result { + fn attest_cvm(&self, report_data: [u8; 64]) -> Result { let attestation = patch_report_data(&self.attestation, report_data); Ok(VersionedAttestation::V1 { attestation }) } From 902ccf8877d74e86d0c436a58435ec38b55dae3b Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 23:07:33 -0700 Subject: [PATCH 7/7] fix(python-sdk): format GPU attestation changes --- sdk/python/src/dstack_sdk/dstack_client.py | 5 ++++- sdk/python/tests/test_client.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 6887917c0..c27571a8c 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -450,7 +450,10 @@ async def attest( hex = binascii.hexlify(report_bytes).decode() result = await self._send_rpc_request( "Attest", - {"report_data": hex, "include_boottime_gpu_evidence": include_boottime_gpu_evidence}, + { + "report_data": hex, + "include_boottime_gpu_evidence": include_boottime_gpu_evidence, + }, ) return AttestResponse(**result) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 09e9ad5dc..75f5efcfa 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -132,7 +132,9 @@ async def fake_send(self, method, payload): monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - result = await AsyncDstackClient().attest("test", include_boottime_gpu_evidence=True) + result = await AsyncDstackClient().attest( + "test", include_boottime_gpu_evidence=True + ) assert isinstance(result, AttestResponse) assert result.boottime_gpu_evidence == evidence