Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- sdk: the Go SDK's `gpu_policy` silently dropped sub-fields it did not declare on a decode/re-hash round trip; it now carries them through like the rest of `Requirements`
- bound VMM-to-guest RPCs and guest-agent dependency calls to prevent stalled peers from retaining request resources indefinitely
- sdk: the Go and Python compose-hash helpers silently dropped every app-compose field they did not declare, so `getComposeHash` returned a digest for an app-compose that was not the one being deployed — and that digest is what gets whitelisted on chain. The missing fields are named above; both now keep unrecognised keys as well, so a guest that gains a field before the SDK does still hashes correctly
- sdk: the JavaScript and Rust clients report a non-2xx answer with its status *and* the server's message. The JS transport ignored the status entirely, and its unix-socket branch never even parsed the status line, so an agent with no `/v1` mount — every pre-0.6 agent — answered a v1 call with an HTML 404 page that reached the caller as `failed to parse response`, which names neither the status nor the cause. The Rust clients discarded the other half: `error_for_status()` keeps the status line and throws away the body the agent puts its reason in, and on the unix path the crate's JSON helper insists on deserializing the *error* body too, so the same HTML page came back as a JSON parse failure. Both now raise `HTTP <status>: <what the server said>` — the prpc `error` field when the body is one, otherwise the body itself, bounded so an error page cannot become the whole message. Python and Go already reported both halves, in their own wording; this aligns JS with Rust rather than all four with each other.

**This changes two things beyond the error text.** In JS, six v0 methods that never checked the response body — `getKey`, `getTlsKey`, `info`, `version`, `sign`, and `TappdClient.deriveKey` — used to *resolve* on a prpc failure, handing back an object whose every field was `undefined` next to an `error` string. They now reject. `version()`, whose own documentation says it throws against an agent too old to have the RPC, previously resolved there; it now does what it says. In Rust, neither transport sends a duplicated `Content-Type` header any more: on the unix path the crate's JSON helper appended its own on top of the one the client passed, and on the HTTP path `reqwest`'s `json()` did the same, since `header()` appends rather than replaces. Rocket reads the first value, so the agent never noticed, but a stricter intermediary is entitled to refuse a request carrying the field twice.

- sdk: fix two encoding defects in the JavaScript transport, both of which corrupted or stalled real calls. The unix-socket branch declared `Content-Length` as `payload.length` — UTF-16 code units — while writing UTF-8, so a request carrying any non-ASCII field sent more bytes than it declared: the agent parsed truncated JSON and the surplus poisoned the connection. `getKey`'s `domain` is specified to accept any byte string a proto3 `string` can carry, so this was reachable by design rather than by accident. On the read path the same branch compared that byte-counted `Content-Length` against a JS string's `.length`, a condition a multi-byte body can never satisfy, so the client waited out the agent's ten-second keep-alive instead of returning — one accented character in an app-compose comment turned `info()` into a ten-second call and made `isReachable()` report a healthy agent as unreachable. That branch no longer speaks HTTP by hand: it uses node's client over `socketPath`, which frames the request, de-chunks the response, and counts bytes where bytes are meant. Responses on both branches are now assembled as bytes and decoded once, so a UTF-8 sequence split across two TCP reads no longer decodes to replacement characters on each side — which the old code did while resolving successfully, handing back quietly wrong data. A timed-out call also says so: the timeout aborted the request before rejecting, and `abort()` runs its listener synchronously, so the abort's `request aborted` always won the race and `request timed out` was unreachable — leaving `isReachable()` unable to tell a hung agent from any other failure

