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 f20ea138a..1a9efcb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ 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. 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 ## [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/docs/usage.md b/docs/usage.md index 08f072f0d..85702f2c9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -82,6 +82,18 @@ services: curl --unix-socket /var/run/dstack.sock http://localhost/GetQuote?report_data=0x1234deadbeef | jq . ``` +`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 . +``` + 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/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 5cf6f25ab..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, } } @@ -171,7 +186,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, } } } @@ -323,8 +342,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}; @@ -338,6 +372,26 @@ impl Attestation { } PlatformEvidence::Tdx { quote, event_log } } + // Same TDX quote layout, so the same patch applies. The vTPM quote + // 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, + 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,8 +406,28 @@ 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, }; + 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, @@ -378,7 +452,7 @@ impl Attestation { }; Self { version: self.version, - platform, + platform: self.platform, stack, } } @@ -547,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]; 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, }) 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, 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/main.rs b/dstack/guest-agent-simulator/src/main.rs index 49e088271..95db32586 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -219,6 +219,89 @@ 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_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( diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 33f277d90..92df0950b 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, }) } @@ -119,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/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..369abbd3f 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -44,6 +44,11 @@ service DstackGuest { rpc GetKey(GetKeyArgs) returns (GetKeyResponse) {} // Generates a TDX quote with given report data. + // + // 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. @@ -197,19 +202,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 @@ -286,8 +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 - 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. // @@ -329,6 +339,6 @@ message VerifyResponse { bool valid = 1; } -message GetAttestationForAppKeyRequest { +message AttestAppKeyRequest { string algorithm = 1; } 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..3002e40ca 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,71 @@ 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 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: request.algorithm.clone(), + algorithm: key_algorithm.to_string(), }) .await?; - let algorithm = normalize_algorithm(&request.algorithm); - match algorithm { + let (prefix, pubkey) = match 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 +761,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 +790,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 +815,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"); @@ -939,7 +972,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 +984,6 @@ pNs85uhOZE8z2jr8Pg== .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: Vec::new(), }) } @@ -968,10 +1002,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, }; @@ -1053,15 +1097,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(); @@ -1083,17 +1126,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(); @@ -1118,17 +1159,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(); @@ -1175,46 +1214,111 @@ 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.attestation.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.attestation.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_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_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/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] diff --git a/dstack/verifier/README.md b/dstack/verifier/README.md index fd3117032..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) 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, 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/dstack/verifier/fixtures/tdx-lite.README.md b/dstack/verifier/fixtures/tdx-lite.README.md index 1eb215bcd..10d782e25 100644 --- a/dstack/verifier/fixtures/tdx-lite.README.md +++ b/dstack/verifier/fixtures/tdx-lite.README.md @@ -11,10 +11,10 @@ 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 `GetQuoteResponse` intentionally omits the `attestation` field to keep - the response compact. +- `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. Captured with: diff --git a/sdk/curl/api.md b/sdk/curl/api.md index fb02b9052..a8b91f085 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -109,7 +109,11 @@ 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. 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` @@ -139,8 +143,7 @@ curl --unix-socket /var/run/dstack.sock http://dstack/GetQuote?report_data=00000 "quote": "", "event_log": "", "report_data": "", - "vm_config": "", - "attestation": "" + "vm_config": "" } ``` 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..c962d6d1d 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,10 @@ 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. 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 8af123892..80f04dd68 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). +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 a060d7656..cc82bf2d9 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,14 @@ export class DstackClient { }) } + /** + * Request a TDX quote for the given report data. + * + * 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) if (hex.length > 128) { diff --git a/sdk/python/README.md b/sdk/python/README.md index d7c0de293..d793435a6 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -73,6 +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 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 fd206483b..cd1caadc0 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,13 @@ 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. + + 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") report_bytes: bytes = ( @@ -576,7 +581,13 @@ 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. + + 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 @call_async diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 630ecf3da..c859b3e09 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -67,6 +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 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 d33a04e62..2f0d31b0f 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -142,6 +142,12 @@ impl DstackClient { Ok(response) } + /// Request a TDX quote for the provided report data. + /// + /// 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") 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) }