From 53a53a21dc09348a727966129d531138933fe7fe Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 24 Aug 2026 01:39:02 -0700 Subject: [PATCH 1/2] feat(guest-agent): restore Verify on the unversioned service 0.6.0 dropped the `Verify` RPC because verification needs no key material and no attestation, and the agent's verdict arrives over the socket unattested -- the reasoning still holds, and SDKs >= 0.6 verify locally. But dropping it broke every 0.5.x SDK against a 0.6 agent for a method those SDKs still call, and prpc answers an unknown method with a bare HTTP 400, so the caller cannot tell a removed method from a wrong socket. The unversioned surface is what 0.5.x clients speak; new API design belongs in `dstack.guest.v1`, not in breaking this one. Restore the RPC and its messages at the v0.5.11 field numbers, marked deprecated and legacy-only, and annotate the service as frozen: no renumbering, no removals, no semantic changes. Semantics are identical to 0.5.11, including k256 rejecting non-canonical high-S signatures outright. --- CHANGELOG.md | 5 +- dstack/guest-agent/rpc/proto/agent_rpc.proto | 33 ++++- dstack/guest-agent/src/rpc_service.rs | 130 ++++++++++++++++++- 3 files changed, 156 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c774941..21cd80629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- sdk: `verify_signature` and `verify_signature_chain` in all four SDKs, replacing the removed guest-agent `Verify` RPC. `verify_signature_chain` is new capability rather than a port: it walks all three links of a `Sign` signature chain -- payload signature, the app root key attesting `"{purpose}:{hex(pubkey)}"`, and the KMS root attesting that app root for this `app_id` -- and requires the chain to anchor at a KMS root public key **the caller supplies**. That anchor has to come from somewhere independently trusted (the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a pinned value); read it from the KMS being checked and an attacker who can answer that query can also mint a self-consistent chain. The four ports are pinned against one committed set of test vectors, `sdk/tests/vectors/signature_chain.json`, generated from the real KMS and guest-agent primitives -- this repo has shipped cross-language crypto drift twice already +- sdk: `verify_signature` and `verify_signature_chain` in all four SDKs, replacing the deprecated guest-agent `Verify` RPC. `verify_signature_chain` is new capability rather than a port: it walks all three links of a `Sign` signature chain -- payload signature, the app root key attesting `"{purpose}:{hex(pubkey)}"`, and the KMS root attesting that app root for this `app_id` -- and requires the chain to anchor at a KMS root public key **the caller supplies**. That anchor has to come from somewhere independently trusted (the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a pinned value); read it from the KMS being checked and an attacker who can answer that query can also mint a self-consistent chain. The four ports are pinned against one committed set of test vectors, `sdk/tests/vectors/signature_chain.json`, generated from the real KMS and guest-agent primitives -- this repo has shipped cross-language crypto drift twice already - guest-agent: `AttestGpu` collects vendor-native GPU evidence on demand against a caller-supplied 32-byte nonce. It returns opaque, versioned evidence bundles identified by vendor and format for independent appraisal. The response format is extensible to additional GPU vendors. Exposed in the Rust, Python, Go, and JS SDKs - guest-agent: `Attest` accepts `include_boottime_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.boottime_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip instead of also calling `GpuInfo`. Exposed in the Rust, Python, Go and JS SDKs - sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs @@ -43,8 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- guest-agent: the `Verify` RPC (`/Verify`), present since v0.5.6. Checking a signature needs no key material and no attestation, and the agent's verdict arrived over the socket unattested -- a caller who believed the TEE was vouching for it was mistaken, and one who did not gained nothing over checking the signature locally. It was also inbound attack surface, parsing attacker-supplied keys and signatures inside the TEE for no benefit. `Sign` stays server-side, because it needs a key only the TEE holds. **Breaking:** an SDK pinned at 0.5.x that calls `/Verify` against a 0.6+ guest agent gets an unknown-method error; update to an SDK that verifies locally. The client-side `verify()` method is gone from the Rust, Python, Go and JavaScript SDKs, replaced by the standalone `verify_signature` above -- it never needed a client connection in the first place. Verification also became stricter in one respect: non-canonical high-S secp256k1 signatures are now rejected explicitly everywhere. `k256` accepted only the canonical form, so the Rust agent already behaved this way, but a naive port to Python or Go would have silently accepted both `(r, s)` and `(r, n-s)` for the same message - +- sdk: the client-side `verify()` method is gone from the Rust, Python, Go and JavaScript SDKs, replaced by the standalone `verify_signature` above -- it never needed a client connection in the first place. Checking a signature needs no key material and no attestation, and the agent's verdict arrived over the socket unattested, so a caller who believed the TEE was vouching for it was mistaken and one who did not gained nothing over checking the signature locally. Verification also became stricter in one respect: non-canonical high-S secp256k1 signatures are now rejected explicitly everywhere. `k256` accepted only the canonical form, so the Rust agent already behaved this way, but a naive port to Python or Go would have silently accepted both `(r, s)` and `(r, n-s)` for the same message. The server-side `Verify` RPC (`/Verify`, present since v0.5.6) is **retained** on the unversioned guest-agent path so 0.5.x SDKs keep working against a 0.6 agent unchanged; it is deprecated, its semantics are frozen at the v0.5.11 wire surface, and it will not be carried into the v1 API. New code should verify locally ## [0.5.5] - 2025-10-20 diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 7a99d32e2..2b2b14283 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -34,7 +34,12 @@ service Tappd { rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} } -// The service for the dstack guest agent +// The service for the dstack guest agent. +// +// This unversioned surface is frozen for wire compatibility with v0.5.x +// clients: no field renumbering, no removals, and no semantic changes to +// existing methods. New functionality goes to the upcoming `dstack.guest.v1` +// package instead. service DstackGuest { // Derives a cryptographic key from the specified key path. // Returns the derived key along with its TLS certificate chain. @@ -77,11 +82,16 @@ service DstackGuest { // Sign a payload rpc Sign(SignRequest) returns (SignResponse) {} - // Removed in v0.6.0: `rpc Verify(VerifyRequest) returns (VerifyResponse)`. - // Signature verification needs no key material and no attestation, and the - // agent's answer arrives over the socket unattested, so a caller gained - // nothing over checking the signature itself. The SDKs now do it locally -- - // see `verify_signature` / `verify_signature_chain`. Do not reuse the name. + // Verify a signature. Deprecated, legacy-only. + // + // Verification needs no key material and no attestation, and the agent's + // answer arrives over the socket unattested, so a caller gains nothing over + // checking the signature itself. SDKs >= 0.6 do it locally -- see + // `verify_signature` / `verify_signature_chain`. + // + // Retained here only so 0.5.x clients keep working. It will not appear in the + // v1 API; do not call it from new code. + rpc Verify(VerifyRequest) returns (VerifyResponse) {} // Get the guest agent version rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} @@ -402,6 +412,17 @@ message SignResponse { bytes public_key = 3; } +message VerifyRequest { + string algorithm = 1; + bytes data = 2; + bytes signature = 3; + bytes public_key = 4; +} + +message VerifyResponse { + bool valid = 1; +} + message AttestAppKeyRequest { string algorithm = 1; } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index b49f67bf8..550b5e685 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -18,11 +18,12 @@ use dstack_guest_agent_rpc::{ AppInfo, AttestAppKeyRequest, AttestArgs, AttestGpuArgs, AttestGpuResponse, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, GetKeyArgs, GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, GpuEvidenceBundle, GpuInfoResponse, HealthResponse, - RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, WorkerVersion, + RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, VerifyRequest, + VerifyResponse, WorkerVersion, }; use dstack_types::{AppKeys, SysConfig, GPU_ATTESTATION_OUTPUT}; -use ed25519_dalek::ed25519::signature::hazmat::PrehashSigner; -use ed25519_dalek::{Signer as Ed25519Signer, SigningKey as Ed25519SigningKey}; +use ed25519_dalek::ed25519::signature::hazmat::{PrehashSigner, PrehashVerifier}; +use ed25519_dalek::{Signer as Ed25519Signer, SigningKey as Ed25519SigningKey, Verifier}; use fs_err as fs; use k256::ecdsa::SigningKey; use or_panic::ResultOrPanic; @@ -474,6 +475,47 @@ impl DstackGuestRpc for InternalRpcHandler { }) } + /// Deprecated, kept for 0.5.x clients only. See the RPC's doc comment in + /// agent_rpc.proto. + /// + /// k256 rejects a non-canonical (high-S) signature outright, so a malleated + /// copy of a valid signature fails to parse rather than verifying. Keep it + /// that way: 0.5.x answered the same, and callers may be treating this + /// answer as a uniqueness check. + async fn verify(self, request: VerifyRequest) -> Result { + let algorithm = normalize_algorithm(&request.algorithm); + let valid = match algorithm { + "ed25519" => { + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes( + &request + .public_key + .as_slice() + .try_into() + .ok() + .context("invalid public key")?, + )?; + let signature = ed25519_dalek::Signature::from_slice(&request.signature)?; + verifying_key.verify(&request.data, &signature).is_ok() + } + "secp256k1" => { + let verifying_key = + k256::ecdsa::VerifyingKey::from_sec1_bytes(&request.public_key)?; + let signature = k256::ecdsa::Signature::from_slice(&request.signature)?; + verifying_key.verify(&request.data, &signature).is_ok() + } + "secp256k1_prehashed" => { + let verifying_key = + k256::ecdsa::VerifyingKey::from_sec1_bytes(&request.public_key)?; + let signature = k256::ecdsa::Signature::from_slice(&request.signature)?; + verifying_key + .verify_prehash(&request.data, &signature) + .is_ok() + } + _ => return Err(anyhow::anyhow!("Unsupported algorithm")), + }; + Ok(VerifyResponse { valid }) + } + async fn attest(self, request: AttestArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; Ok(AttestResponse { @@ -1474,4 +1516,86 @@ pNs85uhOZE8z2jr8Pg== // k256 alias should produce the same public key as secp256k1 assert_eq!(resp_k256.public_key, resp_secp.public_key); } + + /// Sign with `algorithm`, then verify the result through the legacy Verify + /// RPC -- the round trip a 0.5.x SDK performs. + async fn sign_then_verify( + algorithm: &str, + data: Vec, + ) -> (AppState, tempfile::NamedTempFile, SignResponse) { + let (state, guard) = setup_test_state().await; + let signed = InternalRpcHandler { + state: state.clone(), + } + .sign(SignRequest { + algorithm: algorithm.to_string(), + data: data.clone(), + }) + .await + .unwrap(); + + let verified = InternalRpcHandler { + state: state.clone(), + } + .verify(VerifyRequest { + algorithm: algorithm.to_string(), + data, + signature: signed.signature.clone(), + public_key: signed.public_key.clone(), + }) + .await + .unwrap(); + assert!(verified.valid); + + (state, guard, signed) + } + + #[tokio::test] + async fn verify_accepts_an_ed25519_signature_from_sign() { + sign_then_verify("ed25519", b"test message for ed25519".to_vec()).await; + } + + #[tokio::test] + async fn verify_accepts_a_secp256k1_signature_from_sign() { + sign_then_verify("secp256k1", b"test message for secp256k1".to_vec()).await; + } + + #[tokio::test] + async fn verify_accepts_a_secp256k1_prehashed_signature_from_sign() { + let digest = Sha256::digest(b"test message for secp256k1 prehashed"); + sign_then_verify("secp256k1_prehashed", digest.to_vec()).await; + } + + #[tokio::test] + async fn verify_rejects_tampered_data() { + let (state, _guard, signed) = + sign_then_verify("ed25519", b"original message".to_vec()).await; + + let response = InternalRpcHandler { state } + .verify(VerifyRequest { + algorithm: "ed25519".to_string(), + data: b"tampered message".to_vec(), + signature: signed.signature, + public_key: signed.public_key, + }) + .await + .unwrap(); + + assert!(!response.valid); + } + + #[tokio::test] + async fn verify_unsupported_algorithm_fails() { + let (state, _guard) = setup_test_state().await; + let result = InternalRpcHandler { state } + .verify(VerifyRequest { + algorithm: "rsa".to_string(), + data: b"test message".to_vec(), + signature: vec![0; 64], + public_key: vec![0; 32], + }) + .await; + + assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); + } } From b91f1d0b61c9017a5083669a093be8196d4a98e6 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 24 Aug 2026 01:39:35 -0700 Subject: [PATCH 2/2] feat(guest-agent): fail EmitEvent with an explicit removal message `EmitEvent` is gone for good -- runtime RTMR3 events are system-owned in 0.6.0, so an app extending the measurement chain is no longer something this API should offer. Deleting the method outright is the wrong way to say that. prpc answers both "no such method" and "the handler failed" with HTTP 400 and drops the message, so a 0.5.x app that emits events gets a bare 400 that reads identically to a wrong socket path or a broken build, and its author has no way to learn why the events stopped being recorded. Keep the method and the args message on the unversioned service, and have the handler always bail with the reason. Nothing reaches `emit_runtime_event`; this is a deliberate error, not a code path. --- CHANGELOG.md | 2 ++ dstack/guest-agent/rpc/proto/agent_rpc.proto | 17 +++++++++++ dstack/guest-agent/src/rpc_service.rs | 32 +++++++++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21cd80629..8398f05ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - sdk: the client-side `verify()` method is gone from the Rust, Python, Go and JavaScript SDKs, replaced by the standalone `verify_signature` above -- it never needed a client connection in the first place. Checking a signature needs no key material and no attestation, and the agent's verdict arrived over the socket unattested, so a caller who believed the TEE was vouching for it was mistaken and one who did not gained nothing over checking the signature locally. Verification also became stricter in one respect: non-canonical high-S secp256k1 signatures are now rejected explicitly everywhere. `k256` accepted only the canonical form, so the Rust agent already behaved this way, but a naive port to Python or Go would have silently accepted both `(r, s)` and `(r, n-s)` for the same message. The server-side `Verify` RPC (`/Verify`, present since v0.5.6) is **retained** on the unversioned guest-agent path so 0.5.x SDKs keep working against a 0.6 agent unchanged; it is deprecated, its semantics are frozen at the v0.5.11 wire surface, and it will not be carried into the v1 API. New code should verify locally +- guest-agent: the `EmitEvent` RPC no longer records anything -- runtime RTMR3 events are system-owned in 0.6.0, so an app can no longer extend the measurement chain. The method itself stays on the unversioned path and always fails with an error naming the removal, rather than being deleted outright: prpc answers both "no such method" and "the handler failed" with HTTP 400 and drops the message, so a deleted method would leave a 0.5.x caller with a generic error indistinguishable from a wrong socket or a version skew. **Breaking:** any app extending RTMR3 at runtime must stop; bind app data through `report_data` instead, which is what most callers wanted anyway + ## [0.5.5] - 2025-10-20 diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 2b2b14283..5d043a129 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -60,6 +60,14 @@ service DstackGuest { // Returns a dstack-defined attestation format that supports different attestation modes across platforms. rpc Attest(AttestArgs) returns (AttestResponse) {} + // Removed in v0.6.0: always fails. Runtime RTMR3 events are system-owned now, + // so an app can no longer extend them. + // + // The method is kept only so a pre-0.6 client gets a self-explanatory error. + // prpc answers both "no such method" and "the handler failed" with HTTP 400, + // so the message text is the only thing that tells the two apart. + rpc EmitEvent(EmitEventArgs) returns (google.protobuf.Empty) {} + // Get app info rpc Info(google.protobuf.Empty) returns (AppInfo) {} @@ -300,6 +308,15 @@ message GetQuoteResponse { string vm_config = 4; } +// The request of the removed EmitEvent RPC. Kept so a pre-0.6 client's request +// still decodes and reaches the handler that explains the removal. +message EmitEventArgs { + // The event name + string event = 1; + // The event data + bytes payload = 2; +} + // The request to derive a key message AppInfo { // App ID diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 550b5e685..8eb9f1b47 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -16,10 +16,10 @@ use dstack_guest_agent_rpc::{ tappd_server::{TappdRpc, TappdServer}, worker_server::{WorkerRpc, WorkerServer}, AppInfo, AttestAppKeyRequest, AttestArgs, AttestGpuArgs, AttestGpuResponse, AttestResponse, - DeriveK256KeyResponse, DeriveKeyArgs, GetKeyArgs, GetKeyResponse, GetQuoteResponse, - GetTlsKeyArgs, GetTlsKeyResponse, GpuEvidenceBundle, GpuInfoResponse, HealthResponse, - RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, VerifyRequest, - VerifyResponse, WorkerVersion, + DeriveK256KeyResponse, DeriveKeyArgs, EmitEventArgs, GetKeyArgs, GetKeyResponse, + GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, GpuEvidenceBundle, 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}; @@ -389,6 +389,16 @@ impl DstackGuestRpc for InternalRpcHandler { self.state.quote_response(report_data) } + /// Always fails. See the RPC's doc comment in agent_rpc.proto: the method + /// exists so a pre-0.6 client learns why its events stopped being recorded + /// instead of getting an unknown-method 400 it cannot tell apart from + /// talking to the wrong socket. + async fn emit_event(self, _request: EmitEventArgs) -> Result<()> { + anyhow::bail!( + "EmitEvent was removed in dstack 0.6.0; runtime RTMR3 events are system-owned and cannot be extended by apps" + ) + } + async fn info(self) -> Result { get_info(&self.state, false).await } @@ -1598,4 +1608,18 @@ pNs85uhOZE8z2jr8Pg== assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); } + + #[tokio::test] + async fn emit_event_reports_its_removal() { + let (state, _guard) = setup_test_state().await; + let result = InternalRpcHandler { state } + .emit_event(EmitEventArgs { + event: "test-event".to_string(), + payload: b"payload".to_vec(), + }) + .await; + + let err = result.unwrap_err().to_string(); + assert!(err.contains("removed in dstack 0.6.0"), "{err}"); + } }