### Changed
- kms: client certificates are authenticated by the attestation they carry rather than by their issuer. Rocket configures mutual TLS through rustls' `WebPkiClientVerifier`, which pins a CA — but an RA-TLS certificate is self-issued and carries its identity in a TEE quote, so there is nothing to chain to. `GetTempCaCert` bridged the gap by handing every caller a shared CA private key purely so the minted certificate would chain somewhere; the CA established nothing (its key is public by design, and the endpoint is unauthenticated) and the check that has always carried the meaning is the quote verification that runs afterwards. The KMS now hands rustls a verifier that requires an attestation and ignores the issuer. Nothing changes for callers: guests and KMS-to-KMS onboarding still mint their client certificates from the temp CA, and those are now accepted for the attestation they carry. What changes is that the TLS layer went from admitting any certificate signed by a public key to requiring an attested one, and that a self-issued certificate is now accepted — which is what lets callers be migrated off `GetTempCaCert` in a follow-up. `[rpc.tls.mutual]` is no longer the trust anchor and is dropped from `kms.toml` and the KMS config templates; leaving it in an existing deployment's config is inert. The gateway's `[tls.mutual]` is unaffected — it pins the KMS root CA, which is a real trust anchor
Expand All @@ -53,9 +58,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- guest-agent: both unversioned surfaces are closed at exactly v0.5.11, and every new capability goes to `dstack.guest.v1` instead. Everything added to them after v0.5.11 never shipped in a release, so it is removed rather than frozen in: `AttestGpu` and `Attest`'s GPU-evidence field on the internal service, `AttestAppKey` and `Health` on the external one. All of those are v1 features now. No released client is affected -- both services are now byte-identical to v0.5.11 apart from doc comments and `reserved` statements holding the interim field numbers, so an unreleased `next` build in a dev environment cannot have one of them silently absorbed by a future field. "Frozen except for additions" is how all of them arrived in the first place
- 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 **retained**, unchanged and frozen, and v1 ships no counterpart. The method attests the key v0's KDF derives at path `vms` with purpose `signing`, and no v1 `GetKey(domain, algorithm)` can return that key -- different salt, different `info`, no `purpose` input -- so a v1 counterpart would have handed a pure-v1 app an attestation of a public key whose private half it could not obtain, which is worse than having no method because it looks like it works. A v1 app attests its own key instead: derive it at `/v1/GetKey`, commit the public key into `report_data`, call `/v1/Attest`, and serve the result to relying parties itself. That is strictly more capable, since the app chooses which key and which commitment format rather than being limited to the one the agent would derive. Legacy flows keep using the frozen method; it remains Intel TDX only, because it returns a `GetQuoteResponse`
- sdk: the Go SDK's v1 `IssueCert` defaults `usage_server_auth` to true, as the Rust, Python and JavaScript v1 clients already did. Go was the odd one out, so the same argument-free call produced a certificate that could serve TLS in three languages and one that could not in the fourth — and a certificate you cannot serve with is useless to most callers. `WithCertUsageServerAuth(false)` opts out. v0's `GetTlsKey` keeps its `false` default deliberately: that is what the released 0.5.x Go SDK sent, and `DstackClientV0` mirrors released behaviour rather than the better choice
- sdk: the JavaScript v1 `issueCert` response no longer carries a raw-bytes accessor. `asUint8Array()` is **removed rather than renamed**: it existed to feed the private key into the blockchain adapters, and v1 has no chain-flavoured surface. `IssueCert` returns TLS material, PEM is the form a TLS stack takes, and a caller who genuinely needs DER converts it with a standard library. The Rust, Python and Go v1 clients already returned the PEM string and the chain alone, so all four now agree. v0's `GetTlsKeyResponse.asUint8Array` is untouched — released API, and the viem and solana adapters depend on its truncating behaviour
- sdk: the JavaScript v1 GPU evidence bundle's `asUint8Array()` is renamed `decodeEvidence()`, matching Python's and Rust's `decode_evidence` and Go, which hands back the decoded `Evidence` bytes directly. The name now says what the bytes are — the vendor's evidence, hex off the wire and decoded byte-exact, because sha256 over precisely those bytes is what the measured `gpu-attestation` event commits to


### Removed
- sdk: `TlsKeyOptions.path` in the JavaScript SDK. `GetTlsKeyArgs` has no such field and `getTlsKey` never read it, so a caller who set it was silently ignored. Breaking at the type level only, and only for code whose value was already being discarded. `deriveKey`'s `path` is a real, deprecated Tappd-era parameter and stays; the Python, Rust and Go v0 TLS-key options never carried one
- 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: a deleted method answers HTTP 404 `Service not found: EmitEvent`, which tells a 0.5.x caller nothing about why its events stopped being recorded, while the kept stub fails with a message naming the removal and pointing at `report_data`. **Breaking:** any app extending RTMR3 at runtime must stop; bind app data through `report_data` instead, which is what most callers wanted anyway


