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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- sdk: the JavaScript v1 GPU evidence bundle's `asUint8Array()` accessor is gone, and `evidence` is the vendor's bytes directly, as Go's `Evidence` always was. Byte-exact off the wire, because sha256 over precisely those bytes is what the measured `gpu-attestation` event commits to
- sdk: every field the `dstack.guest.v1` proto declares `bytes` is now that language's byte type on the v1 clients — Rust `Vec<u8>`, Python `bytes`, JavaScript `Uint8Array`, Go `[]byte` — and the `decode_*` helpers are gone along with the hex strings they decoded. Eleven fields move: `GetKeyResponse`'s `key`, `public_key` and `signature_chain`; `AttestResponse.attestation`; `GpuEvidenceBundle.evidence`; and `InfoResponse`'s `app_id`, `instance_id`, `compose_hash`, `device_id`, `os_image_hash` and `mr_aggregated`. Rust's `AttestConfig.report_data` moves with them, so the public builder and `attest()` finally agree on a type. **The JSON wire is unchanged** — it still carries lowercase hex; the encoding moved into the serialization layer, as serde's `hex::serde` in Rust, an annotated pydantic type in Python, and the client's decode step in JavaScript. Go already did this and is untouched.

The old typing did quiet damage. `docs/guest-api-v1.md` says of the v1 key claim that `public_key` is the raw derived public key, *not* a hex string, and its verification steps rebuild the claim from raw bytes — so a Rust or Python caller passing `response.public_key` straight into a claim builder built it over 66 ASCII characters instead of 33 bytes. No type error, no exception, just a chain that never verifies. `evidence` had the same shape of problem: three separate documents had to keep repeating "hash the decoded bytes, not the string as returned", precisely because the type did not say it. In Go the mistake was unspellable, and now it is unspellable everywhere -- which is why Rust's `report_data` is a `ReportData` newtype rather than a bare `Vec<u8>`: `&str` and `String` both implement `Into<Vec<u8>>`, so under the builder's `into` coercion `.report_data("00ff")` would still compile and attest the four ASCII bytes of that string. The newtype converts from a `Vec<u8>`, an array or a slice and from nothing else, so the ergonomics survive and the string does not. A `compile_fail` doctest keeps it that way. Nor was there one line to learn: before this, three of the eleven fields had no decoder at all in Rust, six had none in Python, and JavaScript had decoded three of them for a while.

Decoding got stricter where it was silently lenient. JavaScript relied on `Buffer.from(value, 'hex')`, which stops at the first pair it cannot parse and returns the prefix, so a corrupted `app_id` became a short `Uint8Array` and a signature chain with one bad link came back quietly one link short; it now throws and names the field, as Rust, Python and Go already did. A required field that is absent altogether is an error rather than empty bytes -- `os_image_hash` and `mr_aggregated` are the two exceptions, read as empty so a degraded `Info` stays parseable, which is what Rust's `#[serde(default)]` already did and what Python now does instead of rejecting the response. Python also stops accepting hex with embedded whitespace, which `bytes.fromhex` skips and Rust refuses.

The `borsh` encoding of these structs does change, since borsh writes a `Vec<u8>` as length-prefixed bytes where it wrote a hex `String` before. The v1 types shipped in no 0.5.x release, so the window is between 0.6 prereleases: a blob written by an earlier one deserializes without error into these types and yields the ASCII of the hex string. Only the JSON wire is compatible.

The request direction follows: v1's `attest` and `attest_gpu` take bytes and nothing else in all four SDKs. Rust and Go always did; Python and JavaScript also accepted a string and UTF-8 encoded it, so `attest("deadbeef")` committed to the eight ASCII characters rather than the four bytes they spell, and `attestGpu` on a 32-character string passed the length check on its way to attesting the wrong nonce. Both now raise, and say whether to `encode()` the text or decode the hex. **Breaking for a v1 caller passing a string** -- but v1 has not shipped, and the v0 clients keep the old signature, so a 0.5.x program is unaffected.

Decoding a malformed response now reaches the same verdict in all four SDKs. Differential testing -- 194 identical JSON bodies through four real clients -- found them agreeing on 143 and diverging on 51, every divergence in absence, `null`, or JSON type confusion rather than bad hex. Go read an absent or null `bytes` field as empty and returned a nil error, so an error body arriving with a 200 handed back a zero-length private key that looked like an answer; JavaScript's hex check stringified its argument, so `app_id: ["00112233"]` decoded to one attacker-chosen byte. Required fields are now required, an absent `os_image_hash` or `mr_aggregated` is still empty, an explicit `null` is malformed everywhere, and a bundle without the `vendor` a caller dispatches on is an error rather than evidence routed to no verifier. All 194 bodies now agree. None of them is reachable from a conforming agent, which emits every field, always lowercase hex, never `null` -- they are reachable from a compromised or non-dstack server, which is the threat model these fields already take seriously.

