diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c774941..8398f05ff 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,7 +43,8 @@ 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 +- 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 7a99d32e2..5d043a129 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. @@ -55,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) {} @@ -77,11 +90,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) {} @@ -290,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 @@ -402,6 +429,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..8eb9f1b47 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -16,13 +16,14 @@ 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, 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; -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; @@ -388,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 } @@ -474,6 +485,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 +1526,100 @@ 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"); + } + + #[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}"); + } }