Expand Down
6 changes: 5 additions & 1 deletion sdk/go/dstack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,11 @@ func NewDstackClientV0(opts ...DstackClientOption) *DstackClientV0 {
// TlsKeyOption defines a function type for TLS key options
type TlsKeyOption func(*tlsKeyOptions)

// tlsKeyOptions holds all the optional parameters for GetTlsKey
// tlsKeyOptions holds all the optional parameters for GetTlsKey.
//
// usageServerAuth stays false by default. That is what the released 0.5.x Go
// SDK sent, and this surface mirrors released behavior rather than the better
// choice -- v1's IssueCert defaults it to true.
type tlsKeyOptions struct {
subject string
altNames []string
Expand Down
11 changes: 10 additions & 1 deletion sdk/go/dstack/client_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ func WithCertUsageRaTls(usage bool) IssueCertV1Option {
}

// WithCertUsageServerAuth sets the server auth key usage.
//
// Defaults to true, matching the v1 default in the Rust, Python and JS SDKs;
// pass false to issue a certificate that cannot be used for server auth. The
// frozen v0 GetTlsKey defaults it to false instead, because that is what the
// released 0.5.x SDK sent.
func WithCertUsageServerAuth(usage bool) IssueCertV1Option {
return func(o *issueCertV1Options) {
o.usageServerAuth = usage
Expand Down Expand Up @@ -263,7 +268,11 @@ func WithCertAppInfo(enabled bool) IssueCertV1Option {
// is not derived from the app identity -- GetKey is the method that derives a
// stable, attestable key. v0 called this GetTlsKey.
func (c *DstackClientV1) IssueCert(ctx context.Context, options ...IssueCertV1Option) (*IssueCertV1Response, error) {
opts := &issueCertV1Options{}
// usageServerAuth starts true, matching the v1 default in the Rust, Python
// and JS SDKs: a certificate that cannot be served with is useless to most
// callers, and a v1 default that differs per language is a trap. Opt out
// with WithCertUsageServerAuth(false).
opts := &issueCertV1Options{usageServerAuth: true}
for _, option := range options {
option(opts)
}
Expand Down
56 changes: 56 additions & 0 deletions sdk/go/dstack/client_v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,62 @@ func TestV1IssueCertKeyIsFreshPerCall(t *testing.T) {
}
}

// The v1 default is usage_server_auth: true, the same as the Rust, Python and
// JS v1 clients -- a certificate the caller cannot serve with is useless to most
// of them. The default is only observable on the wire, so assert it there, and
// assert the opt-out reaches the wire too.
func TestV1IssueCertDefaultsToServerAuth(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("failed to decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"key":"","certificate_chain":[]}`))
}))
defer server.Close()

client := dstack.NewDstackClientV1(dstack.WithEndpoint(server.URL))
ctx := context.Background()

if _, err := client.IssueCert(ctx); err != nil {
t.Fatal(err)
}
if payload["usage_server_auth"] != true {
t.Errorf("expected usage_server_auth to default to true, got: %v", payload["usage_server_auth"])
}

if _, err := client.IssueCert(ctx, dstack.WithCertUsageServerAuth(false)); err != nil {
t.Fatal(err)
}
if payload["usage_server_auth"] != false {
t.Errorf("expected WithCertUsageServerAuth(false) to opt out, got: %v", payload["usage_server_auth"])
}
}

// v0 sent usage_server_auth: false when the caller said nothing, and that is
// what the released 0.5.x Go SDK did. The frozen surface keeps it, so the two
// defaults differ on purpose rather than by oversight.
func TestV0GetTlsKeyKeepsTheReleasedServerAuthDefault(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("failed to decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"key":"","certificate_chain":[]}`))
}))
defer server.Close()

client := dstack.NewDstackClientV0(dstack.WithEndpoint(server.URL))
if _, err := client.GetTlsKey(context.Background()); err != nil {
t.Fatal(err)
}
if payload["usage_server_auth"] != false {
t.Errorf("expected the frozen surface to keep sending false, got: %v", payload["usage_server_auth"])
}
}

// Version selection is by URL path alone: every v1 method must post under /v1.
func TestV1MethodsPostUnderTheV1Prefix(t *testing.T) {
paths := make(chan string, 1)
Expand Down
11 changes: 6 additions & 5 deletions sdk/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ const cert = await client.issueCert({
})
cert.key // PEM-encoded private key
cert.certificate_chain // PEM entries, leaf first
cert.asUint8Array(32) // the key as raw DER bytes
```

PEM and nothing else: v0 attached a raw-bytes accessor to this response, but it was there to feed the key into the blockchain adapters, and v1 has no chain-flavored surface. This is TLS material, PEM is what a TLS stack takes, and DER is one standard-library call away if you want it.

Options: `subject`, `altNames`, `usageRaTls`, `usageServerAuth` (default `true`), `usageClientAuth` (default `false`), `withAppInfo`, `notBefore`, `notAfter` (Unix seconds).

The key is freshly generated on every call and is not derived from the app identity — two identical requests return two unrelated keys. v0 called this `getTlsKey`, which named the by-product rather than the request. Use `getKey` for stable, attestable material.
Expand Down Expand Up @@ -114,13 +115,13 @@ const { attestation } = await client.attest('app-state-snapshot')
```typescript
const { attestation, boottime_gpu_evidence } = await client.attest('snapshot', true)
for (const bundle of boottime_gpu_evidence) {
console.log(bundle.vendor, bundle.format, bundle.asUint8Array())
console.log(bundle.vendor, bundle.format, bundle.decodeEvidence())
}
```

`boottime_gpu_evidence` is a list of the same `GpuEvidenceBundleV1` objects `attestGpu` returns, so one parser serves both; `format` is what tells them apart (`nvidia-nvattest-boottime-json-v1` here, `nvidia-nvattest-collect-evidence-json-v1` there). Absence is the empty list, not a sentinel: it is empty unless the flag was set and the guest has boot-time output.

That evidence is not bound to `reportData` — nvattest ran at boot against its own nonce. Bind it by replaying the runtime event log and comparing sha256 of the bytes `asUint8Array()` returns — exactly the bytes nvattest emitted, so do not parse and re-serialize the JSON — against `evidence_sha256` in the measured `gpu-attestation` event.
That evidence is not bound to `reportData` — nvattest ran at boot against its own nonce. Bind it by replaying the runtime event log and comparing sha256 of the bytes `decodeEvidence()` returns — exactly the bytes nvattest emitted, so do not parse and re-serialize the JSON — against `evidence_sha256` in the measured `gpu-attestation` event.

### `attestGpu(nonce)`

Expand All @@ -129,13 +130,13 @@ Collect GPU evidence now, against a 32-byte nonce you choose. This answers "is t
```typescript
const { bundles } = await client.attestGpu(crypto.randomBytes(32))
for (const bundle of bundles) {
console.log(bundle.vendor, bundle.format, bundle.asUint8Array())
console.log(bundle.vendor, bundle.format, bundle.decodeEvidence())
}
```

The nonce must be exactly 32 bytes — SPDM fixes the length, and dstack applies no transform, so you can compare these bytes directly against the `eat_nonce` claim. Hash a longer challenge yourself.

Select a verifier from each bundle's `vendor` and `format`, then check the signature, certificate chain, measurements and embedded nonce. `evidence` is opaque and hex-encoded on the wire; `asUint8Array()` gives the vendor's bytes verbatim. It does not by itself bind the GPU to this CVM.
Select a verifier from each bundle's `vendor` and `format`, then check the signature, certificate chain, measurements and embedded nonce. `evidence` is opaque and hex-encoded on the wire; `decodeEvidence()` gives the vendor's bytes verbatim. It does not by itself bind the GPU to this CVM.

### `info()`

Expand Down
Loading
Loading