From 8bece333aa28dd8524cb7f7f49f22ddd95d2ffcb Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 08:18:10 -0700 Subject: [PATCH 01/11] fix(gateway): keep the attestation when the quote request fails --- dstack/gateway/src/distributed_certbot.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index e2337fc63..b9193691d 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -642,7 +642,8 @@ impl DistributedCertBot { .to_report_data(account_uri.as_bytes()) .to_vec(); - // Get quote + // Get quote. GetQuote is Intel TDX only, so this is best-effort: on other + // platforms the versioned attestation below is the only evidence available. let quote = match agent .get_quote(RawQuoteArgs { report_data: report_data.clone(), @@ -652,7 +653,7 @@ impl DistributedCertBot { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!("failed to get TDX quote for ACME account: {err:?}"); - return Ok(()); + String::new() } }; @@ -665,6 +666,11 @@ impl DistributedCertBot { } }; + if quote.is_empty() && attestation_str.is_empty() { + warn!("no attestation evidence for ACME account, skipping save"); + return Ok(()); + } + let attestation = AcmeAttestation { account_uri: account_uri.to_string(), quote, @@ -712,7 +718,8 @@ impl DistributedCertBot { .to_report_data(public_key_der) .to_vec(); - // Get quote + // Get quote. GetQuote is Intel TDX only, so this is best-effort: on other + // platforms the versioned attestation below is the only evidence available. let quote = match agent .get_quote(RawQuoteArgs { report_data: report_data.clone(), @@ -722,7 +729,7 @@ impl DistributedCertBot { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!(domain, "failed to generate TDX quote: {err:?}"); - return Ok(()); + String::new() } }; @@ -735,6 +742,11 @@ impl DistributedCertBot { } }; + if quote.is_empty() && attestation.is_empty() { + warn!(domain, "no attestation evidence for cert, skipping save"); + return Ok(()); + } + let attestation = CertAttestation { public_key: public_key_der.to_vec(), quote, From b283345c645be02906742edf747142f15af2de8a Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 08:18:13 -0700 Subject: [PATCH 02/11] refactor(guest-agent): restrict GetQuote to Intel TDX --- dstack/guest-agent-simulator/src/main.rs | 31 +++++++++++++++++++ dstack/guest-agent-simulator/src/simulator.rs | 9 ++---- dstack/guest-agent/rpc/build.rs | 4 --- dstack/guest-agent/rpc/proto/agent_rpc.proto | 15 ++++----- dstack/guest-agent/src/backend.rs | 18 +++-------- dstack/guest-agent/src/rpc_service.rs | 9 +++--- 6 files changed, 52 insertions(+), 34 deletions(-) diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index 49e088271..592b25b58 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -219,6 +219,37 @@ mod tests { ); } + #[test] + fn simulator_rejects_get_quote_on_non_tdx() { + use ra_tls::attestation::PlatformEvidence; + + let fixture = simulator::load_versioned_attestation( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../guest-agent/fixtures/attestation.bin"), + ) + .expect("fixture attestation should load"); + let mut attestation = fixture.into_v1(); + attestation.platform = PlatformEvidence::SevSnp { + report: vec![0u8; 1184], + cert_chain: Vec::new(), + mr_config: String::new(), + }; + let non_tdx = VersionedAttestation::V1 { attestation }; + let report_data = [0x5a; 64]; + + // GetQuote is Intel TDX only. + let err = simulator::simulated_quote_response(&non_tdx, report_data, "", true, None) + .expect_err("GetQuote must fail on a non-TDX platform"); + assert!( + err.to_string().contains("Intel TDX only"), + "unexpected error: {err}" + ); + + // Attest remains the supported path on the same platform. + simulator::simulated_attest_response(&non_tdx, report_data, true, None) + .expect("Attest must still work on a non-TDX platform"); + } + #[test] fn simulator_can_preserve_fixture_report_data() { let fixture = simulator::load_versioned_attestation( diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 33f277d90..3ec9c0239 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -41,19 +41,16 @@ pub fn simulated_quote_response( "quote", )?; let Some(quote) = attestation.tdx_quote_bytes() else { - return Err(anyhow!("Quote not found")); + return Err(anyhow!( + "GetQuote is Intel TDX only, use Attest on this platform" + )); }; - let versioned = VersionedAttestation::V1 { - attestation: attestation.clone(), - } - .to_bytes()?; Ok(GetQuoteResponse { quote, event_log: attestation.tdx_event_log_string().unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: versioned, }) } diff --git a/dstack/guest-agent/rpc/build.rs b/dstack/guest-agent/rpc/build.rs index bc584fdbe..fe19530a5 100644 --- a/dstack/guest-agent/rpc/build.rs +++ b/dstack/guest-agent/rpc/build.rs @@ -11,10 +11,6 @@ fn main() { .build_scale_ext(false) .disable_package_emission() .enable_serde_extension() - .field_attribute( - ".dstack_guest.GetQuoteResponse.attestation", - "#[serde(skip_serializing_if = \"::prost::alloc::vec::Vec::is_empty\")]", - ) .disable_service_name_emission() .compile_dir("./proto") .expect("failed to compile proto files"); diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 4ba2ca6da..7ae6f7d29 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -44,6 +44,7 @@ service DstackGuest { rpc GetKey(GetKeyArgs) returns (GetKeyResponse) {} // Generates a TDX quote with given report data. + // Intel TDX only. On any other platform this fails; use Attest instead. rpc GetQuote(RawQuoteArgs) returns (GetQuoteResponse) {} // Generates a versioned attestation with the given report data. @@ -197,19 +198,18 @@ message GpuInfoResponse { } message GetQuoteResponse { - // TDX quote (empty on non-TDX platforms such as AMD SEV-SNP) + reserved 5; + reserved "attestation"; + + // TDX quote bytes quote = 1; - // Event log (empty on non-TDX platforms). V2 runtime events always include - // their hex-encoded digest preimage. Clients should verify - // sha384(hex_decode(preimage)) == digest. + // Event log. V2 runtime events always include their hex-encoded digest + // preimage. Clients should verify sha384(hex_decode(preimage)) == digest. string event_log = 2; // Report data bytes report_data = 3; // Hw config string vm_config = 4; - // Platform-adaptive versioned attestation (SCALE/msgpack encoded). Populated - // on non-TDX platforms; TDX uses quote + event_log above. - bytes attestation = 5; } // The request to derive a key @@ -287,6 +287,7 @@ service Worker { // Get the guest agent version rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} // Get attestation + // Intel TDX only, since it returns a GetQuoteResponse. rpc GetAttestationForAppKey(GetAttestationForAppKeyRequest) returns (GetQuoteResponse) {} // Report whether the app is serving. Polled by the gateway to decide whether // this instance should be in its app's load-balancing rotation. diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index 03012ef9e..d2347cdef 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -33,22 +33,14 @@ impl PlatformBackend for RealPlatform { fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { let attestation = Attestation::quote(&report_data).context("Failed to get quote")?; - let tdx_quote = attestation.get_tdx_quote_bytes(); - let tdx_event_log = attestation.get_tdx_event_log_string(); - let versioned = if tdx_quote.is_some() { - Vec::new() - } else { - attestation - .into_versioned() - .to_bytes() - .context("Failed to encode versioned attestation")? - }; + let quote = attestation + .get_tdx_quote_bytes() + .context("GetQuote is Intel TDX only, use Attest on this platform")?; Ok(GetQuoteResponse { - quote: tdx_quote.unwrap_or_default(), - event_log: tdx_event_log.unwrap_or_default(), + quote, + event_log: attestation.get_tdx_event_log_string().unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: versioned, }) } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 3d65a58c2..887bd412c 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -939,7 +939,9 @@ pNs85uhOZE8z2jr8Pg== ) -> Result { let attestation = patch_report_data(&self.attestation, report_data); let Some(quote) = attestation.platform.tdx_quote().map(ToOwned::to_owned) else { - return Err(anyhow::anyhow!("Quote not found")); + return Err(anyhow::anyhow!( + "GetQuote is Intel TDX only, use Attest on this platform" + )); }; Ok(GetQuoteResponse { quote, @@ -949,7 +951,6 @@ pNs85uhOZE8z2jr8Pg== .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: Vec::new(), }) } @@ -1187,7 +1188,7 @@ pNs85uhOZE8z2jr8Pg== const EXPECTED_REPORT_DATA: &str = "dip1::ed25519-pk:5Pbre1Amf1hrp2V2bbfKlIfxpQb2pJAmrgmhxgVoG9s\0\0\0\0"; assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); - assert!(response.attestation.is_empty()); + assert!(!response.quote.is_empty()); } #[tokio::test] @@ -1203,7 +1204,7 @@ pNs85uhOZE8z2jr8Pg== const EXPECTED_REPORT_DATA: &str = "dip1::secp256k1c-pk:A6t_JdVkVdMAocH3f1f20WGT6JzdntxcXimUtEax8zc9"; assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); - assert!(response.attestation.is_empty()); + assert!(!response.quote.is_empty()); } #[tokio::test] From 8a49e60d06af95e5376302d878368565ee2dac78 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 08:18:18 -0700 Subject: [PATCH 03/11] refactor(sdk): drop the GetQuote attestation field --- sdk/go/README.md | 4 +++- sdk/go/dstack/client.go | 16 ++++++---------- sdk/js/README.md | 1 + sdk/js/src/index.ts | 7 ++++++- sdk/python/README.md | 2 ++ sdk/python/src/dstack_sdk/dstack_client.py | 13 ++++++++++--- sdk/rust/README.md | 2 ++ sdk/rust/src/dstack_client.rs | 4 ++++ sdk/rust/types/src/dstack.rs | 9 --------- 9 files changed, 34 insertions(+), 24 deletions(-) diff --git a/sdk/go/README.md b/sdk/go/README.md index a359e590d..26f6be562 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -559,7 +559,9 @@ userB, _ := client.GetKey(ctx, "user/bob/wallet", "", "secp256k1") ##### `GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error)` -Generates a TDX attestation quote containing the provided report data. +Generates a TDX attestation quote containing the provided report data. Intel TDX +only; on any other platform it returns an error and you should call `Attest()` +instead. **Parameters:** - `reportData`: Data to include in quote (max 64 bytes) diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 241b6b312..3e892c6c9 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -85,11 +85,10 @@ func (r *GetKeyResponse) DecodeSignatureChain() ([][]byte, error) { // Represents the response from a quote request. type GetQuoteResponse struct { - Quote string `json:"quote"` - EventLog string `json:"event_log"` - ReportData string `json:"report_data"` - VmConfig string `json:"vm_config"` - Attestation string `json:"attestation"` + Quote string `json:"quote"` + EventLog string `json:"event_log"` + ReportData string `json:"report_data"` + VmConfig string `json:"vm_config"` } // DecodeQuote returns the quote bytes @@ -102,10 +101,6 @@ func (r *GetQuoteResponse) DecodeReportData() ([]byte, error) { return hex.DecodeString(r.ReportData) } -func (r *GetQuoteResponse) DecodeAttestation() ([]byte, error) { - return hex.DecodeString(r.Attestation) -} - // DecodeEventLog returns the event log as structured data func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { var events []EventLog @@ -480,7 +475,8 @@ func (c *DstackClient) GetKey(ctx context.Context, path string, purpose string, return &response, nil } -// Gets a quote from the dstack service. +// Gets a TDX quote from the dstack service. Intel TDX only: on any other +// platform the guest agent returns an error and Attest should be used instead. func (c *DstackClient) GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error) { if len(reportData) > 64 { return nil, fmt.Errorf("report data is too large, it should be at most 64 bytes") diff --git a/sdk/js/README.md b/sdk/js/README.md index 8af123892..85583c6a2 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -81,6 +81,7 @@ Returns `{ key: string, certificate_chain: string[], asUint8Array(maxLength?) }` ### `getQuote(reportData)` Generate a raw TDX quote. `reportData` is up to 64 bytes (string, Buffer, or Uint8Array). +Intel TDX only; on any other platform it throws and you should call `attest()` instead. ```typescript const quote = await client.getQuote('user:alice:nonce123') diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index a060d7656..2cc7cba0c 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -99,7 +99,6 @@ export interface GetQuoteResponse { event_log: string report_data?: Hex vm_config?: string - attestation?: Hex } export interface AttestResponse { @@ -268,6 +267,12 @@ export class DstackClient { }) } + /** + * Request a TDX quote for the given report data. + * + * Intel TDX only. On any other platform the guest agent returns an error and + * this throws; use `attest()` there. + */ async getQuote(report_data: string | Buffer | Uint8Array): Promise { let hex = to_hex(report_data) if (hex.length > 128) { diff --git a/sdk/python/README.md b/sdk/python/README.md index d7c0de293..d37077a5b 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -73,6 +73,8 @@ ed_key = client.get_key('signing/key', algorithm='ed25519') ### Generate Attestation Quotes `get_quote()` creates a TDX quote proving your code runs in a genuine TEE. +It is Intel TDX only; on any other platform it fails and you should call +`attest()` instead. ```python quote = client.get_quote(b'user:alice:nonce123') diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index fd206483b..361d5fe15 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -142,7 +142,6 @@ class GetQuoteResponse(BaseModel): event_log: str report_data: str = "" vm_config: str = "" - attestation: str = "" def decode_quote(self) -> bytes: return bytes.fromhex(self.quote) @@ -411,7 +410,11 @@ async def get_quote( self, report_data: str | bytes, ) -> GetQuoteResponse: - """Request an attestation quote for the provided report data.""" + """Request a TDX quote for the provided report data. + + Intel TDX only. On any other platform the guest agent returns an error; + use ``attest()`` there. + """ if not report_data or not isinstance(report_data, (bytes, str)): raise ValueError("report_data can not be empty") report_bytes: bytes = ( @@ -576,7 +579,11 @@ def get_quote( self, report_data: str | bytes, ) -> GetQuoteResponse: - """Request an attestation quote for the provided report data.""" + """Request a TDX quote for the provided report data. + + Intel TDX only. On any other platform the guest agent returns an error; + use ``attest()`` there. + """ raise NotImplementedError @call_async diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 630ecf3da..ff9052578 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -67,6 +67,8 @@ The Rust SDK currently requests the default `secp256k1` key material. Use distin ### Generate Attestation Quotes `get_quote()` creates a TDX quote proving your code runs in a genuine TEE. +It is Intel TDX only; on any other platform it fails and you should call +`attest()` instead. ```rust let quote = client.get_quote(b"user:alice:nonce123".to_vec()).await?; diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client.rs index d33a04e62..31b6d3627 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -142,6 +142,10 @@ impl DstackClient { Ok(response) } + /// Request a TDX quote for the provided report data. + /// + /// Intel TDX only. On any other platform the guest agent returns an error; + /// use [`Self::attest`] there. pub async fn get_quote(&self, report_data: Vec) -> Result { if report_data.is_empty() || report_data.len() > 64 { anyhow::bail!("Invalid report data length") diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index 01a358abd..3c2ced1fc 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -87,10 +87,6 @@ pub struct GetQuoteResponse { /// VM configuration #[serde(default)] pub vm_config: String, - /// Platform-adaptive versioned attestation, hex-encoded. Populated on - /// non-TDX platforms; TDX uses `quote` and `event_log`. - #[serde(default)] - pub attestation: String, } /// Response containing a versioned attestation @@ -122,11 +118,6 @@ impl GetQuoteResponse { hex::decode(&self.quote) } - /// Decode the platform-adaptive versioned attestation bytes, if present. - pub fn decode_attestation(&self) -> Result, FromHexError> { - hex::decode(&self.attestation) - } - pub fn decode_event_log(&self) -> Result, serde_json::Error> { serde_json::from_str(&self.event_log) } From e9d6bc79742080f445abb7f74ed7e6ecc33c3b13 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 08:18:18 -0700 Subject: [PATCH 04/11] docs: mark GetQuote as Intel TDX only --- CHANGELOG.md | 1 + docs/usage.md | 8 ++++++++ dstack/verifier/README.md | 2 +- dstack/verifier/fixtures/tdx-lite.README.md | 4 ++-- sdk/curl/api.md | 7 ++++--- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f20ea138a..43b382baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - vmm: optionally randomize the KMS and gateway URL orders written to each CVM's system configuration so new CVMs distribute their initial requests across service nodes; both are enabled by default in `vmm.toml` - http-client: HTTP clients are built once and shared instead of per request, which is what every `http_request*` call did until now -- each one paid for a connection pool, a DNS resolver and a TLS configuration it then threw away. Callers choose whether requests may reuse a connection (`RequestOptions::connection_reuse`, `PrpcClient::with_connection_reuse`); the default is to reuse. The gateway's health poller opts out: opening the connection is half of what a probe asks, since an agent that has run out of file descriptors keeps serving connections it already has while refusing every new one -- and every connection the gateway proxies to an app is a new one - os/yocto: nerdctl 2.2.1 → 2.3.5, so `nerdctl compose` honours the Compose `healthcheck:` field (only translated into `--health-*` flags from 2.3.1 on). Requires openembedded-core to move to `wrynose` head for go 1.26.5, which also brings gcc 15.2 → 15.3 — every guest image measurement changes, so the new image hashes need whitelisting in KMS +- guest-agent: `GetQuote` is restricted to Intel TDX. It used to answer on every platform, returning an empty `quote` plus a `GetQuoteResponse.attestation` field carrying the versioned attestation — a shape only that one RPC produced, and one `Attest` already covers. Non-TDX platforms now get an error telling them to call `Attest`, and the `attestation` field is gone from the RPC and from the Rust, Python, Go and JS SDKs. `Tappd.TdxQuote`/`RawQuote` and `Worker.GetAttestationForAppKey` share the same backend path, so they fail closed there too instead of returning an empty quote ## [0.5.5] - 2025-10-20 diff --git a/docs/usage.md b/docs/usage.md index 08f072f0d..5fc4c7842 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -82,6 +82,14 @@ services: curl --unix-socket /var/run/dstack.sock http://localhost/GetQuote?report_data=0x1234deadbeef | jq . ``` +`GetQuote` is Intel TDX only. On other platforms (AMD SEV-SNP, AWS Nitro, GCP +Confidential VMs) it returns an error; use `Attest` instead, which returns a +platform-adaptive attestation: + +```bash +curl --unix-socket /var/run/dstack.sock http://localhost/Attest?report_data=0x1234deadbeef | jq . +``` + For advanced compatibility with unmodified binaries that expect native Linux TEE interfaces such as `/dev/tdx_guest`, `/dev/sev-guest`, or configfs-tsm, see [Advanced Native TEE Interfaces in Containers](./native-tee-interfaces.md). ## Container Logs diff --git a/dstack/verifier/README.md b/dstack/verifier/README.md index fd3117032..6fb014ca0 100644 --- a/dstack/verifier/README.md +++ b/dstack/verifier/README.md @@ -6,7 +6,7 @@ A HTTP server that provides dstack quote verification services using the same ve ### POST /verify -Verifies a dstack attestation or quote with the provided data and VM configuration. The body can be grabbed via [getQuote](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#3-get-quote) or [attest](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#8-attest). +Verifies a dstack attestation or quote with the provided data and VM configuration. The body can be grabbed via [getQuote](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#3-get-quote) (Intel TDX only) or [attest](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#8-attest) (any platform). **Request Body:** Provide either `attestation` or (`quote` + `event_log` + `vm_config`). diff --git a/dstack/verifier/fixtures/tdx-lite.README.md b/dstack/verifier/fixtures/tdx-lite.README.md index 1eb215bcd..b78ea63fb 100644 --- a/dstack/verifier/fixtures/tdx-lite.README.md +++ b/dstack/verifier/fixtures/tdx-lite.README.md @@ -13,8 +13,8 @@ Files: `vm_config` carries `tdx_measurement`. - `tdx-lite-getquote.json`: raw guest-agent `GetQuoteResponse` captured via `GetAttestationForAppKey`, including quote, event log, and vm_config. - TDX `GetQuoteResponse` intentionally omits the `attestation` field to keep - the response compact. + `GetQuoteResponse` is Intel TDX only and carries no versioned attestation; + use `Attest` for the platform-adaptive form. Captured with: diff --git a/sdk/curl/api.md b/sdk/curl/api.md index fb02b9052..d828abf5f 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -109,7 +109,9 @@ curl --unix-socket /var/run/dstack.sock http://dstack/GetKey?path=my/key/path&pu ### 3. Get Quote -Generates a quote with given plain report data. For platform-agnostic verification, use the `attestation` field in the response. +Generates a TDX quote with given plain report data. Intel TDX only: on any other +platform this returns an error. For platform-agnostic verification, use +[Attest](#8-attest) instead. **Endpoint:** `/GetQuote` @@ -139,8 +141,7 @@ curl --unix-socket /var/run/dstack.sock http://dstack/GetQuote?report_data=00000 "quote": "", "event_log": "", "report_data": "", - "vm_config": "", - "attestation": "" + "vm_config": "" } ``` From 7997d9c62f7e8dccc66ac06ca39f279df56aa0ea Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 09:59:27 -0700 Subject: [PATCH 05/11] feat(guest-agent): replace GetAttestationForAppKey with AttestAppKey Restricting `GetQuote` to Intel TDX closes the backend path `Worker.GetAttestationForAppKey` used, so on SEV-SNP and Nitro that method can no longer answer -- its response type is `GetQuoteResponse`, and there is no quote to put in it. That leaves the external listener with no way to attest an app key at all. `Attest` is no substitute: it lives on the internal unix socket, and an external caller could not use it even if it did not, because `Attest` takes the report data from the caller and the caller does not know the app key's public key until the agent derives it. Deriving the key and building the report data is the whole reason this method exists. `AttestAppKey` is that method with a response that can carry every platform's evidence: same request, `AttestResponse` instead of `GetQuoteResponse`. Nothing is lost against the old shape -- the versioned attestation carries the report data and vm_config that `GetQuoteResponse` exposed as separate fields, and the quote and event log inside it. The old method is removed rather than kept alongside. It has shipped since v0.5.7, so this is a breaking change, but no SDK exposes it, the tree has no callers outside these tests, and on every platform but TDX it already returned an empty quote plus the field-5 attestation that never shipped. Keeping a TDX-only alias of a method that now works everywhere would cost more than it buys. `GetAttestationForAppKeyRequest` is renamed to `AttestAppKeyRequest`; message names do not appear on the wire. The report-data derivation moves to `app_key_report_data`, which also gained a bounds check: a public key encoding longer than the 64 bytes of report data used to panic on the slice copy. --- .agent/CODING_TASTE.md | 2 +- CHANGELOG.md | 1 + docs/security/cvm-boundaries.md | 6 +- dstack/guest-agent/rpc/proto/agent_rpc.proto | 19 +- dstack/guest-agent/src/rpc_service.rs | 206 +++++++++++++------ dstack/verifier/fixtures/tdx-lite.README.md | 4 +- 6 files changed, 160 insertions(+), 78 deletions(-) diff --git a/.agent/CODING_TASTE.md b/.agent/CODING_TASTE.md index a58c79d61..f29a14bcf 100644 --- a/.agent/CODING_TASTE.md +++ b/.agent/CODING_TASTE.md @@ -25,7 +25,7 @@ quantified and bounded. parallel paths (a separate `GetAppKeyAmd` was rejected on these grounds, #630). When an existing API is the wrong shape, add a purpose-built one rather than overloading a return value (`is_app_allowed` returning policy → add `auth_api.get_app_policy` instead, #538). -- **Names must say what the thing does.** `GetQuote` for an app key → `GetAttestationForAppKey` +- **Names must say what the thing does.** `GetQuote` for an app key → `AttestAppKey` (#360). An RPC named `ComposeHash` that returns an `app_id` is wrong (#181). - **Avoid enums in protobuf APIs** that surface as JSON — proto has no way to express snake_case serde renaming, so use strings (#241). diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b382baa..f031c349c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - http-client: HTTP clients are built once and shared instead of per request, which is what every `http_request*` call did until now -- each one paid for a connection pool, a DNS resolver and a TLS configuration it then threw away. Callers choose whether requests may reuse a connection (`RequestOptions::connection_reuse`, `PrpcClient::with_connection_reuse`); the default is to reuse. The gateway's health poller opts out: opening the connection is half of what a probe asks, since an agent that has run out of file descriptors keeps serving connections it already has while refusing every new one -- and every connection the gateway proxies to an app is a new one - os/yocto: nerdctl 2.2.1 → 2.3.5, so `nerdctl compose` honours the Compose `healthcheck:` field (only translated into `--health-*` flags from 2.3.1 on). Requires openembedded-core to move to `wrynose` head for go 1.26.5, which also brings gcc 15.2 → 15.3 — every guest image measurement changes, so the new image hashes need whitelisting in KMS - guest-agent: `GetQuote` is restricted to Intel TDX. It used to answer on every platform, returning an empty `quote` plus a `GetQuoteResponse.attestation` field carrying the versioned attestation — a shape only that one RPC produced, and one `Attest` already covers. Non-TDX platforms now get an error telling them to call `Attest`, and the `attestation` field is gone from the RPC and from the Rust, Python, Go and JS SDKs. `Tappd.TdxQuote`/`RawQuote` and `Worker.GetAttestationForAppKey` share the same backend path, so they fail closed there too instead of returning an empty quote +- guest-agent: `Worker.GetAttestationForAppKey` is replaced by `Worker.AttestAppKey`. The old method returned a `GetQuoteResponse`, so restricting `GetQuote` to Intel TDX left it unable to answer anywhere else — and the external listener with no way to attest an app key at all, since `Attest` is on the internal socket and an external caller could not use it anyway, not knowing the app key's public key until the agent derives it. `AttestAppKey` takes the same request and returns an `AttestResponse`, on every platform. This is a breaking change to an RPC present since v0.5.7; it ships no SDK method and has no known callers ## [0.5.5] - 2025-10-20 diff --git a/docs/security/cvm-boundaries.md b/docs/security/cvm-boundaries.md index 3f8cc4f63..28375cca3 100644 --- a/docs/security/cvm-boundaries.md +++ b/docs/security/cvm-boundaries.md @@ -190,7 +190,7 @@ The dstack-guest-agent runs an HTTP server on port 8090 inside the CVM. This por |--------|-------------|------------| | Info | Get application information | AppInfo | | Version | Get guest agent version | WorkerVersion | -| GetAttestationForAppKey | Attest a key the app derived | GetQuoteResponse | +| AttestAppKey | Attest a key the app derived | AttestResponse | | Health | Report whether the application is serving | HealthResponse | Everything on this listener is unauthenticated, so each method is bounded in @@ -204,8 +204,8 @@ what it costs and in what it says: shape its first two lines have; the path itself is already public, since it is measured into the compose hash. The file's *contents* are never quoted back. Container names and statuses were already public through the dashboard below. -- `GetAttestationForAppKey` generates a TDX quote per call and is by far the - most expensive method here. +- `AttestAppKey` generates a fresh platform attestation per call and is by far + the most expensive method here. The service also provides a web dashboard at the root URL (`/`) showing basic CVM information. View the dashboard template [here](../../dstack/guest-agent/templates/dashboard.html). diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 7ae6f7d29..369abbd3f 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -44,7 +44,11 @@ service DstackGuest { rpc GetKey(GetKeyArgs) returns (GetKeyResponse) {} // Generates a TDX quote with given report data. - // Intel TDX only. On any other platform this fails; use Attest instead. + // + // Answers wherever the platform has Intel TDX; anywhere else it fails, and + // Attest is the replacement. On GCP Confidential VMs it answers with the TDX + // quote alone, leaving out the vTPM quote GCP's verification also binds -- + // use Attest there to get evidence a verifier can check in full. rpc GetQuote(RawQuoteArgs) returns (GetQuoteResponse) {} // Generates a versioned attestation with the given report data. @@ -286,9 +290,14 @@ service Worker { rpc Info(google.protobuf.Empty) returns (AppInfo) {} // Get the guest agent version rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} - // Get attestation - // Intel TDX only, since it returns a GetQuoteResponse. - rpc GetAttestationForAppKey(GetAttestationForAppKeyRequest) returns (GetQuoteResponse) {} + // Attest a key the app derived. + // + // Replaces GetAttestationForAppKey, which returned a GetQuoteResponse and so + // could not answer on a platform without Intel TDX. Returns what + // DstackGuest.Attest returns; that method cannot serve this case, because it + // takes the report data from the caller and an external verifier does not + // know the app key's public key until the agent derives it. + rpc AttestAppKey(AttestAppKeyRequest) returns (AttestResponse) {} // Report whether the app is serving. Polled by the gateway to decide whether // this instance should be in its app's load-balancing rotation. // @@ -330,6 +339,6 @@ message VerifyResponse { bool valid = 1; } -message GetAttestationForAppKeyRequest { +message AttestAppKeyRequest { string algorithm = 1; } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 887bd412c..929b3e8d8 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, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, GetAttestationForAppKeyRequest, - GetKeyArgs, GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, - GpuInfoResponse, HealthResponse, RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, - TdxQuoteResponse, VerifyRequest, VerifyResponse, WorkerVersion, + AppInfo, AttestAppKeyRequest, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, GetKeyArgs, + GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, GpuInfoResponse, + HealthResponse, RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, + VerifyRequest, VerifyResponse, WorkerVersion, }; use dstack_types::{AppKeys, SysConfig, GPU_ATTESTATION_OUTPUT}; use ed25519_dalek::ed25519::signature::hazmat::{PrehashSigner, PrehashVerifier}; @@ -675,54 +675,62 @@ impl WorkerRpc for ExternalRpcHandler { Ok(health_response(monitor.report())) } - async fn get_attestation_for_app_key( - self, - request: GetAttestationForAppKeyRequest, - ) -> Result { + 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) + } +} + +impl ExternalRpcHandler { + /// Derive the app key for `algorithm` and build the DIP-1 report data that + /// commits to its public key. + /// + /// The caller cannot compute this itself -- it does not know the public key + /// until the key is derived here -- which is why attesting an app key needs + /// its own method instead of the caller-supplied report data `GetQuote` + /// and `Attest` take. + async fn app_key_report_data(&self, algorithm: &str) -> Result<[u8; 64]> { let key_response = InternalRpcHandler { state: self.state.clone(), } .get_key(GetKeyArgs { path: "vms".to_string(), purpose: "signing".to_string(), - algorithm: request.algorithm.clone(), + algorithm: algorithm.to_string(), }) .await?; - let algorithm = normalize_algorithm(&request.algorithm); - match algorithm { + let (prefix, pubkey) = match normalize_algorithm(algorithm) { "ed25519" => { let key_bytes: [u8; 32] = key_response .key .try_into() .ok() .context("Key is incorrect")?; - let ed25519_key = Ed25519SigningKey::from_bytes(&key_bytes); - let ed25519_pubkey = ed25519_key.verifying_key().to_bytes(); - - let mut ed25519_report_data = [0u8; 64]; - let ed25519_b64 = URL_SAFE_NO_PAD.encode(ed25519_pubkey); - let ed25519_report_string = format!("dip1::ed25519-pk:{}", ed25519_b64); - let ed_bytes = ed25519_report_string.as_bytes(); - ed25519_report_data[..ed_bytes.len()].copy_from_slice(ed_bytes); - - self.state.quote_response(ed25519_report_data) + let key = Ed25519SigningKey::from_bytes(&key_bytes); + ("dip1::ed25519-pk:", key.verifying_key().to_bytes().to_vec()) } "secp256k1" | "secp256k1_prehashed" => { - let secp256k1_key = SigningKey::from_slice(&key_response.key) + let key = SigningKey::from_slice(&key_response.key) .context("Failed to parse secp256k1 key")?; - let secp256k1_pubkey = secp256k1_key.verifying_key().to_sec1_bytes(); - - let mut secp256k1_report_data = [0u8; 64]; - let secp256k1_b64 = URL_SAFE_NO_PAD.encode(secp256k1_pubkey); - let secp256k1_report_string = format!("dip1::secp256k1c-pk:{}", secp256k1_b64); - let secp_bytes = secp256k1_report_string.as_bytes(); - secp256k1_report_data[..secp_bytes.len()].copy_from_slice(secp_bytes); - - self.state.quote_response(secp256k1_report_data) + ( + "dip1::secp256k1c-pk:", + key.verifying_key().to_sec1_bytes().to_vec(), + ) } - _ => Err(anyhow::anyhow!("Unsupported algorithm")), + _ => return Err(anyhow::anyhow!("Unsupported algorithm")), + }; + + let report_string = format!("{prefix}{}", URL_SAFE_NO_PAD.encode(pubkey)); + let bytes = report_string.as_bytes(); + let mut report_data = [0u8; 64]; + // A longer public key encoding than the 64 bytes of report data would + // otherwise truncate into a valid-looking commitment to a different key. + if bytes.len() > report_data.len() { + anyhow::bail!("report data for {algorithm} does not fit in 64 bytes"); } + report_data[..bytes.len()].copy_from_slice(bytes); + Ok(report_data) } } @@ -744,14 +752,14 @@ mod tests { config::{AppComposeWrapper, Config}, }; use dstack_attest::attestation::AttestationVerifier; - use dstack_guest_agent_rpc::{GetAttestationForAppKeyRequest, SignRequest}; + use dstack_guest_agent_rpc::{AttestAppKeyRequest, SignRequest}; use dstack_types::{AppCompose, AppKeys, EventLogVersion, KeyProvider}; use ed25519_dalek::ed25519::signature::hazmat::PrehashVerifier; use ed25519_dalek::{ Signature as Ed25519Signature, Verifier, VerifyingKey as Ed25519VerifyingKey, }; use k256::ecdsa::{Signature as K256Signature, VerifyingKey}; - use ra_tls::attestation::{AttestationV1, VersionedAttestation}; + use ra_tls::attestation::{AttestationV1, PlatformEvidence, VersionedAttestation}; use sha2::Sha256; use std::collections::HashSet; use std::convert::TryFrom; @@ -773,6 +781,14 @@ mod tests { assert_eq!(read_gpu_attestation(&dir.path().join("missing")), ""); } + fn app_key_report_data(response: &AttestResponse) -> [u8; 64] { + VersionedAttestation::from_bytes(&response.attestation) + .expect("failed to decode attestation") + .into_v1() + .report_data() + .expect("attestation carries no report data") + } + fn extract_pubkey_from_report_data(report_data: &[u8], prefix: &str) -> Result> { let end = report_data .iter() @@ -790,6 +806,14 @@ mod tests { } async fn setup_test_state() -> (AppState, tempfile::NamedTempFile) { + setup_test_state_with_platform(None).await + } + + /// The same state, with the fixture's platform evidence swapped out. + /// `None` keeps the fixture's Intel TDX evidence. + async fn setup_test_state_with_platform( + platform: Option, + ) -> (AppState, tempfile::NamedTempFile) { let mut temp_attestation_file = tempfile::NamedTempFile::new().unwrap(); let attestation = include_bytes!("../fixtures/attestation.bin"); @@ -969,10 +993,20 @@ pNs85uhOZE8z2jr8Pg== cert_client: dummy_cert_client, demo_cert: RwLock::new(String::new()), platform: Arc::new(TestSimulatorPlatform { - attestation: VersionedAttestation::from_bytes( - &std::fs::read(temp_attestation_file.path()).unwrap(), - ) - .unwrap(), + attestation: { + let fixture = VersionedAttestation::from_bytes( + &std::fs::read(temp_attestation_file.path()).unwrap(), + ) + .unwrap(); + match platform { + None => fixture, + Some(evidence) => { + let mut attestation = fixture.into_v1(); + attestation.platform = evidence; + VersionedAttestation::V1 { attestation } + } + } + }, }), health: None, }; @@ -1054,15 +1088,14 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); let attestation_response = ExternalRpcHandler::new(state) - .get_attestation_for_app_key(GetAttestationForAppKeyRequest { + .attest_app_key(AttestAppKeyRequest { algorithm: "ed25519".to_string(), }) .await .unwrap(); - let pk_bytes = - extract_pubkey_from_report_data(&attestation_response.report_data, "dip1::ed25519-pk:") - .unwrap(); + let report_data = app_key_report_data(&attestation_response); + let pk_bytes = extract_pubkey_from_report_data(&report_data, "dip1::ed25519-pk:").unwrap(); let public_key = Ed25519VerifyingKey::try_from(pk_bytes.as_slice()).unwrap(); let signature = Ed25519Signature::try_from(response.signature.as_slice()).unwrap(); @@ -1084,17 +1117,15 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); let attestation_response = ExternalRpcHandler::new(state) - .get_attestation_for_app_key(GetAttestationForAppKeyRequest { + .attest_app_key(AttestAppKeyRequest { algorithm: "secp256k1".to_string(), }) .await .unwrap(); - let pk_bytes = extract_pubkey_from_report_data( - &attestation_response.report_data, - "dip1::secp256k1c-pk:", - ) - .unwrap(); + let report_data = app_key_report_data(&attestation_response); + let pk_bytes = + extract_pubkey_from_report_data(&report_data, "dip1::secp256k1c-pk:").unwrap(); let public_key = VerifyingKey::from_sec1_bytes(&pk_bytes).unwrap(); let signature = K256Signature::try_from(response.signature.as_slice()).unwrap(); @@ -1119,17 +1150,15 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); let attestation_response = ExternalRpcHandler::new(state) - .get_attestation_for_app_key(GetAttestationForAppKeyRequest { + .attest_app_key(AttestAppKeyRequest { algorithm: "secp256k1".to_string(), }) .await .unwrap(); - let pk_bytes = extract_pubkey_from_report_data( - &attestation_response.report_data, - "dip1::secp256k1c-pk:", - ) - .unwrap(); + let report_data = app_key_report_data(&attestation_response); + let pk_bytes = + extract_pubkey_from_report_data(&report_data, "dip1::secp256k1c-pk:").unwrap(); let public_key = VerifyingKey::from_sec1_bytes(&pk_bytes).unwrap(); let signature = K256Signature::try_from(response.signature.as_slice()).unwrap(); @@ -1176,46 +1205,89 @@ pNs85uhOZE8z2jr8Pg== } #[tokio::test] - async fn test_get_attestation_for_app_key_ed25519_success() { + async fn test_attest_app_key_ed25519_success() { let (state, _guard) = setup_test_state().await; let handler = ExternalRpcHandler::new(state.clone()); - let request = GetAttestationForAppKeyRequest { + let request = AttestAppKeyRequest { algorithm: "ed25519".to_string(), }; - let response = handler.get_attestation_for_app_key(request).await.unwrap(); + let response = handler.attest_app_key(request).await.unwrap(); const EXPECTED_REPORT_DATA: &str = "dip1::ed25519-pk:5Pbre1Amf1hrp2V2bbfKlIfxpQb2pJAmrgmhxgVoG9s\0\0\0\0"; - assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); - assert!(!response.quote.is_empty()); + assert_eq!( + EXPECTED_REPORT_DATA.as_bytes(), + app_key_report_data(&response).as_slice() + ); } #[tokio::test] - async fn test_get_attestation_for_app_key_secp256k1_success() { + async fn test_attest_app_key_secp256k1_success() { let (state, _guard) = setup_test_state().await; let handler = ExternalRpcHandler::new(state.clone()); - let request = GetAttestationForAppKeyRequest { + let request = AttestAppKeyRequest { algorithm: "secp256k1".to_string(), }; - let response = handler.get_attestation_for_app_key(request).await.unwrap(); + let response = handler.attest_app_key(request).await.unwrap(); const EXPECTED_REPORT_DATA: &str = "dip1::secp256k1c-pk:A6t_JdVkVdMAocH3f1f20WGT6JzdntxcXimUtEax8zc9"; - assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); - assert!(!response.quote.is_empty()); + assert_eq!( + EXPECTED_REPORT_DATA.as_bytes(), + app_key_report_data(&response).as_slice() + ); + } + + #[tokio::test] + async fn test_attest_app_key_works_on_non_tdx() { + // The reason this method exists in place of the GetQuoteResponse-shaped + // one it replaces: the external listener has no other way to attest an + // app key, and `Attest` is no substitute -- it is on the internal + // socket, and its caller would have to know the app key's public key in + // advance to build the report data. + let (state, _guard) = setup_test_state_with_platform(Some(PlatformEvidence::SevSnp { + report: vec![0u8; 1184], + cert_chain: Vec::new(), + mr_config: String::new(), + })) + .await; + + // GetQuote is closed on this platform... + let err = state + .quote_response([0x5a; 64]) + .expect_err("GetQuote must fail on a non-TDX platform"); + assert!( + err.to_string().contains("Intel TDX only"), + "unexpected error: {err}" + ); + + // ...and attesting an app key still works. + let response = ExternalRpcHandler::new(state) + .attest_app_key(AttestAppKeyRequest { + algorithm: "ed25519".to_string(), + }) + .await + .unwrap(); + + const EXPECTED_REPORT_DATA: &str = + "dip1::ed25519-pk:5Pbre1Amf1hrp2V2bbfKlIfxpQb2pJAmrgmhxgVoG9s\0\0\0\0"; + assert_eq!( + EXPECTED_REPORT_DATA.as_bytes(), + app_key_report_data(&response).as_slice() + ); } #[tokio::test] - async fn test_get_attestation_for_app_key_unsupported_algorithm_fails() { + async fn test_attest_app_key_unsupported_algorithm_fails() { let (state, _guard) = setup_test_state().await; let handler = ExternalRpcHandler::new(state); - let request = GetAttestationForAppKeyRequest { + let request = AttestAppKeyRequest { algorithm: "ecdsa".to_string(), // Unsupported algorithm }; - let result = handler.get_attestation_for_app_key(request).await; + let result = handler.attest_app_key(request).await; assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); } diff --git a/dstack/verifier/fixtures/tdx-lite.README.md b/dstack/verifier/fixtures/tdx-lite.README.md index b78ea63fb..10d782e25 100644 --- a/dstack/verifier/fixtures/tdx-lite.README.md +++ b/dstack/verifier/fixtures/tdx-lite.README.md @@ -11,8 +11,8 @@ Files: - `tdx-lite-attestation.json`: verifier input that mimics the KMS `GetAppKey` flow. It contains a stripped `attestation` whose embedded `vm_config` carries `tdx_measurement`. -- `tdx-lite-getquote.json`: raw guest-agent `GetQuoteResponse` captured - via `GetAttestationForAppKey`, including quote, event log, and vm_config. +- `tdx-lite-getquote.json`: raw guest-agent `GetQuoteResponse`, including + quote, event log, and vm_config -- the shape `DstackGuest.GetQuote` returns. `GetQuoteResponse` is Intel TDX only and carries no versioned attestation; use `Attest` for the platform-adaptive form. From 2ad928e313b60dc8e4f6a0c3ce976142272c8382 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 09:59:40 -0700 Subject: [PATCH 06/11] fix(dstack-attest): bind report data into GCP TDX quotes `Attestation::with_report_data` patched the report data into `PlatformEvidence::Tdx` and `SevSnp` and fell through `other => other` for everything else -- including `GcpTdx`, whose quote has the identical TDX layout. The simulator uses this to answer `GetQuote` and `Attest`, so on a simulated GCP platform it served a quote still bound to the fixture's report data while echoing the caller's in the response: two different values, no error, and nothing in the tests looking. Patch `GcpTdx` the same way. The vTPM quote beside it is left alone; it commits to PCRs, not to this report data. Then drop the wildcard that hid this, here and in `strip_for_config_with_events` above it, which has the same shape and the same exposure. A wildcard in a transform like these fails open: a platform nobody thought about gets passed through unmodified and looks like it was handled. Spelling out Nitro and SEV-SNP costs two lines and turns the next such omission into a compile error. `simulator_serves_get_quote_on_gcp_tdx` now asserts the binding rather than just a non-empty quote, and fails without the fix. It also pins the other half of the contract this PR defines: GetQuote answers wherever the platform has a TDX quote, and the vTPM quote GCP's verification also binds is reachable only through Attest. --- dstack/dstack-attest/src/v1.rs | 30 +++++++++++++- dstack/guest-agent-simulator/src/main.rs | 52 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index 5cf6f25ab..a3865ed5c 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -171,7 +171,11 @@ impl PlatformEvidence { event_log: strip_tdx_runtime_event_log(event_log), tpm_quote, }, - other => other, + // No TDX event log to strip. Listed rather than caught by a + // wildcard so that a new platform has to state its answer here. + evidence @ (Self::NitroEnclave { .. } + | Self::AwsNitroTpm { .. } + | Self::SevSnp { .. }) => evidence, } } } @@ -338,6 +342,22 @@ impl Attestation { } PlatformEvidence::Tdx { quote, event_log } } + // Same TDX quote layout, so the same patch applies. The vTPM quote + // is left alone: it commits to PCRs, not to this report data. + PlatformEvidence::GcpTdx { + mut quote, + event_log, + tpm_quote, + } => { + if quote.len() >= TDX_QUOTE_REPORT_DATA_RANGE.end { + quote[TDX_QUOTE_REPORT_DATA_RANGE].copy_from_slice(&report_data); + } + PlatformEvidence::GcpTdx { + quote, + event_log, + tpm_quote, + } + } PlatformEvidence::SevSnp { mut report, cert_chain, @@ -352,7 +372,13 @@ impl Attestation { mr_config, } } - other => other, + // Nothing to patch: both carry their report data inside a signed + // document, and rewriting it would only invalidate the signature. + // Listed rather than caught by a wildcard so that a new platform + // has to state its answer here instead of silently getting this + // one -- GcpTdx was skipped for exactly that reason. + evidence @ (PlatformEvidence::NitroEnclave { .. } + | PlatformEvidence::AwsNitroTpm { .. }) => evidence, }; let stack = match self.stack { StackEvidence::Dstack { diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index 592b25b58..95db32586 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -250,6 +250,58 @@ mod tests { .expect("Attest must still work on a non-TDX platform"); } + #[test] + fn simulator_serves_get_quote_on_gcp_tdx() { + use dstack_types::Platform; + use ra_tls::attestation::{PlatformEvidence, TpmQuote}; + + let fixture = simulator::load_versioned_attestation( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../guest-agent/fixtures/attestation.bin"), + ) + .expect("fixture attestation should load"); + let mut attestation = fixture.into_v1(); + let (quote, event_log) = match attestation.platform { + PlatformEvidence::Tdx { quote, event_log } => (quote, event_log), + other => panic!("fixture should carry bare TDX evidence, got {other:?}"), + }; + attestation.platform = PlatformEvidence::GcpTdx { + quote, + event_log, + tpm_quote: TpmQuote { + message: Vec::new(), + signature: Vec::new(), + pcr_values: Vec::new(), + ak_cert: Vec::new(), + platform: Platform::Gcp, + event_log: Vec::new(), + }, + }; + let gcp_tdx = VersionedAttestation::V1 { attestation }; + let report_data = [0x5a; 64]; + + // The gate is "does this platform have a TDX quote", not "is this bare + // TDX", so GCP Confidential VMs are served, with the report data + // patched into the quote the same way bare TDX gets it. + let response = simulator::simulated_quote_response(&gcp_tdx, report_data, "", true, None) + .expect("GetQuote must answer on GCP TDX"); + assert_eq!( + &response.quote[ra_tls::attestation::TDX_QUOTE_REPORT_DATA_RANGE], + &report_data + ); + assert_eq!(response.report_data, report_data); + + // What the response cannot carry is the vTPM quote GCP's verification + // also binds -- it has no field for one. That is why the docs point + // 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) + .unwrap() + .into_v1(); + assert!(round_tripped.platform.tpm_quote().is_some()); + } + #[test] fn simulator_can_preserve_fixture_report_data() { let fixture = simulator::load_versioned_attestation( From 7f848a68684ae870a2659a69ba1fc5c35b773303 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 18:02:19 -0700 Subject: [PATCH 07/11] refactor(dstack-attest): keep the synthetic-attestation helpers out of production `with_report_data` has one non-test caller in the tree -- the simulator -- and it is a `pub fn` on a crate that kms, gateway, verifier, guest-agent and dstack-util all depend on. What it does there is rewrite bytes 568..632 of a signed TDX quote, which leaves the signature covering the bytes that used to be there. The output is evidence-shaped and cannot pass verification. That is exactly right for a simulator whose callers skip verification, and wrong for every other consumer of this crate. That mismatch is why the missing `GcpTdx` arm went unnoticed for so long: nothing about the function's placement said "only one caller, and it is a simulator". Put it behind a `simulator` feature, `#[cfg(any(test, feature = "simulator"))]`, passed through `ra-tls` to the one crate that needs it. A per-package build -- `cargo build --release -p dstack-kms`, the form the docs prescribe -- now cannot reach it: adding a call to `kms/src/config.rs` fails with `no method named with_report_data`. `cargo build --workspace` unifies features and does still compile it in, so this is a statement of intent with teeth on the release path, not a hard wall. Adding the gate immediately found the one legitimate cfg(test) user outside the simulator, `guest-agent`'s mock platform, which now declares the feature in `[dev-dependencies]` -- so guest-agent's production build drops it too. Also splits out `with_stack_report_data`. The simulator's seeded path called the full version and then overwrote the quote with a freshly generated, properly signed one, so the quote-patching half was dead work that read as if it mattered. It now asks for the half it actually uses. --- dstack/dstack-attest/Cargo.toml | 3 ++ dstack/dstack-attest/src/v1.rs | 33 +++++++++++++++++-- dstack/guest-agent-simulator/Cargo.toml | 2 +- dstack/guest-agent-simulator/src/simulator.rs | 7 +++- dstack/guest-agent/Cargo.toml | 6 ++++ dstack/ra-tls/Cargo.toml | 1 + 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/dstack/dstack-attest/Cargo.toml b/dstack/dstack-attest/Cargo.toml index c5f1d556c..eed37c668 100644 --- a/dstack/dstack-attest/Cargo.toml +++ b/dstack/dstack-attest/Cargo.toml @@ -49,6 +49,9 @@ rsa = { workspace = true, optional = true } tpm2 = { workspace = true, optional = true } [features] +# Synthetic-attestation helpers used to build fixtures. Simulator only -- see +# the gated `impl Attestation` in src/v1.rs for why production must not have it. +simulator = [] quote = [ "aws-nitro-enclaves-nsm-api", "ciborium", diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index a3865ed5c..e3303c927 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -327,8 +327,23 @@ impl Attestation { stack: self.stack.into_dstack_pod(report_data_payload), } } +} - /// Return a new attestation with the report_data patched in both platform quote and stack. +/// Helpers for assembling synthetic attestations from a captured fixture. +/// +/// Simulator only. Patching the report data into a quote leaves the quote's +/// signature covering the bytes that were there before, so what comes out +/// cannot pass verification -- it is evidence-shaped, not evidence. That is +/// fine for a simulator whose callers skip verification, and wrong everywhere +/// else, so these are behind a feature that only the simulator turns on: a +/// per-package build of kms, gateway or verifier cannot reach them at all. +/// +/// (`cargo build --workspace` unifies features, so a workspace-wide build does +/// compile them into every crate. The gate is a statement of intent and a +/// backstop for the documented per-package release builds, not a hard wall.) +#[cfg(any(test, feature = "simulator"))] +impl Attestation { + /// Patch `report_data` into both the platform quote and the stack evidence. pub fn with_report_data(self, report_data: [u8; 64]) -> Self { use crate::attestation::{SNP_REPORT_DATA_RANGE, TDX_QUOTE_REPORT_DATA_RANGE}; @@ -380,6 +395,20 @@ impl Attestation { evidence @ (PlatformEvidence::NitroEnclave { .. } | PlatformEvidence::AwsNitroTpm { .. }) => evidence, }; + Self { + version: self.version, + platform, + stack: self.stack, + } + .with_stack_report_data(report_data) + } + + /// Patch `report_data` into the stack evidence only, leaving the platform + /// quote untouched. + /// + /// For callers that go on to generate a real quote over the same report + /// data: patching the fixture's quote first would only be overwritten. + pub fn with_stack_report_data(self, report_data: [u8; 64]) -> Self { let stack = match self.stack { StackEvidence::Dstack { runtime_events, @@ -404,7 +433,7 @@ impl Attestation { }; Self { version: self.version, - platform, + platform: self.platform, stack, } } diff --git a/dstack/guest-agent-simulator/Cargo.toml b/dstack/guest-agent-simulator/Cargo.toml index 8d7fd4c16..63855dd60 100644 --- a/dstack/guest-agent-simulator/Cargo.toml +++ b/dstack/guest-agent-simulator/Cargo.toml @@ -22,7 +22,7 @@ tracing.workspace = true tracing-subscriber.workspace = true rocket.workspace = true ra-rpc = { workspace = true, features = ["rocket"] } -ra-tls = { workspace = true, features = ["quote"] } +ra-tls = { workspace = true, features = ["quote", "simulator"] } dstack-guest-agent = { path = "../guest-agent" } dstack-guest-agent-rpc.workspace = true dstack-types.workspace = true diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 3ec9c0239..92df0950b 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -116,7 +116,12 @@ fn prepare_attestation( context, )); }; - let mut attestation = attestation.clone().into_v1().with_report_data(report_data); + // Stack half only: the fixture quote read below is replaced outright by + // the generated one, so patching its report data would be undone. + let mut attestation = attestation + .clone() + .into_v1() + .with_stack_report_data(report_data); let quote = attestation .platform .tdx_quote() diff --git a/dstack/guest-agent/Cargo.toml b/dstack/guest-agent/Cargo.toml index 438579973..9b23ae0d6 100644 --- a/dstack/guest-agent/Cargo.toml +++ b/dstack/guest-agent/Cargo.toml @@ -57,3 +57,9 @@ or-panic.workspace = true cc-eventlog.workspace = true listenfd.workspace = true libc.workspace = true + +[dev-dependencies] +# The test-only mock platform builds attestations from a fixture, which needs +# the synthetic-attestation helpers. Declared here so the production build of +# this crate does not carry them. +dstack-attest = { workspace = true, features = ["simulator"] } diff --git a/dstack/ra-tls/Cargo.toml b/dstack/ra-tls/Cargo.toml index 3725f3330..086bcae6b 100644 --- a/dstack/ra-tls/Cargo.toml +++ b/dstack/ra-tls/Cargo.toml @@ -46,6 +46,7 @@ errify.workspace = true rmp-serde.workspace = true [features] +simulator = ["dstack-attest/simulator"] quote = ["dstack-attest/quote"] [dev-dependencies] From 53ec295304b0bfbe2fd88e1fd3e4f4c4d33dbca1 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 09:59:40 -0700 Subject: [PATCH 08/11] fix(gateway): expose the preserved cert attestation through the admin RPC The first commit in this series stopped `generate_and_save_attestation` from discarding the attestation when the quote request fails, so a non-TDX node -- or a TDX node during a QGS outage -- now saves a record carrying only `attestation`. Nothing could read it: `CertAttestationInfo` has fields for the public key and the quote and nothing else. That made the transient-failure case worse than before, not better. `save_cert_attestation` overwrites the `latest` pointer unconditionally, so one failed quote request on a TDX node replaced a record the admin RPC could read with one it could not. Add `attestation` as field 5, additive, and map it in both the `latest` and `history` views. Documents on `quote` when it is empty and what carries the evidence instead. ct_monitor still bails on an empty quote, so it remains TDX-only; that is the follow-up this PR's description names. --- dstack/gateway/rpc/proto/gateway_rpc.proto | 6 +++++- dstack/gateway/src/admin_service.rs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dstack/gateway/rpc/proto/gateway_rpc.proto b/dstack/gateway/rpc/proto/gateway_rpc.proto index ac3ee856b..6e40308d2 100644 --- a/dstack/gateway/rpc/proto/gateway_rpc.proto +++ b/dstack/gateway/rpc/proto/gateway_rpc.proto @@ -804,12 +804,16 @@ message ForceReleaseCertLockRequest { message CertAttestationInfo { // Certificate public key (DER encoded) bytes public_key = 1; - // TDX Quote (JSON serialized) + // TDX Quote (JSON serialized). Empty when the node has no Intel TDX, or when + // the quote request failed; `attestation` is the evidence then. string quote = 2; // Node that generated this attestation uint32 generated_by = 3; // Timestamp when this attestation was generated uint64 generated_at = 4; + // Versioned attestation (JSON serialized `AttestResponse`). Present on every + // platform, and the only evidence on nodes without Intel TDX. + string attestation = 5; } // List certificate attestations request diff --git a/dstack/gateway/src/admin_service.rs b/dstack/gateway/src/admin_service.rs index c36567df9..b9f5e6955 100644 --- a/dstack/gateway/src/admin_service.rs +++ b/dstack/gateway/src/admin_service.rs @@ -674,6 +674,7 @@ impl AdminRpc for AdminRpcHandler { .map(|att| CertAttestationInfo { public_key: att.public_key, quote: att.quote, + attestation: att.attestation, generated_by: att.generated_by, generated_at: att.generated_at, }); @@ -684,6 +685,7 @@ impl AdminRpc for AdminRpcHandler { .map(|att| CertAttestationInfo { public_key: att.public_key, quote: att.quote, + attestation: att.attestation, generated_by: att.generated_by, generated_at: att.generated_at, }) From 4d42fa7ce99cb29054e322345a801050dcd2c7ad Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 09:59:48 -0700 Subject: [PATCH 09/11] docs: GetQuote answers wherever the platform has Intel TDX The gate is `Attestation::get_tdx_quote_bytes()`, which is `Some` for `DstackGcpTdx` as well as `DstackTdx`, so GCP Confidential VMs are served. The docs said they get an error, which was wrong -- and wrong in the direction that matters: a relying party told "GCP errors here" and then handed a GCP quote has no reason to look for the vTPM quote GCP's verification binds alongside it. `GetQuoteResponse` has no field for that quote, and the raw-quote path in the verifier rebuilds its input as `AttestationQuote::DstackTdx`, so it checks the TDX half and stops. Say what actually happens instead: GetQuote needs TDX, GCP gets the TDX half only, and Attest is the answer both where GetQuote errors and where it under-reports. Updated in the proto, docs/usage.md, sdk/curl/api.md, the verifier README, and all four SDKs. Also fixes the `[Attest](#8-attest)` anchor in `sdk/curl/api.md` and `dstack/verifier/README.md` -- Attest is section 7. The verifier README has pointed at the wrong section since Sign and Verify were inserted above it. --- CHANGELOG.md | 2 +- docs/usage.md | 10 +++++++--- dstack/verifier/README.md | 2 +- sdk/curl/api.md | 8 +++++--- sdk/go/dstack/client.go | 6 ++++-- sdk/js/README.md | 2 +- sdk/js/src/index.ts | 6 ++++-- sdk/python/README.md | 5 +++-- sdk/python/src/dstack_sdk/dstack_client.py | 12 ++++++++---- sdk/rust/README.md | 5 +++-- sdk/rust/src/dstack_client.rs | 6 ++++-- 11 files changed, 41 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f031c349c..1a9efcb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - vmm: optionally randomize the KMS and gateway URL orders written to each CVM's system configuration so new CVMs distribute their initial requests across service nodes; both are enabled by default in `vmm.toml` - http-client: HTTP clients are built once and shared instead of per request, which is what every `http_request*` call did until now -- each one paid for a connection pool, a DNS resolver and a TLS configuration it then threw away. Callers choose whether requests may reuse a connection (`RequestOptions::connection_reuse`, `PrpcClient::with_connection_reuse`); the default is to reuse. The gateway's health poller opts out: opening the connection is half of what a probe asks, since an agent that has run out of file descriptors keeps serving connections it already has while refusing every new one -- and every connection the gateway proxies to an app is a new one - os/yocto: nerdctl 2.2.1 → 2.3.5, so `nerdctl compose` honours the Compose `healthcheck:` field (only translated into `--health-*` flags from 2.3.1 on). Requires openembedded-core to move to `wrynose` head for go 1.26.5, which also brings gcc 15.2 → 15.3 — every guest image measurement changes, so the new image hashes need whitelisting in KMS -- guest-agent: `GetQuote` is restricted to Intel TDX. It used to answer on every platform, returning an empty `quote` plus a `GetQuoteResponse.attestation` field carrying the versioned attestation — a shape only that one RPC produced, and one `Attest` already covers. Non-TDX platforms now get an error telling them to call `Attest`, and the `attestation` field is gone from the RPC and from the Rust, Python, Go and JS SDKs. `Tappd.TdxQuote`/`RawQuote` and `Worker.GetAttestationForAppKey` share the same backend path, so they fail closed there too instead of returning an empty quote +- guest-agent: `GetQuote` is restricted to Intel TDX. It used to answer on every platform, returning an empty `quote` plus a `GetQuoteResponse.attestation` field carrying the versioned attestation — a shape only that one RPC produced, and one `Attest` already covers. Platforms without TDX now get an error telling them to call `Attest`, and the `attestation` field is gone from the RPC and from the Rust, Python, Go and JS SDKs. GCP Confidential VMs still get an answer — the gate is whether the platform has a TDX quote — but only the TDX half of one: `GetQuoteResponse` has no field for the vTPM quote GCP's verification also binds, so relying parties there want `Attest`, and the docs say so. `Tappd.TdxQuote`/`RawQuote` share the same backend path, so they fail closed there too instead of returning an empty quote - guest-agent: `Worker.GetAttestationForAppKey` is replaced by `Worker.AttestAppKey`. The old method returned a `GetQuoteResponse`, so restricting `GetQuote` to Intel TDX left it unable to answer anywhere else — and the external listener with no way to attest an app key at all, since `Attest` is on the internal socket and an external caller could not use it anyway, not knowing the app key's public key until the agent derives it. `AttestAppKey` takes the same request and returns an `AttestResponse`, on every platform. This is a breaking change to an RPC present since v0.5.7; it ships no SDK method and has no known callers diff --git a/docs/usage.md b/docs/usage.md index 5fc4c7842..85702f2c9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -82,9 +82,13 @@ services: curl --unix-socket /var/run/dstack.sock http://localhost/GetQuote?report_data=0x1234deadbeef | jq . ``` -`GetQuote` is Intel TDX only. On other platforms (AMD SEV-SNP, AWS Nitro, GCP -Confidential VMs) it returns an error; use `Attest` instead, which returns a -platform-adaptive attestation: +`GetQuote` needs Intel TDX. On platforms without it (AMD SEV-SNP, AWS Nitro) it +returns an error. On GCP Confidential VMs it answers, but with the TDX quote +alone -- GCP's verification also binds a vTPM quote, which this response has no +field for, so a verifier that takes it at face value checks less than it should. + +`Attest` is the replacement in both cases. It returns a platform-adaptive +attestation carrying whatever evidence the platform actually produces: ```bash curl --unix-socket /var/run/dstack.sock http://localhost/Attest?report_data=0x1234deadbeef | jq . diff --git a/dstack/verifier/README.md b/dstack/verifier/README.md index 6fb014ca0..232ede636 100644 --- a/dstack/verifier/README.md +++ b/dstack/verifier/README.md @@ -6,7 +6,7 @@ A HTTP server that provides dstack quote verification services using the same ve ### POST /verify -Verifies a dstack attestation or quote with the provided data and VM configuration. The body can be grabbed via [getQuote](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#3-get-quote) (Intel TDX only) or [attest](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#8-attest) (any platform). +Verifies a dstack attestation or quote with the provided data and VM configuration. The body can be grabbed via [getQuote](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#3-get-quote) (Intel TDX only, and not the full evidence on GCP) or [attest](https://github.com/Dstack-TEE/dstack/blob/next/sdk/curl/api.md#7-attest) (any platform). **Request Body:** Provide either `attestation` or (`quote` + `event_log` + `vm_config`). diff --git a/sdk/curl/api.md b/sdk/curl/api.md index d828abf5f..a8b91f085 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -109,9 +109,11 @@ curl --unix-socket /var/run/dstack.sock http://dstack/GetKey?path=my/key/path&pu ### 3. Get Quote -Generates a TDX quote with given plain report data. Intel TDX only: on any other -platform this returns an error. For platform-agnostic verification, use -[Attest](#8-attest) instead. +Generates a TDX quote with given plain report data. Needs Intel TDX: on a +platform without it this returns an error. On GCP Confidential VMs it answers +with the TDX quote alone, leaving out the vTPM quote GCP's verification also +binds. For evidence a verifier can check in full on any platform, use +[Attest](#7-attest) instead. **Endpoint:** `/GetQuote` diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 3e892c6c9..c962d6d1d 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -475,8 +475,10 @@ func (c *DstackClient) GetKey(ctx context.Context, path string, purpose string, return &response, nil } -// Gets a TDX quote from the dstack service. Intel TDX only: on any other -// platform the guest agent returns an error and Attest should be used instead. +// Gets a TDX quote from the dstack service. Needs Intel TDX: on a platform +// without it the guest agent returns an error, and on GCP Confidential VMs it +// answers with the TDX quote alone, leaving out the vTPM quote GCP's +// verification also binds. Attest should be used in both cases. func (c *DstackClient) GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error) { if len(reportData) > 64 { return nil, fmt.Errorf("report data is too large, it should be at most 64 bytes") diff --git a/sdk/js/README.md b/sdk/js/README.md index 85583c6a2..80f04dd68 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -81,7 +81,7 @@ Returns `{ key: string, certificate_chain: string[], asUint8Array(maxLength?) }` ### `getQuote(reportData)` Generate a raw TDX quote. `reportData` is up to 64 bytes (string, Buffer, or Uint8Array). -Intel TDX only; on any other platform it throws and you should call `attest()` instead. +Needs Intel TDX: without it the call throws, and on GCP Confidential VMs it returns the TDX quote alone, leaving out the vTPM quote GCP's verification also binds. Call `attest()` in both cases. ```typescript const quote = await client.getQuote('user:alice:nonce123') diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 2cc7cba0c..cc82bf2d9 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -270,8 +270,10 @@ export class DstackClient { /** * Request a TDX quote for the given report data. * - * Intel TDX only. On any other platform the guest agent returns an error and - * this throws; use `attest()` there. + * Needs Intel TDX. Without it the guest agent returns an error and this + * throws, and on GCP Confidential VMs it answers with the TDX quote alone, + * leaving out the vTPM quote GCP's verification also binds. Use `attest()` + * in both cases. */ async getQuote(report_data: string | Buffer | Uint8Array): Promise { let hex = to_hex(report_data) diff --git a/sdk/python/README.md b/sdk/python/README.md index d37077a5b..d793435a6 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -73,8 +73,9 @@ ed_key = client.get_key('signing/key', algorithm='ed25519') ### Generate Attestation Quotes `get_quote()` creates a TDX quote proving your code runs in a genuine TEE. -It is Intel TDX only; on any other platform it fails and you should call -`attest()` instead. +It needs Intel TDX: without it the call fails, and on GCP Confidential VMs it +returns the TDX quote alone, leaving out the vTPM quote GCP's verification also +binds. Call `attest()` in both cases. ```python quote = client.get_quote(b'user:alice:nonce123') diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 361d5fe15..cd1caadc0 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -412,8 +412,10 @@ async def get_quote( ) -> GetQuoteResponse: """Request a TDX quote for the provided report data. - Intel TDX only. On any other platform the guest agent returns an error; - use ``attest()`` there. + Needs Intel TDX. Without it the guest agent returns an error, and on + GCP Confidential VMs it answers with the TDX quote alone, leaving out + the vTPM quote GCP's verification also binds. Use ``attest()`` in both + cases. """ if not report_data or not isinstance(report_data, (bytes, str)): raise ValueError("report_data can not be empty") @@ -581,8 +583,10 @@ def get_quote( ) -> GetQuoteResponse: """Request a TDX quote for the provided report data. - Intel TDX only. On any other platform the guest agent returns an error; - use ``attest()`` there. + Needs Intel TDX. Without it the guest agent returns an error, and on + GCP Confidential VMs it answers with the TDX quote alone, leaving out + the vTPM quote GCP's verification also binds. Use ``attest()`` in both + cases. """ raise NotImplementedError diff --git a/sdk/rust/README.md b/sdk/rust/README.md index ff9052578..c859b3e09 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -67,8 +67,9 @@ The Rust SDK currently requests the default `secp256k1` key material. Use distin ### Generate Attestation Quotes `get_quote()` creates a TDX quote proving your code runs in a genuine TEE. -It is Intel TDX only; on any other platform it fails and you should call -`attest()` instead. +It needs Intel TDX: without it the call fails, and on GCP Confidential VMs it +returns the TDX quote alone, leaving out the vTPM quote GCP's verification also +binds. Call `attest()` in both cases. ```rust let quote = client.get_quote(b"user:alice:nonce123".to_vec()).await?; diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client.rs index 31b6d3627..2f0d31b0f 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -144,8 +144,10 @@ impl DstackClient { /// Request a TDX quote for the provided report data. /// - /// Intel TDX only. On any other platform the guest agent returns an error; - /// use [`Self::attest`] there. + /// Needs Intel TDX. Without it the guest agent returns an error, and on GCP + /// Confidential VMs it answers with the TDX quote alone, leaving out the + /// vTPM quote GCP's verification also binds. Use [`Self::attest`] in both + /// cases. pub async fn get_quote(&self, report_data: Vec) -> Result { if report_data.is_empty() || report_data.len() > 64 { anyhow::bail!("Invalid report data length") From 512d6dabc4027b9f941b1ac6e645a32209b70778 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 18:31:51 -0700 Subject: [PATCH 10/11] fix(dstack-attest): make the platform accessors exhaustive `tdx_event_log()` reaches `Tdx | GcpTdx`; `tdx_event_log_mut()` reached only `Tdx`. The two drifted apart because both ended in `_ => None`, so adding `GcpTdx` to one and not the other compiled fine. The simulator fills V2 digest preimages through the mut accessor, so on a GCP attestation it silently filled nothing and returned events without the preimage `agent_rpc.proto` tells clients to verify against the digest. Drop the wildcard from all eight accessors on `PlatformEvidence` and spell the variants out. This is the same failure `with_report_data` had two commits ago; a wildcard is how a family of per-platform methods disagrees with itself without anyone noticing. `tdx_event_log_accessors_agree_on_gcp_tdx` pins the read and mut halves to the same set and fails against the old match. Also corrects the comment on the `GcpTdx` arm of `with_report_data`. It claimed the vTPM quote was left alone because it commits to PCRs rather than to the report data. That is wrong: its `qualified_data` is `sha256(tdx_quote)` (attestation.rs:1087), so patching the quote severs that binding too. The behaviour is right -- re-deriving it would need the AK to re-sign, and the patch has already invalidated the quote signature -- but the reason given was not. --- dstack/dstack-attest/src/v1.rs | 74 +++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index e3303c927..f895a94cf 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -92,7 +92,7 @@ impl PlatformEvidence { pub fn tdx_quote(&self) -> Option<&[u8]> { match self { Self::Tdx { quote, .. } | Self::GcpTdx { quote, .. } => Some(quote.as_slice()), - _ => None, + Self::NitroEnclave { .. } | Self::AwsNitroTpm { .. } | Self::SevSnp { .. } => None, } } @@ -101,42 +101,57 @@ impl PlatformEvidence { Self::Tdx { event_log, .. } | Self::GcpTdx { event_log, .. } => { Some(event_log.as_slice()) } - _ => None, + Self::NitroEnclave { .. } | Self::AwsNitroTpm { .. } | Self::SevSnp { .. } => None, } } pub fn tpm_quote(&self) -> Option<&TpmQuote> { match self { Self::GcpTdx { tpm_quote, .. } => Some(tpm_quote), - _ => None, + Self::Tdx { .. } + | Self::NitroEnclave { .. } + | Self::AwsNitroTpm { .. } + | Self::SevSnp { .. } => None, } } pub fn nsm_quote(&self) -> Option<&[u8]> { match self { Self::NitroEnclave { nsm_quote } => Some(nsm_quote.as_slice()), - _ => None, + Self::Tdx { .. } + | Self::GcpTdx { .. } + | Self::AwsNitroTpm { .. } + | Self::SevSnp { .. } => None, } } pub fn sev_snp_report(&self) -> Option<&[u8]> { match self { Self::SevSnp { report, .. } => Some(report.as_slice()), - _ => None, + Self::Tdx { .. } + | Self::GcpTdx { .. } + | Self::NitroEnclave { .. } + | Self::AwsNitroTpm { .. } => None, } } pub fn sev_snp_cert_chain(&self) -> Option<&[Vec]> { match self { Self::SevSnp { cert_chain, .. } => Some(cert_chain.as_slice()), - _ => None, + Self::Tdx { .. } + | Self::GcpTdx { .. } + | Self::NitroEnclave { .. } + | Self::AwsNitroTpm { .. } => None, } } pub fn sev_snp_mr_config_document(&self) -> Option<&str> { match self { Self::SevSnp { mr_config, .. } => Some(mr_config.as_str()), - _ => None, + Self::Tdx { .. } + | Self::GcpTdx { .. } + | Self::NitroEnclave { .. } + | Self::AwsNitroTpm { .. } => None, } } @@ -147,8 +162,8 @@ impl PlatformEvidence { pub fn tdx_event_log_mut(&mut self) -> Option<&mut Vec> { match self { - Self::Tdx { event_log, .. } => Some(event_log), - _ => None, + Self::Tdx { event_log, .. } | Self::GcpTdx { event_log, .. } => Some(event_log), + Self::NitroEnclave { .. } | Self::AwsNitroTpm { .. } | Self::SevSnp { .. } => None, } } @@ -358,7 +373,11 @@ impl Attestation { PlatformEvidence::Tdx { quote, event_log } } // Same TDX quote layout, so the same patch applies. The vTPM quote - // is left alone: it commits to PCRs, not to this report data. + // beside it cannot follow: its `qualified_data` is + // `sha256(tdx_quote)`, and re-deriving that would need the AK to + // re-sign. So this severs the TPM binding on top of the quote + // signature the patch already invalidates -- acceptable only + // because nothing verifies a simulator attestation. PlatformEvidence::GcpTdx { mut quote, event_log, @@ -602,6 +621,41 @@ mod tests { assert_eq!(stripped[3].event_payload, vec![0x42]); } + #[test] + fn tdx_event_log_accessors_agree_on_gcp_tdx() { + // The mut accessor used to match bare TDX only, so `fill_v2_preimages` + // silently skipped GCP attestations and returned V2 events without the + // preimage the guest-agent proto promises clients can verify. Whatever + // the read accessor reaches, the mut one has to reach too. + let mut evidence = PlatformEvidence::GcpTdx { + quote: vec![0u8; 64], + event_log: vec![TdxEvent { + imr: 3, + event_type: cc_eventlog::DSTACK_RUNTIME_EVENT_TYPE, + digest: vec![0u8; 48], + event: "test-event".into(), + event_payload: b"payload".to_vec(), + version: EventLogVersion::V2, + preimage: None, + }], + tpm_quote: TpmQuote { + message: Vec::new(), + signature: Vec::new(), + pcr_values: Vec::new(), + ak_cert: Vec::new(), + platform: dstack_types::Platform::Gcp, + event_log: Vec::new(), + }, + }; + + assert!(evidence.tdx_event_log().is_some()); + let log = evidence + .tdx_event_log_mut() + .expect("mut accessor must reach GCP TDX too"); + cc_eventlog::tdx::fill_v2_preimages(log); + assert!(evidence.tdx_event_log().unwrap()[0].preimage.is_some()); + } + #[test] fn sev_snp_with_report_data_patches_report_and_stack() { let mut report = vec![0x11; 1184]; From d8c1f4d5b091cad3c3f6119b4667997968484b80 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 18:31:51 -0700 Subject: [PATCH 11/11] fix(guest-agent): accept secp256k1_prehashed in AttestAppKey The algorithm reached `get_key` verbatim, and `get_key` knows only `ed25519` and `secp256k1`, so `AttestAppKey{algorithm: "secp256k1_prehashed"}` came back "Unsupported algorithm" -- while `Sign` accepts that name and has mapped it to its base key since it was added. A client that signs prehashed had no way to attest the key it signs with, under the name it signs with. Prehashing is a signing mode, not a key type: the same secp256k1 key signs both ways and has the same public key, so derive it under the base name exactly as `Sign` does. The arm for it in the match below was already there and simply unreachable; the PR's own prehashed sign test sidestepped this by asking for "secp256k1". `test_attest_app_key_accepts_secp256k1_prehashed` asserts both names attest the same public key. --- dstack/guest-agent/src/rpc_service.rs | 35 +++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 929b3e8d8..3002e40ca 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -690,17 +690,26 @@ impl ExternalRpcHandler { /// its own method instead of the caller-supplied report data `GetQuote` /// and `Attest` take. async fn app_key_report_data(&self, algorithm: &str) -> Result<[u8; 64]> { + let algorithm = normalize_algorithm(algorithm); + // Prehashing is a signing mode, not a key type: the same secp256k1 key + // signs both ways, so derive it under the base name. `Sign` does the + // same, and without this the prehashed name reaches `get_key` verbatim + // and comes back "Unsupported algorithm". + let key_algorithm = match algorithm { + "secp256k1_prehashed" => "secp256k1", + other => other, + }; let key_response = InternalRpcHandler { state: self.state.clone(), } .get_key(GetKeyArgs { path: "vms".to_string(), purpose: "signing".to_string(), - algorithm: algorithm.to_string(), + algorithm: key_algorithm.to_string(), }) .await?; - let (prefix, pubkey) = match normalize_algorithm(algorithm) { + let (prefix, pubkey) = match algorithm { "ed25519" => { let key_bytes: [u8; 32] = key_response .key @@ -1279,6 +1288,28 @@ pNs85uhOZE8z2jr8Pg== ); } + #[tokio::test] + async fn test_attest_app_key_accepts_secp256k1_prehashed() { + // Prehashing changes how the key signs, not which key it is, so this + // must attest the same public key `Sign` uses under that name. + let (state, _guard) = setup_test_state().await; + + let prehashed = ExternalRpcHandler::new(state.clone()) + .attest_app_key(AttestAppKeyRequest { + algorithm: "secp256k1_prehashed".to_string(), + }) + .await + .expect("secp256k1_prehashed must be accepted"); + let plain = ExternalRpcHandler::new(state) + .attest_app_key(AttestAppKeyRequest { + algorithm: "secp256k1".to_string(), + }) + .await + .unwrap(); + + assert_eq!(app_key_report_data(&prehashed), app_key_report_data(&plain)); + } + #[tokio::test] async fn test_attest_app_key_unsupported_algorithm_fails() { let (state, _guard) = setup_test_state().await;