**v0 deliberately keeps its hex strings and `decode_*` helpers.** That surface mirrors the released 0.5.x SDK so a 0.5.x program keeps working by changing only the class name; retyping every byte field would break that promise on an API that is frozen anyway. The blockchain adapters are v0-typed and unaffected
- sdk: the v0 modules carry a `_v0` suffix, so the file a reader opens matches the client it holds. Rust's `dstack_sdk::dstack_client` becomes `dstack_sdk::dstack_client_v0` and `dstack_sdk_types::dstack` becomes `dstack_sdk_types::dstack_v0`; Python's `dstack_sdk.dstack_client` becomes `dstack_sdk.dstack_client_v0`; Go's `client.go`/`client_test.go` become `client_v0.go`/`client_v0_test.go`; and the JavaScript `index.ts`, which held both surfaces in one file, splits into `client-v0.ts`, `client-v1.ts` and a `shared.ts`, leaving `index.ts` as a barrel that re-exports exactly the names it always did. Until now the unsuffixed *file* meant v0 while the unsuffixed *class* meant v1, so a reader opening `dstack_client.rs` for the recommended client found the legacy one instead. **There are deliberately no backward-compat module aliases**: 0.6.0 is the loud-break release, and an import of an old module path fails at build time rather than silently binding the frozen surface under a name that now means something else. Package-level exports are untouched in every SDK — `dstack_sdk::DstackClient`, `from dstack_sdk import DstackClientV0` and `@phala/dstack-sdk`'s public surface are exactly what they were; only a deep import of the module path moves. In Go this is file naming alone, since it is all one `package dstack`
- sdk: the v0 clients are deprecated in the way each language's tooling understands, not only in prose. Rust's `DstackClientV0` and `TappdClient` carry `#[deprecated(since = "0.6.0")]`, so a downstream build warns at every mention of the type — the `use`, the constructor, any signature naming it. Method calls on an already-built client stay silent, because Rust does not propagate the attribute to inherent methods. Python's `DstackClientV0` and `AsyncDstackClientV0` emit a `DeprecationWarning` on construction, through the same helper `TappdClient` already used, alongside the `.. deprecated:: 0.6.0` docstring note they already carried. JavaScript's `DstackClientV0` already had its `@deprecated` JSDoc and `TappdClient` gains one. Go's `// Deprecated:` markers were in place but seven sat mid-comment rather than as their own trailing paragraph, which is the only form gopls and pkg.go.dev recognise, and are repaired.

Expand Down
2 changes: 1 addition & 1 deletion docs/confidential-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ from dstack_sdk import DstackClient
client = DstackClient()
info = client.info()

print(f"Compose hash: {info.compose_hash}")
print(f"Compose hash: {info.compose_hash.hex()}")
# Data provider compares this against the docker-compose they reviewed
```

Expand Down
7 changes: 7 additions & 0 deletions docs/guest-api-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,13 @@ explicitly named and marked legacy, and still carries its `Sign` and `Verify`
RPCs. They are transport mirrors, not a compatibility layer: neither translates
a call to the other, and each one's method set is exactly its surface's.

Every field this document declares `bytes` is that language's byte type in the
v1 clients -- `Vec<u8>`, `bytes`, `[]byte`, `Uint8Array` -- with hex confined to
serialization. `public_key` is why: the claim above is built over the raw key,
and a hex string handed to a claim builder silently produces a chain that never
verifies. The v0 clients keep their hex strings and `decode_*` helpers, because
that surface is frozen.

That alias flipped in 0.6.0. Code that used the unsuffixed client for v0 calls
fails loudly on upgrade -- the v1 signatures differ and `GetKey` requires
`algorithm` explicitly -- rather than silently deriving different keys under the
Expand Down
5 changes: 5 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ a call to the other, and each one's method set is exactly its surface's. v1 has
no `sign` and no `verify`, because any caller that can reach the socket can ask
`get_key` for the private key and do both locally.

Every field the v1 proto declares `bytes` is that language's byte type on the v1
clients -- `Vec<u8>`, `bytes`, `[]byte`, `Uint8Array` -- with hex confined to
serialization; there are no `decode_*` helpers. The v0 clients keep their hex
strings and helpers, because that surface is frozen.

> **v1 keys are not v0 keys.** Deriving under the same name through `DstackClient`
> returns *different key material* than `DstackClientV0` does. This is deliberate --
> the v0 KDF ignored the algorithm, so one secret served both curves -- and
Expand Down
Loading
Loading