diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c774941..d1dd592b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,27 @@ 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 -- 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: every SDK ships two clients, one per guest-agent surface, and nothing else. `DstackClientV0` speaks the frozen v0.5.11 API on the unversioned paths -- every method a v0.5.x SDK had, including `verify()` (which calls the server `Verify` RPC) and `emit_event()` (which surfaces the agent's removal error verbatim). `DstackClientV1` speaks `dstack.guest.v1` at `/v1` and has exactly its six methods: `issue_cert`, `get_key`, `attest`, `attest_gpu`, `info`, `version`. They are transport mirrors, not a compatibility layer: neither translates a call to the other, and each one's method set is its surface's method set. **v1 derives different key material than v0 for the same inputs** -- see `docs/guest-api-v1.md` for the migration. `TappdClient` is untouched. + + The blockchain adapters (Ethereum/viem, Solana) remain **v0-only** and stay typed against the v0 `GetKeyResponse`. v1 has no chain-related surface, deliberately: it returns key material, and what an application builds from those bytes is its own business rather than something the SDKs model. + + **The unsuffixed client name now means v1** in every SDK -- `DstackClient` is `DstackClientV1`, and it is the recommended default rather than a deprecated alias. Code that used the unsuffixed client for v0 calls fails loudly on upgrade, because the v1 method signatures differ and `get_key` requires `algorithm` explicitly; it does not silently derive different keys. To stay on the frozen surface, name `DstackClientV0`, which remains available and is marked legacy. + + Neither client ships a local signature-chain verifier. An SDK mirrors an API surface, and verifying needs no client and no connection -- `docs/guest-api-v1.md` specifies the rules normatively, down to the trust anchor, which is what a relying party implements against rather than four hand-ports of one byte format. `DstackClientV0.verify()` remains for single signatures, because that is what the frozen surface offers; v1 has no counterpart +- guest-agent: a versioned API, `dstack.guest.v1`, covering both trust surfaces. `DstackGuest` is served at `/v1` on the internal socket (`IssueCert`, `GetKey`, `Attest`, `AttestGpu`, `Info`, `Version`); `Worker` at `/prpc/v1` on the external listener (`Info`, `Version`, `Health`). They are two services rather than one mounted twice because the two listeners have different reachability: the internal socket answers only the app itself and hands out key material, the external one answers anyone who can route to the CVM and never does. The scheme is uniform across both listeners: `/v0` and `/v1` on the internal socket, `/prpc/v0` and `/prpc/v1` on the external one, where `v0` is the frozen v0.5.11 surface. The historical unversioned paths (`/` and `/prpc`) stay mounted as aliases onto the same frozen handlers, so a pre-0.6 client keeps working unchanged and cannot drift from `/v0` -- they are additional mounts, not a parallel implementation. Version selection is by URL path alone -- no header negotiation, no default-version redirect -- so a request URL is the whole record of which contract the caller asked for. `Tappd` predates this scheme and is untouched. Specified byte-for-byte in `docs/guest-api-v1.md`, which is the normative reference an implementation is written from, and the constants it specifies -- salt, both context tags, the length-prefixed encoding, the claim -- live in `ra-tls`'s `api_v1` module so the agent, the verifier and the coming SDK support link against one definition instead of three transcriptions of the prose +- guest-agent: v1 derives application keys under a real domain-separated KDF. v0 fed the path alone into HKDF and handed the same 32 bytes to both secp256k1 and ed25519, so the two curves shared one secret and the algorithm a caller asked for changed nothing about the key it got. v1 derives under its own HKDF salt (`dstack-guest-v1`, against the legacy `RATLS`) and binds a versioned context tag, the algorithm and the caller's `domain` as length-prefixed fields, so the curves never collide and no two names encode alike -- a path is arbitrary caller-chosen bytes, so any delimiter it could also contain is a collision waiting to happen. **v1 keys are therefore not v0 keys for the same name**, deliberately and with no compatibility mode; an app holding assets under a v0 key migrates them with a transaction signed by the old key. The separate salt matters because the legacy HKDF `info` is the caller's `path` verbatim: under a shared salt, a caller passing the v1 `info` byte string as a v0 `path` reproduced a v1 key exactly. That was never a privilege boundary -- same app, same root key, either surface reachable -- but a KDF whose separation depends on nobody choosing an awkward input is one refactor from separating nothing, and it costs nothing to close on a surface with no deployed keys. The derivation is flat: `a/b` is not a child of `a`, and there is no BIP-32-style hierarchy. Committed test vectors pin the output bytes +- guest-agent: v1's signature-chain claim cannot be forged through the v0 surface. v0's first link signs `keccak256("{purpose}:{hex(pubkey)}")` over a caller-chosen `purpose`, which lets a malicious app steer the app root key into signing nearly any ASCII string ending in `:` plus hex. The v1 claim is length-prefixed and binds the raw public key bytes, so it always contains `00` bytes inside the region a v0 preimage requires to be hex-only. The exclusion is structural rather than probabilistic, and a regression test builds the strongest available forgery and asserts it fails +- guest-agent: v1 ships no `Sign` and no `Verify`. The agent is not an HSM: anything that can reach this socket can ask `GetKey` for the private key, so a server-side `Sign` grants no capability its caller lacks and buys an IPC round trip and another entry point to audit. Verifying needs neither key nor attestation, and the agent's answer arrives unattested. Apps sign locally with a standard library; relying parties verify locally against the normative rules in `docs/guest-api-v1.md`, which specify the KDF, the claim encoding, and every verification step down to the trust anchor. Both RPCs stay on the unversioned surface for 0.5.x clients +- guest-agent: v1 `GetTlsKey` is renamed `IssueCert`, because certificate issuance is the operation -- the agent builds a CSR and relays it to the KMS `SignCert` flow. The returned private key is incidental: freshly generated per call, fed by none of the request fields, and unrelated to the app identity. Only the integrated one-step mode ships; a caller-supplied-CSR mode would arrive as new fields +- guest-agent: v1 `Info` returns identity and configuration only. The `tcb_info` JSON blob is gone: its measurement values are typed top-level fields that each appear exactly once, and the documents it nested (`app_compose`, `vm_config`) are served directly instead of through two layers of JSON parsing. MRTD, RTMR0-3 and the event log are deliberately absent -- they are attestation data, and `Info` handing out unattested copies invited relying parties to trust values nothing vouched for. Ask `Attest` and verify. The demo `app_cert` is dropped as well +- 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, and the response format is extensible to additional GPU vendors. v1 only +- guest-agent: v1 `Attest` accepts `include_boottime_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.boottime_gpu_evidence`, so a verifier fetches the attestation and the GPU evidence in one round trip. It arrives as a `GpuEvidenceBundle` list -- the same shape `AttestGpu` returns, so a consumer writes one bundle parser and dispatches on `format`: `nvidia-nvattest-boottime-json-v1` is the record written at boot, `nvidia-nvattest-collect-evidence-json-v1` is collected on demand against a caller's nonce, and a verifier for one does not appraise the other. Absence is the empty list. The bundle's `evidence` is the nvattest output byte for byte as read from disk, because the only thing binding it to the boot is sha256 over precisely those bytes against the measured `gpu-attestation` event. `Attest` is also v1's sole CVM attestation entry point: the `VersionedAttestation` it returns already carries the TDX quote and event log, and unlike `GetQuote` it answers on every supported platform - 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 - shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796) - gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer - gateway: `Admin.SetInstanceReady` takes a CVM instance out of its app's load-balancing rotation without stopping it; instance-id routing stays open so the instance can still be investigated, and the setting survives re-registration - gateway: operator-set per-instance overrides now live under their own KV keys — `admin//ready` and `admin//port_policy` — instead of inside the instance record, so a CVM re-registration can no longer drop them and setting one cannot discard a peer's unsynced change to the other. An override left in an instance record by an earlier build is moved across on load -- gateway: opt-in application-level health polling. An app sets `requirements.health_check` in its app-compose; the gateway then asks that CVM's guest agent (new `Worker.Health` RPC) whether the app is serving, and keeps instances that say no -- or that have not answered since registering -- out of app-id load balancing. Apps that do not opt in are never polled. Instance-id routing is never gated, and an app whose every instance reports unhealthy is routed to anyway rather than blackholed. Verdicts are per-node and not persisted: a gateway restart puts the whole app back at `unknown` at once, which is exactly the case the fail-open covers. Documented in `docs/app-health-checks.md` +- gateway: opt-in application-level health polling. An app sets `requirements.health_check` in its app-compose; the gateway then asks that CVM's guest agent (new `Health` RPC on the v1 external surface, polled at `/prpc/v1/Health`) whether the app is serving, and keeps instances that say no -- or that have not answered since registering -- out of app-id load balancing. Apps that do not opt in are never polled. Instance-id routing is never gated, and an app whose every instance reports unhealthy is routed to anyway rather than blackholed. Verdicts are per-node and not persisted: a gateway restart puts the whole app back at `unknown` at once, which is exactly the case the fail-open covers. Documented in `docs/app-health-checks.md`. One unreleased skew: an interim `next` build that served `Health` at `/prpc` and registered with `health_check = true` will 404 every poll and drop out of app-id rotation after `failure_threshold` failures. Instance-id routing is unaffected and restarting on a current build clears it; no released agent is involved, since `Health` never shipped in a release - app-compose: `requirements.health_status_file` names a file the app writes its own verdict into -- two lines, `healthy`/`unhealthy` and the unix timestamp it was written at, treated as unhealthy once older than 60s. It must be a regular file (a FIFO would park a thread of the agent's blocking pool on every refresh); symlinks are followed, and its contents are never quoted back into a report. Without it the agent judges the app's own Compose project: every container that declares a `healthcheck` must be running and healthy, and a project where *no* container declares one reports unhealthy rather than passing silently - guest-agent: container health also covers the `nerdctl-compose` runner, read through `nerdctl inspect` (its output is Docker-compatible). Requires nerdctl >= 2.3.1 for Compose `healthcheck:` to be honoured; the mkosi backend is pinned to 2.3.5 - http-client: a caller can bound the response body (`http_request_bounded`, `PrpcClient::with_max_response_bytes`). Nothing is bounded by default — `dstack vmm logs --lines 100000` is a legitimate multi-megabyte fetch — but every client that talks to a guest agent opts in, in the gateway and in the VMM, because a CVM is untrusted and one of them polls on a timer against the whole fleet @@ -38,12 +50,13 @@ 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: 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 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 +- 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` ### 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 +- 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 ## [0.5.5] - 2025-10-20 diff --git a/README.md b/README.md index 0419cb498..8444cf88e 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Apps communicate with the guest agent via HTTP over `/var/run/dstack.sock`. Use - [Gateway](./docs/dstack-gateway.md) - Gateway configuration **Reference** +- [Guest Agent API v1](./docs/guest-api-v1.md) - Key derivation, signature chains, and the versioned guest API - [App Compose Format](./docs/normalized-app-compose.md) - Compose file specification - [Intel TDX Attestation](./docs/attestation-tdx.md) - Measurement and runtime-event verification - [Native TEE Interfaces](./docs/native-tee-interfaces.md) - Advanced compatibility with Linux TEE devices and configfs-tsm diff --git a/REUSE.toml b/REUSE.toml index 9eccd6a05..3be5549c2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -45,7 +45,6 @@ path = [ "tools/sca/examples/hello-c/config.json", "tools/sca/examples/heartbeat/rootfs/etc/heartbeat/interval", "sdk/simulator/*.json", - "sdk/tests/vectors/*.json", "sdk/go/go.sum", "sdk/go/ratls/go.sum", "dstack/kms/dstack-app/builder/shared/builder-pinned-packages.txt", diff --git a/docs/amd-sev-snp.md b/docs/amd-sev-snp.md index 30da7ed40..d2ebe8139 100644 --- a/docs/amd-sev-snp.md +++ b/docs/amd-sev-snp.md @@ -88,8 +88,8 @@ MrConfigV3 document through `HOST_DATA`, so a verifier can validate the report and document binding and then compare this field with the expected GPU policy digest. If the field is absent, this optional check is not asserted. This binds the GPU policy, but not the later `gpu-attestation` runtime event or the -`GpuInfo` output: the current SEV-SNP path has no quote-bound runtime -measurement register. +boot-time GPU evidence returned by `/v1/Attest`: the current SEV-SNP path has +no quote-bound runtime measurement register. The verifier supports the AMD Milan, Genoa, and Turin KDS product families. Bergamo and Siena are handled through AMD's canonical Genoa KDS product path. diff --git a/docs/app-health-checks.md b/docs/app-health-checks.md index 0f574c299..f0ae7182b 100644 --- a/docs/app-health-checks.md +++ b/docs/app-health-checks.md @@ -46,7 +46,7 @@ older guest chokes on. ## Where the verdict comes from The guest agent recomputes a verdict every 5 seconds and caches it; the gateway -polls `Worker.Health`, which only reads that cache. The two cadences are +polls `/prpc/v1/Health`, which only reads that cache. The two cadences are independent on purpose — a fleet of gateway nodes polling the same instance must not multiply into that many container-runtime queries inside the CVM, and the RPC is served on the CVM's publicly reachable listener. diff --git a/docs/attestation-tdx.md b/docs/attestation-tdx.md index 4df561c2e..6257463bc 100644 --- a/docs/attestation-tdx.md +++ b/docs/attestation-tdx.md @@ -45,7 +45,7 @@ evaluation, `gpu-attestation`. The `gpu-policy-hash` payload is The `gpu-attestation` payload is JSON containing the verified device count, CC/DevTools state, and `evidence_sha256`. -The guest-agent `GpuInfo` API returns the complete `nvattest` JSON captured during boot; `Attest` returns the same bytes in `boottime_gpu_evidence` when called with `include_boottime_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip. It is not trustworthy by itself. (`AttestGpu` runs a *fresh* attestation against a caller nonce, but its result is not bound to the TD and must not be used as remote evidence; only the boot-time record below is.) After verifying the TDX quote and replaying the event log to RTMR3, hash the exact UTF-8 bytes of `GpuInfo.attestation` (or `Attest.boottime_gpu_evidence`) and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. +The guest-agent returns the complete `nvattest` record captured during boot from `/v1/Attest`, when the request sets `include_boottime_gpu_evidence`, so a verifier can fetch the quote and the GPU evidence in one round trip. `AttestResponse.boottime_gpu_evidence` is a list of `GpuEvidenceBundle` (`{vendor, format, evidence}`); the boot record is the bundle whose `vendor` is `nvidia` and whose `format` is `nvidia-nvattest-boottime-json-v1`, and its `evidence` is hex-encoded bytes that decode to the exact UTF-8 `nvattest` output. It is not trustworthy by itself. (`/v1/AttestGpu` runs a *fresh* attestation against a caller nonce and returns bundles tagged `nvidia-nvattest-collect-evidence-json-v1`, a deliberately distinct format that a boot-record verifier does not appraise; its result is not bound to the TD and must not be used as remote evidence; only the boot-time record below is.) After verifying the TDX quote and replaying the event log to RTMR3, hash the *decoded* bundle bytes — `SHA-256(hex_decode(bundle.evidence))`, never the JSON string as returned nor a re-serialized form — and require the result to equal the `gpu-attestation` event's `evidence_sha256`. See [GPU Security for AI Workloads](./security/security-model.md#gpu-security-for-ai-workloads) for the event schema, ordering, Rego example, and platform differences. ### 2.2. Determining expected MRs MRTD, RTMR0, RTMR1, and RTMR2 correspond to the image. dstack OS builds all related software from source. diff --git a/docs/aws-ec2-production-verifier-runbook.md b/docs/aws-ec2-production-verifier-runbook.md index 5b9f4ab98..cf23fa90f 100644 --- a/docs/aws-ec2-production-verifier-runbook.md +++ b/docs/aws-ec2-production-verifier-runbook.md @@ -214,8 +214,12 @@ For GPU workloads, PCR14 also contains `gpu-policy-hash` immediately after `SHA-256(JCS(requirements.gpu_policy))`, using `{}` when the policy is omitted. Because the verifier replays the event chain against the signed NitroTPM Attestation Document, this validates `gpu_policy_hash` on AWS. A successful GPU -launch also adds `gpu-attestation`; its `evidence_sha256` can be compared with -the exact UTF-8 bytes returned by `GpuInfo.attestation` after PCR14 replay. +launch also adds `gpu-attestation`; after PCR14 replay, its `evidence_sha256` +can be compared with `SHA-256(hex_decode(bundle.evidence))`, where `bundle` is +the `AttestResponse.boottime_gpu_evidence` entry whose `format` is +`nvidia-nvattest-boottime-json-v1`, returned by `/v1/Attest` when the request +sets `include_boottime_gpu_evidence`. Hash the decoded bytes exactly as +returned, not a re-serialized form. The guest also extends a `MrConfig` V2 **config commitment** into **PCR8** (`PCR8 = sha384(0^48 || config_id)`). This is **optional** and exists only to diff --git a/docs/guest-api-v1.md b/docs/guest-api-v1.md new file mode 100644 index 000000000..a385837c3 --- /dev/null +++ b/docs/guest-api-v1.md @@ -0,0 +1,708 @@ +# dstack Guest Agent API v1 + +This is the normative specification of `dstack.guest.v1`, the versioned guest +agent API introduced in dstack 0.6.0. It defines the URL scheme, the key +derivation function, the signature chain encoding, and the steps a relying party +follows to verify what the agent produces. + +Read it as the contract. The proto file +(`dstack/guest-agent/rpc/proto/agent_rpc_v1.proto`) describes the message shapes; +this document pins the bytes. Where an implementation and this document disagree, +one of them is a bug. + +## What v1 is for + +v1 exists because the unversioned surface cannot be fixed without breaking the +v0.5.x clients that depend on it. That surface is closed at exactly the v0.5.11 +shape. v1 is where the corrections live. + +The guest agent is not a general-purpose crypto service. It holds two things no +caller can obtain elsewhere: the application root key, and the platform's ability +to attest. v1 serves those two things and nothing else. + +That is the organising principle behind the method set. `Sign` and `Verify` are +absent because they are pure computation over material the caller already has. +Any process that can reach this socket can ask `GetKey` for the private key +itself, so a server-side `Sign` grants no capability, adds an IPC round trip, and +adds an entry point to audit. Verification does not even need a key. + +Four things changed for the operations that remain. + +**Keys are domain-separated.** v0 derived a key from a path alone and handed the +same 32 bytes to both secp256k1 and ed25519. v1 binds the algorithm and a +versioned context tag into the derivation, so the two curves never share a secret +and a v1 key is never a v0 key. + +**A key is named by one field.** v0 had `path` and `purpose`, of which only `path` +reached the KDF while `purpose` was echoed into the chain claim. v1 merges them +into `domain`, and both `domain` and `algorithm` affect the derived key. + +**The chain claim is unambiguous.** v0's claim is a `:`-joined string over a +caller-chosen `purpose`, which lets an application steer what the app root key +signs. v1's claim is length-prefixed and binds raw public key bytes. + +**Names say what things are.** `GetTlsKey` became `IssueCert`, because the +operation is certificate issuance. `Info` no longer nests documents inside a JSON +blob called `tcb_info`. + +## Transport and versioning + +Every surface the agent serves is now a closed v0 fossil plus a v1. The +unversioned surfaces are exactly v0.5.11 and never change again; new capability +arrives only in `dstack.guest.v1`. + +The scheme is uniform: `v0` names the frozen v0.5.11 surface, `v1` the current +one, on both listeners. + +| Listener | Version | Service | Mount | Example path | +|---|---|---|---|---| +| Internal socket | v1 | `DstackGuest` | `/v1` | `/v1/GetKey` | +| Internal socket | v0 | `DstackGuest`, frozen | `/v0` | `/v0/GetKey` | +| Internal socket | v0 alias | `DstackGuest`, frozen | `/` | `/GetKey` | +| External | v1 | `Worker` | `/prpc/v1` | `/prpc/v1/Health` | +| External | v0 | `Worker`, frozen | `/prpc/v0` | `/prpc/v0/Info` | +| External | v0 alias | `Worker`, frozen | `/prpc` | `/prpc/Worker.Info` | + +The unversioned paths are compatibility aliases, kept so a pre-0.6 client keeps +working unchanged. They are additional mounts of the *same* handler, not a +parallel implementation, so they cannot drift from `/v0`. New code should say +which version it means. + +Both packages name their services `DstackGuest` and `Worker`. The version lives +in the package (`dstack.guest.v1`) and in the mount path, not in the service +name -- `dstack.guest.v1.DstackGuestV1` would say it twice. Where this document +needs to distinguish them it writes "the v1 `DstackGuest`" or "the frozen +`Worker`". + +`Tappd` is unchanged and outside this scheme: it predates v0 and stays on its own +socket at `/prpc/`. + +The internal socket is `/var/run/dstack.sock`, reachable only by the application +itself. The external listener is reachable by anyone who can route to the CVM. +That difference is the whole reason there are two services rather than one +mounted twice: the v1 `DstackGuest` hands out key material, and the v1 `Worker` +never does. + +Version selection is by URL path and nothing else. There is no header +negotiation, no `Accept-Version`, and no default-version redirect, so a request +URL is the complete record of which contract the caller asked for. An agent that +predates v1 has no `/v1` mount at all, so it answers `/v1/...` with a plain +HTTP 404 -- see [Detecting an agent without v1](#detecting-an-agent-without-v1). + +Both surfaces run over the same prpc transport. A `POST` carrying +`Content-Type: application/json` takes a JSON body and returns JSON; any other +content type takes a protobuf-encoded request message and returns a +protobuf-encoded response. A `GET` takes its fields as query parameters and +returns JSON. + +### Status codes + +| Status | Body | Meaning | +|---|---|---| +| 200 | the response message | Success | +| 404 | the server's own 404 page | No such mount: this agent has no surface at that path | +| 404 | `{"error": "Service not found: "}` | The surface is mounted; it has no such method | +| 400 | `{"error": ""}` | The method ran and failed | +| other | `{"error": ""}` | A handler chose the status; the message says why | + +A handler failure is a 400 with the error text in the body. v1 does not default, +coerce, or truncate a malformed request into a well-formed one. + +### Detecting an agent without v1 + +Both "this agent is too old for v1" and "v1 exists but has no such method" +answer 404, so the status alone does not separate them. The body does. + +- 404 whose body is **not** JSON carrying `Service not found` -- there is no + surface mounted at that path. The agent predates v1. +- 404 whose body **is** `{"error": "Service not found: "}` -- v1 is + mounted and does not have that method. + +`/v1/Version` is the cheapest probe: it takes no arguments and touches nothing. + +## The internal surface + +The v1 `DstackGuest` has six methods. + +| Method | Purpose | +|---|---| +| `IssueCert` | Issue a certificate, with a freshly generated key | +| `GetKey` | Derive an application key and return it with its signature chain | +| `Attest` | Produce a versioned attestation over caller-supplied report data | +| `AttestGpu` | Collect GPU evidence now, against a caller-chosen nonce | +| `Info` | Return application identity and configuration | +| `Version` | Return the agent version | + +Five v0 methods are deliberately absent. + +`Sign` and `Verify` are absent for the reason above: neither needs the TEE. +Applications sign locally with a standard crypto library, using the key `GetKey` +returns, and verify locally following this document. A v0 client may keep calling +the unversioned `Verify` for single-signature checks; the v0 `Sign` RPC's +per-algorithm signing modes are documented on that surface, not here. + +`GetQuote` is absent because `Attest` subsumes it. `GetQuote` answers on Intel TDX +and nowhere else, and the `VersionedAttestation` that `Attest` returns already +carries the TDX quote and the event log. See +[Extracting a quote from an attestation](#extracting-a-quote-from-an-attestation). + +Boot-time GPU evidence has no method of its own. `Attest` with +`include_boottime_gpu_evidence` returns it, in the same `GpuEvidenceBundle` +shape `AttestGpu` uses, so one parser handles both and one round trip fetches +the evidence together with the attestation needed to authenticate it. + +`EmitEvent` is absent because runtime RTMR3 events became system-owned in 0.6.0. +An application binds its data through `report_data` instead. + +### Naming conventions + +Every request message is `Request` and every response is +`Response`, including for methods that take no arguments. An empty message +can gain a field later; `google.protobuf.Empty` cannot. + +A field's encoding lives in its doc comment, not in its name. Fields carrying JSON +documents say so and name who owns the schema. + +## Certificate issuance + +`IssueCert` is certificate issuance. The agent generates a key, builds a CSR, +signs the CSR with that key, and relays it to the KMS `SignCert` RPC, or to the +local CA when the application runs without a KMS. It returns the chain the signer +produced. + +v0 called this `GetTlsKey`, which named the by-product rather than the request. +The private key is incidental: it is freshly generated on every call, no request +field feeds it, and two identical requests produce two unrelated keys. `GetKey` is +the method that derives a stable, attestable key. + +`not_before` must be earlier than `not_after` when both are set; otherwise the +call fails. + +This first cut serves only the integrated one-step mode, where the agent holds the +key. A mode that signs a caller-supplied CSR or public key, so the private key +never leaves the caller, is a plausible extension. It would arrive as added fields +or a sibling method, never as a change to what these fields mean. + +## Key derivation + +### Inputs + +A v1 application key is named by exactly two values. + +`domain` is a caller-chosen domain-separation string. It is not a DNS name; the +certificate fields on `IssueCertRequest` are the ones that take those. It may be +any byte string a proto3 `string` can carry, including one containing `:`, `/`, or +NUL. + +`algorithm` is `secp256k1` or `ed25519`. There is no default and no alias. An +empty string is an error, and so is any other value, including `k256` and +`secp256k1_prehashed`. + +Derivation is **flat**. Two domains yield unrelated keys. `a/b` is an opaque +string that happens to contain a slash, not a child of `a`, and no key derived +here can be used to derive another. There is no BIP-32-style hierarchy. The old +`path` name invited that reading, which is part of why it is gone. + +### Length-prefixed encoding + +Every construction below builds its input from length-prefixed fields. Write +`LP(x)` for: + +```text +LP(x) = uint32_be(len(x)) || x +``` + +`len(x)` is the byte length, encoded big-endian in exactly four bytes. Encoding +fails if a field is longer than 2^32 - 1 bytes. + +The prefix is what makes the encoding injective. A domain is arbitrary +caller-chosen bytes, so any delimiter it could also contain would let two +different `(domain, algorithm)` pairs encode to one byte string and share a key. +Joining with `:` or `/` is not sufficient here and is not what v1 does. + +### The KDF + +```text +salt = "dstack-guest-v1" (15 bytes, ASCII; v0 uses "RATLS") +IKM = app root secp256k1 private key (32 bytes, `k256_key` from .appkeys.json) +info = LP("dstack-guest-v1-key") || LP(algorithm) || LP(domain) +L = 32 + +key = HKDF-SHA256(salt, IKM, info, L) (RFC 5869: extract, then expand) +``` + +`algorithm` is bound as its canonical name, `secp256k1` or `ed25519`, never as the +string the caller sent. + +The primitive is unchanged from v0, which also used HKDF-SHA256 over the same +input key material. Two things changed. The `info` now binds the algorithm and a +version tag, where v0 passed the path alone, so the algorithm did not participate +and one 32-byte secret served both curves. And the salt is v1's own. + +The salt is what makes v1 a separate derivation tree rather than a +differently-labelled branch of the old one. Under a shared salt the two surfaces +would be separated only by their HKDF `info` -- and the legacy `info` is the +caller's `path` verbatim, so a caller that passed the v1 `info` byte string as a +v0 `path` would reproduce a v1 key exactly. Different salts close that by +construction, whatever either side puts in `info`. + +That collision was never a privilege boundary: both derivations serve the same +single-tenant application from the same root key, and that application may call +either surface. It is closed because a KDF whose separation depends on nobody +choosing an awkward input is one refactor away from not separating anything, and +the fix costs nothing on a surface with no deployed keys yet. + +The 32 output bytes are used directly: + +- **secp256k1**: the big-endian private scalar. If it is zero or at least the + group order, the call fails. That is a ~2^-128 event for a given domain, and the + caller can choose another one. Folding the scalar into range would silently land + two domains on one key. +- **ed25519**: the RFC 8032 seed, from which the key expands as usual. + +### Public key encoding + +| Algorithm | Encoding | Length | +|---|---|---| +| `secp256k1` | SEC1 compressed point, `0x02`/`0x03` prefix | 33 bytes | +| `ed25519` | RFC 8032 raw public key | 32 bytes | + +These are the exact bytes returned in `public_key` and the exact bytes bound into +the chain claim. A relying party never has to re-derive the public key from the +private key to check the chain. + +### Test vectors + +Generated with an app root key of + +```text +1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b +``` + +| domain | algorithm | private key | public key | +|---|---|---|---| +| `""` | secp256k1 | `59f60584ce6fd2a3a31997256db9d77322463fc8a6b1520110401bcb1ee92387` | `0377c7fb050db181d392266a3cee9adb2901c6d665f11bac68be5457f577ba4908` | +| `""` | ed25519 | `b023493030669cf22e9cafa6a464d4cf3ae4edfe5474ec796710f21ea011946d` | `a3dc149fd5b765eab2eb7d3174fa939e39386898f10b15b7b146f6f1358ecf2a` | +| `storage-encryption` | secp256k1 | `5510330f86902ddae38c6d89c93a8408019332c17a429e1abd01c4a28d1544a6` | `03d962450a41748021c8b02787ac36ce642ff0ae25f4c55019eb527e1112cfd764` | +| `storage-encryption` | ed25519 | `3c4c3ece12fa99ccb93fc0090877f80e70545fdd971e2ac93d3398c4684538d3` | `2380c4a33a60b60613fa43866421e5b96eb8dcde211317200fd0d41e7e491288` | +| `a/b/c` | secp256k1 | `7f0973449298085d2d36a3b4c4d3243c100ba1981ffa885fe9e9dee883e69538` | `02e9b1a61b6d70aa9b241753828c316bf90e33e77b2e113f9ba75a8b6dc3cde5c1` | +| `k\0:ey` | ed25519 | `42da8bf0b479ed125c370e3b91f982735bf08ff592abbd586985affa43ee96a1` | `c833107822b003ff5675b33b90b151d4315c3ab9162b17d876e8dffde41abf9b` | + +The last row's domain is the five bytes `6b 00 3a 65 79`. It is there because a +delimiter-joined encoding would mishandle it. + +The same vectors are asserted in +`dstack/guest-agent/src/rpc_service_v1/keys.rs`. They describe deployed key +material, so a diff against them is a bug in the derivation, not a stale fixture. + +## The signature chain + +`GetKey` returns a two-element `signature_chain` alongside the key. + +```text +[0] app root key signs the v1 key claim (specified here) +[1] KMS root key signs the app root public key (unchanged from v0) +``` + +Link 1 is produced by the KMS, outside the agent, and both API surfaces pass the +same bytes through. Only link 0 is new. + +### Link 0: the key claim + +```text +claim = LP("dstack-guest-v1-key-claim") + || LP(algorithm) + || LP(domain) + || LP(public_key) + +digest = keccak256(claim) +link0 = r || s || v (65 bytes) +``` + +`link0` is a recoverable ECDSA secp256k1 signature over `digest` by the app root +key. `r` and `s` are 32-byte big-endian integers, low-S normalised, and `v` is the +one-byte recovery id in `0..=3`. `public_key` is the raw derived public key from +the table above, not a hex string. + +A worked claim, for `domain = "storage-encryption"` and `algorithm = "secp256k1"`: + +```text +00 00 00 19 "dstack-guest-v1-key-claim" 25 bytes +00 00 00 09 "secp256k1" 9 bytes +00 00 00 12 "storage-encryption" 18 bytes +00 00 00 21 03 d9 62 45 ... d7 64 33 bytes +``` + +With the app root key from the test vector table, `link0` is + +```text +5b6193729ce7976ec67863f21692d4b98c69832698aae8e001a7d33a6f818b6e +46ca950725b6e90e8ca9bcf394abd03ce264bf9b7eec1e91693247f9dd53c269 +01 +``` + +The signature is deterministic (RFC 6979), so this vector pins the whole encoding +including the recovery byte. + +#### Why this cannot be forged through v0 + +v0's claim is `keccak256("{purpose}:{hex(pubkey)}")`, and `purpose` is an +arbitrary caller-supplied string. A malicious application can therefore make the +app root key sign nearly any ASCII byte string that ends in `:` followed by +lowercase hex. If a v1 claim were reachable that way, the v1 chain would be worth +nothing. + +It is not reachable, and the reason is structural rather than probabilistic. + +Every v0 preimage ends with `:` followed by the hex encoding of a public key, so +its last 64 bytes (ed25519) or 66 bytes (secp256k1) are all lowercase hex +characters. A v1 claim ends with `LP(public_key)`, whose four length bytes are +`00 00 00 21` for secp256k1 and `00 00 00 20` for ed25519, sitting 37 or 36 bytes +from the end. That is inside the region a v0 preimage requires to be hex-only, and +`0x00` is not a hex character. No choice of `purpose` reproduces a v1 claim byte +string, and since the two byte strings can never be equal, matching their keccak +digests would require a preimage attack on keccak256. + +The regression test is `a_v0_claim_cannot_be_crafted_into_a_v1_claim` in +`dstack/guest-agent/src/rpc_service_v1/keys.rs`. It builds the strongest available +forgery, a `purpose` set to the v1 claim minus exactly the suffix v0 appends on +its own, then asserts both that the byte strings differ and that the structural +property holds. + +The two context tags are also distinct, so a derivation input can never be read as +a claim: `dstack-guest-v1-key` for the KDF, `dstack-guest-v1-key-claim` for the +claim. Because both are length-prefixed, neither encoding is a prefix of the other. + +### Link 1: the KMS attestation + +Unchanged from v0 and reproduced here so this document is self-contained. + +```text +message = "dstack-kms-issued" || ":" || app_id || sec1_compressed(app_root_pubkey) +digest = keccak256(message) +link1 = r || s || v (65 bytes) +``` + +`link1` is a recoverable ECDSA secp256k1 signature over `digest` by the KMS root +key. `app_id` is the raw app id bytes, and the app root public key is SEC1 +compressed, 33 bytes. + +### Verifying a chain + +A relying party holds a `public_key`, a `signature_chain`, the `(domain, +algorithm)` the key was requested under, and the `app_id`. It performs the +following. + +1. **Anchor.** Obtain the KMS root public key from a source you trust + independently of the agent being checked: the `DstackKms` contract's + `kmsInfo().k256Pubkey`, or a value pinned out of band. This step carries the + security of everything below it. An attacker who can answer your query for the + anchor can also mint a self-consistent chain, so reading the anchor from the + KMS you are checking proves nothing. + +2. **Rebuild the claim.** Compute + `claim = LP("dstack-guest-v1-key-claim") || LP(algorithm) || LP(domain) || LP(public_key)` + using the canonical algorithm name and the raw public key bytes, then + `digest0 = keccak256(claim)`. + +3. **Recover the app root key.** Split `signature_chain[0]` into `r`, `s`, `v` and + recover the secp256k1 public key from `(digest0, r, s, v)`. Reject a + non-canonical high-S `s`. Call the result `app_root_pubkey`, SEC1 compressed. + +4. **Rebuild the KMS message.** Compute + `digest1 = keccak256("dstack-kms-issued" || ":" || app_id || app_root_pubkey)`. + +5. **Check link 1.** Verify `signature_chain[1]` over `digest1` against the anchor + from step 1, either by recovering and comparing to the anchor or by verifying + `(r, s)` against it directly. Reject high-S here too. + +6. **Bind the application.** Confirm the `app_id` you used in step 4 is the + application you meant to talk to. The chain proves that the KMS issued this app + root key to *some* application; only this step ties it to yours. + +To also check a payload signature an application produced with a key from +`GetKey`, verify that signature against `public_key` under whatever scheme the +application used, then run steps 2 through 6 to establish that `public_key` is what +it claims to be. The two halves are independent: a valid payload signature under an +unverified public key says nothing. + +Step 3 recovers the app root key rather than requiring it as an input, which is why +link 0 carries a recovery byte. A verifier that already knows the expected app root +public key may verify `(r, s)` against it directly and compare instead. + +## The external surface + +The v1 `Worker` has three methods, served at `/prpc/v1`. + +| Method | Purpose | +|---|---| +| `Info` | Application identity and configuration, subject to `public_tcbinfo` | +| `Version` | The agent version | +| `Health` | Report whether the application is serving | + +Nothing here returns key material, and no caller chooses what gets signed or +attested. + +### There is no v1 AttestAppKey + +The frozen surface has `Worker.GetAttestationForAppKey`, which attests a key the +agent derives from an algorithm name alone. v1 has no counterpart, on purpose. + +That method attests the key v0's KDF derives at path `vms` with purpose +`signing`. No v1 `GetKey(domain, algorithm)` call can return that key: the v1 +KDF has a different salt, a different `info`, and no `purpose` input at all. A +v1 application calling it would receive an attestation of a public key whose +private half it has no way to obtain, which is worse than having no method -- +it looks like it works. + +A v1 application attests its own key instead: + +1. Derive the key: `GetKey(domain, algorithm)` on the internal socket. +2. Commit to it: build `report_data` over `public_key` yourself. Prefix it so a + verifier can tell what it is looking at -- see the `dip1::` convention the + frozen method uses. +3. Attest it: `Attest(report_data)` on the internal socket. +4. Publish it: hand the resulting attestation to relying parties. The + application serves it; the agent's external listener does not. + +This is strictly more capable than the method it replaces. The application picks +which of its keys to attest, and picks the commitment format, instead of being +limited to the single key the agent would derive for it. + +Legacy flows keep using `Worker.GetAttestationForAppKey`, unchanged and frozen. +It is Intel TDX only, because it returns a `GetQuoteResponse`. + +`Health` is polled by the gateway to decide whether an instance belongs in its +application's load-balancing rotation. It answers from a cache the agent +refreshes on its own timer, so one call costs a lock and a clone however many +gateway nodes are polling. Only instances that opted in via +`RegisterCvmRequest.health_check` are ever polled; see +[Application health checks](./app-health-checks.md). + +### public_tcbinfo on the external surface + +The v1 `Worker.Info` returns the same `InfoResponse` the internal surface returns, +minus what the application asked to keep private. Unless the app-compose sets +`public_tcbinfo`, the three document fields come back as empty strings: + +- `app_compose` +- `vm_config` +- `key_provider_info` + +Identity and the measurement hashes are always present. + +This is close to, but deliberately not the same as, what the frozen +`Worker.Info` does. That one blanks `tcb_info` and `vm_config`, and serves +`key_provider_info` externally in every case. v1 blanks `key_provider_info` too: +it names the component holding the application's keys, and an external caller +has no use for it. The frozen behaviour is unchanged on its own surface, so a +v0.5.x client sees exactly what it always did. + +| Field | frozen `Worker.Info` | v1 `Worker.Info` | +|---|---|---| +| identity, measurement hashes | always served | always served | +| `tcb_info` / measurement registers | blanked | not in the message at all | +| `vm_config` | blanked | blanked | +| `app_compose` | (nested in `tcb_info`, blanked) | blanked | +| `key_provider_info` | **always served** | **blanked** | + +The internal v1 `DstackGuest.Info` applies no gating at all. The flag decides what +an outside party may learn, and the caller on the internal socket is the +application itself; an application cannot need protecting from its own +configuration. + +## Attestation + +`Attest` is v1's only CVM attestation entry point. It takes up to 64 bytes of +`report_data`, zero-padded on the right to 64; more than 64 bytes is an error +rather than a truncation. It returns the versioned dstack attestation format, +which covers every supported platform. + +### Extracting a quote from an attestation + +`AttestResponse.attestation` is a `VersionedAttestation`. The authoritative +implementation is `dstack/dstack-attest/src/v1.rs`, and `dstack-verifier` is the +reference consumer. + +The wire format is sniffed from the first byte. A leading `0x00` marks the legacy +SCALE-encoded V0 form; a MessagePack map prefix marks V1. V1 decodes as a +MessagePack map produced by `rmp_serde::to_vec_named`: + +```text +{ "version": u64, + "platform": { "kind": , "data": { ... } }, + "stack": { "kind": , "data": { ... } } } +``` + +`platform.kind` is one of `tdx`, `gcp-tdx`, `nitro-enclave`, `aws-nitro-tpm`, or +`sev-snp`. For `tdx` and `gcp-tdx`, `platform.data` carries `quote` (the raw TDX +quote bytes, exactly what the unversioned `GetQuote` returned) and `event_log`. +`gcp-tdx` additionally carries `tpm_quote`, which GCP's own verification binds and +which `GetQuote` had no field for. The other three platforms have no TDX quote, +which is why `GetQuote` could not answer on them at all. + +`stack.data.report_data` carries the 64 bytes the caller asked for. + +V2 runtime events in the event log always include the hex-encoded preimage of +their digest; a verifier should check that `sha384(hex_decode(preimage))` equals +the digest. + +### GPU evidence + +Both GPU methods return the same thing: a list of `GpuEvidenceBundle`, each +carrying `vendor`, `format`, and opaque `evidence` bytes. `AttestGpu` returns +its bundles directly; `Attest` returns them in +`AttestResponse.boottime_gpu_evidence`. A consumer writes one bundle parser and +dispatches on `(vendor, format)`. + +| Source | `vendor` | `format` | Answers | +|---|---|---|---| +| `AttestGpu` | `nvidia` | `nvidia-nvattest-collect-evidence-json-v1` | is this device genuine right now | +| `Attest` | `nvidia` | `nvidia-nvattest-boottime-json-v1` | what the boot looked like | + +The two `format` values are deliberately distinct. They answer different +questions and a verifier for one does not appraise the other, so sharing a tag +would leave a consumer no way to tell a live measurement from a boot record. + +`boottime_gpu_evidence` is populated only when the request sets +`include_boottime_gpu_evidence` and boot-time output exists. Absence is the +empty list; there is no sentinel value. + +Its `evidence` is the exact bytes of the nvattest output as the agent read them +from disk. That exactness is the contract, not an implementation detail: the +only thing tying this evidence to the boot is `sha256` over precisely those +bytes, compared against the `evidence_sha256` field of the measured +`gpu-attestation` event after replaying the runtime event log. Parsing and +re-serializing the JSON before hashing changes the digest and breaks the +comparison. + +That evidence is not bound to `report_data` -- nvattest ran at boot against its +own nonce, so a fresh `report_data` says nothing about it. + +It is also a historical statement about the boot, not a live one: it does not +prove the GPU is still attached. Sampling the GPU at attestation time would not +fix it, because an NVIDIA report binds the device and a nonce but not the TD the +device is attached to, so a fresh report can be relayed from a genuine remote +GPU. Only TDISP/TEE-IO device binding closes that gap. + +`AttestGpu` answers the narrower question of whether the device reachable right +now is a genuine CC-enabled GPU that signs a caller-chosen 32-byte nonce. It +returns vendor-native evidence rather than a local verdict, so a relying party +appraises it with its own verifier: select one by `vendor` and `format`, then +check the signature, certificate chain, measurements, and the nonce embedded in +the evidence. + +## Info + +`Info` returns identity and configuration. It is not attestation, and nothing it +returns is evidence: the response arrives over a local socket with no quote behind +it. + +That is why the measurement registers and the event log are absent. v0's +`AppInfo.tcb_info` carried MRTD, RTMR0-3 and the event log inside a JSON string, +which invited relying parties to read measurements out of an unattested response. +Those values belong to `Attest`, whose attestation carries them quote-backed. + +`mr_aggregated`, `os_image_hash` and `compose_hash` remain, deliberately. They +identify *which* application and image this is, which is the question `Info` +answers. They are typed bytes rather than hex strings inside a JSON blob, and each +appears exactly once; v0 returned all three both as top-level fields and again, +hex-encoded, inside `tcb_info`. They are still unattested, and a relying party +still confirms them against an attestation. + +`app_cert` is gone. It was a self-issued demo certificate the agent minted for a +dashboard, and it proved nothing. + +`app_compose` carries the verbatim deployed document. `compose_hash` is `sha256` +over exactly those bytes, so do not parse and re-serialize before hashing: key +order, whitespace and unknown fields all change the digest, and that digest is +what gets whitelisted on chain. + +The v1 `DstackGuest.Info` on the internal socket applies no hiding: the caller is the +application itself, which cannot need protecting from its own configuration. +The v1 `Worker.Info` on the external listener honours `public_tcbinfo`; see +[public_tcbinfo on the external surface](#public_tcbinfo-on-the-external-surface), +which is the authoritative description. + +## Errors + +| Condition | Behaviour | +|---|---| +| Empty or unrecognised `algorithm` | Error naming the accepted values | +| Derived secp256k1 scalar out of range | Error; the caller picks another domain | +| `report_data` longer than 64 bytes | Error | +| `not_before` not earlier than `not_after` | Error | +| Unknown method on a mounted surface | HTTP 404, `Service not found: ` | +| `/v1/...` on an agent that predates v1 | HTTP 404, no such mount | + +Everything above the last two rows is an HTTP 400 with the message in the body. +See [Status codes](#status-codes) for the full mapping. v1 does not default, +coerce, or truncate a malformed request into a well-formed one. + +## Migration from the unversioned API + +**v1 keys are different keys.** Deriving under the same name on `/v1` that an +application used on `/` returns different key material. This is the point of the +new KDF, not a defect: the v0 derivation ignored the algorithm, so one secret +served two curves. There is no compatibility mode and no flag to get the old bytes +back from `/v1`. + +An application holding assets or identity under a v0 key must migrate them +deliberately. Derive the v1 key, move the asset with a transaction signed by the +v0 key, and only then cut over. An application with no persistent state can switch +by pointing at the new URL. + +Both unversioned surfaces stay available and closed. A v0.5.x client keeps +working against a 0.6 agent with no changes: `Sign`, `Verify` and `EmitEvent` +remain on the internal one (`EmitEvent` fails with a message naming its removal), +and `GetAttestationForAppKey` remains on the external one. Nothing forces a +migration. + +**SDK shape.** All four SDKs ship two clients mirroring the two surfaces, and +**the unsuffixed client is this one**: `DstackClient` names the v1 client and is +the recommended default. `DstackClientV0` is the closed unversioned API, +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. + +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 +new KDF. To stay on the frozen surface, name `DstackClientV0`. + +`ClientV1` has no `Sign` and no `Verify`, because v1 has neither. An application +signs locally with the key `GetKey` returns, and a relying party verifies +locally, following the rules above. The SDKs deliberately ship no verification +helper: verifying needs no client and no connection, and this document is what a +verifier implements against. + +## Field mapping + +For readers porting from the unversioned API. + +| v0 | v1 | Note | +|---|---|---| +| `GetTlsKey` | `IssueCert` | Renamed; same behaviour | +| `GetKeyArgs.path` + `.purpose` | `GetKeyRequest.domain` | Merged; both KDF inputs now | +| `GetKeyArgs.algorithm` (defaulted) | `GetKeyRequest.algorithm` | Required; no `k256` alias | +| — | `GetKeyResponse.public_key` | Added | +| `GetQuote` | `Attest` | TDX-only channel subsumed | +| `AppInfo.tcb_info` | — | Measurements are typed fields; the rest belongs to `Attest` | +| `AppInfo.app_cert` | — | Dashboard artifact | +| `AppInfo.vm_config` | `InfoResponse.vm_config` | Unchanged content | +| `AppInfo.key_provider_info` | `InfoResponse.key_provider_info` | Unchanged content | +| (nested in `tcb_info`) | `InfoResponse.app_compose` | Promoted to top level | +| `Sign` | — | Removed; sign locally with the key from `GetKey` | +| `Verify` | — | Removed; verify locally per this document | +| `EmitEvent` | — | Removed; RTMR3 is system-owned | +| `Worker.GetAttestationForAppKey` | — | No v1 counterpart; a v1 app attests its own key, see above | +| frozen `Worker.Info` | v1 `Worker.Info` | Also gates `key_provider_info`; see above | + +## Related documents + +- [Attestation on Intel TDX](./attestation-tdx.md) +- [Application health checks](./app-health-checks.md), for `/prpc/v1/Health` +- [App Compose format](./normalized-app-compose.md), for the schema behind + `InfoResponse.app_compose` +- [On-chain governance](./onchain-governance.md), for the `DstackKms` contract that + publishes the KMS root public key diff --git a/docs/security/cvm-boundaries.md b/docs/security/cvm-boundaries.md index 28375cca3..7f3abfd72 100644 --- a/docs/security/cvm-boundaries.md +++ b/docs/security/cvm-boundaries.md @@ -190,8 +190,8 @@ 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 | -| AttestAppKey | Attest a key the app derived | AttestResponse | -| Health | Report whether the application is serving | HealthResponse | +| GetAttestationForAppKey | Attest the key the agent derives for the app | GetQuoteResponse | +| Health | Report whether the application is serving (`/prpc/v1` only) | HealthResponse | Everything on this listener is unauthenticated, so each method is bounded in what it costs and in what it says: @@ -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. -- `AttestAppKey` generates a fresh platform attestation per call and is by far - the most expensive method here. +- `GetAttestationForAppKey` 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/security/security-model.md b/docs/security/security-model.md index f2795537e..2fc39d683 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -144,7 +144,7 @@ boot-mr-done } ``` -`GpuInfo` returns that complete boot-time `nvattest` JSON in its `attestation` string, and `Attest` returns the same bytes in `boottime_gpu_evidence` when called with `include_boottime_gpu_evidence`; neither runs a new attestation. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, and compare `evidence_sha256` with `SHA-256(UTF-8(GpuInfo.attestation))` (equivalently `SHA-256(UTF-8(Attest.boottime_gpu_evidence))`). Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the returned string. +`/v1/Attest` returns that complete boot-time `nvattest` record when the request sets `include_boottime_gpu_evidence`; it runs no new attestation. `AttestResponse.boottime_gpu_evidence` is a list of `GpuEvidenceBundle`, each carrying `vendor`, `format`, and `evidence`. The boot record is the bundle whose `vendor` is `nvidia` and whose `format` is `nvidia-nvattest-boottime-json-v1`; its `evidence` is hex-encoded bytes that decode to the exact UTF-8 nvattest output as the agent read it from disk. The other format, `nvidia-nvattest-collect-evidence-json-v1`, comes from `/v1/AttestGpu` and is fresh evidence against a caller nonce; the two are deliberately distinct because a verifier for one does not appraise the other. To bind the API result to TDX evidence: verify the quote, replay the event log to the quote's RTMR3, require exactly one pre-`system-ready` `gpu-attestation` event, decode its JSON payload, select the boot-time bundle by `format`, and compare `evidence_sha256` with `SHA-256(hex_decode(bundle.evidence))`. Hash the decoded bytes, not the JSON string as returned and not a re-serialized form: parsing and re-serializing changes the digest and breaks the comparison. Only after this comparison should the verifier inspect the returned claims. This exact-byte comparison includes any whitespace or trailing newline in the decoded record. A verifier must replay the measured event log, require exactly one `gpu-policy-hash` event immediately after `compose-hash`, and compare its 32-byte payload with the expected policy digest (`SHA-256(JCS({}))` for the omitted/default policy). When MrConfigV3 includes `gpu_policy_hash`, it must match the same digest. When GPU protection is required, the verifier must also require exactly one pre-`system-ready` `gpu-attestation` event with `devices > 0` and, when applicable, the expected deployment count. The raw `attestation.out` file is not trusted by itself; if it is supplied for inspection, its digest must match the `gpu-attestation` event. @@ -158,7 +158,7 @@ The events make the following **boot-time** statement: immediately before key pr - A mixed launch cannot attest only its TEE-capable subset. Non-NVIDIA display GPUs are rejected, and the sysfs, NVML, and nvattest claim counts must all agree. A non-CC NVIDIA GPU either prevents evidence collection/appraisal or causes the default appraisal, application policy, or CC-state check to fail. - Copying another CVM's result into a file or `report_data` does not work. Only measured pre-application code can place the event before `system-ready`, and event-log replay binds it to the quoted RTMR/PCR value. -This is **not a lifetime or physical co-location guarantee**. After `system-ready`, an application with sufficient guest privileges can unload the NVIDIA driver, and a malicious host may attempt PCI hot-remove/replacement or proxy GPU traffic. The boot event remains a true historical statement but does not prove that the same device is still attached. dstack also cannot rule out a live relay/cuckoo attack to a genuine remote GPU: current Hopper/Blackwell deployments do not provide a CPU-TEE-verifiable TEE-I/O/TDISP device binding. Applications that mutate the driver or PCI topology are outside this guarantee; higher-assurance deployments must prevent that behavior and re-attest before using a newly initialized GPU; the guest-agent `AttestGpu` API does that re-check against a caller-chosen nonce. Being an NVIDIA report, its result is subject to the same relay caveat as any attestation-time GPU sample: it establishes that a genuine CC-enabled GPU is reachable and responsive now, not that the device is bound to this TD, so it must not be forwarded to a remote relying party as proof of GPU possession. +This is **not a lifetime or physical co-location guarantee**. After `system-ready`, an application with sufficient guest privileges can unload the NVIDIA driver, and a malicious host may attempt PCI hot-remove/replacement or proxy GPU traffic. The boot event remains a true historical statement but does not prove that the same device is still attached. dstack also cannot rule out a live relay/cuckoo attack to a genuine remote GPU: current Hopper/Blackwell deployments do not provide a CPU-TEE-verifiable TEE-I/O/TDISP device binding. Applications that mutate the driver or PCI topology are outside this guarantee; higher-assurance deployments must prevent that behavior and re-attest before using a newly initialized GPU; the guest-agent `/v1/AttestGpu` API does that re-check against a caller-chosen nonce, returning bundles tagged `nvidia-nvattest-collect-evidence-json-v1` rather than the boot-time format. Being an NVIDIA report, its result is subject to the same relay caveat as any attestation-time GPU sample: it establishes that a genuine CC-enabled GPU is reachable and responsive now, not that the device is bound to this TD, so it must not be forwarded to a remote relying party as proof of GPU possession. AMD SEV-SNP has no runtime measurement register in the current dstack stack. The local boot gate can still fail closed, but a `gpu-attestation` event carried beside an SNP report is not remotely bound to that report and must not be accepted as dual-attestation evidence. SNP needs a measured vTPM/PCR channel before it can provide the same remote binding. diff --git a/docs/tutorials/attestation-verification.md b/docs/tutorials/attestation-verification.md index 7c2a4e40a..79d759ec1 100644 --- a/docs/tutorials/attestation-verification.md +++ b/docs/tutorials/attestation-verification.md @@ -499,8 +499,18 @@ For a successful GPU launch, the relevant order is `compose-hash`, any and `boot-mr-done`. After replaying the log to the quote's RTMR3, decode the JSON payload of `gpu-attestation` and compare its `evidence_sha256` with the SHA-256 digest of -the exact UTF-8 `GpuInfo.attestation` string. `GpuInfo` reads the result saved -during boot and does not perform a new attestation. +the boot-time GPU evidence bytes. Fetch those bytes by calling `/v1/Attest` +with `include_boottime_gpu_evidence: true`: `AttestResponse.boottime_gpu_evidence` +is a list of `GpuEvidenceBundle` (`{vendor, format, evidence}`); pick the one +whose `format` is `nvidia-nvattest-boottime-json-v1`, hex-decode its `evidence` +field, and hash exactly those bytes — `SHA-256(hex_decode(bundle.evidence))`. +Hash the decoded bytes as returned, not the JSON string and not a re-serialized +form: the comparison is byte-exact, including any whitespace or trailing +newline. That bundle is the record saved during boot; returning it does not +perform a new attestation. `/v1/AttestGpu` does perform a fresh one, but its +bundles carry the distinct `format` `nvidia-nvattest-collect-evidence-json-v1`, +are appraised against a caller nonce rather than the boot record, and are not +bound to the TD, so they must not be used as remote evidence. ### Verify specific event values diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 28d41b459..19513fb95 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -2001,12 +2001,15 @@ name = "dstack-guest-agent-rpc" version = "0.6.0" dependencies = [ "anyhow", + "hex", "parity-scale-codec", "prost 0.13.5", + "prost-types 0.13.5", "prpc", "prpc-build", "serde", "serde_json", + "sha2 0.10.9", ] [[package]] @@ -5727,6 +5730,7 @@ dependencies = [ "dcap-qvl", "dstack-attest", "dstack-types", + "ed25519-dalek", "elliptic-curve", "errify", "ez-hash", @@ -5735,6 +5739,7 @@ dependencies = [ "hex", "hex_fmt", "hkdf", + "k256", "or-panic", "p256", "parity-scale-codec", diff --git a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs index e2a09767c..734744723 100644 --- a/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs +++ b/dstack/cert-client/src/bin/dstack-kms-sign-cert-fixture.rs @@ -3,7 +3,7 @@ //! Generate a v2 KMS CSR whose key is bound to fresh guest attestation. use anyhow::{Context, Result}; -use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, AttestArgs}; +use dstack_guest_agent_rpc::v0::{dstack_guest_client::DstackGuestClient, RawQuoteArgs}; use http_client::prpc::PrpcClient; use ra_tls::{ attestation::{PlatformEvidence, QuoteContentType, VersionedAttestation}, @@ -31,9 +31,8 @@ async fn main() -> Result<()> { let address = dstack_types::dstack_agent_address(); let client = DstackGuestClient::new(PrpcClient::new(address)); let response = client - .attest(AttestArgs { + .attest(RawQuoteArgs { report_data: report_data.to_vec(), - include_boottime_gpu_evidence: false, }) .await .context("failed to obtain key-bound guest attestation")?; diff --git a/dstack/gateway/rpc/proto/gateway_rpc.proto b/dstack/gateway/rpc/proto/gateway_rpc.proto index 6e40308d2..4935974e5 100644 --- a/dstack/gateway/rpc/proto/gateway_rpc.proto +++ b/dstack/gateway/rpc/proto/gateway_rpc.proto @@ -14,16 +14,17 @@ message RegisterCvmRequest { string client_public_key = 1; // Whether this CVM asked for its traffic to be gated on application health, // i.e. `requirements.health_check` in its app-compose. When true the - // gateway polls `Worker.Health` and holds the instance out of rotation until - // the first poll answers. + // gateway polls `Health` on the v1 external surface (`/prpc/v1/Health`) and + // holds the instance out of rotation until the first poll answers. // - // Declared here rather than discovered by probing because prpc answers both - // "no such method" and "the handler failed" with HTTP 400 and drops the - // message: a failed poll cannot tell an old image apart from a broken app, - // and guessing wrong either blackholes healthy legacy instances or lets - // broken ones serve. False -- including "absent", from a CVM that predates - // this field or an app that never opted in -- means the gateway never polls - // and always treats the instance as healthy, exactly as before. + // Declared here rather than discovered by probing because the poller treats + // every failed poll alike: a timeout, a refused connection, and the 404 an + // image without `/prpc/v1` answers all count as one failure, and telling an + // old image apart from a broken app would mean classifying error shapes on + // every poll -- guessing wrong either blackholes healthy legacy instances + // or lets broken ones serve. False -- including "absent", from a CVM that + // predates this field or an app that never opted in -- means the gateway + // never polls and always treats the instance as healthy, exactly as before. bool health_check = 3; // Per-port policy the gateway should apply when proxying to this CVM. // Wrapped in a message so we can distinguish "not reported" (old CVM → diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index 734ab954c..9b5ffacfd 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use certbot::{AcmeClient, Dns01Client}; -use dstack_guest_agent_rpc::{AttestArgs, RawQuoteArgs}; +use dstack_guest_agent_rpc::v0::RawQuoteArgs; use ra_tls::attestation::QuoteContentType; use ra_tls::rcgen::KeyPair; use tokio::sync::Mutex; @@ -658,13 +658,7 @@ impl DistributedCertBot { }; // Get attestation - let attestation_str = match agent - .attest(AttestArgs { - report_data, - include_boottime_gpu_evidence: false, - }) - .await - { + let attestation_str = match agent.attest(RawQuoteArgs { report_data }).await { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!("failed to get attestation for ACME account: {err:?}"); @@ -740,13 +734,7 @@ impl DistributedCertBot { }; // Get attestation - let attestation = match agent - .attest(AttestArgs { - report_data, - include_boottime_gpu_evidence: false, - }) - .await - { + let attestation = match agent.attest(RawQuoteArgs { report_data }).await { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!(domain, "failed to get attestation: {err:?}"); diff --git a/dstack/gateway/src/main.rs b/dstack/gateway/src/main.rs index 2e30b57b3..e7e549291 100644 --- a/dstack/gateway/src/main.rs +++ b/dstack/gateway/src/main.rs @@ -5,7 +5,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; use config::{Config, TlsConfig}; -use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, GetTlsKeyArgs}; +use dstack_guest_agent_rpc::v0::{dstack_guest_client::DstackGuestClient, GetTlsKeyArgs}; use http_client::prpc::PrpcClient; use ra_rpc::{prpc_routes as prpc, rocket_helper::QuoteVerifier}; use ra_tls::attestation::AttestationVerifier; diff --git a/dstack/gateway/src/proxy/health_check.rs b/dstack/gateway/src/proxy/health_check.rs index c364fd3d1..1ebf98434 100644 --- a/dstack/gateway/src/proxy/health_check.rs +++ b/dstack/gateway/src/proxy/health_check.rs @@ -31,7 +31,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::Ipv4Addr; use std::time::Duration; -use dstack_guest_agent_rpc::worker_client::WorkerClient; +use dstack_guest_agent_rpc::v1::worker_client::WorkerClient as WorkerV1Client; use futures::StreamExt; use http_client::ConnectionReuse; use tokio::time::MissedTickBehavior; @@ -317,8 +317,8 @@ fn apply_hysteresis( /// than as a verdict; [`apply_hysteresis`] decides when enough of them in a row /// amount to one. async fn poll_instance(ip: Ipv4Addr, agent_port: u16, timeout: Duration) -> PollResult { - let client = WorkerClient::new(prober_transport(ip, agent_port)); - let response = match tokio::time::timeout(timeout, client.health()).await { + let client = WorkerV1Client::new(prober_transport(ip, agent_port)); + let response = match tokio::time::timeout(timeout, client.health(Default::default())).await { Err(_) => { return PollResult::unreachable(format!("health poll timed out after {timeout:?}")) } @@ -348,7 +348,20 @@ async fn poll_instance(ip: Ipv4Addr, agent_port: u16, timeout: Duration) -> Poll /// no real request can reach. Only the connection is unshared; the client /// itself is process-wide. See `http_client::ConnectionReuse`. fn prober_transport(ip: Ipv4Addr, agent_port: u16) -> http_client::prpc::PrpcClient { - let url = format!("http://{ip}:{agent_port}/prpc"); + // `/prpc/v1`, not `/prpc`: `Health` is a `WorkerV1` method. + // + // No released agent is affected. `Health` never shipped in a release, and a + // pre-0.6 gateway does not poll, so the only guests that can be asked are + // 0.6+ ones that opted in via `RegisterCvmRequest.health_check`. + // + // The one skew that does exist is unreleased: an interim `next` build that + // served `Health` at `/prpc` and registered with `health_check = true` will + // now 404 every poll, and a 404 counts as unreachable, so after + // `failure_threshold` polls the instance drops out of app-id rotation. + // Instance-id routing keeps working and a restart on a current build fixes + // it. Not worth a compatibility probe on every poll for a build nobody was + // asked to run. + let url = format!("http://{ip}:{agent_port}/prpc/v1"); super::guest_agent_client(url, ConnectionReuse::Fresh) } @@ -362,7 +375,7 @@ const MAX_REASON_BYTES: usize = 512; const MAX_NAMED_CONTAINERS: usize = 8; /// Summarize an agent's "no" for the log line. The caller sanitizes. -fn describe_unhealthy(response: &dstack_guest_agent_rpc::HealthResponse) -> String { +fn describe_unhealthy(response: &dstack_guest_agent_rpc::v1::HealthResponse) -> String { if !response.error.is_empty() { return format!("agent could not determine app health: {}", response.error); } @@ -436,6 +449,18 @@ mod tests { /// is what a wedged agent looks like from here: not a refusal, which fails /// fast, but silence that has to be timed out. async fn fake_agent(answer: Option>) -> u16 { + fake_agent_capturing(answer, None).await + } + + /// The same stand-in, optionally reporting the request line it was sent. + /// + /// Kept as one implementation because the drain below is what stops this + /// fixture flaking: answering into a socket the peer is still writing to is + /// a reset on some platforms. + async fn fake_agent_capturing( + answer: Option>, + request_line: Option>, + ) -> u16 { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) .await .expect("bind"); @@ -472,6 +497,10 @@ mod tests { Ok(n) => pending.extend_from_slice(&buf[..n]), } } + if let Some(tx) = request_line { + let head = String::from_utf8_lossy(&pending[..headers_end]).into_owned(); + let _ = tx.send(head.lines().next().unwrap_or_default().to_string()); + } let Some(answer) = answer else { std::future::pending::<()>().await; return; @@ -489,6 +518,25 @@ mod tests { port } + /// The poller must ask the versioned surface. `Health` lives on `WorkerV1` + /// at `/prpc/v1`; the unversioned `Worker` at `/prpc` is closed at v0.5.11 + /// and has no such method, so a poller pointed there gets a 404 that + /// `apply_hysteresis` eventually turns into "unhealthy" for every instance + /// in the fleet at once. + #[tokio::test] + async fn the_poller_asks_the_v1_surface() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let port = fake_agent_capturing(Some(br#"{"healthy":true}"#.to_vec()), Some(tx)).await; + + let _ = poll_instance(Ipv4Addr::LOCALHOST, port, Duration::from_secs(5)).await; + + let request_line = rx.await.expect("the poller sent no request"); + assert!( + request_line.contains("/prpc/v1/Health"), + "expected the v1 Health path, got: {request_line}" + ); + } + async fn poll_fake(answer: Option>) -> PollResult { let port = fake_agent(answer).await; poll_instance(Ipv4Addr::LOCALHOST, port, Duration::from_secs(5)).await @@ -636,7 +684,7 @@ mod tests { /// `describe_unhealthy` composes it and the constructor bounds it, and only /// the pair is ever what an operator sees -- asserting on `describe_unhealthy` /// alone would pass with the sanitizing dropped. - fn unhealthy_reason(response: &dstack_guest_agent_rpc::HealthResponse) -> String { + fn unhealthy_reason(response: &dstack_guest_agent_rpc::v1::HealthResponse) -> String { match PollResult::unhealthy(describe_unhealthy(response)) { PollResult::Unhealthy(reason) => reason, other => panic!("expected an unhealthy verdict, got {other:?}"), @@ -726,7 +774,7 @@ mod tests { #[test] fn an_agent_error_is_described_rather_than_dropped() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: vec![], error: "failed to connect to docker".to_string(), @@ -736,14 +784,14 @@ mod tests { #[test] fn unhealthy_containers_are_named() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: vec![ - dstack_guest_agent_rpc::ContainerHealth { + dstack_guest_agent_rpc::v1::ContainerHealth { name: "web".to_string(), status: "starting".to_string(), }, - dstack_guest_agent_rpc::ContainerHealth { + dstack_guest_agent_rpc::v1::ContainerHealth { name: "db".to_string(), status: "unhealthy".to_string(), }, @@ -761,9 +809,9 @@ mod tests { /// forge a log line or repaint a terminal. #[test] fn control_characters_from_an_agent_never_reach_a_log_line() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, - unhealthy: vec![dstack_guest_agent_rpc::ContainerHealth { + unhealthy: vec![dstack_guest_agent_rpc::v1::ContainerHealth { name: "web\n2026-01-01 INFO forged".to_string(), status: "\x1b[31mstarting\r".to_string(), }], @@ -783,7 +831,7 @@ mod tests { /// the rendering of everything after it. #[test] fn line_separators_and_bidi_overrides_are_stripped_too() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: vec![], error: "web\u{2028}Jan 01 INFO ok\u{202E}desrever".to_string(), @@ -797,7 +845,7 @@ mod tests { /// emitted every time the verdict flips. #[test] fn an_oversized_reason_is_truncated() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: vec![], error: "x".repeat(64 * 1024), @@ -848,10 +896,10 @@ mod tests { /// A thousand containers must not become a thousand-entry log line. #[test] fn only_the_first_few_containers_are_named() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: (0..50) - .map(|index| dstack_guest_agent_rpc::ContainerHealth { + .map(|index| dstack_guest_agent_rpc::v1::ContainerHealth { name: format!("svc-{index}"), status: "starting".to_string(), }) @@ -868,7 +916,7 @@ mod tests { /// reason an operator can act on. #[test] fn an_unhealthy_response_naming_nothing_still_has_a_reason() { - let response = dstack_guest_agent_rpc::HealthResponse { + let response = dstack_guest_agent_rpc::v1::HealthResponse { healthy: false, unhealthy: vec![], error: String::new(), diff --git a/dstack/gateway/src/proxy/port_policy.rs b/dstack/gateway/src/proxy/port_policy.rs index 2a5118b2c..bc8ce05d1 100644 --- a/dstack/gateway/src/proxy/port_policy.rs +++ b/dstack/gateway/src/proxy/port_policy.rs @@ -18,7 +18,7 @@ use std::net::Ipv4Addr; use std::sync::{Arc, Mutex}; use anyhow::{bail, Context, Result}; -use dstack_guest_agent_rpc::dstack_guest_client::DstackGuestClient; +use dstack_guest_agent_rpc::v0::dstack_guest_client::DstackGuestClient; use dstack_types::AppCompose; use http_client::ConnectionReuse; use or_panic::ResultOrPanic; diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index fd29c43c8..8807d167f 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -13,7 +13,7 @@ use dstack_guest_agent::{ config::{self, Config}, run_server, AppState, }; -use dstack_guest_agent_rpc::GetQuoteResponse; +use dstack_guest_agent_rpc::v0::GetQuoteResponse; use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::VersionedAttestation; use serde::Deserialize; diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index b6c731e2d..35652b387 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -6,7 +6,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use dcap_qvl::quote::Quote; -use dstack_guest_agent_rpc::GetQuoteResponse; +use dstack_guest_agent_rpc::v0::GetQuoteResponse; use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::{ AttestationV1, PlatformEvidence, QuoteContentType, TdxAttestationExt, VersionedAttestation, diff --git a/dstack/guest-agent/rpc/Cargo.toml b/dstack/guest-agent/rpc/Cargo.toml index a7e573c64..7f3d1f877 100644 --- a/dstack/guest-agent/rpc/Cargo.toml +++ b/dstack/guest-agent/rpc/Cargo.toml @@ -17,5 +17,10 @@ serde_json.workspace = true anyhow.workspace = true scale.workspace = true +[dev-dependencies] +prost-types.workspace = true +sha2.workspace = true +hex.workspace = true + [build-dependencies] prpc-build.workspace = true diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 7a99d32e2..c4bd0a3ea 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -34,7 +34,27 @@ service Tappd { rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} } -// The service for the dstack guest agent +// The service for the dstack guest agent. +// +// This surface is CLOSED. It is exactly the v0.5.11 surface and stays that way: +// no additions, no renumbering, no removals, and no semantic changes to +// existing methods. It exists so a v0.5.x client keeps working against a 0.6 +// agent unchanged. +// +// It is the `v0` surface, served at `/v0`. Its historical unversioned path `/` +// stays mounted as an alias onto this same handler for pre-0.6 clients. +// +// Every new capability goes to `dstack.guest.v1` (agent_rpc_v1.proto), served +// at `/v1` on the same socket. Do not add a method here, and do not add a +// field to a message here, even a wire-compatible one -- "frozen except for +// additions" is how this surface acquired `GpuInfo`, `AttestGpu` and +// `AttestArgs` between v0.5.11 and 0.6.0, none of which ever shipped and all +// of which have been moved to v1. +// +// Two behaviour-only changes are sanctioned and do not alter the wire shape: +// `GetQuote` now fails on a platform without Intel TDX instead of returning an +// empty quote, and `GetTlsKey` rejects a not_before that is not earlier than +// not_after. `EmitEvent` always fails; see its doc comment. service DstackGuest { // Derives a cryptographic key from the specified key path. // Returns the derived key along with its TLS certificate chain. @@ -53,35 +73,37 @@ service DstackGuest { // Generates a versioned attestation with the given report data. // Returns a dstack-defined attestation format that supports different attestation modes across platforms. - rpc Attest(AttestArgs) returns (AttestResponse) {} + rpc Attest(RawQuoteArgs) 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. + // Deleting it would answer HTTP 404 `Service not found: EmitEvent`, which + // says nothing about why the events stopped being recorded; the kept stub + // fails with HTTP 400 and a message naming the removal and what to do + // instead. The self-explanatory error is the whole point of keeping it. + rpc EmitEvent(EmitEventArgs) returns (google.protobuf.Empty) {} // Get app info rpc Info(google.protobuf.Empty) returns (AppInfo) {} - // Get GPU information collected during boot. - rpc GpuInfo(google.protobuf.Empty) returns (GpuInfoResponse) {} - - // Collect GPU attestation evidence now, against a nonce the caller chooses. - // - // This answers "is the device I can talk to right now a genuine, CC-enabled - // NVIDIA GPU that signs my challenge", which `GpuInfo` cannot: that returns a - // record written at boot. Use it after anything that may have reinitialised - // the GPU -- a driver reload leaves a device that responds to NVML but can no - // longer attest -- and before submitting work you care about. - // - // Returns vendor-native evidence, not a local verdict, so a relying party can - // appraise it with its own verifier. Evidence still does not bind the GPU to - // this TD; see `AttestGpuResponse.bundles`. - rpc AttestGpu(AttestGpuArgs) returns (AttestGpuResponse) {} - // 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. It is retained because it is part of this + // frozen surface, and the SDKs' v0 clients still expose it. + // + // v1 has no counterpart. A relying party verifies locally, against the rules + // in `docs/guest-api-v1.md`. + // + // 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) {} @@ -191,22 +213,15 @@ message TdxQuoteArgs { message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; -} -// The request to get a versioned attestation -message AttestArgs { - // 64 bytes of report data - bytes report_data = 1; - // Field 2 and 3 carried `include_ccel` and `include_preimages` while this RPC - // took a `RawQuoteArgs`. Both were bools, so reusing either number here would - // make a pre-0.6.0 client's `include_ccel = true` arrive as a request for - // something else entirely. - reserved 2, 3; - reserved "include_ccel", "include_preimages"; - // Also return the boot-time GPU attestation evidence in - // `boottime_gpu_evidence`. This does not sample the GPU now and does not - // answer `report_data`; see that field. - bool include_boottime_gpu_evidence = 4; + // Between v0.5.11 and 0.6.0, `Attest` briefly took its own `AttestArgs` + // carrying `include_ccel` (2), `include_preimages` (3) and + // `include_boottime_gpu_evidence` (4). None of that shipped in a release, + // and `Attest` takes this message again, but an interim `next` build in + // somebody's dev environment may still send those numbers. Reserved so a + // future field cannot silently absorb one. + reserved 2, 3, 4; + reserved "include_ccel", "include_preimages", "include_boottime_gpu_evidence"; } message TdxQuoteResponse { @@ -225,54 +240,12 @@ message TdxQuoteResponse { message AttestResponse { // The attestation bytes attestation = 1; - // Complete JSON output produced by nvattest at boot, the same bytes `GpuInfo` - // serves. Only `DstackGuest.Attest` populates it, and only when the request set - // `include_boottime_gpu_evidence` and boot-time GPU attestation output exists. - // - // Not bound to `report_data`: nvattest ran at boot against its own nonce, so a - // fresh `report_data` says nothing about it. Bind it by replaying the runtime - // event log and comparing sha256 of these exact UTF-8 bytes against the - // `evidence_sha256` field of the measured `gpu-attestation` event. - // - // This is a historical statement about the boot, not a live one: it does not - // prove the GPU is still attached. Sampling the GPU at attestation time would - // not fix that -- an NVIDIA report binds the device and a nonce but not the TD - // the device is attached to, so a fresh report can be relayed from a genuine - // remote GPU. Only TDISP/TEE-IO device binding closes that. - string boottime_gpu_evidence = 2; -} - -message AttestGpuArgs { - // Exactly 32 bytes of caller-chosen challenge, passed to the GPU verbatim. - // - // SPDM fixes the evidence nonce at 32 bytes, and dstack applies no transform - // so a caller can compare these bytes directly against the `eat_nonce` claim - // rather than reversing a hash. To bind a longer challenge, hash it yourself. - bytes nonce = 1; -} -message AttestGpuResponse { - // Vendor-native evidence bundles. The caller must select a verifier using - // `vendor` and `format`, then verify the signature, certificate chain, - // measurements, and the nonce embedded in the evidence. - repeated GpuEvidenceBundle bundles = 1; -} - -message GpuEvidenceBundle { - // Stable GPU vendor identifier, for example `nvidia`, `amd`, or `intel`. - string vendor = 1; - - // Vendor-specific evidence format and version. - string format = 2; - - // Opaque vendor-native evidence bytes. Do not assume UTF-8 or JSON. - bytes evidence = 3; -} - -message GpuInfoResponse { - // Complete JSON output produced by nvattest. Empty when no boot-time GPU - // attestation output is available. - string attestation = 1; + // Field 2 briefly carried `boottime_gpu_evidence` on `next` and never + // shipped. `dstack.guest.v1`'s own AttestResponse serves those bytes, under + // the same name, on `/v1/Attest`. + reserved 2; + reserved "boottime_gpu_evidence"; } message GetQuoteResponse { @@ -290,6 +263,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 @@ -328,61 +310,27 @@ message WorkerVersion { string rev = 2; } -// One container that is not reporting healthy. -message ContainerHealth { - // Container name, without the leading slash Docker prepends. - string name = 1; - // Docker health state: "starting" or "unhealthy". A healthy container is - // never listed. A string rather than an enum so it stays readable as JSON. - string status = 2; -} - -// Aggregate application health, as the agent last determined it. -message HealthResponse { - // False when the app's own health file says so or has gone stale, or -- when - // no health file was declared -- when any container that declares a Compose - // `healthcheck` is not running and healthy. - bool healthy = 1; - // The containers that made `healthy` false, so an operator reading gateway - // logs can tell which one is holding the instance out of rotation. Purely - // diagnostic -- the gateway routes on `healthy` alone. - repeated ContainerHealth unhealthy = 2; - // Set when the agent could not see the app at all for several refreshes in - // a row (container runtime unreachable, permission denied). `healthy` is - // false in that case: an agent that cannot see the app is in no position to - // vouch for it. - // - // Reported in the response rather than as an RPC error on purpose. prpc - // answers both "no such method" and "the handler failed" with HTTP 400 and - // drops the message, so an error here would be indistinguishable from an - // agent too old to know this method at all. - string error = 3; -} - +// The external guest agent service. +// +// CLOSED, like `DstackGuest`: exactly the v0.5.11 surface, served at +// `/prpc/v0` on the external listener, with `/prpc` kept as an alias onto the +// same handler for pre-0.6 clients. New capability goes to `dstack.guest.v1`'s +// `dstack.guest.v1`'s `Worker`, served at `/prpc/v1`. service Worker { // Get app info rpc Info(google.protobuf.Empty) returns (AppInfo) {} // Get the guest agent version rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} - // Attest a key the app derived. + // Get attestation. // - // 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. + // Legacy and frozen. Returns a `GetQuoteResponse`, so it answers on Intel TDX + // and fails everywhere else. // - // Answers from a cache the agent refreshes on its own timer, so the cost of - // one call is a lock and a clone however many gateway nodes are polling. - // - // Only instances that asked for gating (`RegisterCvmRequest.health_check`) - // are ever polled. That flag, not this method's absence, is what protects an - // older image: the gateway counts *any* failed poll as unhealthy, so probing - // to discover support would blackhole every guest agent that predates this. - rpc Health(google.protobuf.Empty) returns (HealthResponse) {} + // The v1 surface has no counterpart, deliberately: this attests the key v0's + // KDF derives at path `vms`, which no v1 `GetKey(domain, algorithm)` can + // return. A v1 application attests its own key instead -- derive it at + // `/v1/GetKey`, commit the public key into `report_data`, call `/v1/Attest`. + rpc GetAttestationForAppKey(GetAttestationForAppKeyRequest) returns (GetQuoteResponse) {} } message SignRequest { @@ -402,6 +350,17 @@ message SignResponse { bytes public_key = 3; } -message AttestAppKeyRequest { +message VerifyRequest { + string algorithm = 1; + bytes data = 2; + bytes signature = 3; + bytes public_key = 4; +} + +message VerifyResponse { + bool valid = 1; +} + +message GetAttestationForAppKeyRequest { string algorithm = 1; } diff --git a/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto b/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto new file mode 100644 index 000000000..4ce01866a --- /dev/null +++ b/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package dstack.guest.v1; + +// The versioned dstack guest agent API. +// +// Two services, one per trust surface, mirroring the two unversioned ones: +// +// `DstackGuest` internal socket (`/var/run/dstack.sock`), at `/v1` +// `Worker` external listener, at `/prpc/v1` +// +// The names carry no version suffix: the package `dstack.guest.v1` already +// says which surface this is, and `dstack.guest.v1.DstackGuestV1` would say it +// twice. +// +// The frozen v0.5.11 services are named `v0` and served at `/v0` and +// `/prpc/v0`; their historical unversioned paths (`/` and `/prpc`) stay mounted +// as aliases onto the same handlers, so a pre-0.6 client keeps working. The +// version a caller gets is decided by the URL path alone, never by a header. +// +// The two are not the same surface with different mounts. The internal socket +// is reachable only by the application itself, so `DstackGuest` hands out key +// material; the external listener is reachable by anyone who can route to the +// CVM, so `Worker` never does. +// +// Conventions, applied without exception: +// - every request message is `Request`, every response +// `Response`, including for methods that take no arguments -- an +// empty message can gain a field, `google.protobuf.Empty` cannot; +// - a field's encoding is stated in its doc comment, not in its name. The +// fields that carry JSON documents say so and name who owns the schema; +// - a key is named by `(domain, algorithm)` and nothing else. There is no +// `purpose` field anywhere in v1. Encode roles in the domain string. +// +// v1 keys are NOT the v0 keys. The v1 KDF derives under its own HKDF salt and +// binds the algorithm and a versioned context tag alongside the domain, so the +// same name yields different key material here than on the frozen surface, and +// secp256k1 and ed25519 no longer share one 32-byte secret. See `docs/guest-api-v1.md` for the +// byte-level construction. +// +// v1 serves only what genuinely needs the TEE: deriving keys from the app root +// key, and attesting. It is not an HSM and does not pretend to be one. +// +// So there is no `Sign` and no `Verify`. Any caller that can reach this socket +// can ask `GetKey` for the private key itself, so a server-side `Sign` grants +// no capability the caller does not already have -- it is pure computation +// behind an IPC round trip, and one more entry point to audit. Verifying is +// the same argument without even the key: the agent's answer arrives +// unattested, so a relying party gains nothing over checking it itself. +// Applications sign locally with a standard library, using the key `GetKey` +// returns; `docs/guest-api-v1.md` specifies chain verification normatively. +// +// There is no `EmitEvent` either: runtime RTMR3 events are system-owned as of +// 0.6.0 and cannot be extended by an app. +// +// The unversioned surface keeps `Sign` and `Verify` for v0.5.x clients. They +// are frozen there and are not carried forward. +service DstackGuest { + // Issue a certificate for this application. + // + // The agent builds a CSR, signs it with the certificate's own key, and + // relays it to the KMS `SignCert` RPC (or to the local CA when the app runs + // without a KMS), then returns the chain the signer produced. Certificate + // issuance is the operation; v0 called this `GetTlsKey`, which named the + // by-product instead of the request. + // + // The private key is freshly generated for every call and is NOT derived + // from the app identity: none of the request fields feed it, and calling + // twice with the same arguments yields two unrelated keys. `GetKey` is the + // method that derives a stable, attestable key. + // + // This first cut serves only the integrated one-step mode, where the agent + // holds the key. A mode that signs a caller-supplied CSR or public key -- + // so the private key never leaves the caller -- is a plausible extension and + // would arrive as added fields or a sibling method, not as a change to what + // these fields mean. + rpc IssueCert(IssueCertRequest) returns (IssueCertResponse) {} + + // Derive an application key from `(domain, algorithm)` and return the private + // key with its signature chain. + rpc GetKey(GetKeyRequest) returns (GetKeyResponse) {} + + // Produce a versioned attestation over the given report data. + // + // The sole CVM attestation entry point in v1. The dstack-defined attestation + // format covers every supported platform and already carries the TDX quote + // and the event log, so v0's TDX-only `GetQuote` has nothing left to add: + // it answers on Intel TDX and nowhere else, and on GCP Confidential VMs it + // returns the TDX quote without the vTPM quote GCP's verification also + // binds. `docs/guest-api-v1.md` says how to extract a raw quote and event + // log from the attestation. `GetQuote` stays on the unversioned surface for + // v0.5.x clients. + rpc Attest(AttestRequest) returns (AttestResponse) {} + + // Collect GPU attestation evidence now, against a nonce the caller chooses. + // + // This answers "is the device I can talk to right now a genuine, CC-enabled + // NVIDIA GPU that signs my challenge", which `GpuInfo` cannot: that returns + // a record written at boot. Use it after anything that may have + // reinitialised the GPU -- a driver reload leaves a device that responds to + // NVML but can no longer attest -- and before submitting work you care + // about. + // + // Returns vendor-native evidence, not a local verdict, so a relying party + // can appraise it with its own verifier. Evidence still does not bind the + // GPU to this TD; see `AttestGpuResponse.bundles`. + rpc AttestGpu(AttestGpuRequest) returns (AttestGpuResponse) {} + + // Return this application's identity and measurements. + rpc Info(InfoRequest) returns (InfoResponse) {} + + // Return the guest agent version. + rpc Version(VersionRequest) returns (VersionResponse) {} +} + +// --------------------------------------------------------------------------- +// Certificate issuance +// --------------------------------------------------------------------------- + +message IssueCertRequest { + // Subject of the certificate to request. + string subject = 1; + // DNS alternative names for the certificate. + repeated string alt_names = 2; + // Include the attestation quote in the certificate (RA-TLS). + bool usage_ra_tls = 3; + // Key usage server auth. + bool usage_server_auth = 4; + // Key usage client auth. + bool usage_client_auth = 5; + // Certificate validity start, seconds since the UNIX epoch. Rejected when + // it is not earlier than `not_after`. + optional uint64 not_before = 6; + // Certificate validity end, seconds since the UNIX epoch. + optional uint64 not_after = 7; + // Include app info in the certificate. + bool with_app_info = 8; +} + +message IssueCertResponse { + // The private key the agent generated for this certificate, PEM-encoded. + // Fresh per call; see `IssueCert`. + string key = 1; + // The certificate chain, leaf first, each entry PEM-encoded, exactly as the + // signer returned it. + repeated string certificate_chain = 2; +} + +// --------------------------------------------------------------------------- +// Application keys +// --------------------------------------------------------------------------- + +message GetKeyRequest { + // Caller-chosen domain-separation string identifying the key. Not a DNS + // name -- the certificate fields on `IssueCertRequest` are the ones that + // take those. + // + // Any byte string a proto3 `string` can carry, including one with `:`, `/` + // or NUL in it: the KDF length-prefixes it, so no domain can be confused + // with another. + // + // Derivation is flat. Two domains yield unrelated keys, and `a/b` is not a + // child of `a` in any sense -- there is no BIP-32-style hierarchy and no + // parent key from which a sub-domain's key can be computed. + // + // This replaces v0's `path` plus `purpose`. In v0 only `path` reached the + // KDF and `purpose` was merely echoed into the chain claim; here the one + // field feeds both, alongside `algorithm`. + string domain = 1; + // Key type. Exactly `secp256k1` or `ed25519`. + // + // There is no default and no alias: an empty or unrecognised value is an + // error. v0 defaulted an empty string to secp256k1 and accepted `k256`, + // which meant a typo silently produced a key of the wrong type under a name + // the caller thought meant something else. + // + // Names the key type only. v1 has no signing modes, because it has no + // signing method: v0's `secp256k1_prehashed` was a mode wearing an + // algorithm's name, and it does not appear here. + string algorithm = 2; +} + +message GetKeyResponse { + // The derived private key: 32 raw bytes for both supported algorithms. + bytes key = 1; + // The corresponding public key. SEC1 compressed, 33 bytes, for secp256k1; + // 32 raw bytes for ed25519. This is the exact byte string the signature + // chain's first link commits to, so a relying party never has to re-derive + // it from `key` to check the chain. + bytes public_key = 2; + // Two links, in order: + // [0] the app root key's secp256k1 signature over the v1 key claim, which + // binds algorithm, domain and `public_key` -- see `docs/guest-api-v1.md` + // for the encoding and the verification steps; + // [1] the KMS root key's secp256k1 signature over the app root public key. + // + // Link [1] is produced by the KMS and is byte-identical to the one the + // unversioned surface returns. Link [0] is not: its claim encoding is new, + // and it is deliberately not one the v0 claim format can produce. + repeated bytes signature_chain = 3; +} + +// --------------------------------------------------------------------------- +// Attestation +// --------------------------------------------------------------------------- + +message AttestRequest { + // Up to 64 bytes of report data, zero-padded on the right to 64. + bytes report_data = 1; + // Also return the boot-time GPU attestation evidence in + // `AttestResponse.boottime_gpu_evidence`. This does not sample the GPU + // now and does not answer `report_data`; see that field. + bool include_boottime_gpu_evidence = 2; +} + +message AttestResponse { + // The versioned dstack attestation. + bytes attestation = 1; + + // Boot-time GPU attestation evidence. Empty unless the request set + // `include_boottime_gpu_evidence` and boot-time output exists, so absence is + // just the empty list rather than a sentinel value. + // + // Same bundle shape `AttestGpu` returns, so a consumer writes one parser for + // both. `format` is what separates them: this carries + // `nvidia-nvattest-boottime-json-v1`, the record written at boot, while + // `AttestGpu` carries `nvidia-nvattest-collect-evidence-json-v1`, collected + // on demand against a caller's nonce. Do not appraise one with the other's + // verifier. + // + // `evidence` is the exact UTF-8 bytes of the nvattest output as read from + // disk, byte for byte. That exactness is load-bearing: the only thing tying + // this evidence to the boot is sha256 over precisely these bytes, compared + // against the `evidence_sha256` field of the measured `gpu-attestation` + // event after replaying the runtime event log. Re-encoding or reformatting + // the JSON breaks the comparison. + // + // Not bound to `report_data`: nvattest ran at boot against its own nonce, so + // a fresh `report_data` says nothing about it. + // + // This is a historical statement about the boot, not a live one: it does not + // prove the GPU is still attached. Sampling the GPU at attestation time + // would not fix that -- an NVIDIA report binds the device and a nonce but + // not the TD the device is attached to, so a fresh report can be relayed + // from a genuine remote GPU. Only TDISP/TEE-IO device binding closes that. + repeated GpuEvidenceBundle boottime_gpu_evidence = 2; +} + +message AttestGpuRequest { + // Exactly 32 bytes of caller-chosen challenge, passed to the GPU verbatim. + // + // SPDM fixes the evidence nonce at 32 bytes, and dstack applies no transform + // so a caller can compare these bytes directly against the `eat_nonce` claim + // rather than reversing a hash. To bind a longer challenge, hash it + // yourself. + bytes nonce = 1; +} + +message AttestGpuResponse { + // Vendor-native evidence bundles. The caller must select a verifier using + // `vendor` and `format`, then verify the signature, certificate chain, + // measurements, and the nonce embedded in the evidence. + repeated GpuEvidenceBundle bundles = 1; +} + +// One vendor's GPU evidence. +// +// Shared by `AttestGpu` and `AttestResponse.boottime_gpu_evidence` so a +// consumer writes one parser for both. Dispatch on `(vendor, format)`; the two +// sources carry different `format` values because they answer different +// questions, and a verifier for one does not appraise the other. +message GpuEvidenceBundle { + // Stable GPU vendor identifier, for example `nvidia`, `amd`, or `intel`. + string vendor = 1; + // Vendor-specific evidence format and version. Known values: + // `nvidia-nvattest-collect-evidence-json-v1` fresh, from `AttestGpu` + // `nvidia-nvattest-boottime-json-v1` the record written at boot + string format = 2; + // Opaque vendor-native evidence bytes. Do not assume UTF-8 or JSON. + bytes evidence = 3; +} + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +message InfoRequest {} + +// Identity and configuration. Not attestation. +// +// Everything here is what this application *is*: who it is, what it was +// configured with, and where it runs. Nothing here is evidence, and nothing +// here should be trusted on its own -- it arrives over a local socket with no +// quote behind it. +// +// That is why the measurement registers and the event log are absent. v0's +// `AppInfo.tcb_info` carried MRTD, RTMR0-3 and the event log as a JSON blob, +// which invited relying parties to read measurements out of an unattested +// response. Those values belong to `Attest`, whose `VersionedAttestation` +// carries them quote-backed. Ask `Attest` and verify, or do not use them. +// +// `mr_aggregated`, `os_image_hash` and `compose_hash` are the exception, and a +// deliberate one: they identify *which* application and image this is, which is +// the question `Info` answers. They are typed bytes here rather than hex +// strings inside a JSON blob, and each appears exactly once -- v0 returned all +// three both as top-level `AppInfo` fields and again, hex-encoded, inside +// `tcb_info`, two encodings of one fact and free to disagree. They are still +// unattested, and a relying party still confirms them against an attestation. +// +// `app_cert` is gone as well. It was a self-issued demo certificate the agent +// minted for a dashboard, proved nothing, and had no business on a key API. +message InfoResponse { + // Application -- what is deployed. + + // App ID. + bytes app_id = 1; + // App name, from app-compose. + string app_name = 2; + // Compose hash. Adjacent to the document it commits to, so a caller can see + // at a glance which field the hash covers. + bytes compose_hash = 3; + // The app-compose document, as a JSON document owned by the app-compose + // schema (`docs/normalized-app-compose.md`). + // + // These are the verbatim bytes that were deployed, and `compose_hash` is + // sha256 over exactly these bytes. Do not parse and re-serialize before + // hashing: key order, whitespace and unknown fields all change the digest, + // and that digest is what gets whitelisted on chain. + // + // Served directly rather than nested inside another JSON string, which is + // what v0 did via `tcb_info`. + string app_compose = 4; + + // Instance -- which running copy. + + // App instance ID. + bytes instance_id = 5; + + // Platform -- what it runs on. + + // Device ID. Identifies the host machine, not this instance. + bytes device_id = 6; + // OS image hash. + bytes os_image_hash = 7; + // Aggregated measurement register value. + bytes mr_aggregated = 8; + // The VM's hardware configuration, as a JSON document produced and owned by + // the VMM. The guest agent passes it through unparsed. + string vm_config = 9; + // The key provider that supplied this app's keys, as a JSON document owned + // by dstack-util (`{"name": ..., "id": ...}`), passed through unparsed. + string key_provider_info = 10; + // Cloud provider sys_vendor, for example "Google". + string cloud_vendor = 11; + // Cloud provider product_name, for example "Google Compute Engine". + string cloud_product = 12; +} + +message VersionRequest {} + +message VersionResponse { + // dstack version. + string version = 1; + // Git revision. + string rev = 2; +} + +// --------------------------------------------------------------------------- +// The external surface +// --------------------------------------------------------------------------- + +// The versioned external guest agent service. +// +// Served on the external listener at `/prpc/v1`, alongside the closed +// unversioned `Worker` at `/prpc`. +// +// Anyone who can route to the CVM can call this, so nothing here returns key +// material, and nothing here lets a caller choose what gets signed or attested. +// +// There is deliberately no `AttestAppKey`. v0's version attests the key its own +// KDF derives at path `vms` with purpose `signing`, and no v1 `GetKey(domain, +// algorithm)` can return that key -- so a pure-v1 application could be handed +// an attestation of a public key whose private half it has no way to obtain. +// A v1 application attests its own key instead: derive it at `/v1/GetKey`, +// commit the public key into `report_data`, and call `/v1/Attest`. The +// application then hands that attestation to its relying parties. Legacy flows +// keep using the frozen `Worker.GetAttestationForAppKey`. +service Worker { + // Return this application's identity and configuration. + // + // The same `InfoResponse` the internal surface returns, minus what the app + // asked to keep private: unless the app-compose sets `public_tcbinfo`, the + // three document fields (`app_compose`, `vm_config`, `key_provider_info`) + // come back empty. Identity and the measurement hashes are always present, + // which is what the unversioned `Worker.Info` also did. + rpc Info(InfoRequest) returns (InfoResponse) {} + + // Return the guest agent version. + rpc Version(VersionRequest) returns (VersionResponse) {} + + // Report whether the app is serving. Polled by the gateway to decide whether + // this instance should be in its app's load-balancing rotation. + // + // Answers from a cache the agent refreshes on its own timer, so the cost of + // one call is a lock and a clone however many gateway nodes are polling. + // + // Only instances that asked for gating (`RegisterCvmRequest.health_check`) + // are ever polled. That flag, not this method's absence, is what protects an + // older image: the gateway counts *any* failed poll as unhealthy, so probing + // to discover support would blackhole every guest agent that predates this. + rpc Health(HealthRequest) returns (HealthResponse) {} +} + +message HealthRequest {} + +// One container that is not reporting healthy. +message ContainerHealth { + // Container name, without the leading slash Docker prepends. + string name = 1; + // Docker health state: "starting" or "unhealthy". A healthy container is + // never listed. A string rather than an enum so it stays readable as JSON. + string status = 2; +} + +// Aggregate application health, as the agent last determined it. +message HealthResponse { + // False when the app's own health file says so or has gone stale, or -- when + // no health file was declared -- when any container that declares a Compose + // `healthcheck` is not running and healthy. + bool healthy = 1; + // The containers that made `healthy` false, so an operator reading gateway + // logs can tell which one is holding the instance out of rotation. Purely + // diagnostic -- the gateway routes on `healthy` alone. + repeated ContainerHealth unhealthy = 2; + // Set when the agent could not see the app at all for several refreshes in + // a row (container runtime unreachable, permission denied). `healthy` is + // false in that case: an agent that cannot see the app is in no position to + // vouch for it. + // + // Reported in the response rather than as an RPC error on purpose. The + // gateway's health poller counts any failed poll as a failed poll; it does + // not read the status code or the body. A verdict of "the agent cannot see + // the app" raised as an RPC error would therefore be filed alongside an + // agent that is unreachable or has no such method, and the reason would + // never reach the operator. Carried here alongside `healthy` false, it does. + string error = 3; +} diff --git a/dstack/guest-agent/rpc/src/generated.rs b/dstack/guest-agent/rpc/src/generated.rs index 28748082f..352061b2e 100644 --- a/dstack/guest-agent/rpc/src/generated.rs +++ b/dstack/guest-agent/rpc/src/generated.rs @@ -3,4 +3,23 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/file_descriptor_set.bin")); -include!(concat!(env!("OUT_DIR"), "/dstack_guest.rs")); +/// The frozen v0 surface, closed at exactly what dstack v0.5.11 shipped. +/// +/// Served at `/v0` and, for pre-0.6 clients, at the unversioned paths it has +/// always had. Nothing new goes here. +pub mod v0 { + include!(concat!(env!("OUT_DIR"), "/dstack_guest.rs")); +} + +/// The `dstack.guest.v1` package: the current surface. +/// +/// Each version gets its own module because both packages define types with the +/// same names on purpose -- `GpuEvidenceBundle`, and the services themselves -- +/// and the two surfaces must stay independently evolvable. A caller names the +/// surface it wants: `dstack_guest_agent_rpc::v0::AppInfo` is the frozen one, +/// `dstack_guest_agent_rpc::v1::InfoResponse` is the current one. Nothing is +/// re-exported at the crate root, so no import can be ambiguous about which +/// contract it speaks. +pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/dstack.guest.v1.rs")); +} diff --git a/dstack/guest-agent/rpc/tests/frozen_surface.rs b/dstack/guest-agent/rpc/tests/frozen_surface.rs new file mode 100644 index 000000000..6f9c3b95c --- /dev/null +++ b/dstack/guest-agent/rpc/tests/frozen_surface.rs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! A structural freeze on the v0.5.11 wire surface. +//! +//! `DstackGuest`, `Worker` and `Tappd` are closed: they are exactly what +//! v0.5.11 shipped, and every new capability belongs to `dstack.guest.v1`. +//! Reviewing that by eye is how three never-released methods and a handful of +//! fields accumulated on them between v0.5.11 and 0.6.0 in the first place. +//! +//! So this pins a digest of each frozen service's descriptor: its methods, and +//! the full field list of every message reachable from them. A *wire-compatible* +//! addition -- a new optional field, a new method -- changes the digest and +//! fails here, which is the point. Nothing else in the tree would notice. +//! +//! If this test fails, the fix is almost always to move the addition to +//! `agent_rpc_v1.proto`, not to update the digest. + +use prost::Message; +use prost_types::{ + DescriptorProto, FileDescriptorProto, FileDescriptorSet, ServiceDescriptorProto, +}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +fn descriptor_set() -> FileDescriptorSet { + FileDescriptorSet::decode(dstack_guest_agent_rpc::FILE_DESCRIPTOR_SET) + .expect("the embedded descriptor set must decode") +} + +fn frozen_file(set: &FileDescriptorSet) -> &FileDescriptorProto { + set.file + .iter() + .find(|file| file.package() == "dstack_guest") + .expect("the frozen package must be present") +} + +fn message<'a>(file: &'a FileDescriptorProto, name: &str) -> &'a DescriptorProto { + file.message_type + .iter() + .find(|message| message.name() == name) + .unwrap_or_else(|| panic!("frozen message {name} vanished")) +} + +/// Render a message as one line per field: number, name, type, label. +/// +/// Type and label are included so that changing a field's type or making it +/// repeated is caught, not just adding or removing one. Reserved ranges are +/// included too: dropping a `reserved` frees a number for silent reuse. +fn describe_message(file: &FileDescriptorProto, name: &str) -> String { + let message = message(file, name); + let mut out = format!("message {name}\n"); + let mut fields: Vec = message + .field + .iter() + .map(|field| { + format!( + " {} {} type={:?} label={:?}\n", + field.number(), + field.name(), + field.r#type(), + field.label() + ) + }) + .collect(); + fields.sort(); + out.extend(fields); + for range in &message.reserved_range { + out.push_str(&format!(" reserved {}..{}\n", range.start(), range.end())); + } + for name in &message.reserved_name { + out.push_str(&format!(" reserved-name {name}\n")); + } + out +} + +/// Render a service as its methods plus every message it can reach. +fn describe_service(file: &FileDescriptorProto, service: &ServiceDescriptorProto) -> String { + let mut out = format!("service {}\n", service.name()); + let mut reachable = BTreeSet::new(); + for method in &service.method { + out.push_str(&format!( + " rpc {}({}) returns ({})\n", + method.name(), + method.input_type(), + method.output_type() + )); + for type_name in [method.input_type(), method.output_type()] { + // `.dstack_guest.Foo` -> `Foo`; google.protobuf.Empty is not ours. + if let Some(local) = type_name.strip_prefix(".dstack_guest.") { + reachable.insert(local.to_string()); + } + } + } + // One level of nesting is enough for this surface: the only message-typed + // field on it is `HealthResponse.unhealthy`, which v1 owns now. + for name in reachable.clone() { + for field in &message(file, &name).field { + if let Some(local) = field.type_name().strip_prefix(".dstack_guest.") { + reachable.insert(local.to_string()); + } + } + } + for name in &reachable { + out.push_str(&describe_message(file, name)); + } + out +} + +fn digest(shape: &str) -> String { + hex::encode(Sha256::digest(shape.as_bytes())) +} + +/// The three frozen services, pinned. +/// +/// Regenerate a digest only when you have confirmed against +/// `git show v0.5.11:guest-agent/rpc/proto/agent_rpc.proto` that the change is +/// comment-only or a `reserved` addition. +#[test] +fn the_frozen_services_match_their_pinned_shape() { + let set = descriptor_set(); + let file = frozen_file(&set); + let expected = [ + ( + "DstackGuest", + "aa6b5814627a26284b12c180acb0eb13f91c3e57ce00952734fb358a729762f3", + ), + ( + "Worker", + "e5e88ddbba3e9acd2ac68c6f5e3c99e394c1842be3d01395dd214972ada5bdc1", + ), + ( + "Tappd", + "65c2e3b49f0ffbdcda4712f4b87b2ca4be386205e57e7956ddc56bec7616cffd", + ), + ]; + for (name, want) in expected { + let service = file + .service + .iter() + .find(|service| service.name() == name) + .unwrap_or_else(|| panic!("frozen service {name} vanished")); + let shape = describe_service(file, service); + assert_eq!( + digest(&shape), + want, + "the frozen {name} surface changed:\n{shape}" + ); + } +} + +/// The method lists, spelled out, so a failure above is readable without +/// reaching for the descriptor dump. +#[test] +fn the_frozen_services_expose_the_v0_5_11_methods() { + let set = descriptor_set(); + let file = frozen_file(&set); + let methods = |name: &str| -> Vec { + file.service + .iter() + .find(|service| service.name() == name) + .unwrap_or_else(|| panic!("frozen service {name} vanished")) + .method + .iter() + .map(|method| method.name().to_string()) + .collect() + }; + assert_eq!( + methods("DstackGuest"), + [ + "GetTlsKey", + "GetKey", + "GetQuote", + "Attest", + "EmitEvent", + "Info", + "Sign", + "Verify", + "Version" + ] + ); + assert_eq!( + methods("Worker"), + ["Info", "Version", "GetAttestationForAppKey"] + ); + assert_eq!( + methods("Tappd"), + [ + "DeriveKey", + "DeriveK256Key", + "TdxQuote", + "RawQuote", + "Info", + "Version" + ] + ); +} diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index 1c6671454..fcdf471bb 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; -use dstack_guest_agent_rpc::GetQuoteResponse; +use dstack_guest_agent_rpc::v0::GetQuoteResponse; use ra_tls::attestation::Attestation; use ra_tls::attestation::{QuoteContentType, VersionedAttestation}; @@ -11,9 +11,8 @@ pub trait PlatformBackend: Send + Sync { fn attestation_for_info(&self) -> Result; fn certificate_attestation(&self, pubkey: &[u8]) -> Result; fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result; - /// Attest the CVM itself: the attestation `Attest` and `AttestAppKey` - /// return, with digest preimages filled in. Encoding it is the RPC - /// layer's job. + /// Attest the CVM itself: the attestation `Attest` returns, with digest + /// preimages filled in. Encoding it is the RPC layer's job. fn attest_cvm(&self, report_data: [u8; 64]) -> Result; } diff --git a/dstack/guest-agent/src/container_health.rs b/dstack/guest-agent/src/container_health.rs index 2aa65caeb..9c4c69ed3 100644 --- a/dstack/guest-agent/src/container_health.rs +++ b/dstack/guest-agent/src/container_health.rs @@ -37,7 +37,7 @@ use serde::Deserialize; use tokio::process::Command; use tracing::debug; -use dstack_guest_agent_rpc::ContainerHealth; +use dstack_guest_agent_rpc::v1::ContainerHealth; use crate::health::Verdict; diff --git a/dstack/guest-agent/src/guest_api_service.rs b/dstack/guest-agent/src/guest_api_service.rs index f59acf5f0..22f64ada5 100644 --- a/dstack/guest-agent/src/guest_api_service.rs +++ b/dstack/guest-agent/src/guest_api_service.rs @@ -6,7 +6,7 @@ use std::{collections::HashSet, fmt::Debug, path::PathBuf, process::ExitStatus, use anyhow::{Context, Result}; use bollard::{container::ListContainersOptions, Docker}; -use dstack_guest_agent_rpc::worker_server::WorkerRpc as _; +use dstack_guest_agent_rpc::v0::worker_server::WorkerRpc as _; use dstack_types::shared_filenames::{HOST_SHARED_DIR, SYS_CONFIG}; use dstack_types::SysConfig; use fs_err as fs; diff --git a/dstack/guest-agent/src/health.rs b/dstack/guest-agent/src/health.rs index 35f381634..5a2c94b9d 100644 --- a/dstack/guest-agent/src/health.rs +++ b/dstack/guest-agent/src/health.rs @@ -33,7 +33,7 @@ use dstack_types::HEALTH_FILE_MAX_AGE_SECS; use or_panic::ResultOrPanic; use tracing::{debug, info, warn}; -use dstack_guest_agent_rpc::ContainerHealth; +use dstack_guest_agent_rpc::v1::ContainerHealth; use crate::container_health; diff --git a/dstack/guest-agent/src/http_routes.rs b/dstack/guest-agent/src/http_routes.rs index c8fa44df2..d95f81fb7 100644 --- a/dstack/guest-agent/src/http_routes.rs +++ b/dstack/guest-agent/src/http_routes.rs @@ -9,7 +9,7 @@ use crate::guest_api_service::{list_containers, GuestApiHandler}; use crate::rpc_service::{AppState, ExternalRpcHandler}; use anyhow::Result; use docker_logs::parse_duration; -use dstack_guest_agent_rpc::{worker_server::WorkerRpc, AppInfo}; +use dstack_guest_agent_rpc::v0::{worker_server::WorkerRpc, AppInfo}; use guest_api::guest_api_server::GuestApiRpc; use ra_rpc::{CallContext, RpcCall}; use rinja::Template; diff --git a/dstack/guest-agent/src/lib.rs b/dstack/guest-agent/src/lib.rs index 5ecb7359e..0aee897be 100644 --- a/dstack/guest-agent/src/lib.rs +++ b/dstack/guest-agent/src/lib.rs @@ -14,6 +14,7 @@ mod health; mod http_routes; mod models; pub mod rpc_service; +pub mod rpc_service_v1; mod server; mod socket_activation; diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index b49f67bf8..de897695c 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -2,32 +2,31 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::{ - path::Path, - sync::{Arc, RwLock}, -}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use cert_client::CertRequestClient; use dstack_attest::default_verifier; -use dstack_guest_agent_rpc::{ +use dstack_guest_agent_rpc::v0::{ dstack_guest_server::{DstackGuestRpc, DstackGuestServer}, 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, + AppInfo, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, EmitEventArgs, + GetAttestationForAppKeyRequest, GetKeyArgs, GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, + GetTlsKeyResponse, 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 dstack_types::{AppKeys, SysConfig}; +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; use ra_rpc::{CallContext, RpcCall}; use ra_tls::{ + api_v1::sign_recoverable_keccak256, attestation::{ QuoteContentType, TdxAttestationExt, VersionedAttestation, DEFAULT_HASH_ALGORITHM, }, @@ -37,44 +36,53 @@ use ra_tls::{ use rcgen::KeyPair; use ring::rand::{SecureRandom, SystemRandom}; use serde_json::json; -use sha3::{Digest, Keccak256}; -use tracing::{error, warn}; + +use tracing::error; use crate::{ backend::{PlatformBackend, RealPlatform}, config::Config, }; +/// How long a failed identity decode is left alone before another call is +/// allowed to touch the platform again. +/// +/// The cache makes the happy path free, but nothing caches a failure, and the +/// retry path is reachable from `/prpc/v1/Info` -- anonymous, publicly +/// reachable, and one hardware quote plus an event-log replay per attempt, +/// under the global quote lock. Retrying per call would hand any caller that +/// can route to the CVM exactly the lever the cache exists to remove, for as +/// long as the platform stays broken. A floor of half a minute bounds that to +/// one attempt per interval while still letting a platform that was only +/// momentarily unable to attest recover on its own. +const IDENTITY_RETRY_INTERVAL: Duration = Duration::from_secs(30); + +/// Decode the immutable identity fields out of a boot attestation. +/// +/// Costs a quote and an event-log replay, which is why its result is cached for +/// the life of the process. +fn decode_identity(inner: &AppStateInner) -> Result { + let attestation = inner.info_attestation()?.into_v1(); + let app_info = attestation + .decode_app_info(false) + .context("failed to decode app info")?; + Ok(AppIdentity { + app_id: app_info.app_id, + instance_id: app_info.instance_id, + device_id: app_info.device_id, + mr_aggregated: app_info.mr_aggregated.to_vec(), + os_image_hash: app_info.os_image_hash, + compose_hash: app_info.compose_hash, + key_provider_info: String::from_utf8(app_info.key_provider_info).unwrap_or_default(), + }) +} + fn read_dmi_file(name: &str) -> String { fs::read_to_string(format!("/sys/class/dmi/id/{name}")) .map(|s| s.trim().to_string()) .unwrap_or_default() } -/// Read the GPU attestation output saved during boot. Returns an empty string -/// when no output is available (e.g. no GPU attached or attestation disabled). -fn read_gpu_attestation(path: &Path) -> String { - match fs::read_to_string(path) { - Ok(attestation) => attestation, - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - warn!("failed to read GPU attestation output: {err:?}"); - } - String::new() - } - } -} - -/// GPU evidence to return alongside an attestation. Opt-in, so a caller that -/// does not care about GPUs neither pays the disk read nor carries the payload. -fn boottime_gpu_evidence(include: bool, path: &Path) -> String { - if include { - read_gpu_attestation(path) - } else { - String::new() - } -} - #[derive(Clone)] pub struct AppState { inner: Arc, @@ -91,6 +99,43 @@ struct AppStateInner { health: Option>, /// Serialises on-demand GPU attestation. gpu_attestor: crate::gpu_attest::GpuAttestor, + /// The app root key, parsed once. + /// + /// `None` when the key provider handed us something that is not a valid + /// secp256k1 scalar. Kept non-fatal so this cannot turn a boot problem into + /// a boot failure -- the RPCs that need it report it per call, as they did + /// when each parsed the key itself. + app_root_signing_key: Option, + /// Identity as decoded from the boot attestation. See [`AppIdentity`]. + identity: RwLock>>, + /// When the last identity decode failed, so the retry can be throttled. + /// See [`IDENTITY_RETRY_INTERVAL`]. Separate from the cache above because + /// it is written only on the degraded path, and read only when the cache + /// is empty. + identity_last_failure: Mutex>, + /// `sys_vendor` and `product_name`, read once. Neither changes while the + /// VM is running. + cloud_vendor: String, + cloud_product: String, +} + +/// The identity fields v1 `Info` reports, decoded once. +/// +/// Every one of these is fixed for the life of the VM: they come out of the +/// launch measurements. Recomputing them per call meant generating a fresh +/// hardware quote and replaying the RTMR event log under the global quote lock +/// on every `Info` -- including anonymous calls to the public `/prpc/v1/Info`, +/// which let any caller that can route to the CVM monopolise that lock and +/// starve the attestation path the agent actually needs. +#[derive(Debug)] +pub(crate) struct AppIdentity { + pub(crate) app_id: Vec, + pub(crate) instance_id: Vec, + pub(crate) device_id: Vec, + pub(crate) mr_aggregated: Vec, + pub(crate) os_image_hash: Vec, + pub(crate) compose_hash: Vec, + pub(crate) key_provider_info: String, } impl AppStateInner { @@ -196,6 +241,15 @@ impl AppState { config.app_compose.runner.clone(), ) }); + // Parsed once, and non-fatally: an unusable app root key is reported by + // the RPCs that need one, not by refusing to start. + let app_root_signing_key = match SigningKey::from_slice(&keys.k256_key) { + Ok(key) => Some(key), + Err(err) => { + error!("the app root k256 key did not parse: {err:?}"); + None + } + }; let me = Self { inner: Arc::new(AppStateInner { config, @@ -206,8 +260,21 @@ impl AppState { platform, health, gpu_attestor, + app_root_signing_key, + identity: RwLock::new(None), + identity_last_failure: Mutex::new(None), + cloud_vendor: read_dmi_file("sys_vendor"), + cloud_product: read_dmi_file("product_name"), }), }; + // Decode identity now so no request has to. Non-fatal: a platform that + // cannot attest at this instant would otherwise take the whole agent + // down, and `identity()` retries on demand. A failure here counts as + // the first attempt and arms the retry throttle, so the boot attempt + // and a request-driven one are on the same budget. + if let Err(err) = me.identity() { + error!("failed to decode app identity at startup: {err:?}"); + } me.maybe_request_demo_cert(); Ok(me) } @@ -220,25 +287,206 @@ impl AppState { &self.inner.config } - fn health(&self) -> Option<&crate::health::HealthMonitor> { + pub(crate) fn health(&self) -> Option<&crate::health::HealthMonitor> { self.inner.health.as_deref() } - fn quote_response(&self, report_data: [u8; 64]) -> Result { + pub(crate) fn quote_response(&self, report_data: [u8; 64]) -> Result { self.inner .platform .quote_response(report_data, &self.inner.vm_config) } - fn attest_cvm(&self, report_data: [u8; 64]) -> Result> { + pub(crate) fn attest_cvm(&self, report_data: [u8; 64]) -> Result> { self.inner.platform.attest_cvm(report_data)?.to_bytes() } + + /// The application's root secp256k1 key, the root of every derived key and + /// the signer of the first link of every signature chain. + pub(crate) fn app_root_k256_key(&self) -> &[u8] { + &self.inner.keys.k256_key + } + + /// The same key, parsed. Shared rather than re-parsed per request. + pub(crate) fn app_root_signing_key(&self) -> Result<&SigningKey> { + let Some(key) = self.inner.app_root_signing_key.as_ref() else { + anyhow::bail!("the app root k256 key is not a valid secp256k1 scalar"); + }; + Ok(key) + } + + /// The KMS root key's signature over the app root public key: the second + /// link of every signature chain, produced outside this agent and passed + /// through byte-for-byte on both API surfaces. + pub(crate) fn kms_k256_signature(&self) -> &[u8] { + &self.inner.keys.k256_signature + } + + pub(crate) fn cloud_vendor(&self) -> &str { + &self.inner.cloud_vendor + } + + pub(crate) fn cloud_product(&self) -> &str { + &self.inner.cloud_product + } + + /// The decoded identity, computed at most once on success. + /// + /// Populated at construction; the decode below only runs when that attempt + /// failed, so a platform that could not attest at boot still answers later + /// rather than staying broken for the life of the process. It runs at most + /// once per [`IDENTITY_RETRY_INTERVAL`], because the callers reaching it + /// include anonymous ones. Within the window the caller is told the + /// identity is unavailable and when the next attempt is, and the platform + /// is not touched at all. + pub(crate) fn identity(&self) -> Result> { + if let Some(identity) = self + .inner + .identity + .read() + .or_panic("lock should never fail") + .as_ref() + { + return Ok(identity.clone()); + } + // Two callers arriving together on a cold cache may both attempt once. + // That is the same race the cache has always had, and one extra quote + // is not worth holding a lock across the decode for. + if let Some(failed_at) = *self + .inner + .identity_last_failure + .lock() + .or_panic("lock should never fail") + { + let elapsed = failed_at.elapsed(); + if elapsed < IDENTITY_RETRY_INTERVAL { + let retry_in = (IDENTITY_RETRY_INTERVAL - elapsed).as_secs() + 1; + anyhow::bail!( + "the app identity is unavailable: decoding it failed and the next attempt is at most {retry_in}s away" + ); + } + } + // Blocking, and deliberately left on the executor: the throttle above + // caps this at one quote per interval for the whole process, which is + // far short of what would justify a `spawn_blocking` hop and making + // every caller of `identity()` async to reach it. + let identity = match decode_identity(&self.inner) { + Ok(identity) => Arc::new(identity), + Err(err) => { + *self + .inner + .identity_last_failure + .lock() + .or_panic("lock should never fail") = Some(Instant::now()); + return Err(err); + } + }; + *self + .inner + .identity + .write() + .or_panic("lock should never fail") = Some(identity.clone()); + Ok(identity) + } + + /// The VM's hardware configuration, as the VMM produced it. + pub(crate) fn vm_config(&self) -> &str { + &self.inner.vm_config + } + + pub(crate) fn gpu_attestor(&self) -> &crate::gpu_attest::GpuAttestor { + &self.inner.gpu_attestor + } + + pub(crate) async fn issue_cert( + &self, + key: &KeyPair, + config: CertConfigV2, + ) -> Result> { + self.inner.issue_cert(key, config).await + } +} + +/// Generate the fresh P-256 key that backs a certificate the agent issues. +/// +/// Random, not derived: the certificate is minted per call, so there is +/// nothing for a stable key to buy, and a key nobody can re-derive is a +/// smaller thing to hold. +/// +/// The `Failed to ...` contexts are the v0.5.11 strings verbatim. They are what +/// a frozen-surface caller sees, so they keep their original capitalisation +/// rather than following the lowercase house rule. +fn generate_cert_key() -> Result { + let mut seed = [0u8; 32]; + SystemRandom::new() + .fill(&mut seed) + .context("Failed to generate secure seed")?; + derive_p256_key_pair_from_bytes(&seed, &[]).context("Failed to derive key") +} + +/// The certificate request fields both surfaces take. +/// +/// The two wire messages are different types with identical fields, so this is +/// where they meet. Without it the shared body is copied per surface and the +/// copies drift. +pub(crate) struct CertRequestFields { + pub(crate) subject: String, + pub(crate) alt_names: Vec, + pub(crate) usage_ra_tls: bool, + pub(crate) usage_server_auth: bool, + pub(crate) usage_client_auth: bool, + pub(crate) with_app_info: bool, + pub(crate) not_before: Option, + pub(crate) not_after: Option, +} + +/// A freshly issued certificate and the key that backs it. +pub(crate) struct IssuedCert { + pub(crate) key: String, + pub(crate) certificate_chain: Vec, +} + +/// Validate, generate a key, build the CSR, and get it signed. +/// +/// The whole body of the frozen `GetTlsKey` and of v1's `IssueCert`: they +/// differ only in which message type carries the fields in and the result out. +pub(crate) async fn issue_cert_for_request( + state: &AppState, + request: CertRequestFields, +) -> Result { + validate_cert_validity(request.not_before, request.not_after)?; + let key = generate_cert_key()?; + let config = CertConfigV2 { + org_name: None, + subject: request.subject, + subject_alt_names: request.alt_names, + usage_server_auth: request.usage_server_auth, + usage_client_auth: request.usage_client_auth, + ext_quote: request.usage_ra_tls, + ext_app_info: request.with_app_info, + not_after: request.not_after, + not_before: request.not_before, + }; + let certificate_chain = state.issue_cert(&key, config).await?; + Ok(IssuedCert { + key: key.serialize_pem(), + certificate_chain, + }) } pub struct InternalRpcHandler { state: AppState, } +impl InternalRpcHandler { + /// Only the router constructs this in a running agent; the v1 tests build + /// one directly to assert the unversioned surface still answers as it did. + #[cfg(test)] + pub(crate) fn new(state: AppState) -> Self { + Self { state } + } +} + pub async fn get_info(state: &AppState, external: bool) -> Result { let hide_tcb_info = external && !state.config().app_compose.public_tcbinfo; let versioned_attestation = state.inner.info_attestation()?; @@ -304,7 +552,10 @@ pub async fn get_info(state: &AppState, external: bool) -> Result { }) } -fn validate_cert_validity(not_before: Option, not_after: Option) -> Result<()> { +pub(crate) fn validate_cert_validity( + not_before: Option, + not_after: Option, +) -> Result<()> { if let (Some(not_before), Some(not_after)) = (not_before, not_after) { if not_before >= not_after { anyhow::bail!("not_before must be earlier than not_after"); @@ -315,28 +566,23 @@ fn validate_cert_validity(not_before: Option, not_after: Option) -> Re impl DstackGuestRpc for InternalRpcHandler { async fn get_tls_key(self, request: GetTlsKeyArgs) -> anyhow::Result { - validate_cert_validity(request.not_before, request.not_after)?; - let mut seed = [0u8; 32]; - SystemRandom::new() - .fill(&mut seed) - .context("Failed to generate secure seed")?; - let derived_key = - derive_p256_key_pair_from_bytes(&seed, &[]).context("Failed to derive key")?; - let config = CertConfigV2 { - org_name: None, - subject: request.subject, - subject_alt_names: request.alt_names, - usage_server_auth: request.usage_server_auth, - usage_client_auth: request.usage_client_auth, - ext_quote: request.usage_ra_tls, - ext_app_info: request.with_app_info, - not_after: request.not_after, - not_before: request.not_before, - }; - let certificate_chain = self.state.inner.issue_cert(&derived_key, config).await?; + let issued = issue_cert_for_request( + &self.state, + CertRequestFields { + subject: request.subject, + alt_names: request.alt_names, + usage_ra_tls: request.usage_ra_tls, + usage_server_auth: request.usage_server_auth, + usage_client_auth: request.usage_client_auth, + with_app_info: request.with_app_info, + not_before: request.not_before, + not_after: request.not_after, + }, + ) + .await?; Ok(GetTlsKeyResponse { - key: derived_key.serialize_pem(), - certificate_chain, + key: issued.key, + certificate_chain: issued.certificate_chain, }) } @@ -372,10 +618,10 @@ impl DstackGuestRpc for InternalRpcHandler { let msg_to_sign = format!("{}:{}", request.purpose, pubkey_hex); let app_signing_key = SigningKey::from_slice(k256_app_key).context("Failed to parse app k256 key")?; - let digest = Keccak256::new_with_prefix(msg_to_sign); - let (signature, recid) = app_signing_key.sign_digest_recoverable(digest)?; - let mut signature = signature.to_vec(); - signature.push(recid.to_byte()); + // The shared 65-byte `r || s || v` envelope. Byte-identical to the copy + // this replaced -- `get_key_pins_the_frozen_chain_link` is the vector + // that says so, and it exists for exactly this de-duplication. + let signature = sign_recoverable_keccak256(&app_signing_key, msg_to_sign.as_bytes())?; Ok(GetKeyResponse { key, @@ -388,31 +634,18 @@ impl DstackGuestRpc for InternalRpcHandler { self.state.quote_response(report_data) } - async fn info(self) -> Result { - get_info(&self.state, false).await - } - - async fn attest_gpu(self, request: AttestGpuArgs) -> Result { - let evidence = self - .state - .inner - .gpu_attestor - .attest(&request.nonce) - .await - .context("GPU attestation failed")?; - Ok(AttestGpuResponse { - bundles: vec![GpuEvidenceBundle { - vendor: "nvidia".to_string(), - format: "nvidia-nvattest-collect-evidence-json-v1".to_string(), - evidence, - }], - }) + /// 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 the bare `Service not found` a deleted method would answer + /// with, which says nothing about the removal. + 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 gpu_info(self) -> Result { - Ok(GpuInfoResponse { - attestation: read_gpu_attestation(Path::new(GPU_ATTESTATION_OUTPUT)), - }) + async fn info(self) -> Result { + get_info(&self.state, false).await } async fn sign(self, request: SignRequest) -> Result { @@ -474,14 +707,51 @@ impl DstackGuestRpc for InternalRpcHandler { }) } - async fn attest(self, request: AttestArgs) -> Result { + /// 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: RawQuoteArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; Ok(AttestResponse { attestation: self.state.attest_cvm(report_data)?, - boottime_gpu_evidence: boottime_gpu_evidence( - request.include_boottime_gpu_evidence, - Path::new(GPU_ATTESTATION_OUTPUT), - ), }) } @@ -502,7 +772,7 @@ fn normalize_algorithm(algorithm: &str) -> &str { } } -fn pad64(data: &[u8]) -> Option<[u8; 64]> { +pub(crate) fn pad64(data: &[u8]) -> Option<[u8; 64]> { if data.len() > 64 { return None; } @@ -624,14 +894,6 @@ impl RpcCall for InternalRpcHandlerV0 { } } -fn health_response(verdict: crate::health::Verdict) -> HealthResponse { - HealthResponse { - healthy: verdict.healthy, - unhealthy: verdict.unhealthy, - error: verdict.error, - } -} - pub struct ExternalRpcHandler { state: AppState, } @@ -654,36 +916,24 @@ impl WorkerRpc for ExternalRpcHandler { }) } - async fn health(self) -> Result { - // One lock and one clone. Everything that costs anything happens on the - // agent's own timer in `health`, because this method is served on the - // publicly reachable listener and is polled by every gateway node in - // the cluster: any work done here is work an anonymous caller can ask - // for at an arbitrary rate, multiplied by the operator's fleet size. - let Some(monitor) = self.state.health() else { - // The app did not opt in, so it registered as "do not poll me" and - // no gateway asks. Anything that does ask gets the same answer the - // gateway would have assumed. - return Ok(HealthResponse { - healthy: true, - unhealthy: vec![], - error: String::new(), - }); - }; - // Deliberately infallible: see `HealthResponse.error`. A failure to see - // the app has to come back as a verdict, because an RPC error is - // indistinguishable from an agent that predates this method. - Ok(health_response(monitor.report())) - } - - async fn attest_app_key(self, request: AttestAppKeyRequest) -> Result { + /// Legacy and frozen at the v0.5.11 shape. See the RPC's doc comment in + /// agent_rpc.proto. + /// + /// Returns a `GetQuoteResponse`, which only Intel TDX can fill, so this + /// fails on every other platform exactly as `GetQuote` does. v1 ships no + /// counterpart that lifts the limitation: a v1 application 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. See the `Worker` service comment in agent_rpc_v1.proto. + /// + /// The report data comes from the same `app_key_report_data` the v1 method + /// uses, so both attest the same public key for a given algorithm. + async fn get_attestation_for_app_key( + self, + request: GetAttestationForAppKeyRequest, + ) -> Result { let report_data = self.app_key_report_data(&request.algorithm).await?; - Ok(AttestResponse { - attestation: self.state.attest_cvm(report_data)?, - // This method attests a key, not the machine. A caller that wants - // the boot-time GPU evidence asks `Attest` or `GpuInfo` for it. - boottime_gpu_evidence: String::new(), - }) + self.state.quote_response(report_data) } } @@ -695,7 +945,7 @@ impl ExternalRpcHandler { /// 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]> { + pub(crate) 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 @@ -760,14 +1010,17 @@ impl RpcCall for ExternalRpcHandler { } #[cfg(test)] -mod tests { +// `pub(crate)` so the v1 handler's tests can build a state from the same +// fixture. Two fixtures would let the two surfaces be tested against different +// app root keys, which is exactly what the cross-version key assertions check. +pub(crate) mod tests { use super::*; use crate::{ backend::PlatformBackend, config::{AppComposeWrapper, Config}, }; use dstack_attest::attestation::AttestationVerifier; - use dstack_guest_agent_rpc::{AttestAppKeyRequest, SignRequest}; + use dstack_guest_agent_rpc::v0::{GetAttestationForAppKeyRequest, SignRequest}; use dstack_types::{AppCompose, AppKeys, EventLogVersion, KeyProvider}; use ed25519_dalek::ed25519::signature::hazmat::PrehashVerifier; use ed25519_dalek::{ @@ -775,45 +1028,11 @@ mod tests { }; use k256::ecdsa::{Signature as K256Signature, VerifyingKey}; use ra_tls::attestation::{AttestationV1, PlatformEvidence, VersionedAttestation}; - use sha2::Sha256; + use sha2::{Digest as _, Sha256}; use std::collections::HashSet; use std::convert::TryFrom; use std::io::Write; - - #[test] - fn reads_gpu_attestation_output_verbatim() { - let mut output = tempfile::NamedTempFile::new().unwrap(); - let attestation = r#"{"result_code":0,"claims":[]}"#; - output.write_all(attestation.as_bytes()).unwrap(); - output.flush().unwrap(); - - assert_eq!(read_gpu_attestation(output.path()), attestation); - } - - #[test] - fn attest_returns_boottime_gpu_evidence_only_when_requested() { - let mut output = tempfile::NamedTempFile::new().unwrap(); - let evidence = r#"{"result_code":0,"claims":[]}"#; - output.write_all(evidence.as_bytes()).unwrap(); - output.flush().unwrap(); - - assert_eq!(boottime_gpu_evidence(true, output.path()), evidence); - assert_eq!(boottime_gpu_evidence(false, output.path()), ""); - } - - #[test] - fn missing_gpu_attestation_output_reads_as_empty() { - let dir = tempfile::tempdir().unwrap(); - 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") - } + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; fn extract_pubkey_from_report_data(report_data: &[u8], prefix: &str) -> Result> { let end = report_data @@ -831,14 +1050,74 @@ mod tests { } } - async fn setup_test_state() -> (AppState, tempfile::NamedTempFile) { + pub(crate) async fn setup_test_state() -> (AppState, tempfile::NamedTempFile) { setup_test_state_with_platform(None).await } + /// How many times the fixture platform was asked to attest for `Info`, and + /// whether it should refuse. Counting the calls is the only way to see the + /// identity throttle work: what it changes is how often the platform is + /// touched, not what any single call returns. + #[derive(Default)] + struct InfoAttestationProbe { + calls: AtomicUsize, + failing: AtomicBool, + } + + impl InfoAttestationProbe { + fn failing() -> Self { + Self { + calls: AtomicUsize::new(0), + failing: AtomicBool::new(true), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::Relaxed) + } + + fn set_failing(&self, failing: bool) { + self.failing.store(failing, Ordering::Relaxed); + } + } + + /// The fixture state, built against a platform the probe watches. + async fn setup_test_state_with_probe( + probe: Arc, + ) -> (AppState, tempfile::NamedTempFile) { + build_test_state(None, |_| {}, probe).await + } + + /// The same state with the app having opted into publishing its TCB info, + /// which is what unlocks the document fields on the external surface. + pub(crate) async fn setup_test_state_with_public_tcbinfo() -> (AppState, tempfile::NamedTempFile) + { + build_test_state( + None, + |config| { + config.app_compose.app_compose.public_tcbinfo = true; + config.app_compose.raw = r#"{"name":"test"}"#.to_string(); + }, + Arc::default(), + ) + .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( + pub(crate) async fn setup_test_state_with_platform( platform: Option, + ) -> (AppState, tempfile::NamedTempFile) { + build_test_state(platform, |_| {}, Arc::default()).await + } + + /// The one fixture body. `configure` adjusts the app config before the + /// state is built, which is cheaper and clearer than rebuilding an + /// already-shared `Arc` afterwards. + async fn build_test_state( + platform: Option, + configure: impl FnOnce(&mut Config), + probe: Arc, ) -> (AppState, tempfile::NamedTempFile) { let mut temp_attestation_file = tempfile::NamedTempFile::new().unwrap(); @@ -878,12 +1157,13 @@ mod tests { raw: String::new(), }; - let dummy_config = Config { + let mut dummy_config = Config { keys_file: String::new(), app_compose: dummy_appcompose_wrapper, sys_config_file: String::new().into(), data_disks: HashSet::new(), }; + configure(&mut dummy_config); const DUMMY_PEM_KEY: &str = r#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCSeV81CKVqILf/ @@ -961,6 +1241,7 @@ pNs85uhOZE8z2jr8Pg== struct TestSimulatorPlatform { attestation: VersionedAttestation, + probe: Arc, } fn patch_report_data( @@ -972,6 +1253,10 @@ pNs85uhOZE8z2jr8Pg== impl PlatformBackend for TestSimulatorPlatform { fn attestation_for_info(&self) -> Result { + self.probe.calls.fetch_add(1, Ordering::Relaxed); + if self.probe.failing.load(Ordering::Relaxed) { + anyhow::bail!("the platform cannot attest right now"); + } Ok(self.attestation.clone()) } @@ -1031,9 +1316,17 @@ pNs85uhOZE8z2jr8Pg== } } }, + probe, }), health: None, gpu_attestor: crate::gpu_attest::GpuAttestor::new(), + app_root_signing_key: SigningKey::from_slice(&DUMMY_K256_KEY).ok(), + identity: RwLock::new(None), + identity_last_failure: Mutex::new(None), + // Read the same way production does, so a test comparing v1 `Info` + // against v0 `get_info` compares like with like. + cloud_vendor: read_dmi_file("sys_vendor"), + cloud_product: read_dmi_file("product_name"), }; ( @@ -1058,14 +1351,10 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); - let attestation_response = ExternalRpcHandler::new(state) - .attest_app_key(AttestAppKeyRequest { - algorithm: "ed25519".to_string(), - }) + let report_data = ExternalRpcHandler::new(state) + .app_key_report_data("ed25519") .await .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(); @@ -1087,14 +1376,10 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); - let attestation_response = ExternalRpcHandler::new(state) - .attest_app_key(AttestAppKeyRequest { - algorithm: "secp256k1".to_string(), - }) + let report_data = ExternalRpcHandler::new(state) + .app_key_report_data("secp256k1") .await .unwrap(); - - let report_data = app_key_report_data(&attestation_response); let pk_bytes = extract_pubkey_from_report_data(&report_data, "dip1::secp256k1c-pk:").unwrap(); @@ -1120,14 +1405,10 @@ pNs85uhOZE8z2jr8Pg== let response = handler.sign(request).await.unwrap(); - let attestation_response = ExternalRpcHandler::new(state) - .attest_app_key(AttestAppKeyRequest { - algorithm: "secp256k1".to_string(), - }) + let report_data = ExternalRpcHandler::new(state) + .app_key_report_data("secp256k1") .await .unwrap(); - - let report_data = app_key_report_data(&attestation_response); let pk_bytes = extract_pubkey_from_report_data(&report_data, "dip1::secp256k1c-pk:").unwrap(); @@ -1175,49 +1456,61 @@ pNs85uhOZE8z2jr8Pg== assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); } + const ED25519_REPORT_DATA: &str = + "dip1::ed25519-pk:5Pbre1Amf1hrp2V2bbfKlIfxpQb2pJAmrgmhxgVoG9s\0\0\0\0"; + const SECP256K1_REPORT_DATA: &str = + "dip1::secp256k1c-pk:A6t_JdVkVdMAocH3f1f20WGT6JzdntxcXimUtEax8zc9"; + + /// The DIP-1 report data both external surfaces commit to. `WorkerV1` + /// wraps these exact bytes in an attestation and the frozen + /// `GetAttestationForAppKey` wraps them in a TDX quote, so pinning them + /// here pins both. #[tokio::test] - async fn test_attest_app_key_ed25519_success() { + async fn app_key_report_data_matches_its_vectors() { let (state, _guard) = setup_test_state().await; - let handler = ExternalRpcHandler::new(state.clone()); - let request = AttestAppKeyRequest { - algorithm: "ed25519".to_string(), - }; - - 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(), - app_key_report_data(&response).as_slice() - ); + for (algorithm, expected) in [ + ("ed25519", ED25519_REPORT_DATA), + ("secp256k1", SECP256K1_REPORT_DATA), + ] { + let report_data = ExternalRpcHandler::new(state.clone()) + .app_key_report_data(algorithm) + .await + .unwrap(); + assert_eq!(expected.as_bytes(), report_data.as_slice(), "{algorithm}"); + } } + /// Prehashing changes how a key signs, not which key it is, so it must + /// commit to the same public key as the plain name. #[tokio::test] - async fn test_attest_app_key_secp256k1_success() { + async fn app_key_report_data_accepts_secp256k1_prehashed() { let (state, _guard) = setup_test_state().await; - let handler = ExternalRpcHandler::new(state.clone()); - let request = AttestAppKeyRequest { - algorithm: "secp256k1".to_string(), - }; - - let response = handler.attest_app_key(request).await.unwrap(); + let prehashed = ExternalRpcHandler::new(state.clone()) + .app_key_report_data("secp256k1_prehashed") + .await + .expect("secp256k1_prehashed must be accepted"); + let plain = ExternalRpcHandler::new(state) + .app_key_report_data("secp256k1") + .await + .unwrap(); + assert_eq!(prehashed, plain); + } - const EXPECTED_REPORT_DATA: &str = - "dip1::secp256k1c-pk:A6t_JdVkVdMAocH3f1f20WGT6JzdntxcXimUtEax8zc9"; - assert_eq!( - EXPECTED_REPORT_DATA.as_bytes(), - app_key_report_data(&response).as_slice() - ); + #[tokio::test] + async fn app_key_report_data_rejects_an_unsupported_algorithm() { + let (state, _guard) = setup_test_state().await; + let result = ExternalRpcHandler::new(state) + .app_key_report_data("ecdsa") + .await; + assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); } + /// The frozen method returns a `GetQuoteResponse`, which only Intel TDX can + /// fill. That limitation is why v1 replaced the method with the + /// attest-your-own-key flow rather than porting it, so it has to stay + /// observable here. #[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. + async fn get_attestation_for_app_key_is_tdx_only() { let (state, _guard) = setup_test_state_with_platform(Some(PlatformEvidence::SevSnp { report: vec![0u8; 1184], cert_chain: Vec::new(), @@ -1225,64 +1518,52 @@ pNs85uhOZE8z2jr8Pg== })) .await; - // GetQuote is closed on this platform... - let err = state - .quote_response([0x5a; 64]) - .expect_err("GetQuote must fail on a non-TDX platform"); + let err = ExternalRpcHandler::new(state) + .get_attestation_for_app_key(GetAttestationForAppKeyRequest { + algorithm: "ed25519".to_string(), + }) + .await + .expect_err("the frozen method cannot answer without a TDX quote"); assert!( err.to_string().contains("Intel TDX only"), "unexpected error: {err}" ); + } - // ...and attesting an app key still works. + #[tokio::test] + async fn get_attestation_for_app_key_answers_on_tdx() { + let (state, _guard) = setup_test_state().await; let response = ExternalRpcHandler::new(state) - .attest_app_key(AttestAppKeyRequest { + .get_attestation_for_app_key(GetAttestationForAppKeyRequest { 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() - ); + assert_eq!(ED25519_REPORT_DATA.as_bytes(), response.report_data); + assert!(!response.quote.is_empty()); } + /// The frozen v0 signature chain, pinned byte for byte. + /// + /// Added when the keccak256 -> recoverable-sign -> `r || s || v` envelope + /// was de-duplicated into `ra_tls::api_v1`: without a vector here, + /// nothing would have caught the shared helper disagreeing with the copy it + /// replaced. RFC 6979 makes the signature deterministic, so this is exact. #[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. + async fn get_key_pins_the_frozen_chain_link() { 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 { + let response = InternalRpcHandler::new(state) + .get_key(GetKeyArgs { + path: "test".to_string(), + purpose: "signing".to_string(), algorithm: "secp256k1".to_string(), }) .await .unwrap(); - - assert_eq!(app_key_report_data(&prehashed), app_key_report_data(&plain)); - } - - #[tokio::test] - async fn test_attest_app_key_unsupported_algorithm_fails() { - let (state, _guard) = setup_test_state().await; - let handler = ExternalRpcHandler::new(state); - let request = AttestAppKeyRequest { - algorithm: "ecdsa".to_string(), // Unsupported algorithm - }; - - let result = handler.attest_app_key(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string(), "Unsupported algorithm"); + assert_eq!( + hex::encode(&response.signature_chain[0]), + "c8a3dcf06c4e95bd78a5d7a1c8fcff171fc5848cfae804c6fc11bda4dc5d4062379995390843827444992c4c0e4bac70f0f878e01b9fc8b98cd7126fe5a3876b01" + ); } #[test] @@ -1474,4 +1755,165 @@ 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}"); + } + + /// A failed decode must be as cheap to repeat as a cached success is. + /// `identity()` is reached from the anonymous `/prpc/v1/Info`, and each + /// attempt is a hardware quote plus an RTMR replay under the global quote + /// lock -- retrying per call would give a caller on a broken platform the + /// very lever the cache exists to take away. + #[tokio::test] + async fn a_failed_identity_decode_is_not_retried_within_the_throttle_window() { + let probe = Arc::new(InfoAttestationProbe::failing()); + let (state, _guard) = setup_test_state_with_probe(probe.clone()).await; + + let err = state + .identity() + .expect_err("the platform refuses to attest"); + assert!(err.to_string().contains("cannot attest"), "{err}"); + assert_eq!(probe.calls(), 1); + + for _ in 0..8 { + let err = state.identity().expect_err("still throttled"); + assert!( + err.to_string().contains("the app identity is unavailable"), + "{err}" + ); + } + assert_eq!( + probe.calls(), + 1, + "the platform was asked again inside the throttle window" + ); + } + + /// The throttle bounds the retry rate; it must not turn a transient failure + /// into a permanent one. Once the window has passed the next call attests + /// again, and a success from then on is cached like any other. + #[tokio::test] + async fn the_identity_decode_is_retried_once_the_throttle_window_has_passed() { + let probe = Arc::new(InfoAttestationProbe::failing()); + let (state, _guard) = setup_test_state_with_probe(probe.clone()).await; + state + .identity() + .expect_err("the platform refuses to attest"); + + // Age the recorded failure rather than sleeping out the interval. + *state + .inner + .identity_last_failure + .lock() + .expect("lock should never fail") = Some( + Instant::now() + .checked_sub(IDENTITY_RETRY_INTERVAL) + .expect("the monotonic clock is older than the throttle window"), + ); + probe.set_failing(false); + + let identity = state.identity().expect("the platform recovered"); + assert_eq!(probe.calls(), 2); + + let again = state.identity().expect("a decoded identity is cached"); + assert!(Arc::ptr_eq(&identity, &again)); + assert_eq!( + probe.calls(), + 2, + "a success must be cached, not re-decoded per call" + ); + } } diff --git a/dstack/guest-agent/src/rpc_service_v1.rs b/dstack/guest-agent/src/rpc_service_v1.rs new file mode 100644 index 000000000..1ec7b1ca2 --- /dev/null +++ b/dstack/guest-agent/src/rpc_service_v1.rs @@ -0,0 +1,785 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! The `dstack.guest.v1` API surface. +//! +//! Two handlers, one per trust surface, mirroring the two unversioned ones: +//! +//! - [`V1RpcHandler`] on the internal socket at `/v1`, which hands out key +//! material because only the application itself can reach that socket; +//! - [`ExternalV1RpcHandler`] on the external listener at `/prpc/v1`, which +//! never does, because anyone who can route to the CVM can reach it. +//! +//! The unversioned handlers in [`crate::rpc_service`] keep serving `/` and +//! `/prpc` unchanged; a caller picks a surface by URL and nothing else. +//! +//! This handler shares every backend with the unversioned one -- the same +//! `AppState`, the same certificate client, the same platform attestation and +//! GPU attestor. What it does not share is key derivation and the +//! signature-chain claim, which are new in v1 and live in the `keys` submodule. +//! +//! v1 serves only what needs the TEE: deriving from the app root key, and +//! attesting. It has no `Sign` and no `Verify`, because a caller that can +//! reach this socket can get the private key from `GetKey` and do both +//! itself. + +use std::path::Path; + +use anyhow::{Context, Result}; +use dstack_guest_agent_rpc::v1::{ + dstack_guest_server::{DstackGuestRpc, DstackGuestServer}, + worker_server::{WorkerRpc, WorkerServer}, + AttestGpuRequest, AttestGpuResponse, AttestRequest, AttestResponse, GetKeyRequest, + GetKeyResponse, GpuEvidenceBundle, HealthRequest, HealthResponse, InfoRequest, InfoResponse, + IssueCertRequest, IssueCertResponse, VersionRequest, VersionResponse, +}; +use dstack_types::GPU_ATTESTATION_OUTPUT; +use fs_err as fs; +use ra_rpc::{CallContext, RpcCall}; +use tracing::warn; + +use crate::rpc_service::{issue_cert_for_request, pad64, AppState}; + +pub(crate) mod keys; + +use keys::{Algorithm, AppKey}; + +/// The vendor every bundle this agent produces carries today. +const GPU_VENDOR: &str = "nvidia"; + +/// Format tag for evidence collected on demand, against a caller's nonce. +const GPU_FORMAT_ON_DEMAND: &str = "nvidia-nvattest-collect-evidence-json-v1"; + +/// Format tag for the record nvattest wrote at boot. +/// +/// Distinct from [`GPU_FORMAT_ON_DEMAND`] because the two answer different +/// questions -- "is this device genuine right now" versus "what did the boot +/// look like" -- and a verifier for one does not appraise the other. Sharing a +/// tag would leave a consumer no way to tell them apart. +const GPU_FORMAT_BOOTTIME: &str = "nvidia-nvattest-boottime-json-v1"; + +/// Read the GPU attestation output saved during boot. +/// +/// Returns the bytes exactly as they sit on disk. That exactness is the whole +/// contract: the only thing binding this evidence to the boot is sha256 over +/// precisely these bytes against the measured `gpu-attestation` event, so this +/// must never normalise, re-encode, or trim what it read. +/// +/// `None` when no output is available (no GPU attached, or attestation +/// disabled). +fn read_gpu_attestation(path: &Path) -> Option> { + match fs::read(path) { + Ok(attestation) => Some(attestation), + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + warn!("failed to read GPU attestation output: {err:?}"); + } + None + } + } +} + +/// GPU evidence to return alongside an attestation. +/// +/// Opt-in, so a caller that does not care about GPUs neither pays the disk read +/// nor carries the payload. Returns the same bundle shape `AttestGpu` uses, so +/// a consumer needs one parser rather than two; absence is the empty list. +fn boottime_gpu_evidence(include: bool, path: &Path) -> Vec { + if !include { + return Vec::new(); + } + read_gpu_attestation(path) + .map(|evidence| { + vec![GpuEvidenceBundle { + vendor: GPU_VENDOR.to_string(), + format: GPU_FORMAT_BOOTTIME.to_string(), + evidence, + }] + }) + .unwrap_or_default() +} + +/// Build the v1 `Info` response. +/// +/// Reads identity from the cache `AppState` decoded once; see +/// [`crate::rpc_service::AppIdentity`] for why this must not attest per call. +/// +/// `hide_documents` applies the app's `public_tcbinfo` choice: with it set, the +/// three document fields come back empty and everything identifying the app +/// stays visible. Each hidden field is skipped rather than built and thrown +/// away, so the public listener does not clone an app-compose document per +/// anonymous call in order to discard it. +/// +/// This is close to, but not the same as, the line the frozen `Worker.Info` +/// draws. That one blanks `tcb_info` and `vm_config` and always serves +/// `key_provider_info`; v1 blanks `key_provider_info` too, because it names the +/// component that holds the app's keys and an external caller has no need for +/// it. The frozen behaviour is unchanged on its own surface. +fn info_response(state: &AppState, hide_documents: bool) -> Result { + let identity = state.identity()?; + let document = |value: &dyn Fn() -> String| { + if hide_documents { + String::new() + } else { + value() + } + }; + Ok(InfoResponse { + app_id: identity.app_id.clone(), + app_name: state.config().app_compose.name.clone(), + compose_hash: identity.compose_hash.clone(), + app_compose: document(&|| state.config().app_compose.raw.clone()), + instance_id: identity.instance_id.clone(), + device_id: identity.device_id.clone(), + os_image_hash: identity.os_image_hash.clone(), + mr_aggregated: identity.mr_aggregated.clone(), + vm_config: document(&|| state.vm_config().to_string()), + key_provider_info: document(&|| identity.key_provider_info.clone()), + cloud_vendor: state.cloud_vendor().to_string(), + cloud_product: state.cloud_product().to_string(), + }) +} + +/// The version both v1 services report. +fn version_response() -> VersionResponse { + VersionResponse { + version: crate::CARGO_PKG_VERSION.to_string(), + rev: crate::GIT_REV.to_string(), + } +} + +pub struct V1RpcHandler { + state: AppState, +} + +impl V1RpcHandler { + #[cfg(test)] + pub(crate) fn new(state: AppState) -> Self { + Self { state } + } + + /// Derive the key named by `(domain, algorithm)` together with its + /// signature chain. + fn derive(&self, domain: &str, algorithm: &str) -> Result<(AppKey, Vec>)> { + let algorithm = Algorithm::parse(algorithm)?; + let key = AppKey::derive(self.state.app_root_k256_key(), domain, algorithm)?; + let chain = vec![ + key.claim_signature(self.state.app_root_signing_key()?)?, + self.state.kms_k256_signature().to_vec(), + ]; + Ok((key, chain)) + } +} + +impl DstackGuestRpc for V1RpcHandler { + async fn issue_cert(self, request: IssueCertRequest) -> Result { + let issued = issue_cert_for_request( + &self.state, + crate::rpc_service::CertRequestFields { + subject: request.subject, + alt_names: request.alt_names, + usage_ra_tls: request.usage_ra_tls, + usage_server_auth: request.usage_server_auth, + usage_client_auth: request.usage_client_auth, + with_app_info: request.with_app_info, + not_before: request.not_before, + not_after: request.not_after, + }, + ) + .await?; + Ok(IssueCertResponse { + key: issued.key, + certificate_chain: issued.certificate_chain, + }) + } + + async fn get_key(self, request: GetKeyRequest) -> Result { + let (key, signature_chain) = self.derive(&request.domain, &request.algorithm)?; + Ok(GetKeyResponse { + key: key.secret(), + public_key: key.public_key(), + signature_chain, + }) + } + + async fn attest(self, request: AttestRequest) -> Result { + let report_data = pad64(&request.report_data).context("report data is too long")?; + // Generating a quote takes a global mutex and then blocks in an ioctl. + // On the async executor that parks a worker thread for the duration and + // stalls every other connection this agent is serving. + let state = self.state.clone(); + let attestation = tokio::task::spawn_blocking(move || state.attest_cvm(report_data)) + .await + .context("the attestation task panicked")??; + Ok(AttestResponse { + attestation, + boottime_gpu_evidence: boottime_gpu_evidence( + request.include_boottime_gpu_evidence, + Path::new(GPU_ATTESTATION_OUTPUT), + ), + }) + } + + async fn attest_gpu(self, request: AttestGpuRequest) -> Result { + let evidence = self + .state + .gpu_attestor() + .attest(&request.nonce) + .await + .context("GPU attestation failed")?; + Ok(AttestGpuResponse { + bundles: vec![GpuEvidenceBundle { + vendor: GPU_VENDOR.to_string(), + format: GPU_FORMAT_ON_DEMAND.to_string(), + evidence, + }], + }) + } + + /// Identity and configuration, never attestation. + /// + /// Ungated: the internal socket is reachable only by the application + /// itself, so there is nobody to hide from. The external surface applies + /// `public_tcbinfo`; see [`ExternalV1RpcHandler::info`]. + async fn info(self, _request: InfoRequest) -> Result { + info_response(&self.state, false) + } + + async fn version(self, _request: VersionRequest) -> Result { + Ok(version_response()) + } +} + +impl RpcCall for V1RpcHandler { + type PrpcService = DstackGuestServer; + + fn construct(context: CallContext<'_, AppState>) -> Result { + Ok(V1RpcHandler { + state: context.state.clone(), + }) + } +} + +/// The v1 handler on the external listener. +/// +/// Reachable by anyone who can route to the CVM, so it serves no key material +/// and lets no caller choose what gets signed or attested. +pub struct ExternalV1RpcHandler { + state: AppState, +} + +impl ExternalV1RpcHandler { + #[cfg(test)] + pub(crate) fn new(state: AppState) -> Self { + Self { state } + } +} + +impl WorkerRpc for ExternalV1RpcHandler { + async fn info(self, _request: InfoRequest) -> Result { + let hide = !self.state.config().app_compose.public_tcbinfo; + info_response(&self.state, hide) + } + + async fn version(self, _request: VersionRequest) -> Result { + Ok(version_response()) + } + + async fn health(self, _request: HealthRequest) -> Result { + // One lock and one clone. Everything that costs anything happens on the + // agent's own timer in `health`, because this method is served on the + // publicly reachable listener and is polled by every gateway node in + // the cluster: any work done here is work an anonymous caller can ask + // for at an arbitrary rate, multiplied by the operator's fleet size. + let Some(monitor) = self.state.health() else { + // The app did not opt in, so it registered as "do not poll me" and + // no gateway asks. Anything that does ask gets the same answer the + // gateway would have assumed. + return Ok(HealthResponse { + healthy: true, + unhealthy: vec![], + error: String::new(), + }); + }; + // Deliberately infallible: see `HealthResponse.error`. The gateway + // counts every failed poll alike, so a failure to see the app has to + // come back as a verdict -- raised as an RPC error it would be lumped + // in with an unreachable agent and the reason would never reach the + // operator. + let verdict = monitor.report(); + Ok(HealthResponse { + healthy: verdict.healthy, + unhealthy: verdict.unhealthy, + error: verdict.error, + }) + } +} + +impl RpcCall for ExternalV1RpcHandler { + type PrpcService = WorkerServer; + + fn construct(context: CallContext<'_, AppState>) -> Result { + Ok(ExternalV1RpcHandler { + state: context.state.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc_service::get_info; + use crate::rpc_service::tests::setup_test_state; + use dstack_guest_agent_rpc::v0::dstack_guest_server::DstackGuestRpc as DstackGuestV0Rpc; + use dstack_guest_agent_rpc::v0::GetKeyArgs; + use k256::ecdsa::Signature as K256Signature; + use std::io::Write as _; + + async fn state() -> (AppState, tempfile::NamedTempFile) { + setup_test_state().await + } + + fn get_key_request(domain: &str, algorithm: &str) -> GetKeyRequest { + GetKeyRequest { + domain: domain.to_string(), + algorithm: algorithm.to_string(), + } + } + + /// The fixture's app root key is what `keys::tests` committed its vectors + /// against. If the two drift, the vectors stop describing what this + /// handler serves and every assertion built on them goes quiet. + #[tokio::test] + async fn the_fixture_root_key_matches_the_committed_vectors() { + let (state, _guard) = state().await; + assert_eq!( + hex::encode(state.app_root_k256_key()), + "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b" + ); + } + + #[tokio::test] + async fn get_key_returns_a_two_link_chain_for_both_algorithms() { + for algorithm in ["secp256k1", "ed25519"] { + let (state, _guard) = state().await; + let key = V1RpcHandler::new(state) + .get_key(get_key_request("storage-encryption", algorithm)) + .await + .unwrap(); + + assert_eq!(key.key.len(), 32); + assert_eq!(key.signature_chain.len(), 2); + // r || s || recovery id. + assert_eq!(key.signature_chain[0].len(), 65); + } + } + + /// The chain's first link verifies under the app root key, over the claim + /// a relying party rebuilds from `(domain, algorithm, public_key)`. + #[tokio::test] + async fn the_chain_claim_verifies_under_the_app_root_key() { + use k256::ecdsa::{RecoveryId, SigningKey, VerifyingKey}; + use sha3::{Digest as _, Keccak256}; + + let (state, _guard) = state().await; + let key = V1RpcHandler::new(state.clone()) + .get_key(get_key_request("storage-encryption", "secp256k1")) + .await + .unwrap(); + + let claim = ra_tls::api_v1::key_claim( + keys::Algorithm::Secp256k1, + "storage-encryption", + &key.public_key, + ) + .unwrap(); + let link = &key.signature_chain[0]; + let recovered = VerifyingKey::recover_from_digest( + Keccak256::new_with_prefix(&claim), + &K256Signature::from_slice(&link[..64]).unwrap(), + RecoveryId::from_byte(link[64]).unwrap(), + ) + .unwrap(); + + let app_root = *SigningKey::from_slice(state.app_root_k256_key()) + .unwrap() + .verifying_key(); + assert_eq!(recovered, app_root); + } + + /// The public key in the response is the one the returned private key + /// actually has. An app that signs locally with `key` must land on + /// `public_key`, which is what the chain vouches for. + #[tokio::test] + async fn the_public_key_belongs_to_the_returned_private_key() { + let (state, _guard) = state().await; + + let secp = V1RpcHandler::new(state.clone()) + .get_key(get_key_request("storage-encryption", "secp256k1")) + .await + .unwrap(); + let derived = k256::ecdsa::SigningKey::from_slice(&secp.key).unwrap(); + assert_eq!( + derived.verifying_key().to_sec1_bytes().to_vec(), + secp.public_key + ); + + let ed = V1RpcHandler::new(state) + .get_key(get_key_request("storage-encryption", "ed25519")) + .await + .unwrap(); + let seed: [u8; 32] = ed.key.as_slice().try_into().unwrap(); + let derived = ed25519_dalek::SigningKey::from_bytes(&seed); + assert_eq!(derived.verifying_key().to_bytes().to_vec(), ed.public_key); + } + + #[tokio::test] + async fn rejects_an_empty_or_unknown_algorithm() { + let (state, _guard) = state().await; + for algorithm in ["", "k256", "rsa", "secp256k1_prehashed"] { + let result = V1RpcHandler::new(state.clone()) + .get_key(get_key_request("storage-encryption", algorithm)) + .await; + assert!(result.is_err(), "v1 accepted algorithm {algorithm:?}"); + } + } + + /// v1 keys are new key material. An app that reuses its v0 path as a v1 + /// domain gets a different key, and that is the migration contract, not a + /// bug to paper over. + #[tokio::test] + async fn v1_keys_differ_from_the_unversioned_keys_for_the_same_name() { + let (state, _guard) = state().await; + let v1 = V1RpcHandler::new(state.clone()) + .get_key(get_key_request("storage-encryption", "secp256k1")) + .await + .unwrap(); + let v0 = crate::rpc_service::InternalRpcHandler::new(state) + .get_key(GetKeyArgs { + path: "storage-encryption".to_string(), + purpose: "signing".to_string(), + algorithm: "secp256k1".to_string(), + }) + .await + .unwrap(); + + assert_ne!(v1.key, v0.key); + // The second link is the KMS's, produced outside the agent, so both + // surfaces pass through the same bytes. + assert_eq!(v1.signature_chain[1], v0.signature_chain[1]); + assert_ne!(v1.signature_chain[0], v0.signature_chain[0]); + } + + /// The unversioned handler is untouched by any of this: same fixture, same + /// path, the v0.5.x answer. + #[tokio::test] + async fn the_unversioned_surface_still_serves_its_own_key() { + let (state, _guard) = state().await; + // v0 defaults an empty algorithm to secp256k1 and derives from the + // path alone; v1 rejects the same request outright. + let expected = ra_tls::kdf::derive_key(state.app_root_k256_key(), &[b"test"], 32).unwrap(); + let v0 = crate::rpc_service::InternalRpcHandler::new(state) + .get_key(GetKeyArgs { + path: "test".to_string(), + purpose: "signing".to_string(), + algorithm: String::new(), + }) + .await + .unwrap(); + assert_eq!(v0.key, expected); + } + + /// `Info` must not attest. Decoding identity costs a hardware quote and an + /// RTMR replay under a global lock, and `WorkerV1.Info` is served on the + /// public listener -- so doing it per call hands any caller that can route + /// to the CVM a way to monopolise the attestation path. + /// + /// One decoded `AppIdentity`, shared by pointer, is what says it is cached. + #[tokio::test] + async fn info_decodes_identity_at_most_once() { + let (state, _guard) = state().await; + + let first = state.identity().unwrap(); + for _ in 0..8 { + V1RpcHandler::new(state.clone()) + .info(InfoRequest {}) + .await + .unwrap(); + ExternalV1RpcHandler::new(state.clone()) + .info(InfoRequest {}) + .await + .unwrap(); + } + let last = state.identity().unwrap(); + + assert!( + std::sync::Arc::ptr_eq(&first, &last), + "identity was decoded again; every Info call is generating a quote" + ); + } + + /// v1 `Info` reports identity and configuration, and nothing that only an + /// attestation should be trusted for. + #[tokio::test] + async fn info_reports_identity_and_configuration() { + let (state, _guard) = state().await; + let v1 = V1RpcHandler::new(state.clone()) + .info(InfoRequest {}) + .await + .unwrap(); + let v0 = get_info(&state, false).await.unwrap(); + + // The identity fields carry the same values the unversioned surface + // reports; only the shape changed. + assert_eq!(v1.app_id, v0.app_id); + assert_eq!(v1.instance_id, v0.instance_id); + assert_eq!(v1.device_id, v0.device_id); + assert_eq!(v1.mr_aggregated, v0.mr_aggregated); + assert_eq!(v1.os_image_hash, v0.os_image_hash); + assert_eq!(v1.compose_hash, v0.compose_hash); + assert_eq!(v1.app_name, v0.app_name); + assert_eq!(v1.vm_config, v0.vm_config); + assert_eq!(v1.key_provider_info, v0.key_provider_info); + assert_eq!(v1.cloud_vendor, v0.cloud_vendor); + assert_eq!(v1.cloud_product, v0.cloud_product); + + // The app-compose document is served directly rather than nested in a + // JSON string, which is the one thing v0 could not do. + assert_eq!(v1.app_compose, state.config().app_compose.raw); + } + + /// GPU evidence rides on `Attest`, which is the only way to get it now, + /// and it comes back in the same bundle shape `AttestGpu` uses. + #[tokio::test] + async fn attest_returns_boot_time_gpu_evidence_only_when_asked() { + let mut output = tempfile::NamedTempFile::new().unwrap(); + let evidence = r#"{"result_code":0,"claims":[]}"#; + output.write_all(evidence.as_bytes()).unwrap(); + output.flush().unwrap(); + + let bundles = boottime_gpu_evidence(true, output.path()); + assert_eq!(bundles.len(), 1); + assert_eq!(bundles[0].vendor, "nvidia"); + // The tag that separates the boot record from on-demand collection. + assert_eq!(bundles[0].format, "nvidia-nvattest-boottime-json-v1"); + assert_ne!(bundles[0].format, GPU_FORMAT_ON_DEMAND); + // Byte-exact: the event-log binding is sha256 over precisely these + // bytes, so anything that reformats the JSON breaks verification. + assert_eq!(bundles[0].evidence, evidence.as_bytes()); + + assert!(boottime_gpu_evidence(false, output.path()).is_empty()); + } + + /// No GPU output is the empty list, not a bundle carrying nothing. + #[tokio::test] + async fn attest_returns_no_bundle_when_there_is_no_gpu_output() { + let dir = tempfile::tempdir().unwrap(); + assert!(boottime_gpu_evidence(true, &dir.path().join("missing")).is_empty()); + } + + #[test] + fn reads_gpu_attestation_output_verbatim() { + let mut output = tempfile::NamedTempFile::new().unwrap(); + // Trailing newline and internal spacing included on purpose: this is + // read byte for byte, never normalised. + let attestation = "{\"result_code\": 0, \"claims\": []}\n"; + output.write_all(attestation.as_bytes()).unwrap(); + output.flush().unwrap(); + + assert_eq!( + read_gpu_attestation(output.path()).unwrap(), + attestation.as_bytes() + ); + } + + #[test] + fn missing_gpu_attestation_output_reads_as_none() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_gpu_attestation(&dir.path().join("missing")).is_none()); + } + + /// `Attest` is v1's only CVM attestation entry point, and the versioned + /// attestation it returns carries the report data the caller asked for. + #[tokio::test] + async fn attest_reports_the_padded_report_data() { + use ra_tls::attestation::VersionedAttestation; + + let (state, _guard) = state().await; + let response = V1RpcHandler::new(state) + .attest(AttestRequest { + report_data: b"hello".to_vec(), + include_boottime_gpu_evidence: false, + }) + .await + .unwrap(); + + let report_data = VersionedAttestation::from_bytes(&response.attestation) + .unwrap() + .into_v1() + .report_data() + .unwrap(); + assert_eq!(&report_data[..5], b"hello"); + assert!(report_data[5..].iter().all(|b| *b == 0)); + } + + #[tokio::test] + async fn rejects_report_data_longer_than_64_bytes() { + let (state, _guard) = state().await; + let err = V1RpcHandler::new(state) + .attest(AttestRequest { + report_data: vec![0; 65], + include_boottime_gpu_evidence: false, + }) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("too long"), "{err}"); + } + + #[tokio::test] + async fn version_answers() { + let (state, _guard) = state().await; + let response = V1RpcHandler::new(state) + .version(VersionRequest {}) + .await + .unwrap(); + assert!(!response.version.is_empty()); + } + + /// The external surface honours the app's `public_tcbinfo` choice; the + /// internal one has nobody to hide from. + #[tokio::test] + async fn the_external_surface_hides_documents_unless_the_app_opted_in() { + let (state, _guard) = state().await; + assert!( + !state.config().app_compose.public_tcbinfo, + "the fixture must default to private for this test to mean anything" + ); + + let external = ExternalV1RpcHandler::new(state.clone()) + .info(InfoRequest {}) + .await + .unwrap(); + assert_eq!(external.app_compose, ""); + assert_eq!(external.vm_config, ""); + assert_eq!(external.key_provider_info, ""); + + // Identity and the measurement hashes stay visible, which is the line + // the unversioned `Worker.Info` drew too. + let internal = V1RpcHandler::new(state).info(InfoRequest {}).await.unwrap(); + assert_eq!(external.app_id, internal.app_id); + assert_eq!(external.instance_id, internal.instance_id); + assert_eq!(external.compose_hash, internal.compose_hash); + assert_eq!(external.os_image_hash, internal.os_image_hash); + assert_eq!(external.mr_aggregated, internal.mr_aggregated); + } + + /// An app that opted in gets the full response on the external surface. + #[tokio::test] + async fn the_external_surface_serves_documents_when_the_app_opted_in() { + let (state, _guard) = + crate::rpc_service::tests::setup_test_state_with_public_tcbinfo().await; + let external = ExternalV1RpcHandler::new(state.clone()) + .info(InfoRequest {}) + .await + .unwrap(); + assert_eq!(external.app_compose, state.config().app_compose.raw); + } + + /// An app that never opted into health gating is never polled, and anything + /// that asks anyway gets the answer the gateway would have assumed. + #[tokio::test] + async fn health_fails_open_for_an_app_that_did_not_opt_in() { + let (state, _guard) = state().await; + let response = ExternalV1RpcHandler::new(state) + .health(HealthRequest {}) + .await + .unwrap(); + assert!(response.healthy); + assert!(response.unhealthy.is_empty()); + assert!(response.error.is_empty()); + } + + /// Route-level check without a live socket: the two generated dispatchers + /// own disjoint method tables, which is what makes mounting one at `/` and + /// the other at `/v1` a version selector rather than a name collision. + #[test] + fn the_two_surfaces_expose_different_method_sets() { + // Both packages call the service `DstackGuest` now, so the module path + // is what disambiguates them -- which is the point of the v0/v1 split. + use dstack_guest_agent_rpc::v0::dstack_guest_server::DstackGuestServer as V0Server; + use dstack_guest_agent_rpc::v1::dstack_guest_server::DstackGuestServer as V1Server; + + let v0 = V0Server::::supported_methods(); + let v1 = V1Server::::supported_methods(); + + assert_eq!( + v1, + &[ + "IssueCert", + "GetKey", + "Attest", + "AttestGpu", + "Info", + "Version" + ], + "the v1 surface changed" + ); + + // The unversioned surface is closed at exactly v0.5.11. + assert_eq!( + v0, + &[ + "GetTlsKey", + "GetKey", + "GetQuote", + "Attest", + "EmitEvent", + "Info", + "Sign", + "Verify", + "Version" + ], + "the unversioned surface is frozen at the v0.5.11 method set" + ); + + // Everything v1 deliberately does not serve. `Sign` and `Verify` are + // pure computation over a key the caller can already fetch, `GetQuote` + // is the TDX-only channel `Attest` subsumes, and RTMR3 is + // system-owned. All four stay on the frozen surface. + for dropped in ["Sign", "Verify", "GetQuote", "EmitEvent"] { + assert!(!v1.contains(&dropped), "v1 must not serve {dropped}"); + } + + // v0 keeps the old name for what v1 calls `IssueCert`. + assert!(!v1.contains(&"GetTlsKey")); + + // Never-released `next` additions that now live only in v1, or nowhere. + assert!(!v0.contains(&"AttestGpu")); + assert!(v1.contains(&"AttestGpu")); + assert!(!v0.contains(&"GpuInfo") && !v1.contains(&"GpuInfo")); + } + + /// The external pair, checked the same way. + #[test] + fn the_two_external_surfaces_expose_different_method_sets() { + use dstack_guest_agent_rpc::v0::worker_server::WorkerServer as WorkerV0Server; + use dstack_guest_agent_rpc::v1::worker_server::WorkerServer as WorkerV1Server; + + let v0 = WorkerV0Server::::supported_methods(); + let v1 = WorkerV1Server::::supported_methods(); + + // Closed at v0.5.11. `Health` is post-0.5.11 and never released, so it + // lives only on v1. There is no v1 `AttestAppKey`: it would attest the + // v0-KDF `vms` key, which no v1 `GetKey(domain, algorithm)` can return, + // so a pure-v1 app could never hold the attested private key. A v1 app + // attests its own key through `/v1/Attest`. + assert_eq!( + v0, + &["Info", "Version", "GetAttestationForAppKey"], + "the unversioned external surface is frozen at the v0.5.11 method set" + ); + assert_eq!(v1, &["Info", "Version", "Health"]); + } +} diff --git a/dstack/guest-agent/src/rpc_service_v1/keys.rs b/dstack/guest-agent/src/rpc_service_v1/keys.rs new file mode 100644 index 000000000..7163b674e --- /dev/null +++ b/dstack/guest-agent/src/rpc_service_v1/keys.rs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! The v1 application key, assembled from the shared normative primitives. +//! +//! The encodings themselves -- the salt, the context tags, the length-prefixed +//! `info` and claim, and the chain-link envelope -- live in +//! [`ra_tls::api_v1`], so the agent, the verifier and the SDK share one +//! definition instead of three transcriptions of the specification prose. What +//! is left here is turning derived bytes into a usable key pair. + +use anyhow::{anyhow, Context, Result}; +use ed25519_dalek::SigningKey as Ed25519SigningKey; +use k256::ecdsa::SigningKey; +use ra_tls::api_v1::{derive_app_key, key_claim, sign_recoverable_keccak256}; + +pub(crate) use ra_tls::api_v1::KeyAlgorithm as Algorithm; + +/// An application key derived for one `(domain, algorithm)` pair. +pub(crate) struct AppKey { + algorithm: Algorithm, + domain: String, + secret: [u8; 32], + public_key: Vec, +} + +impl AppKey { + /// Derive the key for `(domain, algorithm)` from the app root key. + /// + /// Flat, not hierarchical: `a/b` is an opaque domain string like any other, + /// unrelated to `a`, and no key here derives another. + pub(crate) fn derive(app_root_key: &[u8], domain: &str, algorithm: Algorithm) -> Result { + let secret = derive_app_key(app_root_key, domain, algorithm)?; + let public_key = match algorithm { + // Rejects a scalar that is zero or at least the group order. That + // is a ~2^-128 event for one domain and the caller can just pick + // another one, so failing is better than folding the scalar into + // range and quietly landing two domains on one key. + Algorithm::Secp256k1 => SigningKey::from_slice(&secret) + .context("derived secp256k1 key is not a valid scalar")? + .verifying_key() + .to_sec1_bytes() + .to_vec(), + Algorithm::Ed25519 => Ed25519SigningKey::from_bytes(&secret) + .verifying_key() + .to_bytes() + .to_vec(), + }; + Ok(Self { + algorithm, + domain: domain.to_string(), + secret, + public_key, + }) + } + + /// The raw 32-byte private key. + pub(crate) fn secret(&self) -> Vec { + self.secret.to_vec() + } + + /// SEC1 compressed for secp256k1, 32 raw bytes for ed25519. + pub(crate) fn public_key(&self) -> Vec { + self.public_key.clone() + } + + /// The app root key's signature over this key's claim: the first link of + /// the signature chain. + /// + /// Takes the parsed key rather than its bytes, so the caller can share one + /// across requests instead of re-parsing a scalar per `GetKey`. + pub(crate) fn claim_signature(&self, app_root_key: &SigningKey) -> Result> { + let claim = key_claim(self.algorithm, &self.domain, &self.public_key)?; + sign_recoverable_keccak256(app_root_key, &claim) + .map_err(|err| anyhow!("failed to sign the key claim: {err:#}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The app root key `ra_tls::api_v1` committed its vectors against. + const TEST_APP_ROOT_KEY: [u8; 32] = [ + 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x7A, 0x8B, 0x9C, 0x0D, 0x1E, 0x2F, 0x3A, 0x4B, 0x5C, + 0x6D, 0x7E, 0x8F, 0x9A, 0x0B, 0x1C, 0x2D, 0x3E, 0x4F, 0x5A, 0x6B, 0x7C, 0x8D, 0x9E, 0x0F, + 0x1A, 0x2B, + ]; + + fn derive(domain: &str, algorithm: Algorithm) -> AppKey { + AppKey::derive(&TEST_APP_ROOT_KEY, domain, algorithm).unwrap() + } + + /// The public key vectors, checked through the type the handler actually + /// uses. The private-key and encoding vectors are pinned next to the + /// primitives in `ra_tls::api_v1`. + #[test] + fn derives_the_committed_public_key_vectors() { + let vectors = [ + ( + "", + Algorithm::Secp256k1, + "0377c7fb050db181d392266a3cee9adb2901c6d665f11bac68be5457f577ba4908", + ), + ( + "", + Algorithm::Ed25519, + "a3dc149fd5b765eab2eb7d3174fa939e39386898f10b15b7b146f6f1358ecf2a", + ), + ( + "storage-encryption", + Algorithm::Secp256k1, + "03d962450a41748021c8b02787ac36ce642ff0ae25f4c55019eb527e1112cfd764", + ), + ( + "storage-encryption", + Algorithm::Ed25519, + "2380c4a33a60b60613fa43866421e5b96eb8dcde211317200fd0d41e7e491288", + ), + ( + "a/b/c", + Algorithm::Secp256k1, + "02e9b1a61b6d70aa9b241753828c316bf90e33e77b2e113f9ba75a8b6dc3cde5c1", + ), + ( + "k\u{0}:ey", + Algorithm::Ed25519, + "c833107822b003ff5675b33b90b151d4315c3ab9162b17d876e8dffde41abf9b", + ), + ]; + for (domain, algorithm, expected) in vectors { + assert_eq!( + hex::encode(derive(domain, algorithm).public_key()), + expected, + "v1 public key vector changed for ({domain:?}, {})", + algorithm.name() + ); + } + } + + #[test] + fn the_public_key_lengths_are_the_specified_ones() { + assert_eq!( + derive("storage-encryption", Algorithm::Secp256k1) + .public_key() + .len(), + 33 + ); + assert_eq!( + derive("storage-encryption", Algorithm::Ed25519) + .public_key() + .len(), + 32 + ); + } + + #[test] + fn the_two_algorithms_never_share_key_material() { + for domain in ["", "storage-encryption", "a/b/c"] { + assert_ne!( + derive(domain, Algorithm::Secp256k1).secret(), + derive(domain, Algorithm::Ed25519).secret(), + "cross-algorithm key reuse at {domain:?}" + ); + } + } + + #[test] + fn the_claim_signature_verifies_under_the_app_root_key() { + use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; + use sha3::{Digest as _, Keccak256}; + + let key = derive("storage-encryption", Algorithm::Secp256k1); + let app_root = SigningKey::from_slice(&TEST_APP_ROOT_KEY).unwrap(); + let link = key.claim_signature(&app_root).unwrap(); + assert_eq!(link.len(), 65); + + let claim = key_claim( + Algorithm::Secp256k1, + "storage-encryption", + &key.public_key(), + ) + .unwrap(); + let recovered = VerifyingKey::recover_from_digest( + Keccak256::new_with_prefix(&claim), + &Signature::from_slice(&link[..64]).unwrap(), + RecoveryId::from_byte(link[64]).unwrap(), + ) + .unwrap(); + assert_eq!(&recovered, app_root.verifying_key()); + } +} diff --git a/dstack/guest-agent/src/server.rs b/dstack/guest-agent/src/server.rs index 53a0690ca..aea536575 100644 --- a/dstack/guest-agent/src/server.rs +++ b/dstack/guest-agent/src/server.rs @@ -8,6 +8,7 @@ use crate::config::BindAddr; use crate::guest_api_service::GuestApiHandler; use crate::http_routes; use crate::rpc_service::{AppState, ExternalRpcHandler, InternalRpcHandler, InternalRpcHandlerV0}; +use crate::rpc_service_v1::{ExternalV1RpcHandler, V1RpcHandler}; use crate::socket_activation::{ActivatedSockets, ActivatedUnixListener}; use anyhow::{anyhow, Context, Result}; use ra_rpc::rocket_helper::UnixPeerCredListener; @@ -15,6 +16,7 @@ use rocket::{ fairing::AdHoc, figment::Figment, listener::{unix::UnixListener, Bind, DefaultListener, Endpoint}, + Build, Rocket, }; use rocket_vsock_listener::VsockListener; use sd_notify::{notify as sd_notify, NotifyState}; @@ -80,15 +82,57 @@ async fn run_internal_v0( Ok(()) } +/// Mount everything the internal socket serves. +/// +/// `/v0` is the name of the frozen v0.5.11 surface and `/v1` is the current +/// one. `/` is the same frozen surface under its historical path, kept as a +/// compatibility alias so a pre-0.6 client keeps working unchanged -- it is the +/// identical handler, not a copy, so the two paths cannot drift. +/// +/// Selection is by URL path alone: no header negotiation, no default-version +/// redirect, so a caller's URL is the whole record of which contract it asked +/// for. +/// +/// Factored out of `run_internal` so a test can exercise the real mount table +/// rather than a restatement of it. +fn mount_internal(rocket: Rocket) -> Rocket { + rocket + .mount("/", ra_rpc::prpc_routes!(AppState, InternalRpcHandler)) + .mount("/v0", ra_rpc::prpc_routes!(AppState, InternalRpcHandler)) + .mount("/v1", ra_rpc::prpc_routes!(AppState, V1RpcHandler)) +} + +/// Mount the pRPC services the external listener serves. +/// +/// Same scheme as the internal socket, one level down: `/prpc/v0` is the frozen +/// v0.5.11 `Worker`, `/prpc/v1` is `WorkerV1`, and `/prpc` is the frozen surface +/// under its historical path. +/// +/// The `trim` on the frozen mounts strips the service name a pre-0.6 client +/// prefixes, so `/prpc/Worker.Info` and `/prpc/Info` both land on `Info`. +fn mount_external(rocket: Rocket) -> Rocket { + rocket + .mount( + "/prpc", + ra_rpc::prpc_routes!(AppState, ExternalRpcHandler, trim: "Worker."), + ) + .mount( + "/prpc/v0", + ra_rpc::prpc_routes!(AppState, ExternalRpcHandler, trim: "Worker."), + ) + .mount( + "/prpc/v1", + ra_rpc::prpc_routes!(AppState, ExternalV1RpcHandler), + ) +} + async fn run_internal( state: AppState, figment: Figment, activated_socket: Option, sock_ready_tx: oneshot::Sender<()>, ) -> Result<()> { - let rocket = rocket::custom(figment) - .mount("/", ra_rpc::prpc_routes!(AppState, InternalRpcHandler)) - .manage(state); + let rocket = mount_internal(rocket::custom(figment)).manage(state); let ignite = rocket .ignite() .await @@ -133,12 +177,8 @@ async fn run_internal( } async fn run_external(state: AppState, figment: Figment) -> Result<()> { - let rocket = rocket::custom(figment) + let rocket = mount_external(rocket::custom(figment)) .mount("/", http_routes::external_routes(state.config())) - .mount( - "/prpc", - ra_rpc::prpc_routes!(AppState, ExternalRpcHandler, trim: "Worker."), - ) .attach(AdHoc::on_response("Add app version header", |_req, res| { Box::pin(async move { res.set_raw_header("X-App-Version", app_version()); @@ -257,3 +297,132 @@ pub async fn run(state: AppState, figment: Figment, watchdog: bool) -> Result<() ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc_service::tests::setup_test_state; + use rocket::local::asynchronous::Client; + + /// One agent, serving a whole mount table, for the life of a test. + /// + /// A `Client` per request would compare two different rocket instances and + /// two different `AppState`s; the alias property under test is that `/v0` + /// and `/` reach the *same* handler on the *same* instance. + async fn client( + mount: fn(Rocket) -> Rocket, + ) -> (Client, tempfile::NamedTempFile) { + let (state, guard) = setup_test_state().await; + let client = Client::tracked(mount(rocket::build()).manage(state)) + .await + .expect("rocket failed to ignite"); + (client, guard) + } + + async fn get(client: &Client, path: &str) -> (u16, String) { + let response = client.get(path).dispatch().await; + let status = response.status().code; + (status, response.into_string().await.unwrap_or_default()) + } + + /// The frozen surface answers identically on `/v0` and on the unversioned + /// path it has always had. + /// + /// The alias is what lets a pre-0.6 client keep working, so it has to stay + /// the same handler rather than a second one that happens to agree today. + #[tokio::test] + async fn the_internal_v0_mount_is_an_alias_for_the_unversioned_path() { + let (client, _guard) = client(mount_internal).await; + let (unversioned_status, unversioned) = get(&client, "/Version").await; + let (v0_status, v0) = get(&client, "/v0/Version").await; + + assert_eq!(unversioned_status, 200, "{unversioned}"); + assert_eq!(v0_status, 200, "{v0}"); + assert_eq!(unversioned, v0); + assert!(v0.contains("version"), "{v0}"); + } + + /// Same on the external listener, one level down. + #[tokio::test] + async fn the_external_v0_mount_is_an_alias_for_the_unversioned_path() { + let (client, _guard) = client(mount_external).await; + let (unversioned_status, unversioned) = get(&client, "/prpc/Version").await; + let (v0_status, v0) = get(&client, "/prpc/v0/Version").await; + + assert_eq!(unversioned_status, 200, "{unversioned}"); + assert_eq!(v0_status, 200, "{v0}"); + assert_eq!(unversioned, v0); + } + + /// A pre-0.6 client prefixes the service name. That has to keep working on + /// the alias and on `/v0`. + #[tokio::test] + async fn the_external_mounts_accept_the_service_name_prefix() { + let (client, _guard) = client(mount_external).await; + for path in ["/prpc/Worker.Version", "/prpc/v0/Worker.Version"] { + let (status, body) = get(&client, path).await; + assert_eq!(status, 200, "{path}: {body}"); + } + } + + /// The version in the path selects the surface, and nothing else does. + #[tokio::test] + async fn each_internal_mount_serves_only_its_own_surface() { + let (client, _guard) = client(mount_internal).await; + + // `Verify` is frozen-only; `IssueCert` is v1-only. + let (status, _) = get(&client, "/v1/Verify").await; + assert_ne!(status, 200, "/v1 must not serve the frozen Verify"); + + for path in ["/IssueCert", "/v0/IssueCert"] { + let (status, _) = get(&client, path).await; + assert_ne!(status, 200, "{path} must not serve the v1 IssueCert"); + } + } + + #[tokio::test] + async fn each_external_mount_serves_only_its_own_surface() { + let (client, _guard) = client(mount_external).await; + + // `Health` is v1-only on the external listener. + for path in ["/prpc/Health", "/prpc/v0/Health"] { + let (status, _) = get(&client, path).await; + assert_ne!(status, 200, "{path} must not serve the v1 Health"); + } + } + + /// How a client tells "this agent has no v1" from "v1 said no". + /// + /// Both an absent mount and an unknown method answer 404, so the status + /// alone is not enough: only the body separates them. A failed handler is + /// the 400. `docs/guest-api-v1.md` documents this as the probe rule, and + /// this test is what keeps the documented rule true. + #[tokio::test] + async fn version_probing_can_tell_an_absent_mount_from_an_unknown_method() { + let (state, _guard) = setup_test_state().await; + // An agent that predates v1: the frozen surface and nothing else. + let pre_v1 = Client::tracked( + rocket::build() + .mount("/", ra_rpc::prpc_routes!(AppState, InternalRpcHandler)) + .manage(state), + ) + .await + .expect("rocket failed to ignite"); + + // No `/v1` mount: Rocket has no route to match, and answers its own + // 404 page. This is what an agent too old for v1 looks like. + let (status, body) = get(&pre_v1, "/v1/GetKey").await; + assert_eq!(status, 404); + assert!(!body.contains("Service not found"), "{body}"); + + // A mounted surface, unknown method: also 404, but prpc's, naming the + // method. This is what a *current* agent says to a method it lacks. + let (status, body) = get(&pre_v1, "/NoSuchMethod").await; + assert_eq!(status, 404); + assert!(body.contains("Service not found: NoSuchMethod"), "{body}"); + + // A mounted surface, known method, handler says no: 400. + let (status, _) = get(&pre_v1, "/EmitEvent").await; + assert_eq!(status, 400); + } +} diff --git a/dstack/guest-agent/tests/signature_chain_vectors.rs b/dstack/guest-agent/tests/signature_chain_vectors.rs deleted file mode 100644 index 24cd7a40b..000000000 --- a/dstack/guest-agent/tests/signature_chain_vectors.rs +++ /dev/null @@ -1,231 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -//! Generates and pins the cross-SDK signature-chain test vectors. -//! -//! The Rust, Python, Go and JavaScript SDKs each reimplement `verify_signature` -//! and `verify_signature_chain`. Four independent ports of the same byte-exact -//! format is precisely how this project has shipped cross-language digest bugs -//! before -- the Go compose-hash helper HTML-escaped `<`, so it hashed an -//! app-compose differently from every other SDK, and that digest is what gets -//! whitelisted on chain. -//! -//! So the format lives in one committed file, `sdk/tests/vectors/signature_chain.json`, -//! generated here from the same primitives KMS and the guest agent actually use, -//! and every SDK asserts against it. If the chain format ever changes, this test -//! fails first and the SDK suites fail right after. -//! -//! Run `UPDATE_VECTORS=1 cargo test -p dstack-guest-agent --test signature_chain_vectors` -//! to regenerate after an intentional format change. -//! -//! What these vectors deliberately do NOT pin: the handling of recovery ids 2 -//! and 3 in the two recoverable chain links. Rust, Go and JavaScript accept the -//! full 0..3 range; Python rejects 2 and 3, because `eth_keys` only models v in -//! {0, 1}. Reaching either case requires an ECDSA nonce whose `r` wrapped the -//! curve order, which happens with probability around 2^-128 and which no -//! dstack signer has ever produced -- so there is no way to generate a fixture -//! for it here, and the divergence is unreachable rather than latent. Recorded -//! so the next person does not have to rediscover it. - -use ed25519_dalek::{Signer as _, SigningKey as Ed25519SigningKey}; -use k256::ecdsa::SigningKey; -use serde_json::json; -use sha2::{Digest as _, Sha256}; -use sha3::Keccak256; - -const VECTORS_PATH: &str = "../../sdk/tests/vectors/signature_chain.json"; - -/// Fixed, obviously-fake KMS root scalar. Never a real key. -const KMS_ROOT_SCALAR: [u8; 32] = [ - 0x4b, 0x4d, 0x53, 0x2d, 0x72, 0x6f, 0x6f, 0x74, 0x2d, 0x74, 0x65, 0x73, 0x74, 0x2d, 0x6b, 0x65, - 0x79, 0x2d, 0x64, 0x6f, 0x2d, 0x6e, 0x6f, 0x74, 0x2d, 0x75, 0x73, 0x65, 0x21, 0x21, 0x21, 0x01, -]; -/// 20-byte app id, as `ensure_app_id_len` enforces. -const APP_ID: [u8; 20] = [ - 0xa9, 0x01, 0x9d, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71, 0x82, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, - 0xe8, 0xf9, 0x0a, 0x1b, -]; - -/// `ra_tls::kdf::derive_key` -- HKDF-SHA256, salt "RATLS", info = concat(context_data). -fn derive_key(ikm: &[u8], context: &[&[u8]], len: usize) -> Vec { - ra_tls::kdf::derive_key(ikm, context, len).expect("derive_key") -} - -/// `kms::crypto::sign_message` -- keccak256(prefix ‖ ":" ‖ app_id ‖ message), recoverable. -fn sign_message(key: &SigningKey, prefix: &[u8], appid: &[u8], message: &[u8]) -> Vec { - let digest = - ::new_with_prefix([prefix, b":", appid, message].concat()); - let (sig, recid) = key - .sign_digest_recoverable(digest) - .expect("sign_digest_recoverable"); - let mut out = sig.to_vec(); - out.push(recid.to_byte()); - out -} - -#[test] -fn signature_chain_vectors_are_stable() { - let kms_root = SigningKey::from_bytes(&KMS_ROOT_SCALAR.into()).expect("kms root key"); - let kms_root_pubkey = kms_root.verifying_key().to_sec1_bytes().to_vec(); - - // Link [2]: KMS root signs the derived app root pubkey. Mirrors derive_k256_key(). - let app_root_scalar: [u8; 32] = derive_key(&kms_root.to_bytes(), &[&APP_ID, b"app-key"], 32) - .try_into() - .expect("app root scalar"); - let app_root = SigningKey::from_bytes(&app_root_scalar.into()).expect("app root key"); - let app_root_pubkey = app_root.verifying_key().to_sec1_bytes().to_vec(); - let kms_signature = sign_message(&kms_root, b"dstack-kms-issued", &APP_ID, &app_root_pubkey); - - // Sign() hardcodes path "vms" / purpose "signing". - let path = "vms"; - let purpose = "signing"; - let derived = derive_key(&app_root_scalar, &[path.as_bytes()], 32); - let derived_arr: [u8; 32] = derived.clone().try_into().expect("derived key"); - - let data = b"dstack signature chain test vector".to_vec(); - let prehash: Vec = Sha256::digest(&data).to_vec(); - - let mut cases = Vec::new(); - for algorithm in ["ed25519", "secp256k1", "secp256k1_prehashed"] { - // The signing pubkey, encoded exactly as get_key() hexes it for link [1]. - let (public_key, signature, signed_data) = match algorithm { - "ed25519" => { - let sk = Ed25519SigningKey::from_bytes(&derived_arr); - let pk = sk.verifying_key().to_bytes().to_vec(); - (pk, sk.sign(&data).to_bytes().to_vec(), data.clone()) - } - "secp256k1" => { - let sk = SigningKey::from_slice(&derived).expect("k256 key"); - let pk = sk.verifying_key().to_sec1_bytes().to_vec(); - let sig: k256::ecdsa::Signature = sk.sign(&data); - (pk, sig.to_bytes().to_vec(), data.clone()) - } - "secp256k1_prehashed" => { - use k256::ecdsa::signature::hazmat::PrehashSigner; - let sk = SigningKey::from_slice(&derived).expect("k256 key"); - let pk = sk.verifying_key().to_sec1_bytes().to_vec(); - let sig: k256::ecdsa::Signature = sk.sign_prehash(&prehash).expect("prehash sign"); - (pk, sig.to_bytes().to_vec(), prehash.clone()) - } - _ => unreachable!(), - }; - - // Link [1]: app root signs "{purpose}:{lowerhex(pubkey)}". - let msg = format!("{purpose}:{}", hex::encode(&public_key)); - let app_signature = { - let digest = ::new_with_prefix(msg.as_bytes()); - let (sig, recid) = app_root - .sign_digest_recoverable(digest) - .expect("app root sign"); - let mut out = sig.to_vec(); - out.push(recid.to_byte()); - out - }; - - cases.push(json!({ - "algorithm": algorithm, - // What Sign() hashes over. For secp256k1_prehashed this is the digest itself. - "data": hex::encode(&signed_data), - "public_key": hex::encode(&public_key), - "signature": hex::encode(&signature), - "signature_chain": [ - hex::encode(&signature), - hex::encode(&app_signature), - hex::encode(&kms_signature), - ], - })); - } - - // Negative cases. These pin behaviour that differs between crypto libraries, - // so a port cannot quietly disagree with what the guest agent used to do. - let mut invalid_cases = Vec::new(); - - // 1. High-S malleability. For every ECDSA signature (r, s), the pair (r, n-s) - // is also arithmetically valid. k256 -- and therefore the Sign RPC that used - // to back Verify -- rejects the high-S form. `@noble/curves` rejects it by - // default too, but Python's `cryptography` and Go's decred secp256k1 accept - // it unless the caller checks explicitly. Accepting it would mean a signature - // is not a unique identifier for a signed message. - { - let sk = SigningKey::from_slice(&derived).expect("k256 key"); - let pk = sk.verifying_key().to_sec1_bytes().to_vec(); - let sig: k256::ecdsa::Signature = sk.sign(&data); - let high_s = - k256::ecdsa::Signature::from_scalars(*sig.r(), -*sig.s()).expect("high-s signature"); - assert!( - high_s.normalize_s().is_some(), - "mutated signature must actually be high-S" - ); - invalid_cases.push(json!({ - "name": "secp256k1_high_s", - "reason": "high-S form of an otherwise valid signature; must be rejected", - "algorithm": "secp256k1", - "data": hex::encode(&data), - "public_key": hex::encode(&pk), - "signature": hex::encode(high_s.to_bytes()), - })); - } - - // 2. A valid signature checked against data it does not cover. - { - let sk = SigningKey::from_slice(&derived).expect("k256 key"); - let pk = sk.verifying_key().to_sec1_bytes().to_vec(); - let sig: k256::ecdsa::Signature = sk.sign(&data); - invalid_cases.push(json!({ - "name": "secp256k1_wrong_data", - "reason": "signature is valid, but not over this data", - "algorithm": "secp256k1", - "data": hex::encode(b"not the data that was signed"), - "public_key": hex::encode(&pk), - "signature": hex::encode(sig.to_bytes()), - })); - } - { - let sk = Ed25519SigningKey::from_bytes(&derived_arr); - let pk = sk.verifying_key().to_bytes().to_vec(); - invalid_cases.push(json!({ - "name": "ed25519_wrong_data", - "reason": "signature is valid, but not over this data", - "algorithm": "ed25519", - "data": hex::encode(b"not the data that was signed"), - "public_key": hex::encode(&pk), - "signature": hex::encode(sk.sign(&data).to_bytes()), - })); - } - - // 3. A self-consistent chain that simply is not anchored at our KMS root. - // A verifier that skips the final comparison accepts this, and accepting it - // means the chain proves nothing at all. - let foreign_root = SigningKey::from_bytes(&[0x5au8; 32].into()).expect("foreign root"); - let foreign_pubkey = foreign_root.verifying_key().to_sec1_bytes().to_vec(); - - let vectors = json!({ - "_comment": "Generated by dstack/guest-agent/tests/signature_chain_vectors.rs. \ - Do not edit by hand; run with UPDATE_VECTORS=1 to regenerate.", - "app_id": hex::encode(APP_ID), - "purpose": purpose, - "path": path, - "kms_root_pubkey": hex::encode(&kms_root_pubkey), - "app_root_pubkey": hex::encode(&app_root_pubkey), - "cases": cases, - "invalid_cases": invalid_cases, - "wrong_kms_root_pubkey": hex::encode(&foreign_pubkey), - }); - let rendered = format!("{}\n", serde_json::to_string_pretty(&vectors).unwrap()); - - if std::env::var("UPDATE_VECTORS").is_ok() { - std::fs::write(VECTORS_PATH, &rendered).expect("write vectors"); - return; - } - - let committed = std::fs::read_to_string(VECTORS_PATH).unwrap_or_else(|e| { - panic!("{VECTORS_PATH} missing ({e}); run with UPDATE_VECTORS=1 to generate") - }); - assert_eq!( - committed.trim(), - rendered.trim(), - "signature chain format drifted from the committed cross-SDK vectors; \ - if intentional, regenerate with UPDATE_VECTORS=1 and update all four SDKs" - ); -} diff --git a/dstack/kms/src/crypto.rs b/dstack/kms/src/crypto.rs index 478d802fb..faa8e01f8 100644 --- a/dstack/kms/src/crypto.rs +++ b/dstack/kms/src/crypto.rs @@ -4,8 +4,8 @@ use anyhow::{Context, Result}; use k256::ecdsa::SigningKey; -use sha3::{Digest, Keccak256}; +use ra_tls::api_v1::sign_recoverable_keccak256; use ra_tls::kdf; pub(crate) fn derive_k256_key( @@ -35,11 +35,9 @@ pub(crate) fn sign_message( appid: &[u8], message: &[u8], ) -> Result> { - let digest = Keccak256::new_with_prefix([prefix, b":", appid, message].concat()); - let (signature, recid) = key.sign_digest_recoverable(digest)?; - let mut signature_bytes = signature.to_vec(); - signature_bytes.push(recid.to_byte()); - Ok(signature_bytes) + // Same 65-byte `r || s || v` envelope every dstack chain link uses; the + // preimage is what differs between them. + sign_recoverable_keccak256(key, &[prefix, b":", appid, message].concat()) } /// Sign a message with a timestamp to prevent replay attacks. @@ -52,17 +50,120 @@ pub(crate) fn sign_message_with_timestamp( message: &[u8], ) -> Result> { let timestamp_bytes = timestamp.to_be_bytes(); - let digest = - Keccak256::new_with_prefix([prefix, b":", appid, ×tamp_bytes[..], message].concat()); - let (signature, recid) = key.sign_digest_recoverable(digest)?; - let mut signature_bytes = signature.to_vec(); - signature_bytes.push(recid.to_byte()); - Ok(signature_bytes) + sign_recoverable_keccak256( + key, + &[prefix, b":", appid, ×tamp_bytes[..], message].concat(), + ) } #[cfg(test)] mod tests { use super::*; + use sha3::{Digest as _, Keccak256}; + + /// The pre-refactor envelope, reproduced verbatim from the implementation + /// that shipped before `sign_message` moved onto the shared helper. + /// + /// Kept as executable code rather than prose so the compatibility claim is + /// checked on every run: whatever KMS sends to already-deployed CVMs was + /// produced by exactly this, and a divergence here is a divergence in + /// issued app keys and their signatures. + fn legacy_sign_message( + key: &SigningKey, + prefix: &[u8], + appid: &[u8], + message: &[u8], + ) -> Vec { + let digest = Keccak256::new_with_prefix([prefix, b":", appid, message].concat()); + let (signature, recid) = key.sign_digest_recoverable(digest).unwrap(); + let mut signature_bytes = signature.to_vec(); + signature_bytes.push(recid.to_byte()); + signature_bytes + } + + /// The pre-refactor timestamped envelope, likewise verbatim. + fn legacy_sign_message_with_timestamp( + key: &SigningKey, + prefix: &[u8], + appid: &[u8], + timestamp: u64, + message: &[u8], + ) -> Vec { + let timestamp_bytes = timestamp.to_be_bytes(); + let digest = Keccak256::new_with_prefix( + [prefix, b":", appid, ×tamp_bytes[..], message].concat(), + ); + let (signature, recid) = key.sign_digest_recoverable(digest).unwrap(); + let mut signature_bytes = signature.to_vec(); + signature_bytes.push(recid.to_byte()); + signature_bytes + } + + /// Moving `sign_message` onto `ra_tls::api_v1::sign_recoverable_keccak256` + /// must not change one byte KMS sends to an already-deployed CVM. + /// + /// ECDSA here is deterministic (RFC 6979), so this is an exact comparison + /// rather than a signature check. The cases below are the two shapes KMS + /// actually signs; broader coverage of the KMS surface belongs to the + /// test-infrastructure effort in PR #841, not here. + #[test] + fn the_shared_envelope_matches_the_pre_refactor_bytes() { + let key = SigningKey::from_slice(&[7_u8; 32]).unwrap(); + let cases: [(&[u8], &[u8], &[u8]); 4] = [ + (b"dstack-kms-issued", b"app-a", &[0x42_u8; 33]), + (b"dstack-kms-issued", b"", &[]), + (b"dstack-env-encrypt-pubkey", b"app-b", &[0x01_u8; 32]), + (b"x", b"\x00\xff", &[0xde, 0xad, 0xbe, 0xef]), + ]; + for (prefix, appid, message) in cases { + assert_eq!( + sign_message(&key, prefix, appid, message).unwrap(), + legacy_sign_message(&key, prefix, appid, message), + "envelope changed for prefix {prefix:?}" + ); + } + + for timestamp in [0_u64, 1, 1_786_194_000, u64::MAX] { + assert_eq!( + sign_message_with_timestamp(&key, b"p", b"app", timestamp, b"msg").unwrap(), + legacy_sign_message_with_timestamp(&key, b"p", b"app", timestamp, b"msg"), + "timestamped envelope changed at {timestamp}" + ); + } + } + + /// Golden bytes, so the reference implementation above cannot drift along + /// with the real one and hide a change. + /// + /// Captured from the pre-refactor code. Do not update these to match new + /// output -- a mismatch means KMS would issue different key signatures than + /// the ones deployed CVMs already hold. + #[test] + fn issued_app_key_signatures_match_their_golden_vectors() { + let root = SigningKey::from_slice(&[7_u8; 32]).unwrap(); + let (derived, signature) = derive_k256_key(&root, b"app-a").unwrap(); + + assert_eq!( + hex::encode(derived.to_bytes()), + "ed0fd39ce7c26a185f396945168972807b2f77820f14ee4bda9e1f46e8b4596d", + "the issued app key changed" + ); + assert_eq!( + hex::encode(&signature), + "e12a5f4567f6d9f80b01944dea333bbed5b418347f4260ed95e9ee198a03014e1716a40d2ed6cd367e335b33e231510aa98216e62b631f2b75c181c2f989656800", + "the issued app key signature changed" + ); + // ...and it is still what the pre-refactor envelope produces. + assert_eq!( + signature, + legacy_sign_message( + &root, + b"dstack-kms-issued", + b"app-a", + &derived.verifying_key().to_sec1_bytes() + ) + ); + } #[test] fn environment_public_key_signatures_bind_domain_app_key_and_timestamp() { diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index c315e0128..8b1c20742 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -5,7 +5,9 @@ use super::build_boot_info_for_attestation; use crate::config::{AuthApi, KmsConfig}; use anyhow::{bail, Context, Result}; -use dstack_guest_agent_rpc::{dstack_guest_client::DstackGuestClient, AttestArgs, AttestResponse}; +use dstack_guest_agent_rpc::v0::{ + dstack_guest_client::DstackGuestClient, AttestResponse, RawQuoteArgs, +}; use http_client::prpc::PrpcClient; use ra_tls::attestation::{AttestationVerifier, VerifiedAttestation, VersionedAttestation}; use serde::de::DeserializeOwned; @@ -180,12 +182,7 @@ pub(crate) fn dstack_client() -> DstackGuestClient { } pub(crate) async fn app_attest(report_data: Vec) -> Result { - dstack_client() - .attest(AttestArgs { - report_data, - include_boottime_gpu_evidence: false, - }) - .await + dstack_client().attest(RawQuoteArgs { report_data }).await } pub(crate) fn pad64(hash: [u8; 32]) -> Vec { diff --git a/dstack/ra-tls/Cargo.toml b/dstack/ra-tls/Cargo.toml index 086bcae6b..2530e695b 100644 --- a/dstack/ra-tls/Cargo.toml +++ b/dstack/ra-tls/Cargo.toml @@ -17,6 +17,7 @@ elliptic-curve.workspace = true fs-err.workspace = true hex.workspace = true hkdf.workspace = true +k256.workspace = true p256.workspace = true rcgen = { workspace = true, features = ["x509-parser", "pem"] } ring = { workspace = true, features = ["std"] } @@ -50,4 +51,5 @@ simulator = ["dstack-attest/simulator"] quote = ["dstack-attest/quote"] [dev-dependencies] +ed25519-dalek.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/dstack/ra-tls/src/api_v1.rs b/dstack/ra-tls/src/api_v1.rs new file mode 100644 index 000000000..9cddc2969 --- /dev/null +++ b/dstack/ra-tls/src/api_v1.rs @@ -0,0 +1,438 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Normative constants and encodings for the v1 application-key API. +//! +//! Everything here is wire behaviour, specified in `docs/guest-api-v1.md`. The +//! byte strings these functions build are what a relying party re-derives to +//! check a signature chain, so a change to a constant or a field order changes +//! every key and every claim a deployed agent produces. +//! +//! This lives in `ra-tls` rather than in the guest agent so that the agent, the +//! verifier and the Rust SDK link against one definition. The alternative is +//! each of them transcribing the constants out of the specification prose, +//! which is how cross-implementation crypto drift starts. +//! +//! The module is named for the version of the construction, not for the API +//! that happens to expose it: `ra-tls` hosts versioned crypto, and knowing +//! about a guest API is not its job. + +use crate::kdf::derive_key_with_salt; +use anyhow::{anyhow, bail, Context, Result}; +use k256::ecdsa::SigningKey; +use sha3::{Digest, Keccak256}; + +/// The HKDF salt for every v1 derivation. +/// +/// Distinct from [`crate::kdf::LEGACY_SALT`], which gives v1 its own derivation +/// tree rather than a differently-labelled branch of the old one. Under a +/// shared salt the two surfaces are separated only by their HKDF `info`, and +/// the legacy `info` is the caller's `path` verbatim -- so a caller that passed +/// the v1 `info` byte string as a v0 path would reproduce a v1 key. Different +/// salts close that by construction, whatever either side puts in `info`. +pub const KDF_SALT: &[u8] = b"dstack-guest-v1"; + +/// Context tag bound into every v1 key derivation. +pub const KEY_CONTEXT_TAG: &[u8] = b"dstack-guest-v1-key"; + +/// Context tag bound into every v1 signature-chain key claim. +/// +/// Distinct from [`KEY_CONTEXT_TAG`] so that no derivation input can ever be +/// read as a claim, or the other way round -- the two encodings are otherwise +/// built the same way and would share a prefix. +pub const CLAIM_CONTEXT_TAG: &[u8] = b"dstack-guest-v1-key-claim"; + +/// The key types the v1 API serves. +/// +/// A closed set, matched exhaustively: an algorithm name that is not one of +/// these is an error, never a default. v0 defaulted an empty string to +/// secp256k1 and accepted `k256` as an alias, so a caller could ask for nothing +/// in particular and get a key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyAlgorithm { + /// secp256k1, SEC1-compressed public keys. + Secp256k1, + /// Ed25519, RFC 8032 raw public keys. + Ed25519, +} + +impl KeyAlgorithm { + /// Parse a wire algorithm name. There is no default and no alias. + pub fn parse(name: &str) -> Result { + match name { + "secp256k1" => Ok(Self::Secp256k1), + "ed25519" => Ok(Self::Ed25519), + "" => bail!("algorithm is required, use `secp256k1` or `ed25519`"), + other => bail!("unsupported algorithm `{other}`, use `secp256k1` or `ed25519`"), + } + } + + /// The canonical name, and the exact bytes bound into derivations and + /// claims. Never the string the caller sent. + pub fn name(self) -> &'static str { + match self { + Self::Secp256k1 => "secp256k1", + Self::Ed25519 => "ed25519", + } + } +} + +/// Append one length-prefixed field: a 4-byte big-endian length, then the +/// bytes. +/// +/// Prefixing rather than joining with a delimiter is the whole point. A domain +/// is an arbitrary caller-chosen string -- it may contain `:`, `/`, or NUL -- +/// so any delimiter it could also contain lets two different `(domain, +/// algorithm)` pairs encode to the same byte string and share a key. +pub fn push_length_prefixed(out: &mut Vec, field: &[u8]) -> Result<()> { + let len = u32::try_from(field.len()).context("field is too long to encode")?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(field); + Ok(()) +} + +/// The HKDF `info` for a v1 application key. +/// +/// `LP(tag) || LP(algorithm) || LP(domain)`, where `LP(x)` is `len(x)` as a +/// 4-byte big-endian integer followed by `x`. +pub fn key_derivation_info(domain: &str, algorithm: KeyAlgorithm) -> Result> { + let mut info = Vec::new(); + push_length_prefixed(&mut info, KEY_CONTEXT_TAG)?; + push_length_prefixed(&mut info, algorithm.name().as_bytes())?; + push_length_prefixed(&mut info, domain.as_bytes())?; + Ok(info) +} + +/// Derive the 32 raw bytes of a v1 application key. +/// +/// HKDF-SHA256 over the app root secp256k1 key, under [`KDF_SALT`], with the +/// `info` from [`key_derivation_info`]. +pub fn derive_app_key( + app_root_key: &[u8], + domain: &str, + algorithm: KeyAlgorithm, +) -> Result<[u8; 32]> { + let info = key_derivation_info(domain, algorithm)?; + let derived = derive_key_with_salt(KDF_SALT, app_root_key, &[&info], 32) + .map_err(|_| anyhow!("failed to derive the application key"))?; + derived + .as_slice() + .try_into() + .map_err(|_| anyhow!("derived key has the wrong length")) +} + +/// The claim the app root key signs to vouch for a derived public key. +/// +/// `LP(tag) || LP(algorithm) || LP(domain) || LP(public_key)`, with the public +/// key as raw bytes. +/// +/// Raw bytes, and a length prefix in front of them, are what makes this +/// unforgeable through the v0 surface. v0's claim is +/// `keccak256("{purpose}:{hex(pubkey)}")` over a caller-chosen `purpose`, so +/// the app root key can be made to sign nearly any ASCII string that ends in +/// `:` followed by lowercase hex. This encoding ends in `LP(public_key)`, whose +/// four length bytes are `00 00 00 21` for secp256k1 and `00 00 00 20` for +/// ed25519 and sit inside the region a v0 preimage requires to be hex-only. +/// `0x00` is not a hex character, so no `purpose` reproduces this byte string +/// -- the exclusion is structural, not probabilistic. +pub fn key_claim(algorithm: KeyAlgorithm, domain: &str, public_key: &[u8]) -> Result> { + let mut claim = Vec::new(); + push_length_prefixed(&mut claim, CLAIM_CONTEXT_TAG)?; + push_length_prefixed(&mut claim, algorithm.name().as_bytes())?; + push_length_prefixed(&mut claim, domain.as_bytes())?; + push_length_prefixed(&mut claim, public_key)?; + Ok(claim) +} + +/// Sign `message` as `keccak256(message)`, recoverably. +/// +/// Returns the 65-byte `r || s || v` envelope every dstack signature chain link +/// uses: `r` and `s` big-endian and low-S normalised, then the one-byte +/// recovery id. The recovery byte lets a relying party recover the signing +/// public key from the link alone. +/// +/// One definition, because three copies of this five-line envelope had already +/// accumulated and a chain link that disagrees about byte order verifies +/// nowhere. +pub fn sign_recoverable_keccak256(key: &SigningKey, message: &[u8]) -> Result> { + let (signature, recovery_id) = key + .sign_digest_recoverable(Keccak256::new_with_prefix(message)) + .context("failed to sign the message")?; + let mut envelope = signature.to_vec(); + envelope.push(recovery_id.to_byte()); + Ok(envelope) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The app root key the committed vectors were generated from. + const TEST_APP_ROOT_KEY: [u8; 32] = [ + 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x7A, 0x8B, 0x9C, 0x0D, 0x1E, 0x2F, 0x3A, 0x4B, 0x5C, + 0x6D, 0x7E, 0x8F, 0x9A, 0x0B, 0x1C, 0x2D, 0x3E, 0x4F, 0x5A, 0x6B, 0x7C, 0x8D, 0x9E, 0x0F, + 0x1A, 0x2B, + ]; + + #[test] + fn rejects_an_empty_or_unknown_algorithm() { + assert_eq!( + KeyAlgorithm::parse("").unwrap_err().to_string(), + "algorithm is required, use `secp256k1` or `ed25519`" + ); + assert_eq!( + KeyAlgorithm::parse("k256").unwrap_err().to_string(), + "unsupported algorithm `k256`, use `secp256k1` or `ed25519`" + ); + assert!(KeyAlgorithm::parse("secp256k1_prehashed").is_err()); + assert!(KeyAlgorithm::parse("rsa").is_err()); + } + + #[test] + fn encodes_derivation_info_with_length_prefixes() { + let info = key_derivation_info("storage-encryption", KeyAlgorithm::Secp256k1).unwrap(); + let expected = [ + &19u32.to_be_bytes()[..], + b"dstack-guest-v1-key", + &9u32.to_be_bytes()[..], + b"secp256k1", + &18u32.to_be_bytes()[..], + b"storage-encryption", + ] + .concat(); + assert_eq!(info, expected); + } + + /// A delimiter-joined encoding would collide here; a length-prefixed one + /// cannot. A domain carrying the delimiter is the shape that breaks + /// `join(":")`. + #[test] + fn no_two_domains_encode_the_same_way() { + let a = key_derivation_info("a\u{0}b", KeyAlgorithm::Ed25519).unwrap(); + let b = key_derivation_info("a", KeyAlgorithm::Ed25519).unwrap(); + let c = key_derivation_info("ab", KeyAlgorithm::Ed25519).unwrap(); + assert_ne!(a, b); + assert_ne!(a, c); + assert_ne!(b, c); + } + + /// Committed vectors, also published in `docs/guest-api-v1.md` so a + /// non-Rust implementation can check itself against them. + /// + /// These bytes are the v1 keys of every deployment whose app root key is + /// `TEST_APP_ROOT_KEY`. A diff here is a change to deployed key material, + /// not a fixture update: fix the derivation, do not update the vector. + #[test] + fn derives_the_committed_key_vectors() { + let vectors = [ + ( + "", + KeyAlgorithm::Secp256k1, + "59f60584ce6fd2a3a31997256db9d77322463fc8a6b1520110401bcb1ee92387", + ), + ( + "", + KeyAlgorithm::Ed25519, + "b023493030669cf22e9cafa6a464d4cf3ae4edfe5474ec796710f21ea011946d", + ), + ( + "storage-encryption", + KeyAlgorithm::Secp256k1, + "5510330f86902ddae38c6d89c93a8408019332c17a429e1abd01c4a28d1544a6", + ), + ( + "storage-encryption", + KeyAlgorithm::Ed25519, + "3c4c3ece12fa99ccb93fc0090877f80e70545fdd971e2ac93d3398c4684538d3", + ), + ( + "a/b/c", + KeyAlgorithm::Secp256k1, + "7f0973449298085d2d36a3b4c4d3243c100ba1981ffa885fe9e9dee883e69538", + ), + // A domain carrying NUL and `:`, the two characters a + // delimiter-joined encoding would choke on. + ( + "k\u{0}:ey", + KeyAlgorithm::Ed25519, + "42da8bf0b479ed125c370e3b91f982735bf08ff592abbd586985affa43ee96a1", + ), + ]; + for (domain, algorithm, expected) in vectors { + let key = derive_app_key(&TEST_APP_ROOT_KEY, domain, algorithm).unwrap(); + assert_eq!( + hex::encode(key), + expected, + "v1 key vector changed for ({domain:?}, {})", + algorithm.name() + ); + } + } + + #[test] + fn the_two_algorithms_never_share_key_material() { + for domain in ["", "storage-encryption", "a/b/c", "\u{0}"] { + assert_ne!( + derive_app_key(&TEST_APP_ROOT_KEY, domain, KeyAlgorithm::Secp256k1).unwrap(), + derive_app_key(&TEST_APP_ROOT_KEY, domain, KeyAlgorithm::Ed25519).unwrap(), + "cross-algorithm key reuse at {domain:?}" + ); + } + } + + /// v0 derived `derive_key(app_root, [path], 32)` and handed the same 32 + /// bytes to both curves. v1 must not land on those bytes when the domain + /// string equals the old path. + #[test] + fn v1_keys_differ_from_v0_keys_for_the_same_name() { + for name in ["", "storage-encryption", "vms"] { + let v0 = crate::kdf::derive_key(&TEST_APP_ROOT_KEY, &[name.as_bytes()], 32).unwrap(); + for algorithm in [KeyAlgorithm::Secp256k1, KeyAlgorithm::Ed25519] { + assert_ne!( + derive_app_key(&TEST_APP_ROOT_KEY, name, algorithm) + .unwrap() + .as_slice(), + v0.as_slice() + ); + } + } + } + + /// The one v0 path that could reach a v1 key under a shared salt: the + /// legacy `info` is the caller's `path` verbatim, so passing the v1 `info` + /// byte string as a v0 path made the two derivations identical. + /// + /// The v1 salt closes it by construction. This is the test that would have + /// failed before the salt changed, so it is the one that keeps it changed. + #[test] + fn a_v0_path_cannot_reproduce_a_v1_key() { + for (domain, algorithm) in [ + ("", KeyAlgorithm::Secp256k1), + ("storage-encryption", KeyAlgorithm::Secp256k1), + ("storage-encryption", KeyAlgorithm::Ed25519), + ] { + let info = key_derivation_info(domain, algorithm).unwrap(); + // The best a v0 caller can do: hand the whole v1 info to `path`. + let v0 = crate::kdf::derive_key(&TEST_APP_ROOT_KEY, &[&info], 32).unwrap(); + assert_ne!( + derive_app_key(&TEST_APP_ROOT_KEY, domain, algorithm) + .unwrap() + .as_slice(), + v0.as_slice(), + "a v0 path reproduced the v1 key for ({domain:?}, {})", + algorithm.name() + ); + } + } + + #[test] + fn the_v1_salt_is_not_the_legacy_salt() { + assert_eq!(KDF_SALT, b"dstack-guest-v1"); + assert_ne!(KDF_SALT, crate::kdf::LEGACY_SALT); + } + + #[test] + fn encodes_the_claim_with_the_raw_public_key() { + let public_key = + hex::decode("03d962450a41748021c8b02787ac36ce642ff0ae25f4c55019eb527e1112cfd764") + .unwrap(); + let claim = key_claim(KeyAlgorithm::Secp256k1, "storage-encryption", &public_key).unwrap(); + let expected = [ + &25u32.to_be_bytes()[..], + b"dstack-guest-v1-key-claim", + &9u32.to_be_bytes()[..], + b"secp256k1", + &18u32.to_be_bytes()[..], + b"storage-encryption", + &33u32.to_be_bytes()[..], + &public_key, + ] + .concat(); + assert_eq!(claim, expected); + } + + /// The forgery a malicious app would attempt: v0's `purpose` is + /// caller-chosen, so it picks one that reproduces the v1 claim's prefix + /// and lets v0 append `":" + hex(pubkey)` itself. + #[test] + fn a_v0_claim_cannot_be_crafted_into_a_v1_claim() { + let public_key = + hex::decode("03d962450a41748021c8b02787ac36ce642ff0ae25f4c55019eb527e1112cfd764") + .unwrap(); + let v1_claim = + key_claim(KeyAlgorithm::Secp256k1, "storage-encryption", &public_key).unwrap(); + + // Best case for the attacker: the v1 claim minus exactly the suffix v0 + // appends on its own, used verbatim as `purpose`. + let hex_pubkey = hex::encode(&public_key); + let appended = format!(":{hex_pubkey}"); + let purpose_len = v1_claim.len().saturating_sub(appended.len()); + let purpose = String::from_utf8_lossy(&v1_claim[..purpose_len]).into_owned(); + let v0_claim = format!("{purpose}:{hex_pubkey}").into_bytes(); + + assert_ne!( + v0_claim, v1_claim, + "a v0 purpose reproduced the v1 claim byte string" + ); + assert_ne!( + Keccak256::digest(&v0_claim), + Keccak256::digest(&v1_claim), + "a v0 claim collided with a v1 claim" + ); + + // And the structural reason, so a future encoding change cannot make + // the assertion above pass by accident: a v0 preimage ends in `:` + // followed by lowercase hex only, while the v1 claim's last 37 bytes + // start with the public key's `00 00 00 21` length prefix. + let tail = &v1_claim[v1_claim.len() - 37..]; + assert_eq!(&tail[..4], &33u32.to_be_bytes()); + assert!( + tail.iter().any(|b| !b.is_ascii_hexdigit()), + "the v1 claim tail is entirely hex, which v0 could reproduce" + ); + } + + #[test] + fn the_two_context_tags_are_not_prefixes_of_one_another_once_encoded() { + let derivation = key_derivation_info("p", KeyAlgorithm::Ed25519).unwrap(); + let claim = key_claim(KeyAlgorithm::Ed25519, "p", &[0u8; 32]).unwrap(); + assert!(!claim.starts_with(&derivation)); + assert!(!derivation.starts_with(&claim)); + } + + /// The claim signature is deterministic: RFC 6979 fixes `k` from the key + /// and the digest, so a committed vector pins the whole chain-link + /// encoding, recovery byte included. + #[test] + fn produces_the_committed_claim_signature_vector() { + let public_key = + hex::decode("03d962450a41748021c8b02787ac36ce642ff0ae25f4c55019eb527e1112cfd764") + .unwrap(); + let claim = key_claim(KeyAlgorithm::Secp256k1, "storage-encryption", &public_key).unwrap(); + let key = SigningKey::from_slice(&TEST_APP_ROOT_KEY).unwrap(); + assert_eq!( + hex::encode(sign_recoverable_keccak256(&key, &claim).unwrap()), + "5b6193729ce7976ec67863f21692d4b98c69832698aae8e001a7d33a6f818b6e\ + 46ca950725b6e90e8ca9bcf394abd03ce264bf9b7eec1e91693247f9dd53c269\ + 01" + ); + } + + #[test] + fn the_chain_link_envelope_recovers_the_signer() { + use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; + + let key = SigningKey::from_slice(&TEST_APP_ROOT_KEY).unwrap(); + let link = sign_recoverable_keccak256(&key, b"message").unwrap(); + assert_eq!(link.len(), 65); + + let recovered = VerifyingKey::recover_from_digest( + Keccak256::new_with_prefix(b"message"), + &Signature::from_slice(&link[..64]).unwrap(), + RecoveryId::from_byte(link[64]).unwrap(), + ) + .unwrap(); + assert_eq!(&recovered, key.verifying_key()); + } +} diff --git a/dstack/ra-tls/src/kdf.rs b/dstack/ra-tls/src/kdf.rs index 2b1aa1145..15303bdd5 100644 --- a/dstack/ra-tls/src/kdf.rs +++ b/dstack/ra-tls/src/kdf.rs @@ -19,13 +19,34 @@ impl KeyType for AnySizeKey { } } -/// Derives a key using HKDF-SHA256. +/// The salt every pre-0.6 derivation uses. +/// +/// Load-bearing: it is baked into every key deployed before the versioned guest +/// API. Never change it. +pub const LEGACY_SALT: &[u8] = b"RATLS"; + +/// Derives a key using HKDF-SHA256 under the legacy [`LEGACY_SALT`]. pub fn derive_key( input_key_material: &[u8], context_data: &[&[u8]], key_size: usize, ) -> Result, Unspecified> { - let salt = Salt::new(HKDF_SHA256, b"RATLS"); + derive_key_with_salt(LEGACY_SALT, input_key_material, context_data, key_size) +} + +/// Derives a key using HKDF-SHA256 under an explicit salt. +/// +/// A distinct salt gives a genuinely separate derivation tree: two callers using +/// different salts cannot land on the same key however their `context_data` +/// happens to be built, which a shared salt cannot promise when one caller's +/// context is attacker-chosen. +pub fn derive_key_with_salt( + salt: &[u8], + input_key_material: &[u8], + context_data: &[&[u8]], + key_size: usize, +) -> Result, Unspecified> { + let salt = Salt::new(HKDF_SHA256, salt); let pseudo_rand_key: Prk = salt.extract(input_key_material); let output_key_material: Okm = pseudo_rand_key.expand(context_data, AnySizeKey(key_size))?; @@ -102,6 +123,28 @@ mod tests { assert!(key.iter().any(|&x| x != 0)); } + /// The refactor that introduced an explicit salt must not have moved the + /// legacy derivation, which every pre-0.6 key depends on. + #[test] + fn derive_key_still_uses_the_legacy_salt() { + let context = [b"context one".as_ref(), b"context two".as_ref()]; + assert_eq!( + derive_key(b"input key material", &context, 32).unwrap(), + derive_key_with_salt(b"RATLS", b"input key material", &context, 32).unwrap() + ); + } + + /// Two salts, one everything else: the outputs must be unrelated. This is + /// what makes a salt change a real domain separation rather than a rename. + #[test] + fn a_different_salt_gives_a_different_key() { + let context = [b"context one".as_ref()]; + assert_ne!( + derive_key_with_salt(b"RATLS", b"ikm", &context, 32).unwrap(), + derive_key_with_salt(b"dstack-guest-v1", b"ikm", &context, 32).unwrap() + ); + } + #[test] fn test_derive_key256() { let key = derive_key(b"input key material", &[b"context one"], 256).unwrap(); diff --git a/dstack/ra-tls/src/lib.rs b/dstack/ra-tls/src/lib.rs index 70bb712e8..36dc07585 100644 --- a/dstack/ra-tls/src/lib.rs +++ b/dstack/ra-tls/src/lib.rs @@ -9,6 +9,7 @@ pub extern crate rcgen; pub mod attestation; +pub mod api_v1; pub mod cert; pub mod kdf; pub mod oids; diff --git a/sdk/README.md b/sdk/README.md index 17aa1b4fa..df1a05a99 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -6,6 +6,50 @@ Client libraries for interacting with the dstack guest agent from inside a TEE. All SDKs communicate with the guest agent via HTTP over a Unix socket (`/var/run/dstack.sock`). See the [HTTP API Reference](curl/api.md) for direct access using curl or any HTTP client. +## Two API surfaces, two clients + +The guest agent serves two surfaces on that socket, selected by URL path, and +every SDK mirrors both: + +| Client | Surface | Paths | +|---|---|---| +| `DstackClient` (= `DstackClientV1`) | `dstack.guest.v1` | `/v1/GetKey` | +| `DstackClientV0` | the frozen v0.5.11 API | `/GetKey`, also served at `/v0/GetKey` | + +**The unsuffixed client is v1** in every SDK -- that is the recommended default. +v1 is specified byte-for-byte in +[`docs/guest-api-v1.md`](../docs/guest-api-v1.md). + +`DstackClientV0` is legacy and explicitly named. That surface is closed: it +gains no methods and changes no behaviour, so a v0.5.x program keeps working +against a 0.6 agent unchanged. + +> **The unsuffixed name flipped to v1 in 0.6.0.** Code that used it for v0 calls +> fails loudly on upgrade -- the v1 method signatures differ and `get_key` +> requires `algorithm` explicitly -- rather than silently deriving different +> keys. To stay on the frozen surface, switch to `DstackClientV0`. + +The clients 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. 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. + +> **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 +> there is no compatibility mode. An application holding assets under a v0 key +> must migrate them with a transaction signed by the old key before cutting +> over. + +### Verifying a signature chain + +The SDKs ship no verification helper. Verifying needs no client and no +connection, and it is the relying party's job. +[`docs/guest-api-v1.md`](../docs/guest-api-v1.md) specifies the rules +normatively -- the claim encoding, the recovery step, and the trust anchor the +chain has to terminate at. `DstackClientV0.verify()` remains for single signatures on +the frozen surface, since that is what the v0 surface offers. + ## SDKs | Language | Path | diff --git a/sdk/curl/api.md b/sdk/curl/api.md index 5b07cdc5f..b755cf645 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -16,7 +16,41 @@ services: - /var/run/dstack.sock:/var/run/dstack.sock ``` -## Endpoints +## API versions + +The agent serves two surfaces on this socket, chosen by URL path: + +| Path | Surface | +|---|---| +| `/v1/` | `dstack.guest.v1`, the current API | +| `/v0/` | the frozen v0.5.11 API | +| `/` | the same frozen API, under its historical path | + +**`/v1` is the current API** and what new integrations should target. It is +specified byte-for-byte in +[`docs/guest-api-v1.md`](../../docs/guest-api-v1.md), which is the normative +reference -- this page is a curl-oriented tour, not the contract. + +`/v1` serves exactly six methods: + +| Endpoint | Purpose | +|---|---| +| `/v1/IssueCert` | Issue a certificate, with a freshly generated key | +| `/v1/GetKey` | Derive an application key with its signature chain | +| `/v1/Attest` | Versioned attestation, optionally with boot-time GPU evidence | +| `/v1/AttestGpu` | Collect GPU evidence now, against a nonce you choose | +| `/v1/Info` | Application identity and configuration | +| `/v1/Version` | Agent version | + +> **v1 derives different key material than v0 for the same name**, on purpose, +> with no compatibility mode. See the migration note in the spec. + +The remaining sections document the **legacy v0 surface**. It is frozen at +v0.5.11 and keeps working unchanged, reachable at `/v0/` and at the +unversioned `/` paths it has always had. Sections marked *(v1)* describe +the current API instead. + +## Endpoints (legacy v0) ### 1. Get TLS Key @@ -76,7 +110,7 @@ Generates a deterministic private key from the application key and returns both | `purpose` | string | Purpose for the key. Can be any string. This is used in the signature chain and does not affect the private key bytes. | `"signing"` | | `algorithm` | string | `secp256k1` (default), `k256` (alias), or `ed25519`. For compatibility, this selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. | `ed25519` | -Use algorithm-specific paths, such as `wallet/ethereum` and `wallet/solana`, when independent keys are required across algorithms. +Use algorithm-specific paths, such as `backup-signing/secp256k1` and `backup-signing/ed25519`, when independent keys are required across algorithms. **Example:** ```bash @@ -260,17 +294,19 @@ curl --unix-socket /var/run/dstack.sock http://dstack/Attest?report_data=0000000 } ``` -`boottime_gpu_evidence` carries the same bytes [`GpuInfo`](#8-gpu-info) serves, so one call -returns both the quote and the GPU evidence a verifier needs. It is empty unless -`include_boottime_gpu_evidence` was set and boot-time GPU attestation output exists. It is -**not** bound to `report_data` — authenticate it with the `evidence_sha256` -procedure documented under `GpuInfo` below. +> **v1 only.** `include_boottime_gpu_evidence` and the `boottime_gpu_evidence` +> response field are not on this frozen endpoint. Use +> [`/v1/Attest`](#8-boot-time-gpu-evidence-v1) for them; a request sending +> `include_boottime_gpu_evidence` here is ignored, not honoured. -### 7. Attest GPU +### 7. Attest GPU *(v1)* Collects vendor-native GPU evidence for a caller-chosen 32-byte nonce. -**Endpoint:** `/AttestGpu` +**Endpoint:** `/v1/AttestGpu` + +> **v1 only.** This method is not on the frozen surface. It never appeared in a +> v0.5.x release, so no existing client is affected. **Request Parameters:** @@ -295,38 +331,62 @@ the evidence signature, certificate chain, measurements, and embedded nonce. The agent does not appraise the evidence. Evidence does not by itself bind the GPU to this CVM. -### 8. GPU Info +### 8. Boot-time GPU evidence *(v1)* -Returns GPU information collected during boot. Currently, this includes the -complete JSON output produced by NVIDIA `nvattest`. -The `attestation` field is empty when no GPU attestation output is available, -for example on a VM without an NVIDIA GPU or when GPU attestation was disabled. +Returns the complete output NVIDIA `nvattest` produced during boot, as part of +an attestation -- so one round trip fetches the evidence together with the +attestation needed to authenticate it. -**Endpoint:** `/GpuInfo` +**Endpoint:** `/v1/Attest` **Example:** ```bash -curl --unix-socket /var/run/dstack.sock http://dstack/GpuInfo +curl --unix-socket /var/run/dstack.sock -X POST \ + http://dstack/v1/Attest \ + -H 'Content-Type: application/json' \ + -d '{ + "report_data": "1234deadbeaf", + "include_boottime_gpu_evidence": true + }' ``` **Response:** ```json { - "attestation": "{\"result_code\": 0, \"claims\": [...]}" + "attestation": "", + "boottime_gpu_evidence": [ + { + "vendor": "nvidia", + "format": "nvidia-nvattest-boottime-json-v1", + "evidence": "" + } + ] } ``` -`GpuInfo.attestation` is the exact UTF-8 `nvattest` output saved during boot; -calling this endpoint does not perform a new attestation. To authenticate it on -TDX, first verify the quote and replay the supplied event log to the quote's -RTMR3. Then decode the `gpu-attestation` event payload and compare its -`evidence_sha256` with the SHA-256 digest of the exact returned string: +`boottime_gpu_evidence` uses the same `GpuEvidenceBundle` shape +[`/v1/AttestGpu`](#7-attest-gpu) returns, so one parser handles both. Dispatch +on `format`: `nvidia-nvattest-boottime-json-v1` is the record written at boot, +`nvidia-nvattest-collect-evidence-json-v1` is collected on demand against a +nonce you choose. A verifier for one does not appraise the other. + +It is an empty list when the flag was not set or the guest has no boot-time GPU +output — there is no sentinel value. + +Each bundle's `evidence` decodes to the exact UTF-8 `nvattest` output saved +during boot, byte for byte. Requesting it does not perform a new attestation, +and it is **not** bound to `report_data`. + +To authenticate it on TDX, first verify the quote and replay the supplied event +log to the quote's RTMR3. Then decode the `gpu-attestation` event payload and +compare its `evidence_sha256` with the SHA-256 digest of the exact returned +string: ```python import hashlib import json -gpu_info = json.load(open("gpu-info.json")) +attest_response = json.load(open("attest.json")) quote_response = json.load(open("quote.json")) events = quote_response["event_log"] if isinstance(events, str): @@ -334,7 +394,13 @@ if isinstance(events, str): entry = next(event for event in events if event["event"] == "gpu-attestation") measured = json.loads(bytes.fromhex(entry["event_payload"])) -actual = hashlib.sha256(gpu_info["attestation"].encode()).hexdigest() +# sha256 over the exact bytes the agent read from disk. Do not parse and +# re-serialize the JSON first: that changes the digest. +bundle = next( + b for b in attest_response["boottime_gpu_evidence"] + if b["format"] == "nvidia-nvattest-boottime-json-v1" +) +actual = hashlib.sha256(bytes.fromhex(bundle["evidence"])).hexdigest() assert actual == measured["evidence_sha256"] ``` diff --git a/sdk/go/README.md b/sdk/go/README.md index 58f2a7e14..81ec1e02b 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -21,11 +21,51 @@ dstack applications consist of: ### SDK Capabilities -- **Key Derivation**: Deterministic key derivation for wallets, signing, encryption, and other application-specific secrets -- **Remote Attestation**: TDX quote generation providing cryptographic proof of execution environment -- **TLS Certificate Management**: Fresh certificate generation with optional RA-TLS support for secure connections +- **Key Derivation**: Deterministic key derivation for signing, encryption, and other application-specific secrets +- **Remote Attestation**: Versioned attestations providing cryptographic proof of execution environment, including GPU evidence +- **TLS Certificate Management**: Fresh certificate issuance with optional RA-TLS support for secure connections - **Deployment Security**: Client-side encryption of sensitive environment variables ensuring secrets are only accessible to target TEE applications -- **Blockchain Integration**: Ready-to-use adapters for Ethereum and Solana ecosystems +- **Blockchain Integration (legacy)**: v0-era adapters for Ethereum and Solana, not part of v1 — see [Blockchain adapters](#blockchain-adapters) + +### Two API versions + +dstack 0.6.0 splits the guest agent API into two surfaces on the same socket, +selected by URL path. The SDK mirrors both, and it is a transport mirror only: +it does not translate between them. + +| Client | Paths | Status | +|---|---|---| +| `DstackClient` (= `DstackClientV1`) | `/v1/` | **Current, and the default.** Six methods: `IssueCert`, `GetKey`, `Attest`, `AttestGpu`, `Info`, `Version` | +| `DstackClientV0` | `/GetKey`, equivalently `/v0/GetKey` | Retiring. Frozen at v0.5.11, served unchanged for pre-0.6 clients, never extended | + +```go +client := dstack.NewDstackClient() // v1, the current API — use this +v0 := dstack.NewDstackClientV0() // frozen v0.5.11 API, legacy +``` + +The unsuffixed names mean v1: `DstackClient` is an alias for `DstackClientV1`, +and `NewDstackClient` returns a v1 client. The frozen surface remains available, +but only under its explicit `V0` name. + +What v1 changes: + +- `GetTlsKey` is now `IssueCert` — certificate issuance is the operation; the key was only ever a by-product. +- `path` plus `purpose` collapse into a single `domain`, and `algorithm` is required, with no `k256` alias and no default. +- `Attest` subsumes `GetQuote`; `Info` is flat, with no `tcb_info` blob and no `app_cert`. +- `Sign`, `Verify` and `EmitEvent` are gone. Sign and verify locally with a standard library, using the key `GetKey` returns; `EmitEvent` is gone because runtime RTMR3 events became system-owned. + +> **⚠️ v1 derives different key material than v0.** `client.GetKey(ctx, "storage-encryption", "secp256k1")` +> and `v0.GetKey(ctx, "storage-encryption", "", "secp256k1")` return **unrelated** keys. v1 derives +> under its own HKDF salt and binds the algorithm and a versioned context tag alongside +> the domain, so secp256k1 and ed25519 no longer share one 32-byte secret either. There is +> no compatibility mode and no way to reach a v0 key through v1. An application holding +> anything under a v0 key must migrate it deliberately: derive the v1 key, then re-key +> whatever the old one protected. See `docs/guest-api-v1.md` for the byte-level construction. +> +> Code that used the unsuffixed client for v0 calls fails **loudly** on upgrade rather +> than silently deriving different keys, because the v1 method signatures differ and +> `GetKey` requires `algorithm` explicitly. To stay on the frozen surface, switch to +> `DstackClientV0`. ### Socket Connection Requirements @@ -37,7 +77,7 @@ services: your-app: image: your-app-image volumes: - - /var/run/dstack.sock:/var/run/dstack.sock # dstack OS 0.5.x + - /var/run/dstack.sock:/var/run/dstack.sock # dstack OS 0.5.x and later # For dstack OS 0.3.x compatibility (deprecated): # - /var/run/tappd.sock:/var/run/tappd.sock ``` @@ -70,6 +110,7 @@ package main import ( "context" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -93,22 +134,22 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Println("App ID:", info.AppID) - fmt.Println("Instance ID:", info.InstanceID) + fmt.Printf("App ID: %x\n", info.AppID) + fmt.Printf("Instance ID: %x\n", info.InstanceID) fmt.Println("App Name:", info.AppName) - fmt.Println("TCB Info:", info.TcbInfo) + fmt.Println("App Compose:", info.AppCompose) // Derive deterministic keys for application-specific secrets - walletKey, err := client.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") + storageKey, err := client.GetKey(ctx, "storage-encryption", "secp256k1") if err != nil { log.Fatal(err) } - keyBytes, _ := walletKey.DecodeKey() - fmt.Println("Derived key (32 bytes):", hex.EncodeToString(keyBytes)) // secp256k1 private key - fmt.Println("Signature chain:", walletKey.SignatureChain) // Authenticity proof + fmt.Println("Derived key (32 bytes):", hex.EncodeToString(storageKey.Key)) // secp256k1 private key + fmt.Println("Public key:", hex.EncodeToString(storageKey.PublicKey)) + fmt.Println("Signature chain links:", len(storageKey.SignatureChain)) // Authenticity proof - // Generate remote attestation quote + // Generate a remote attestation, bound to your own data applicationData := map[string]interface{}{ "version": "1.0.0", "timestamp": time.Now().Unix(), @@ -116,94 +157,53 @@ func main() { } jsonData, _ := json.Marshal(applicationData) - quote, err := client.GetQuote(ctx, jsonData) + digest := sha256.Sum256(jsonData) // report data is at most 64 bytes + attestation, err := client.Attest(ctx, digest[:], false) if err != nil { log.Fatal(err) } - fmt.Println("TDX Quote:", quote.Quote) - fmt.Println("Event Log:", quote.EventLog) + fmt.Println("Attestation:", hex.EncodeToString(attestation.Attestation)) } ``` ### Version Compatibility -- **dstack OS 0.5.x**: Use `/var/run/dstack.sock` (current) -- **dstack OS 0.3.x**: Use `/var/run/tappd.sock` (deprecated but supported) +- **dstack OS 0.6.x and later**: serves both `/v1/` and the frozen v0 paths on `/var/run/dstack.sock` +- **dstack OS 0.5.x**: serves the v0 paths only; `DstackClient` (v1) gets a plain HTTP 404 +- **dstack OS 0.3.x**: `/var/run/tappd.sock` (deprecated but supported) -The SDK automatically detects the correct socket path, but you must ensure the appropriate volume binding in your Docker Compose configuration. +The SDK automatically detects the correct socket path, but you must ensure the appropriate volume binding in your Docker Compose configuration. `Version()` is the cheapest probe for whether an agent speaks v1 at all. ## Advanced Features -### TLS Certificate Generation +### TLS Certificate Issuance -Generate fresh TLS certificates with optional Remote Attestation support. **Important**: `GetTlsKey()` generates random keys on each call - it's designed specifically for TLS/SSL scenarios where fresh keys are required. +Issue fresh TLS certificates with optional Remote Attestation support. **Important**: `IssueCert()` generates a random key on each call — it is designed specifically for TLS/SSL scenarios where fresh keys are required. Use `GetKey()` when you need a stable, attestable key. ```go -// Generate TLS certificate with different usage scenarios -tlsKey, err := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - Subject: "my-secure-service", // Certificate common name - AltNames: []string{"localhost", "127.0.0.1"}, // Additional valid domains/IPs - UsageRaTls: true, // Include remote attestation - UsageServerAuth: true, // Enable server authentication (default) - UsageClientAuth: false, // Disable client authentication -}) +// Issue a certificate with different usage scenarios +cert, err := client.IssueCert(ctx, + dstack.WithCertSubject("my-secure-service"), // Certificate common name + dstack.WithCertAltNames([]string{"localhost", "127.0.0.1"}), // Additional valid domains/IPs + dstack.WithCertUsageRaTls(true), // Include remote attestation + dstack.WithCertUsageServerAuth(true), // Enable server authentication + dstack.WithCertUsageClientAuth(false), // Disable client authentication +) if err != nil { log.Fatal(err) } -fmt.Println("Private Key (PEM):", tlsKey.Key) -fmt.Println("Certificate Chain:", tlsKey.CertificateChain) +fmt.Println("Private Key (PEM):", cert.Key) +fmt.Println("Certificate Chain:", cert.CertificateChain) // ⚠️ WARNING: Each call generates a different key -tlsKey1, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) -tlsKey2, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) -// tlsKey1.Key != tlsKey2.Key (always different!) -``` - -## Optional blockchain helpers (build tags) - -By default, the Go SDK builds a **core profile** (attestation, key derivation, info, signing, env encryption). - -Optional helpers are split by tags: - -- `ethereum` tag: - - `ToEthereumAccount()` - - `ToEthereumAccountSecure()` -- `solana` tag: - - `ToSolanaKeypair()` - - `ToSolanaKeypairSecure()` - -### Enable Ethereum helpers - -```bash -# add optional dependency -go get github.com/ethereum/go-ethereum@v1.16.8 - -# build/test with ethereum helpers enabled -go build -tags ethereum ./... -go test -tags ethereum ./... -``` - -### Enable Solana helpers - -```bash -# no extra dependency is required for solana helper APIs -go build -tags solana ./... -go test -tags solana ./... -``` - -### Enable both - -```bash -go get github.com/ethereum/go-ethereum@v1.16.8 -go build -tags "ethereum solana" ./... -go test -tags "ethereum solana" ./... +cert1, _ := client.IssueCert(ctx) +cert2, _ := client.IssueCert(ctx) +// cert1.Key != cert2.Key (always different!) ``` -If you don't need blockchain helper APIs, do not use these tags and you won't pull optional helper imports. - -### Testing against a local starter app +## Testing against a local starter app You can validate SDK changes immediately from another Go project by using `replace`: @@ -219,7 +219,8 @@ go mod tidy go run . ``` -If your starter enables optional blockchain routes, run with matching tags: +If your starter enables the v0-era blockchain routes, run with matching tags +(see [Blockchain adapters](#blockchain-adapters)): ```bash # ethereum only @@ -233,80 +234,6 @@ go run -tags solana . go run -tags "ethereum solana" . ``` -## Blockchain Integration - -### Ethereum - -> requires build tag: `ethereum` - -```go -import ( - "github.com/Dstack-TEE/dstack/sdk/go/dstack" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" -) - -keyResult, err := client.GetKey(ctx, "ethereum/main", "wallet", "secp256k1") -if err != nil { - log.Fatal(err) -} - -// Standard account creation -account, err := dstack.ToEthereumAccount(keyResult) -if err != nil { - log.Fatal(err) -} - -// Enhanced security with SHA256 hashing (recommended) -secureAccount, err := dstack.ToEthereumAccountSecure(keyResult) -if err != nil { - log.Fatal(err) -} - -fmt.Println("Ethereum Address:", secureAccount.Address.Hex()) - -// Connect to Ethereum network -ethClient, err := ethclient.Dial("https://mainnet.infura.io/v3/YOUR-PROJECT-ID") -if err != nil { - log.Fatal(err) -} - -// Use account for transactions... -``` - -### Solana - -> requires build tag: `solana` - -```go -import ( - "encoding/hex" - - "github.com/Dstack-TEE/dstack/sdk/go/dstack" -) - -keyResult, err := client.GetKey(ctx, "solana/main", "wallet", "ed25519") -if err != nil { - log.Fatal(err) -} - -secureKeypair, err := dstack.ToSolanaKeypairSecure(keyResult) -if err != nil { - log.Fatal(err) -} - -fmt.Println("Solana Public Key:", hex.EncodeToString(secureKeypair.PublicKey)) - -// Sign messages -message := []byte("Hello Solana") -signature := secureKeypair.Sign(message) -fmt.Println("Signature:", hex.EncodeToString(signature)) - -// Verify signature -isValid := secureKeypair.Verify(message, signature) -fmt.Println("Valid signature:", isValid) -``` - ## Environment Variables Encryption **Important**: This feature is specifically for **deployment-time security**, not runtime SDK operations. @@ -329,7 +256,7 @@ envVars := []dstack.EnvVar{ {Key: "DATABASE_URL", Value: "postgresql://user:pass@host:5432/db"}, {Key: "API_SECRET_KEY", Value: "your-secret-key"}, {Key: "JWT_PRIVATE_KEY", Value: "-----BEGIN PRIVATE KEY-----\n..."}, - {Key: "WALLET_MNEMONIC", Value: "abandon abandon abandon..."}, + {Key: "BACKUP_SIGNING_SEED", Value: "hex-encoded seed..."}, } // 2. Obtain encryption public key from KMS API (dstack-vmm or Phala Cloud). @@ -387,26 +314,32 @@ fmt.Println("Encrypted payload:", encryptedData) The SDK implements secure key derivation using: - **Deterministic Generation**: Keys are derived using HMAC-based Key Derivation Function (HKDF) -- **Application Isolation**: Different `app_id` values derive different keys even with the same path +- **Application Isolation**: Different `app_id` values derive different keys even with the same domain - **Signature Verification**: All derived keys include cryptographic proof of origin - **TEE Protection**: Master keys never leave the secure enclave ```go -// Each path generates a unique, deterministic key -wallet1, _ := client.GetKey(ctx, "app1/wallet", "ethereum", "secp256k1") -wallet2, _ := client.GetKey(ctx, "app2/wallet", "ethereum", "secp256k1") -// wallet1.Key != wallet2.Key (guaranteed different) +// Each domain generates a unique, deterministic key +storageKey, _ := client.GetKey(ctx, "storage-encryption", "secp256k1") +authKey, _ := client.GetKey(ctx, "api-auth", "secp256k1") +// storageKey.Key != authKey.Key (guaranteed different) + +sameStorageKey, _ := client.GetKey(ctx, "storage-encryption", "secp256k1") +// storageKey.Key == sameStorageKey.Key (guaranteed identical) -sameWallet, _ := client.GetKey(ctx, "app1/wallet", "ethereum", "secp256k1") -// wallet1.Key == sameWallet.Key (guaranteed identical) +// The algorithm is bound into the derivation, so the two curves never share a +// secret — this is a second, unrelated key, not a reinterpretation of the first. +storageKeyEd25519, _ := client.GetKey(ctx, "storage-encryption", "ed25519") ``` +Derivation is **flat**: `a/b` is not a child of `a`. The `/` is a naming convention, nothing more, and two domains that share a prefix yield unrelated keys. + ### Remote Attestation -TDX quotes provide cryptographic proof of: +Attestations provide cryptographic proof of: - **Code Integrity**: Measurement of loaded application code -- **Data Integrity**: Inclusion of application-specific data in quote +- **Data Integrity**: Inclusion of application-specific data in the attestation - **Environment Authenticity**: Verification of TEE platform and configuration ```go @@ -417,13 +350,14 @@ applicationState := map[string]interface{}{ } stateData, _ := json.Marshal(applicationState) -quote, err := client.GetQuote(ctx, stateData) +digest := sha256.Sum256(stateData) // report data is 1-64 bytes +attestation, err := client.Attest(ctx, digest[:], false) if err != nil { log.Fatal(err) } -// Quote can be verified by external parties to confirm: -// 1. Application is running in genuine TEE +// The attestation can be verified by external parties to confirm: +// 1. Application is running in a genuine TEE // 2. Application code matches expected measurements // 3. Application state is authentic and unmodified ``` @@ -459,10 +393,10 @@ export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 ```go client := dstack.NewDstackClient() -// Check if dstack service is available -isAvailable := client.IsReachable(context.Background()) -if !isAvailable { - log.Fatal("dstack service is not reachable") +// Version takes no arguments and touches nothing, so it is the cheapest probe +// for whether the agent is up and speaks v1. +if _, err := client.Version(context.Background()); err != nil { + log.Fatal("dstack v1 service is not reachable: ", err) } ``` @@ -472,7 +406,7 @@ The client automatically connects to `/var/run/dstack.sock`. For local developme client := dstack.NewDstackClient(dstack.WithEndpoint("http://localhost:8090")) ``` -**Options:** +**Options:** the same set applies to `NewDstackClient` and `NewDstackClientV0`. - `WithEndpoint(endpoint string)`: Connection endpoint - Unix socket path (production): `/var/run/dstack.sock` - HTTP/HTTPS URL (development): `http://localhost:8090` @@ -495,187 +429,125 @@ The Docker Compose configuration is embedded in `app-compose.json`: **Important**: The `docker_compose_file` contains YAML content as a string, ensuring the volume binding for `/var/run/dstack.sock` is included. -#### Methods +## `DstackClient` -##### `Info(ctx context.Context) (*InfoResponse, error)` - -Retrieves comprehensive information about the TEE instance. - -**Returns:** `InfoResponse` -- `AppID`: Unique application identifier -- `InstanceID`: Unique instance identifier -- `AppName`: Application name from configuration -- `DeviceID`: TEE device identifier -- `TcbInfo`: Trusted Computing Base information - - `Mrtd`: Measurement of TEE domain - - `Rtmr0-3`: Runtime Measurement Registers - - `EventLog`: Boot and runtime events -- `AppCert`: Application certificate in PEM format - -##### `GetKey(ctx context.Context, path string, purpose string, algorithm string) (*GetKeyResponse, error)` - -Derives deterministic private key material for wallets, signing, encryption, stable service identities, and other application-specific secrets. - -**Parameters:** -- `path`: Unique identifier for key derivation (e.g., `"wallet/ethereum"`, `"signing/solana"`) -- `purpose`: Included in the signature-chain message; does not affect the private key bytes -- `algorithm`: `"secp256k1"` (default behavior), `"k256"` (alias), or `"ed25519"` - -**Returns:** `GetKeyResponse` -- `Key`: 32-byte private key material as a hex string -- `SignatureChain`: Array of cryptographic signatures proving key authenticity - -**Key Characteristics:** -- **Deterministic**: Same path always generates identical raw key material for the same app -- **Isolated**: Different paths produce cryptographically independent keys -- **Blockchain-Ready**: Use `secp256k1` for Ethereum and Bitcoin-style signing; use `ed25519` with a Solana-specific path for independent Solana keys -- **Verifiable**: Signature chain proves key was derived inside genuine TEE - -For compatibility, `algorithm` selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. Use algorithm-specific paths when independent keys are required. - -**Use Cases:** -- Stable service identity keys -- Application signing keys -- Encryption key seeds -- Cryptocurrency wallets and transaction signing -- Any scenario requiring consistent, reproducible keys +The current API, and the default. `DstackClient` is an alias for +`DstackClientV1`; `NewDstackClient` and `NewDstackClientV1` are the same +constructor, with the same options and the same endpoint resolution as the v0 +client — the two surfaces share one socket and differ only in the URL path. ```go -// Examples of deterministic key derivation -ethWallet, _ := client.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") -btcWallet, _ := client.GetKey(ctx, "wallet/bitcoin", "mainnet", "secp256k1") -solWallet, _ := client.GetKey(ctx, "wallet/solana", "mainnet", "ed25519") - -// Same path always returns same key -key1, _ := client.GetKey(ctx, "my-app/signing", "", "secp256k1") -key2, _ := client.GetKey(ctx, "my-app/signing", "", "secp256k1") -// key1.Key == key2.Key (guaranteed identical) - -// Different paths return different keys -userA, _ := client.GetKey(ctx, "user/alice/wallet", "", "secp256k1") -userB, _ := client.GetKey(ctx, "user/bob/wallet", "", "secp256k1") -// userA.Key != userB.Key (guaranteed different) +client := dstack.NewDstackClient() ``` -##### `GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error)` +Protobuf `bytes` fields travel as lowercase hex on the wire and are exposed as +`[]byte`; the fields carrying JSON documents (`AppCompose`, `VmConfig`, +`KeyProviderInfo`) stay `string`. -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) +### Methods -**Returns:** `GetQuoteResponse` -- `Quote`: TDX quote as hex string -- `EventLog`: JSON string of system events +#### `IssueCert(ctx context.Context, options ...IssueCertV1Option) (*IssueCertV1Response, error)` -**Use Cases:** -- Remote attestation of application state -- Cryptographic proof of execution environment -- Audit trail generation +Issues a certificate for this application. Options are `WithCertSubject`, +`WithCertAltNames`, `WithCertUsageRaTls`, `WithCertUsageServerAuth`, +`WithCertUsageClientAuth`, `WithCertAppInfo`, `WithCertNotBefore`, +`WithCertNotAfter`. -##### `AttestWithOptions(ctx context.Context, reportData []byte, opts AttestOptions) (*AttestResponse, error)` +**Returns:** `Key` (PEM) and `CertificateChain` (PEM, leaf first). -Same as `Attest`, with options. Set `IncludeBoottimeGpuEvidence` to also return the boot-time -GPU attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. +The key is freshly generated on every call and is **not** derived from the app +identity — that is what `GetKey` is for. v0 called this `GetTlsKey`. -```go -resp, err := client.AttestWithOptions(ctx, reportData, dstack.AttestOptions{IncludeBoottimeGpuEvidence: true}) -if err != nil { - log.Fatal(err) -} -fmt.Println(resp.BoottimeGpuEvidence) -``` +#### `GetKey(ctx context.Context, domain string, algorithm string) (*GetKeyV1Response, error)` -The evidence is the same bytes ``GpuInfo`` serves and is empty unless the flag was set -and boot-time GPU attestation output exists. It is not bound to `report_data`; verify -it with the measured `gpu-attestation` event digest as described under ``GpuInfo``. +Derives an application key from `(domain, algorithm)`. -##### `AttestGpu(ctx context.Context, nonce []byte) (*AttestGpuResponse, error)` +- `domain`: any byte string, including one containing `:`, `/` or NUL. Derivation + is **flat**: `a/b` is not a child of `a`, and two domains yield unrelated keys. +- `algorithm`: `secp256k1` or `ed25519`. **Required** — there is no default and + no `k256` alias, so a typo is an error rather than a key of the wrong type + under a name you thought meant something else. An empty value is rejected + client-side. -Collects vendor-native GPU evidence for a caller-chosen 32-byte nonce. +**Returns:** `Key` (32 bytes), `PublicKey` (33 bytes SEC1-compressed for +secp256k1, 32 raw bytes for ed25519), and a two-element `SignatureChain`. ```go -resp, err := client.AttestGpu(ctx, nonce) -if err != nil { - log.Fatal(err) -} -for _, bundle := range resp.Bundles { - fmt.Println(bundle.Vendor, bundle.Format, bundle.Evidence) -} +key, err := client.GetKey(ctx, "backup-signing", "secp256k1") ``` -Select a verifier using each bundle's `Vendor` and `Format`. The verifier must check -the evidence signature, certificate chain, measurements, and embedded nonce. Evidence -is opaque and hex-encoded by the JSON RPC. It does not by itself bind the GPU to this -CVM. +**Use Cases:** +- Stable service identity keys +- Application signing keys +- Encryption key seeds +- Any scenario requiring consistent, reproducible keys + +#### `Attest(ctx context.Context, reportData []byte, includeBoottimeGpuEvidence bool) (*AttestV1Response, error)` -##### `GpuInfo(ctx context.Context) (*GpuInfoResponse, error)` +Produces a versioned attestation over `reportData` (1–64 bytes, zero-padded on +the right to 64). The sole CVM attestation entry point in v1: the attestation +already carries the TDX quote and the event log, so there is no `GetQuote`. -Returns GPU information collected during boot. Currently, this includes the -complete NVIDIA `nvattest` JSON output. +Setting `includeBoottimeGpuEvidence` also returns `BoottimeGpuEvidence`, the GPU +evidence `nvattest` recorded at boot, as `[]GpuEvidenceBundle` — the same type +`AttestGpu` returns, so one parser serves both methods. Absence is the empty +slice, not a sentinel: it stays empty unless you asked for it *and* the guest has +boot-time output. + +It is **not** bound to `reportData`: bind a bundle by replaying the runtime event +log and comparing sha256 of its `Evidence` against the `evidence_sha256` field of +the measured `gpu-attestation` event. `Evidence` holds the exact bytes `nvattest` +wrote, byte for byte, and that exactness is what makes the comparison work — +re-serializing the JSON changes the digest. ```go -gpu, err := client.GpuInfo(ctx) -if err != nil { - log.Fatal(err) +att, err := client.Attest(ctx, reportData, true) +for _, bundle := range att.BoottimeGpuEvidence { + // bundle.Format == "nvidia-nvattest-boottime-json-v1" + digest := sha256.Sum256(bundle.Evidence) + // compare digest against the `gpu-attestation` event's evidence_sha256 } -fmt.Println(gpu.Attestation) ``` -The `Attestation` field is empty when no GPU attestation output is available. -The raw output is not trusted by itself; remote verifiers should compare its -digest with the measured `gpu-attestation` runtime event. +#### `AttestGpu(ctx context.Context, nonce []byte) (*AttestGpuV1Response, error)` -##### `GetTlsKey(ctx context.Context, options TlsKeyOptions) (*GetTlsKeyResponse, error)` +Collects GPU evidence now, against a nonce you choose. The nonce must be +**exactly 32 bytes** (checked client-side): SPDM fixes the evidence nonce at that +length and dstack passes it through verbatim, so you can compare it directly +against the `eat_nonce` claim rather than reversing a hash. -Generates a fresh, random TLS key pair with X.509 certificate for TLS/SSL connections. **Important**: This method generates different keys on each call - use `GetKey()` for deterministic keys. +**Returns:** `Bundles`, each a `GpuEvidenceBundle` with `Vendor`, `Format` and +opaque `Evidence` bytes — the same type `Attest` returns in +`BoottimeGpuEvidence`. `Format` is what separates them: these carry +`nvidia-nvattest-collect-evidence-json-v1`, the boot-time record carries +`nvidia-nvattest-boottime-json-v1`, and a verifier for one does not appraise the +other. -**Parameters:** `TlsKeyOptions` -- `Subject`: Certificate subject (Common Name) - typically the domain name (default: `""`) -- `AltNames`: Subject Alternative Names - additional domains/IPs for the certificate (default: `[]`) -- `UsageRaTls`: Include TDX attestation quote in certificate extension for remote verification (default: `false`) -- `UsageServerAuth`: Enable server authentication - allows certificate to authenticate servers (default: `true`) -- `UsageClientAuth`: Enable client authentication - allows certificate to authenticate clients (default: `false`) +This is evidence, not a verdict — select a verifier by vendor and format, then +check the signature, certificate chain, measurements and embedded nonce. It +still does not bind the GPU to this CVM. -**Returns:** `GetTlsKeyResponse` -- `Key`: Private key in PEM format (X.509/PKCS#8) -- `CertificateChain`: Certificate chain array +#### `Info(ctx context.Context) (*InfoV1Response, error)` -**Key Characteristics:** -- **Random Generation**: Each call produces a completely different key -- **TLS-Optimized**: Keys and certificates designed for TLS/SSL scenarios -- **RA-TLS Support**: Optional remote attestation extension in certificates -- **TEE-Signed**: Certificates signed by TEE-resident Certificate Authority +Identity and configuration, in a flat shape: `AppID`, `AppName`, `ComposeHash`, +`AppCompose`, `InstanceID`, `DeviceID`, `OsImageHash`, `MrAggregated`, +`VmConfig`, `KeyProviderInfo`, `CloudVendor`, `CloudProduct`. -```go -// Example 1: Standard HTTPS server certificate -serverCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - Subject: "api.example.com", - AltNames: []string{"api.example.com", "www.api.example.com", "10.0.0.1"}, - // UsageServerAuth: true (default) - allows server authentication - // UsageClientAuth: false (default) - no client authentication -}) +No `TcbInfo` and no `AppCert`. The measurement registers and the event log live +on the attestation `Attest` returns, which is the only place they are +quote-backed. Nothing here is evidence — it arrives over a local socket with no +quote behind it, so confirm the hashes against an attestation before relying on +them. -// Example 2: Certificate with remote attestation (RA-TLS) -attestedCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - Subject: "secure-api.example.com", - UsageRaTls: true, // Include TDX quote for remote verification - // Clients can verify the TEE environment through the certificate -}) +`ComposeHash` is sha256 over the exact `AppCompose` bytes. Do not parse and +re-serialize before hashing: key order, whitespace and unknown fields all change +the digest, and that digest is what gets whitelisted on chain. -// ⚠️ Each call generates different keys (unlike GetKey) -cert1, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) -cert2, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) -// cert1.Key != cert2.Key (always different) -``` - -##### `IsReachable(ctx context.Context) bool` +#### `Version(ctx context.Context) (*VersionV1Response, error)` -Tests connectivity to the dstack service. - -**Returns:** `bool` indicating service availability +Returns the agent `Version` and `Rev`. The cheapest probe for whether an agent +speaks v1 at all: an agent that predates v1 has no `/v1` mount and answers with +a plain HTTP 404. ## Utility Functions @@ -700,82 +572,29 @@ fmt.Println("Configuration hash:", hash) ### Signature Verification -Signatures produced by `client.Sign()` are verified **locally**. Verification -needs no key material and no attestation, so it does not belong behind an RPC to -the guest agent: the agent's answer would arrive over the socket unattested, -which is no better than checking the signature yourself. The `Verify` RPC these -functions replace was removed in v0.6.0. +The SDK no longer ships local signature or signature-chain helpers, and v1 has +no `Verify` RPC. Verification needs no key material and no attestation, so the +guest agent is not the right place for it: its answer arrives over the socket +unattested, which is no better than checking the signature yourself. Sign and +verify locally with a standard Go crypto library, using the key `GetKey` returns. -#### `VerifySignature(algorithm string, data, signature, publicKey []byte) (bool, error)` +**`docs/guest-api-v1.md` is the normative specification for verifying a v1 +chain.** It pins the bytes: the length-prefixed claim encoding, the KDF, and the +step-by-step procedure a relying party follows. In outline, `GetKey` returns two +links — -Checks one signature against a public key you already have. `algorithm` is -`ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, where `data` is -already a 32-byte digest. secp256k1 public keys are SEC1 (compressed or -uncompressed) and signatures are raw 64-byte `r || s`, not DER. - -Returns `(false, nil)` when the inputs are well-formed but the signature does not -check out, and a non-nil error when they are not well-formed at all (bad key -encoding, wrong signature length, unknown algorithm, non-canonical high-S -signature) — a malformed input is a caller bug, not a verdict. - -```go -signResp, err := client.Sign(ctx, "secp256k1", payload) -if err != nil { - log.Fatal(err) -} - -valid, err := dstack.VerifySignature("secp256k1", payload, signResp.Signature, signResp.PublicKey) -if err != nil { - log.Fatalf("malformed signature input: %v", err) -} -fmt.Println("signature valid:", valid) -``` - -On its own this proves only that whoever holds `signResp.PublicKey` signed the -data. To establish that the signer was a dstack app, verify the chain. - -#### `VerifySignatureChain(input SignatureChainInput) ([]byte, error)` - -Walks the full chain from a `SignResponse` back to a KMS root key **you supply**, -and returns the app root public key (compressed SEC1, 33 bytes). Three links must -all hold: - -1. `SignatureChain[0]` is a signature over `Data` by `PublicKey`. -2. `SignatureChain[1]` is the app root key attesting `"{purpose}:{hex(PublicKey)}"`. -3. `SignatureChain[2]` is `KMSRootPubKey` attesting that app root key for `AppID`. - -Link 3 is the one that matters. Without comparing against a KMS root key you -independently trust, a chain is just three signatures an attacker could have -produced with their own keys. Get the root from the `DstackKms` contract -(`kmsInfo().k256Pubkey`) or pin it. Reading it from the KMS you are verifying -against proves nothing. - -```go -// Both anchors come from you, not from the CVM being checked. -appID, _ := hex.DecodeString("a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b") -kmsRoot, _ := hex.DecodeString("03...") // pinned, or read from the DstackKms contract - -appRootPubKey, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: "secp256k1", - Data: payload, - PublicKey: signResp.PublicKey, - SignatureChain: signResp.SignatureChain, - AppID: appID, - KMSRootPubKey: kmsRoot, - // Purpose defaults to dstack.SignPurpose ("signing"), which is what Sign uses. -}) -if err != nil { - log.Fatalf("signature chain rejected: %v", err) -} -fmt.Printf("app root key: %x\n", appRootPubKey) +```text +[0] app root key signs keccak256(LP("dstack-guest-v1-key-claim") || LP(algorithm) || LP(domain) || LP(public_key)) +[1] KMS root key signs keccak256("dstack-kms-issued" || ":" || app_id || app_root_pubkey) ``` -Note what the example does *not* do: it never passes `info.AppID` from -`client.Info()` straight through. That value is reported by the very CVM being -verified, so a chain checked against it proves only that the CVM is -self-consistent with itself. Use the app id you registered on chain, and if you -want `Info` in the picture, compare it against that value rather than trusting -it. +— and the step that carries the security of all the others is the anchor: obtain +the KMS root public key from a source you trust independently of the agent being +checked, either the `DstackKms` contract's `kmsInfo().k256Pubkey` or a value +pinned out of band. An attacker who can answer your query for the anchor can also +mint a self-consistent chain, so reading it from the KMS you are checking proves +nothing. The same goes for `app_id`: use the one you registered on chain, not the +one `Info()` echoed back from the CVM you are verifying. ### KMS Public Key Verification @@ -820,13 +639,13 @@ fmt.Println("Trusted KMS identity:", actualKMSIdentity) ## Security Best Practices 1. **Key Management** - - Use descriptive, unique paths for key derivation + - Use descriptive, unique domains for key derivation - Never expose derived keys outside the TEE - Implement proper access controls in your application 2. **Remote Attestation** - - Always verify quotes before trusting remote TEE instances - - Include application-specific data in quote generation + - Always verify attestations before trusting remote TEE instances + - Include application-specific data in `reportData` - Validate RTMR measurements against expected values 3. **TLS Configuration** @@ -839,22 +658,273 @@ fmt.Println("Trusted KMS identity:", actualKMSIdentity) - Log security events for monitoring - Avoid fallback behavior that weakens verification or key isolation -## Migration Guide +## Development -### Critical API Changes: Understanding the Separation +### Running the Simulator -The legacy client mixed two different use cases that have now been properly separated: +For local development without TDX devices, you can use the simulator: -1. **`GetKey()`**: Deterministic key derivation for application-specific secrets -2. **`GetTlsKey()`**: Random TLS certificate generation for HTTPS/SSL +```bash +git clone https://github.com/Dstack-TEE/dstack.git +cd dstack/sdk/simulator +./build.sh +./dstack-simulator +``` -### From TappdClient to DstackClient +### Running Tests -**⚠️ BREAKING CHANGE**: `TappdClient` is deprecated and will be removed. All users must migrate to `DstackClient`. +```bash +# Set environment variables and run tests +TAPPD_SIMULATOR_ENDPOINT=/path/to/simulator/tappd.sock \ +DSTACK_SIMULATOR_ENDPOINT=/path/to/simulator/dstack.sock \ +go test -v ./dstack ./tappd +``` + +Run tests: + +```bash +go test -v ./dstack +``` + +--- + +# Legacy (v0, frozen) + +Everything below this line describes the frozen v0.5.11 surface. It is retiring: +present so pre-0.6 applications keep working, never extended, and reachable only +under its explicit `V0` name. New code should use +[`DstackClient`](#dstackclient), which is v1. + +```go +v0 := dstack.NewDstackClientV0() + +info, _ := v0.Info(ctx) // AppID and friends are hex strings +key, _ := v0.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") +quote, _ := v0.GetQuote(ctx, reportData) // Intel TDX only +tlsKey, _ := v0.GetTlsKey(ctx, dstack.WithSubject("api.example.com")) +``` + +> **⚠️ v0 keys are not v1 keys.** Moving a name from `DstackClientV0` to +> `DstackClient` derives **unrelated** key material — see the warning under +> [Two API versions](#two-api-versions). Code that used the unsuffixed client for +> v0 calls fails **loudly** on upgrade rather than silently deriving different +> keys, because the v1 method signatures differ and `GetKey` requires +> `algorithm` explicitly. To stay on the frozen surface, switch to +> `DstackClientV0`. + +## `DstackClientV0` methods + +### `Info(ctx context.Context) (*InfoResponse, error)` + +Retrieves comprehensive information about the TEE instance. + +**Returns:** `InfoResponse` +- `AppID`: Unique application identifier +- `InstanceID`: Unique instance identifier +- `AppName`: Application name from configuration +- `DeviceID`: TEE device identifier +- `TcbInfo`: Trusted Computing Base information + - `Mrtd`: Measurement of TEE domain + - `Rtmr0-3`: Runtime Measurement Registers + - `EventLog`: Boot and runtime events +- `AppCert`: Application certificate in PEM format + +### `GetKey(ctx context.Context, path string, purpose string, algorithm string) (*GetKeyResponse, error)` + +Derives deterministic private key material for wallets, signing, encryption, stable service identities, and other application-specific secrets. + +**Parameters:** +- `path`: Unique identifier for key derivation (e.g., `"wallet/ethereum"`, `"signing/solana"`) +- `purpose`: Included in the signature-chain message; does not affect the private key bytes +- `algorithm`: `"secp256k1"` (default behavior), `"k256"` (alias), or `"ed25519"` + +**Returns:** `GetKeyResponse` +- `Key`: 32-byte private key material as a hex string +- `SignatureChain`: Array of cryptographic signatures proving key authenticity + +**Key Characteristics:** +- **Deterministic**: Same path always generates identical raw key material for the same app +- **Isolated**: Different paths produce cryptographically independent keys +- **Blockchain-Ready**: Use `secp256k1` for Ethereum and Bitcoin-style signing; use `ed25519` with a Solana-specific path for independent Solana keys +- **Verifiable**: Signature chain proves key was derived inside genuine TEE + +For compatibility, `algorithm` selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. Use algorithm-specific paths when independent keys are required. v1 fixes this by binding `algorithm` and a versioned context tag into the KDF — and, for the same reason, a v1 key is never a v0 key. + +```go +// Examples of deterministic key derivation +ethWallet, _ := v0.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") +btcWallet, _ := v0.GetKey(ctx, "wallet/bitcoin", "mainnet", "secp256k1") +solWallet, _ := v0.GetKey(ctx, "wallet/solana", "mainnet", "ed25519") + +// Same path always returns same key +key1, _ := v0.GetKey(ctx, "my-app/signing", "", "secp256k1") +key2, _ := v0.GetKey(ctx, "my-app/signing", "", "secp256k1") +// key1.Key == key2.Key (guaranteed identical) + +// Different paths return different keys +userA, _ := v0.GetKey(ctx, "user/alice/wallet", "", "secp256k1") +userB, _ := v0.GetKey(ctx, "user/bob/wallet", "", "secp256k1") +// userA.Key != userB.Key (guaranteed different) +``` + +### `GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error)` + +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) + +**Returns:** `GetQuoteResponse` +- `Quote`: TDX quote as hex string +- `EventLog`: JSON string of system events + +### `Attest(ctx context.Context, reportData []byte) (*AttestResponse, error)` + +Produces a versioned dstack attestation over `reportData` (at most 64 bytes), +covering every supported platform rather than Intel TDX alone. + +**Returns:** `AttestResponse` +- `Attestation`: the versioned attestation bytes + +There is no GPU option on this surface. GPU attestation is v1 only — see +`DstackClient.Attest` and `DstackClient.AttestGpu`. + +### `Sign(ctx context.Context, algorithm string, data []byte) (*SignResponse, error)` + +Signs a payload with the app signing key. `algorithm` is `ed25519`, `secp256k1`, +or `secp256k1_prehashed` (where `data` is already a 32-byte digest). + +### `Verify(ctx context.Context, algorithm string, data, signature, publicKey []byte) (*VerifyResponse, error)` + +Asks the agent to check a signature, and reports the agent's verdict, not an +attested one. Frozen surface only: v1 has no `Verify`, because verification needs +no key material and no attestation, so the agent's answer arrives unattested and +is no better than checking the signature yourself. See `docs/guest-api-v1.md` for +how to verify a v1 chain. + +### `EmitEvent(ctx context.Context, event string, payload []byte) error` + +**Removed server-side in dstack 0.6.0.** Runtime RTMR3 events became +system-owned, so an agent from 0.6.0 on answers this with an error, which the +client returns rather than swallowing — an application that believes it measured +something it did not is worse off than one that fails loudly. The method remains +so that pre-0.6 code still compiles. Bind application data through `reportData` +on `Attest` instead. + +### `GetTlsKey(ctx context.Context, options ...TlsKeyOption) (*GetTlsKeyResponse, error)` + +Generates a fresh, random TLS key pair with X.509 certificate for TLS/SSL connections. **Important**: This method generates different keys on each call - use `GetKey()` for deterministic keys. v1 calls this `IssueCert`. + +**Options:** `WithSubject`, `WithAltNames`, `WithUsageRaTls`, `WithUsageServerAuth`, `WithUsageClientAuth`, `WithNotBefore`, `WithNotAfter`, `WithAppInfo`. + +**Returns:** `GetTlsKeyResponse` +- `Key`: Private key in PEM format (X.509/PKCS#8) +- `CertificateChain`: Certificate chain array + +```go +// Example 1: Standard HTTPS server certificate +serverCert, _ := v0.GetTlsKey(ctx, + dstack.WithSubject("api.example.com"), + dstack.WithAltNames([]string{"api.example.com", "www.api.example.com", "10.0.0.1"}), + dstack.WithUsageServerAuth(true), +) + +// Example 2: Certificate with remote attestation (RA-TLS) +attestedCert, _ := v0.GetTlsKey(ctx, + dstack.WithSubject("secure-api.example.com"), + dstack.WithUsageRaTls(true), // Include TDX quote for remote verification +) + +// ⚠️ Each call generates different keys (unlike GetKey) +cert1, _ := v0.GetTlsKey(ctx) +cert2, _ := v0.GetTlsKey(ctx) +// cert1.Key != cert2.Key (always different) +``` + +### `GetVersion(ctx context.Context) (*VersionResponse, error)` + +Returns the guest-agent version. Available on dstack OS 0.5.7 and later; older +agents have no `Version` RPC and this returns an error. -### Complete Migration Reference +### `IsReachable(ctx context.Context) bool` -| Component | TappdClient (Old) | DstackClient (New) | Status | +Tests connectivity to the dstack service. + +**Returns:** `bool` indicating service availability + +## Blockchain adapters + +The chain adapters are v0-era. `ToEthereumAccount`, `ToEthereumAccountSecure`, +`ToSolanaKeypair` and `ToSolanaKeypairSecure` accept the v0 `*GetKeyResponse` +(and, with a warning, `*GetTlsKeyResponse`), and that is the only shape they +take: v1 has no chain-related surface. `GetKey` returns key material, and what +an application builds out of those bytes is its own business. + +```go +keyResult, _ := v0.GetKey(ctx, "ethereum/main", "wallet", "secp256k1") + +// Enhanced security with SHA256 hashing (recommended over ToEthereumAccount) +secureAccount, err := dstack.ToEthereumAccountSecure(keyResult) +if err != nil { + log.Fatal(err) +} +fmt.Println("Ethereum Address:", secureAccount.Address.Hex()) +``` + +### Build tags + +By default, the Go SDK builds a **core profile** (attestation, key derivation, info, env encryption). + +The adapters are split by tags: + +- `ethereum` tag: + - `ToEthereumAccount()` + - `ToEthereumAccountSecure()` +- `solana` tag: + - `ToSolanaKeypair()` + - `ToSolanaKeypairSecure()` + +#### Enable Ethereum helpers + +```bash +# add optional dependency +go get github.com/ethereum/go-ethereum@v1.16.8 + +# build/test with ethereum helpers enabled +go build -tags ethereum ./... +go test -tags ethereum ./... +``` + +#### Enable Solana helpers + +```bash +# no extra dependency is required for solana helper APIs +go build -tags solana ./... +go test -tags solana ./... +``` + +#### Enable both + +```bash +go get github.com/ethereum/go-ethereum@v1.16.8 +go build -tags "ethereum solana" ./... +go test -tags "ethereum solana" ./... +``` + +If you don't need blockchain helper APIs, do not use these tags and you won't pull optional helper imports. + +## Migration from TappdClient + +**⚠️ BREAKING CHANGE**: `TappdClient` is deprecated and will be removed. + +The legacy tappd client mixed two different use cases that v0 already separated: + +1. **`GetKey()`**: Deterministic key derivation for application-specific secrets +2. **`GetTlsKey()`**: Random TLS certificate generation for HTTPS/SSL + +| Component | TappdClient (Old) | DstackClientV0 (New) | Status | |-----------|-------------------|-------------------|--------| | **Socket Path** | `/var/run/tappd.sock` | `/var/run/dstack.sock` | ✅ Updated | | **HTTP URL Format** | `http://localhost/prpc/Tappd.` | `http://localhost/` | ✅ Simplified | @@ -862,39 +932,42 @@ The legacy client mixed two different use cases that have now been properly sepa | **TLS Certificate Method** | `DeriveKey(...)` | `GetTlsKey(...)` | ✅ Separated | | **TDX Quote** | `TdxQuote(...)` | `GetQuote(report_data)` | ✅ Renamed | -#### Migration Steps - **Step 1: Update Imports and Client** ```go // Before import "github.com/Dstack-TEE/dstack/sdk/go/tappd" -client := tappd.NewTappdClient() +tappdClient := tappd.NewTappdClient() // After import "github.com/Dstack-TEE/dstack/sdk/go/dstack" -client := dstack.NewDstackClient() +v0 := dstack.NewDstackClientV0() ``` +The table above maps tappd onto the v0 method set, which is the smallest step +away from `TappdClient`. It is not the destination: new code should target +`NewDstackClient` (v1). See [Two API versions](#two-api-versions) for what +changes, including the warning that v1 derives different key material. + **Step 2: Update Method Calls** ```go // For deterministic application keys (most common) // Before: TappdClient methods -keyResult, _ := client.DeriveKey(ctx, "wallet") +keyResult, _ := tappdClient.DeriveKey(ctx, "wallet") -// After: DstackClient methods -keyResult, _ := client.GetKey(ctx, "wallet/ethereum", "ethereum", "secp256k1") +// After: DstackClientV0 methods +keyResult, _ := v0.GetKey(ctx, "wallet/ethereum", "ethereum", "secp256k1") // For TLS certificates // Before: DeriveKey with TLS options -tlsCert, _ := client.DeriveKeyWithSubjectAndAltNames(ctx, "api", "example.com", []string{"localhost"}) +tlsCert, _ := tappdClient.DeriveKeyWithSubjectAndAltNames(ctx, "api", "example.com", []string{"localhost"}) // After: GetTlsKey with proper options -tlsCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - Subject: "example.com", - AltNames: []string{"localhost"}, -}) +tlsCert, _ := v0.GetTlsKey(ctx, + dstack.WithSubject("example.com"), + dstack.WithAltNames([]string{"localhost"}), +) ``` ### Migration Checklist @@ -904,7 +977,7 @@ tlsCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - [ ] Change environment variables from `TAPPD_*` to `DSTACK_*` - [ ] **Client Code Updates:** - - [ ] Replace `tappd.NewTappdClient()` with `dstack.NewDstackClient()` + - [ ] Replace `tappd.NewTappdClient()` with `dstack.NewDstackClientV0()` - [ ] Replace `DeriveKey()` calls with appropriate method: - [ ] `GetKey()` for deterministic application keys - [ ] `GetTlsKey()` for TLS certificates (random) @@ -919,56 +992,8 @@ tlsCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ - [ ] Test quote generation with new interface - [ ] Verify blockchain integrations work with secure functions -## Development - -### Running the Simulator - -For local development without TDX devices, you can use the simulator: - -```bash -git clone https://github.com/Dstack-TEE/dstack.git -cd dstack/sdk/simulator -./build.sh -./dstack-simulator -``` - -### Running Tests - -```bash -# Set environment variables and run tests -TAPPD_SIMULATOR_ENDPOINT=/path/to/simulator/tappd.sock \ -DSTACK_SIMULATOR_ENDPOINT=/path/to/simulator/dstack.sock \ -go test -v ./dstack ./tappd - -# Run cross-language consistency tests -TAPPD_SIMULATOR_ENDPOINT=/path/to/simulator/tappd.sock \ -DSTACK_SIMULATOR_ENDPOINT=/path/to/simulator/dstack.sock \ -go run test-outputs.go -``` - -Run tests: - -```bash -go test -v ./dstack -``` - ---- - -## Migration from TappdClient - -Replace `tappd` package with `dstack`: - -```go -// Before -import "github.com/Dstack-TEE/dstack/sdk/go/tappd" -client := tappd.NewTappdClient() - -// After -import "github.com/Dstack-TEE/dstack/sdk/go/dstack" -client := dstack.NewDstackClient() -``` - -Socket path: `/var/run/tappd.sock` → `/var/run/dstack.sock` +- [ ] **Then move on from v0:** port to `DstackClient` (v1), migrating any assets + held under a v0 key deliberately — the derivations are unrelated. ## License diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 1458ff4ec..3d2ebe6b8 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -7,7 +7,6 @@ package dstack import ( - "bytes" "context" "crypto/ecdsa" "crypto/ed25519" @@ -16,12 +15,7 @@ import ( "encoding/json" "encoding/pem" "fmt" - "io" - "log/slog" - "net" - "net/http" "os" - "strings" "time" ) @@ -111,37 +105,6 @@ func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { // Represents the response from an attestation request. type AttestResponse struct { Attestation []byte - // BoottimeGpuEvidence is the complete JSON output produced by nvattest during guest - // boot. Empty unless the request set IncludeBoottimeGpuEvidence and the guest has - // boot-time GPU attestation output. - // - // It is not bound to reportData: verify it by replaying the runtime event log - // and comparing sha256 of these exact UTF-8 bytes against evidence_sha256 in - // the `gpu-attestation` event. - BoottimeGpuEvidence string -} - -// AttestOptions tunes what an Attest call returns. -type AttestOptions struct { - // IncludeBoottimeGpuEvidence also returns the boot-time GPU attestation evidence. - IncludeBoottimeGpuEvidence bool -} - -// GpuInfoResponse contains GPU information collected during boot. -type GpuInfoResponse struct { - Attestation string `json:"attestation"` -} - -// AttestGpuResponse is the result of fresh, on-demand GPU evidence collection. -type AttestGpuResponse struct { - Bundles []GpuEvidenceBundle `json:"bundles"` -} - -type GpuEvidenceBundle struct { - Vendor string `json:"vendor"` - Format string `json:"format"` - // Evidence contains hex-encoded opaque bytes, as represented by the JSON RPC. - Evidence string `json:"evidence"` } // Represents an event log entry in the TCB info @@ -221,124 +184,28 @@ const ( RAW QuoteHashAlgorithm = "raw" ) -// Handles communication with the dstack service. -type DstackClient struct { - endpoint string - baseURL string - httpClient *http.Client - logger *slog.Logger -} - -// Functional option for configuring a DstackClient. -type DstackClientOption func(*DstackClient) - -// Sets the endpoint for the DstackClient. -func WithEndpoint(endpoint string) DstackClientOption { - return func(c *DstackClient) { - c.endpoint = endpoint - } -} - -// Sets the logger for the DstackClient -func WithLogger(logger *slog.Logger) DstackClientOption { - return func(c *DstackClient) { - c.logger = logger - } +// Handles communication with the frozen v0.5.11 guest agent surface. +// +// The methods here mirror the unversioned paths (`/GetKey`, equivalently +// `/v0/GetKey`), which the agent serves unchanged for pre-0.6 clients and will +// never extend. New capability lives on DstackClientV1. +// +// Deprecated: legacy surface, frozen at v0.5.11 and never extended. New code +// should use DstackClient, which is DstackClientV1. Reaching for the explicit +// V0 name is the deliberate way to stay on the frozen surface. +type DstackClientV0 struct { + transport } -// Creates a new DstackClient instance based on the provided endpoint. +// Creates a new DstackClientV0 instance based on the provided endpoint. // If the endpoint is empty, it will use the simulator endpoint if it is // set in the environment through DSTACK_SIMULATOR_ENDPOINT. Otherwise, it // will use the default endpoint at /var/run/dstack.sock. -func NewDstackClient(opts ...DstackClientOption) *DstackClient { - client := &DstackClient{ - endpoint: "", - baseURL: "", - httpClient: &http.Client{}, - logger: slog.Default(), - } - - for _, opt := range opts { - opt(client) - } - - client.endpoint = client.getEndpoint() - - if strings.HasPrefix(client.endpoint, "http://") || strings.HasPrefix(client.endpoint, "https://") { - client.baseURL = client.endpoint - } else { - client.baseURL = "http://localhost" - client.httpClient = &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", client.endpoint) - }, - }, - } - } - - return client -} - -// Returns the appropriate endpoint based on environment and input. If the -// endpoint is empty, it will use the simulator endpoint if it is set in the -// environment through DSTACK_SIMULATOR_ENDPOINT. Otherwise, it will try -// /var/run/dstack/dstack.sock first, falling back to /var/run/dstack.sock -// for backward compatibility. -func (c *DstackClient) getEndpoint() string { - if c.endpoint != "" { - return c.endpoint - } - if simEndpoint, exists := os.LookupEnv("DSTACK_SIMULATOR_ENDPOINT"); exists { - c.logger.Info("using simulator endpoint", "endpoint", simEndpoint) - return simEndpoint - } - // Try paths in order: legacy paths first, then namespaced paths - socketPaths := []string{ - "/var/run/dstack.sock", - "/run/dstack.sock", - "/var/run/dstack/dstack.sock", - "/run/dstack/dstack.sock", - } - for _, path := range socketPaths { - if _, err := os.Stat(path); err == nil { - return path - } - } - // Default to new path even if not exists (will fail with clear error) - return socketPaths[0] -} - -// Sends an RPC request to the dstack service. -func (c *DstackClient) sendRPCRequest(ctx context.Context, path string, payload interface{}) ([]byte, error) { - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "dstack-sdk-go/0.1.0") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - return body, nil +// +// Deprecated: constructs a client for the frozen v0.5.11 surface. New code +// should use NewDstackClient, which returns a v1 client. +func NewDstackClientV0(opts ...DstackClientOption) *DstackClientV0 { + return &DstackClientV0{transport: newTransport(opts)} } // TlsKeyOption defines a function type for TLS key options @@ -413,7 +280,7 @@ func WithAppInfo(enabled bool) TlsKeyOption { } // Gets a TLS key from the dstack service with optional parameters. -func (c *DstackClient) GetTlsKey( +func (c *DstackClientV0) GetTlsKey( ctx context.Context, options ...TlsKeyOption, ) (*GetTlsKeyResponse, error) { @@ -468,7 +335,7 @@ func requiresVersionCheck(algorithm string) bool { // ensureAlgorithmSupported checks the OS version when a non-secp256k1 algorithm is requested. // On old OS (no Version RPC), it returns an error to prevent silent key type mismatch. -func (c *DstackClient) ensureAlgorithmSupported(ctx context.Context, algorithm string) error { +func (c *DstackClientV0) ensureAlgorithmSupported(ctx context.Context, algorithm string) error { if !requiresVersionCheck(algorithm) { return nil } @@ -479,7 +346,7 @@ func (c *DstackClient) ensureAlgorithmSupported(ctx context.Context, algorithm s } // Gets a key from the dstack service. -func (c *DstackClient) GetKey(ctx context.Context, path string, purpose string, algorithm string) (*GetKeyResponse, error) { +func (c *DstackClientV0) GetKey(ctx context.Context, path string, purpose string, algorithm string) (*GetKeyResponse, error) { if err := c.ensureAlgorithmSupported(ctx, algorithm); err != nil { return nil, err } @@ -505,7 +372,7 @@ func (c *DstackClient) GetKey(ctx context.Context, path string, purpose string, // 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) { +func (c *DstackClientV0) 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") } @@ -528,20 +395,13 @@ func (c *DstackClient) GetQuote(ctx context.Context, reportData []byte) (*GetQuo } // Gets a versioned attestation from the dstack service. -func (c *DstackClient) Attest(ctx context.Context, reportData []byte) (*AttestResponse, error) { - return c.AttestWithOptions(ctx, reportData, AttestOptions{}) -} - -// Gets a versioned attestation from the dstack service, optionally bundling the -// boot-time GPU attestation evidence so a verifier can check both in one round trip. -func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, opts AttestOptions) (*AttestResponse, error) { +func (c *DstackClientV0) Attest(ctx context.Context, reportData []byte) (*AttestResponse, error) { if len(reportData) > 64 { return nil, fmt.Errorf("report data is too large, it should be at most 64 bytes") } payload := map[string]interface{}{ - "report_data": hex.EncodeToString(reportData), - "include_boottime_gpu_evidence": opts.IncludeBoottimeGpuEvidence, + "report_data": hex.EncodeToString(reportData), } data, err := c.sendRPCRequest(ctx, "/Attest", payload) @@ -551,7 +411,6 @@ func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, var response struct { Attestation string `json:"attestation"` - BoottimeGpuEvidence string `json:"boottime_gpu_evidence"` } if err := json.Unmarshal(data, &response); err != nil { return nil, err @@ -562,42 +421,7 @@ func (c *DstackClient) AttestWithOptions(ctx context.Context, reportData []byte, return nil, err } - return &AttestResponse{Attestation: attestation, BoottimeGpuEvidence: response.BoottimeGpuEvidence}, nil -} - -// AttestGpu runs NVIDIA GPU attestation now, against a 32-byte nonce you choose. -// -// See AttestGpuResponse for what this does and does not prove. -func (c *DstackClient) AttestGpu(ctx context.Context, nonce []byte) (*AttestGpuResponse, error) { - if len(nonce) != 32 { - return nil, fmt.Errorf("nonce must be exactly 32 bytes, got %d", len(nonce)) - } - - payload := map[string]interface{}{"nonce": hex.EncodeToString(nonce)} - data, err := c.sendRPCRequest(ctx, "/AttestGpu", payload) - if err != nil { - return nil, err - } - - var response AttestGpuResponse - if err := json.Unmarshal(data, &response); err != nil { - return nil, err - } - return &response, nil -} - -// GpuInfo returns GPU information collected during boot. -func (c *DstackClient) GpuInfo(ctx context.Context) (*GpuInfoResponse, error) { - data, err := c.sendRPCRequest(ctx, "/GpuInfo", map[string]interface{}{}) - if err != nil { - return nil, err - } - - var response GpuInfoResponse - if err := json.Unmarshal(data, &response); err != nil { - return nil, err - } - return &response, nil + return &AttestResponse{Attestation: attestation}, nil } // Represents the response from a Version request. @@ -610,7 +434,7 @@ type VersionResponse struct { // // Returns the version on OS >= 0.5.7. // Returns an error on older OS versions that lack the Version RPC. -func (c *DstackClient) GetVersion(ctx context.Context) (*VersionResponse, error) { +func (c *DstackClientV0) GetVersion(ctx context.Context) (*VersionResponse, error) { data, err := c.sendRPCRequest(ctx, "/Version", map[string]interface{}{}) if err != nil { return nil, err @@ -624,7 +448,7 @@ func (c *DstackClient) GetVersion(ctx context.Context) (*VersionResponse, error) } // Sends a request to get information about the CVM instance -func (c *DstackClient) Info(ctx context.Context) (*InfoResponse, error) { +func (c *DstackClientV0) Info(ctx context.Context) (*InfoResponse, error) { data, err := c.sendRPCRequest(ctx, "/Info", map[string]interface{}{}) if err != nil { return nil, err @@ -645,7 +469,7 @@ type SignResponse struct { } // Signs a payload. -func (c *DstackClient) Sign(ctx context.Context, algorithm string, data []byte) (*SignResponse, error) { +func (c *DstackClientV0) Sign(ctx context.Context, algorithm string, data []byte) (*SignResponse, error) { payload := map[string]interface{}{ "algorithm": algorithm, "data": hex.EncodeToString(data), @@ -689,25 +513,72 @@ func (c *DstackClient) Sign(ctx context.Context, algorithm string, data []byte) }, nil } +type VerifyResponse struct { + Valid bool `json:"valid"` +} + +// Verifies a payload. +func (c *DstackClientV0) Verify(ctx context.Context, algorithm string, data []byte, signature []byte, publicKey []byte) (*VerifyResponse, error) { + payload := map[string]interface{}{ + "algorithm": algorithm, + "data": hex.EncodeToString(data), + "signature": hex.EncodeToString(signature), + "public_key": hex.EncodeToString(publicKey), + } + + respData, err := c.sendRPCRequest(ctx, "/Verify", payload) + if err != nil { + return nil, err + } + + var response VerifyResponse + if err := json.Unmarshal(respData, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal verify response: %w", err) + } + + return &response, nil +} + // IsReachable checks if the service is reachable -func (c *DstackClient) IsReachable(ctx context.Context) bool { +func (c *DstackClientV0) IsReachable(ctx context.Context) bool { ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() _, err := c.Info(ctx) return err == nil } +// EmitEvent sends an event to be extended to RTMR3 on TDX platform. +// The event will be extended to RTMR3 with the provided name and payload. +// +// Requires dstack OS 0.5.0 or later. +// +// Removed in dstack 0.6.0: runtime RTMR3 events became system-owned, so an +// agent from 0.6.0 on answers this with an error. The method stays on the +// frozen surface so that a pre-0.6 client still compiles, and the error is +// returned to the caller rather than swallowed -- silently succeeding would +// leave an application believing it had measured something it had not. +func (c *DstackClientV0) EmitEvent(ctx context.Context, event string, payload []byte) error { + if event == "" { + return fmt.Errorf("event name cannot be empty") + } + _, err := c.sendRPCRequest(ctx, "/EmitEvent", map[string]interface{}{ + "event": event, + "payload": hex.EncodeToString(payload), + }) + return err +} + // Legacy methods for backward compatibility with warnings // DeriveKey is deprecated. Use GetKey instead. // Deprecated: Use GetKey instead. -func (c *DstackClient) DeriveKey(path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { +func (c *DstackClientV0) DeriveKey(path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { return nil, fmt.Errorf("deriveKey is deprecated, please use GetKey instead") } // TdxQuote is deprecated. Use GetQuote instead. // Deprecated: Use GetQuote instead. -func (c *DstackClient) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { +func (c *DstackClientV0) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { c.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") if hashAlgorithm != "raw" { return nil, fmt.Errorf("tdxQuote only supports raw hash algorithm") @@ -715,10 +586,14 @@ func (c *DstackClient) TdxQuote(ctx context.Context, reportData []byte, hashAlgo return c.GetQuote(ctx, reportData) } -// TappdClient is a deprecated wrapper around DstackClient for backward compatibility. +// TappdClient is a deprecated wrapper around DstackClientV0 for backward +// compatibility. It wraps v0 and not v1 because tappd predates both: its +// callers expect the v0.5.11 method set, and v1 has no equivalent for the +// tappd-era methods overridden below. +// // Deprecated: Use DstackClient instead. type TappdClient struct { - *DstackClient + *DstackClientV0 } // NewTappdClient creates a new deprecated TappdClient. @@ -728,13 +603,13 @@ func NewTappdClient(opts ...DstackClientOption) *TappdClient { tappdOpts := make([]DstackClientOption, 0, len(opts)+1) // Add default endpoint option that checks TAPPD_SIMULATOR_ENDPOINT - tappdOpts = append(tappdOpts, func(c *DstackClient) { - if c.endpoint == "" { + tappdOpts = append(tappdOpts, func(o *clientOptions) { + if o.endpoint == "" { if simEndpoint, exists := os.LookupEnv("TAPPD_SIMULATOR_ENDPOINT"); exists { - c.logger.Warn("Using tappd endpoint", "endpoint", simEndpoint) - c.endpoint = simEndpoint + o.logger.Warn("Using tappd endpoint", "endpoint", simEndpoint) + o.endpoint = simEndpoint } else { - c.endpoint = "/var/run/tappd.sock" + o.endpoint = "/var/run/tappd.sock" } } }) @@ -742,11 +617,11 @@ func NewTappdClient(opts ...DstackClientOption) *TappdClient { // Add user-provided options tappdOpts = append(tappdOpts, opts...) - client := NewDstackClient(tappdOpts...) + client := NewDstackClientV0(tappdOpts...) client.logger.Warn("TappdClient is deprecated, please use DstackClient instead") return &TappdClient{ - DstackClient: client, + DstackClientV0: client, } } diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 5a941e98f..ef27922dd 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -9,7 +9,6 @@ import ( "context" "crypto/sha256" "crypto/x509" - "encoding/hex" "encoding/json" "encoding/pem" "fmt" @@ -22,7 +21,7 @@ import ( ) func TestGetKey(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() resp, err := client.GetKey(context.Background(), "/", "test", "ed25519") if err != nil { t.Fatal(err) @@ -38,7 +37,7 @@ func TestGetKey(t *testing.T) { } func TestGetQuote(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() resp, err := client.GetQuote(context.Background(), []byte("test")) if err != nil { t.Fatal(err) @@ -60,7 +59,7 @@ func TestGetQuote(t *testing.T) { } func TestAttest(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() resp, err := client.Attest(context.Background(), []byte("test")) if err != nil { t.Fatal(err) @@ -79,102 +78,40 @@ func TestAttest(t *testing.T) { } } -func TestAttestWithBoottimeGpuEvidence(t *testing.T) { - const evidence = `{"result_code":0,"claims":[]}` +// The frozen surface is exactly v0.5.11, so v0's Attest must keep sending the +// v0.5.11 request body. The GPU flag belongs to v1 and must not leak back here. +func TestAttestRequestIsFrozenAtV0(t *testing.T) { + var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/Attest" { - t.Fatalf("unexpected path: %s", r.URL.Path) + t.Errorf("unexpected path: %s", r.URL.Path) } - var payload map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request: %v", err) - } - if payload["include_boottime_gpu_evidence"] != true { - t.Fatalf("expected include_boottime_gpu_evidence to be forwarded, got: %v", payload["include_boottime_gpu_evidence"]) + t.Errorf("failed to decode request: %v", err) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "attestation": "deadbeef", - "boottime_gpu_evidence": evidence, - }) + _ = json.NewEncoder(w).Encode(map[string]string{"attestation": "deadbeef"}) })) defer server.Close() - client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) - resp, err := client.AttestWithOptions(context.Background(), []byte("test"), dstack.AttestOptions{IncludeBoottimeGpuEvidence: true}) + client := dstack.NewDstackClientV0(dstack.WithEndpoint(server.URL)) + resp, err := client.Attest(context.Background(), []byte("test")) if err != nil { t.Fatal(err) } - if resp.BoottimeGpuEvidence != evidence { - t.Fatalf("unexpected gpu evidence: %s", resp.BoottimeGpuEvidence) - } -} - -func TestAttestGpu(t *testing.T) { - const evidence = `[{"arch":"HOPPER","nonce":"ab","evidence":"BASE64","certificate":"BASE64"}]` - nonce := bytes.Repeat([]byte{0xab}, 32) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/AttestGpu" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request: %v", err) - } - if payload["nonce"] != hex.EncodeToString(nonce) { - t.Fatalf("nonce was not forwarded verbatim, got: %v", payload["nonce"]) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "bundles": []map[string]string{{ - "vendor": "nvidia", "format": "nvidia-test-v1", "evidence": evidence, - }}, - }) - })) - defer server.Close() - - client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) - resp, err := client.AttestGpu(context.Background(), nonce) - if err != nil { - t.Fatal(err) + if !bytes.Equal(resp.Attestation, []byte{0xde, 0xad, 0xbe, 0xef}) { + t.Errorf("unexpected attestation: %x", resp.Attestation) } - if len(resp.Bundles) != 1 || resp.Bundles[0].Vendor != "nvidia" || resp.Bundles[0].Evidence != evidence { - t.Fatalf("unexpected evidence bundles: %+v", resp.Bundles) + if len(payload) != 1 { + t.Errorf("expected report_data to be the only request field, got %v", payload) } -} - -func TestAttestGpuRejectsWrongNonceLength(t *testing.T) { - client := dstack.NewDstackClient() - for _, n := range [][]byte{nil, bytes.Repeat([]byte{1}, 31), bytes.Repeat([]byte{1}, 33)} { - if _, err := client.AttestGpu(context.Background(), n); err == nil { - t.Fatalf("expected a %d-byte nonce to be rejected", len(n)) - } - } -} - -func TestGpuInfo(t *testing.T) { - const attestation = `{"result_code":0,"claims":[]}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/GpuInfo" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"attestation": attestation}) - })) - defer server.Close() - - client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) - response, err := client.GpuInfo(context.Background()) - if err != nil { - t.Fatal(err) - } - if response.Attestation != attestation { - t.Fatalf("unexpected attestation: %s", response.Attestation) + if _, present := payload["include_boottime_gpu_evidence"]; present { + t.Error("include_boottime_gpu_evidence is a v1 field and must not appear on the frozen surface") } } func TestGetTlsKey(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() altNames := []string{"localhost"} resp, err := client.GetTlsKey( context.Background(), @@ -240,7 +177,7 @@ func TestGetTlsKey(t *testing.T) { } func TestGetTlsKeyMinimalOptions(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() // Test with minimal options (just subject) resp, err := client.GetTlsKey( context.Background(), @@ -280,7 +217,7 @@ func TestGetTlsKeyMinimalOptions(t *testing.T) { } func TestGetTlsKeyServerOnly(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() // Test with server auth only resp, err := client.GetTlsKey( context.Background(), @@ -332,7 +269,7 @@ func TestGetTlsKeyServerOnly(t *testing.T) { } func TestGetTlsKeyClientOnly(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() // Test with client auth only resp, err := client.GetTlsKey( context.Background(), @@ -384,7 +321,7 @@ func TestGetTlsKeyClientOnly(t *testing.T) { } func TestGetTlsKeyWithMultipleAltNames(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() // Test with multiple alternative names altNames := []string{"example.com", "test.example.com"} resp, err := client.GetTlsKey( @@ -448,7 +385,7 @@ func parseCertificate(pemCert string) (*x509.Certificate, error) { } func TestInfo(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() resp, err := client.Info(context.Background()) if err != nil { t.Fatal(err) @@ -506,7 +443,7 @@ func TestInfo(t *testing.T) { } func TestSignAndVerifyEd25519(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() dataToSign := []byte("test message for ed25519") algorithm := "ed25519" @@ -528,30 +465,28 @@ func TestSignAndVerifyEd25519(t *testing.T) { t.Error("expected Signature to be the same as SignatureChain[0]") } - // Verification is local: it needs no key material, so the SDK checks the - // signature itself rather than asking the agent for an unattested verdict. - valid, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey) + verifyResp, err := client.Verify(context.Background(), algorithm, dataToSign, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("VerifySignature() error = %v", err) + t.Fatalf("Verify() error = %v", err) } - if !valid { + if !verifyResp.Valid { t.Error("expected verification to be valid") } badData := []byte("wrong message") - valid, err = dstack.VerifySignature(algorithm, badData, signResp.Signature, signResp.PublicKey) + verifyResp, err = client.Verify(context.Background(), algorithm, badData, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("VerifySignature() with bad data error = %v", err) + t.Fatalf("Verify() with bad data error = %v", err) } - if valid { + if verifyResp.Valid { t.Error("expected verification with bad data to be invalid") } } func TestSignAndVerifySecp256k1(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() dataToSign := []byte("test message for secp256k1") algorithm := "secp256k1" @@ -570,12 +505,12 @@ func TestSignAndVerifySecp256k1(t *testing.T) { t.Errorf("expected signature chain to have 3 elements, got %d", len(signResp.SignatureChain)) } - valid, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey) + verifyResp, err := client.Verify(context.Background(), algorithm, dataToSign, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("VerifySignature() error = %v", err) + t.Fatalf("Verify() error = %v", err) } - if !valid { + if !verifyResp.Valid { t.Error("expected verification to be valid") } @@ -587,7 +522,7 @@ func TestSignAndVerifySecp256k1(t *testing.T) { } func TestSignAndVerifySecp256k1Prehashed(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() dataToSign := []byte("test message for secp256k1 prehashed") digest := sha256.Sum256(dataToSign) algorithm := "secp256k1_prehashed" @@ -601,18 +536,23 @@ func TestSignAndVerifySecp256k1Prehashed(t *testing.T) { t.Error("expected signature to not be empty") } - valid, err := dstack.VerifySignature(algorithm, digest[:], signResp.Signature, signResp.PublicKey) + verifyResp, err := client.Verify(context.Background(), algorithm, digest[:], signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("VerifySignature() error = %v", err) + t.Fatalf("Verify() error = %v", err) } - if !valid { + if !verifyResp.Valid { t.Error("expected verification to be valid") } - // A pre-hashed digest must be exactly 32 bytes on the verifying side too. - if _, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey); err == nil { - t.Error("expected VerifySignature to reject a non-digest payload for secp256k1_prehashed") + // The signature covers the digest, so the raw payload it was taken over does + // not verify: secp256k1_prehashed treats data as the digest itself. + verifyResp, err = client.Verify(context.Background(), algorithm, dataToSign, signResp.Signature, signResp.PublicKey) + if err != nil { + t.Fatalf("Verify() with a non-digest payload error = %v", err) + } + if verifyResp.Valid { + t.Error("expected a non-digest payload not to verify under secp256k1_prehashed") } // Test invalid digest length for signing @@ -627,7 +567,7 @@ func TestSignAndVerifySecp256k1Prehashed(t *testing.T) { } func TestGetVersion(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() resp, err := client.GetVersion(context.Background()) if err != nil { t.Fatal(err) @@ -639,7 +579,7 @@ func TestGetVersion(t *testing.T) { } func TestGetKeyK256Alias(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() respK256, err := client.GetKey(context.Background(), "/test", "purpose", "k256") if err != nil { @@ -658,7 +598,7 @@ func TestGetKeyK256Alias(t *testing.T) { } func TestGetKeyUnsupportedAlgorithm(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() _, err := client.GetKey(context.Background(), "/test", "purpose", "rsa") if err == nil { t.Fatal("expected error for unsupported algorithm") @@ -666,7 +606,7 @@ func TestGetKeyUnsupportedAlgorithm(t *testing.T) { } func TestGetKeySecp256k1PrehashedRejected(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() _, err := client.GetKey(context.Background(), "/test", "purpose", "secp256k1_prehashed") if err == nil { t.Fatal("expected error for secp256k1_prehashed in GetKey") @@ -674,7 +614,7 @@ func TestGetKeySecp256k1PrehashedRejected(t *testing.T) { } func TestGetKeyAlgorithmValidation(t *testing.T) { - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() // ed25519 should succeed (Version RPC is available on the simulator) resp, err := client.GetKey(context.Background(), "/test", "purpose", "ed25519") @@ -685,3 +625,32 @@ func TestGetKeyAlgorithmValidation(t *testing.T) { t.Error("expected key to not be empty") } } + +// EmitEvent is gone from the agent as of 0.6.0, but it stays on the client so a +// pre-0.6 application still compiles. The agent's removal message must reach the +// caller verbatim: an application that thinks it measured something it did not +// is worse off than one that fails loudly. +func TestEmitEventSurfacesTheRemovalMessage(t *testing.T) { + client := dstack.NewDstackClientV0() + err := client.EmitEvent(context.Background(), "test-event", []byte("payload")) + if err == nil { + t.Fatal("expected EmitEvent to fail against a 0.6.0 agent") + } + if !strings.Contains(err.Error(), "EmitEvent was removed in dstack 0.6.0") { + t.Errorf("expected the agent's removal message to be surfaced, got: %v", err) + } + + // The client-side guard still runs first, so an empty name never reaches the wire. + if err := client.EmitEvent(context.Background(), "", nil); err == nil { + t.Error("expected an empty event name to be rejected") + } +} + +// The frozen surface stays reachable, but only under its explicit name now that +// the unsuffixed client means v1. +func TestV0RemainsAvailableUnderItsExplicitName(t *testing.T) { + var client *dstack.DstackClientV0 = dstack.NewDstackClientV0() + if !client.IsReachable(context.Background()) { + t.Error("expected the v0 client to reach the simulator") + } +} diff --git a/sdk/go/dstack/client_v1.go b/sdk/go/dstack/client_v1.go new file mode 100644 index 000000000..e5555f3cb --- /dev/null +++ b/sdk/go/dstack/client_v1.go @@ -0,0 +1,514 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Client for `dstack.guest.v1`, the versioned guest agent API added in dstack +// 0.6.0 and served at `/v1/` on the same socket as the frozen v0 +// surface. +// +// `docs/guest-api-v1.md` is the normative specification; this file is a +// transport mirror of it and nothing more. It does not paper over differences +// between the two surfaces, because there is no compatibility to preserve: a v1 +// key derived from a given name is not the v0 key of that name. + +package dstack + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" +) + +// Response from a v1 certificate issuance request. +type IssueCertV1Response struct { + // The private key the agent generated for this certificate, PEM-encoded. + // Freshly generated per call and not derived from the app identity. + Key string `json:"key"` + // The certificate chain, leaf first, each entry PEM-encoded. + CertificateChain []string `json:"certificate_chain"` +} + +// Response from a v1 key derivation request. +type GetKeyV1Response struct { + // The derived private key: 32 raw bytes for both supported algorithms. + Key []byte + // The corresponding public key: SEC1 compressed (33 bytes) for secp256k1, + // raw (32 bytes) for ed25519. These are the exact bytes the chain's first + // link commits to. + PublicKey []byte + // Two links: [0] the app root key over the v1 key claim, [1] the KMS root + // key over the app root public key. See `docs/guest-api-v1.md` for the claim + // encoding and the verification steps. + SignatureChain [][]byte +} + +// Response from a v1 attestation request. +type AttestV1Response struct { + // The versioned dstack attestation. + Attestation []byte + // The GPU evidence nvattest recorded at guest boot, in the same bundle + // shape AttestGpu returns. Empty unless the request asked for it and the + // guest has boot-time output, so absence is the empty slice rather than a + // sentinel value. + // + // Not bound to reportData: nvattest ran at boot against its own nonce. + // Bind a bundle by replaying the runtime event log and comparing sha256 of + // its Evidence against the evidence_sha256 field of the measured + // `gpu-attestation` event. + BoottimeGpuEvidence []GpuEvidenceBundle +} + +// One vendor's GPU evidence, however it was obtained. +// +// Shared by AttestGpu and AttestV1Response.BoottimeGpuEvidence so a consumer +// writes one parser for both, then dispatches on (Vendor, Format): the two +// sources answer different questions and a verifier for one does not appraise +// the other. +type GpuEvidenceBundle struct { + // Stable GPU vendor identifier, for example `nvidia`. + Vendor string + // Vendor-specific evidence format and version. Known values: + // `nvidia-nvattest-collect-evidence-json-v1` fresh, from AttestGpu + // `nvidia-nvattest-boottime-json-v1` the record written at boot + Format string + // Opaque vendor-native evidence bytes, hex-encoded on the wire. Do not + // assume UTF-8 or JSON. + // + // These are the vendor's bytes verbatim, and for the boot-time format that + // exactness is load-bearing: the binding rule is sha256 over precisely + // these bytes, compared against evidence_sha256 in the measured + // `gpu-attestation` event. Parsing and re-serializing the JSON changes key + // order and whitespace, and so changes the digest. + Evidence []byte +} + +// Response from a v1 on-demand GPU attestation request. +type AttestGpuV1Response struct { + // Evidence, not a verdict: select a verifier by Vendor and Format, then + // check the signature, certificate chain, measurements and the nonce + // embedded in the evidence. + Bundles []GpuEvidenceBundle +} + +// Response from a v1 info request. +// +// Identity and configuration, not attestation. Nothing here is evidence: it +// arrives over a local socket with no quote behind it, so confirm the hashes +// against an attestation before relying on them. +type InfoV1Response struct { + AppID []byte + AppName string + // sha256 over the exact AppCompose bytes below. + ComposeHash []byte + // The app-compose document as deployed, verbatim. Do not parse and + // re-serialize before hashing: key order and whitespace change the digest. + AppCompose string + InstanceID []byte + // Identifies the host machine, not this instance. + DeviceID []byte + OsImageHash []byte + MrAggregated []byte + // JSON document produced and owned by the VMM. + VmConfig string + // JSON document owned by dstack-util. + KeyProviderInfo string + CloudVendor string + CloudProduct string +} + +// Response from a v1 version request. +type VersionV1Response struct { + Version string `json:"version"` + Rev string `json:"rev"` +} + +// Handles communication with `dstack.guest.v1` on the guest agent socket. +// +// The method set is exactly the six the service defines. There is no Sign, no +// Verify, no GetQuote and no EmitEvent: see `docs/guest-api-v1.md` for why each +// is absent and what replaces it. +type DstackClientV1 struct { + transport +} + +// DstackClient is the recommended client, and it is v1: the unsuffixed name +// tracks the current API rather than pinning the surface a caller happened to +// start on. Code that used it for v0 fails to compile after the upgrade, +// because the v1 signatures differ -- which is the point. A silent switch would +// hand back different key material under the same call. +type DstackClient = DstackClientV1 + +// Creates a new DstackClientV1 instance based on the provided endpoint. +// Endpoint resolution is identical to NewDstackClientV0 -- the two surfaces +// share one socket and differ only in the URL path. +func NewDstackClientV1(opts ...DstackClientOption) *DstackClientV1 { + return &DstackClientV1{transport: newTransport(opts)} +} + +// NewDstackClient creates a client for the current API, which is v1. Use it +// unless you specifically need the frozen v0.5.11 surface, in which case name +// NewDstackClientV0 explicitly. +func NewDstackClient(opts ...DstackClientOption) *DstackClient { + return NewDstackClientV1(opts...) +} + +// decodeHexField decodes one hex-encoded protobuf `bytes` field, naming the +// field so a malformed response says which one was wrong. +func decodeHexField(name string, value string) ([]byte, error) { + decoded, err := hex.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("failed to decode %s: %w", name, err) + } + return decoded, nil +} + +// Wire form of GpuEvidenceBundle, identical under `bundles` and under +// `boottime_gpu_evidence`. +type gpuEvidenceBundleJSON struct { + Vendor string `json:"vendor"` + Format string `json:"format"` + Evidence string `json:"evidence"` +} + +// decodeGpuEvidenceBundles decodes one repeated GpuEvidenceBundle field, naming +// the field so a malformed response says which one was wrong. An absent or +// empty list decodes to an empty slice: absence is not an error. +func decodeGpuEvidenceBundles(name string, wire []gpuEvidenceBundleJSON) ([]GpuEvidenceBundle, error) { + bundles := make([]GpuEvidenceBundle, len(wire)) + for i, bundle := range wire { + evidence, err := decodeHexField(fmt.Sprintf("evidence of %s element %d", name, i), bundle.Evidence) + if err != nil { + return nil, err + } + bundles[i] = GpuEvidenceBundle{Vendor: bundle.Vendor, Format: bundle.Format, Evidence: evidence} + } + return bundles, nil +} + +// IssueCertV1Option defines a function type for v1 certificate options. +type IssueCertV1Option func(*issueCertV1Options) + +type issueCertV1Options struct { + subject string + altNames []string + usageRaTls bool + usageServerAuth bool + usageClientAuth bool + notBefore *uint64 + notAfter *uint64 + withAppInfo *bool +} + +// WithCertSubject sets the subject of the certificate to request. +func WithCertSubject(subject string) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.subject = subject + } +} + +// WithCertAltNames sets the DNS alternative names for the certificate. +func WithCertAltNames(altNames []string) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.altNames = altNames + } +} + +// WithCertUsageRaTls includes the attestation quote in the certificate (RA-TLS). +func WithCertUsageRaTls(usage bool) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.usageRaTls = usage + } +} + +// WithCertUsageServerAuth sets the server auth key usage. +func WithCertUsageServerAuth(usage bool) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.usageServerAuth = usage + } +} + +// WithCertUsageClientAuth sets the client auth key usage. +func WithCertUsageClientAuth(usage bool) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.usageClientAuth = usage + } +} + +// WithCertNotBefore sets the validity start, seconds since the UNIX epoch. +func WithCertNotBefore(t uint64) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.notBefore = &t + } +} + +// WithCertNotAfter sets the validity end, seconds since the UNIX epoch. +func WithCertNotAfter(t uint64) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.notAfter = &t + } +} + +// WithCertAppInfo includes app info in the certificate. +func WithCertAppInfo(enabled bool) IssueCertV1Option { + return func(o *issueCertV1Options) { + o.withAppInfo = &enabled + } +} + +// IssueCert issues a certificate for this application. +// +// The agent generates a key, builds a CSR, and relays it to the KMS (or to the +// local CA when the app runs without one). The key is fresh on every call and +// 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{} + for _, option := range options { + option(opts) + } + + payload := map[string]interface{}{ + "subject": opts.subject, + "usage_ra_tls": opts.usageRaTls, + "usage_server_auth": opts.usageServerAuth, + "usage_client_auth": opts.usageClientAuth, + } + if len(opts.altNames) > 0 { + payload["alt_names"] = opts.altNames + } + if opts.notBefore != nil { + payload["not_before"] = *opts.notBefore + } + if opts.notAfter != nil { + payload["not_after"] = *opts.notAfter + } + if opts.withAppInfo != nil { + payload["with_app_info"] = *opts.withAppInfo + } + + data, err := c.sendRPCRequest(ctx, "/v1/IssueCert", payload) + if err != nil { + return nil, err + } + + var response IssueCertV1Response + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} + +// GetKey derives an application key from (domain, algorithm) and returns it +// with its signature chain. +// +// algorithm is `secp256k1` or `ed25519`, and is required: v1 has no default and +// no `k256` alias, because a typo that silently yields a key of another type +// under a name the caller thought meant something else is worse than an error. +// +// Derivation is flat -- `a/b` is not a child of `a` -- and binds both arguments, +// so these keys differ from the v0 keys of the same name. +func (c *DstackClientV1) GetKey(ctx context.Context, domain string, algorithm string) (*GetKeyV1Response, error) { + if algorithm == "" { + return nil, fmt.Errorf("algorithm is required, use `secp256k1` or `ed25519`") + } + + payload := map[string]interface{}{ + "domain": domain, + "algorithm": algorithm, + } + + data, err := c.sendRPCRequest(ctx, "/v1/GetKey", payload) + if err != nil { + return nil, err + } + + var response struct { + Key string `json:"key"` + PublicKey string `json:"public_key"` + SignatureChain []string `json:"signature_chain"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + + key, err := decodeHexField("key", response.Key) + if err != nil { + return nil, err + } + publicKey, err := decodeHexField("public_key", response.PublicKey) + if err != nil { + return nil, err + } + + chain := make([][]byte, len(response.SignatureChain)) + for i, link := range response.SignatureChain { + chain[i], err = decodeHexField(fmt.Sprintf("signature chain element %d", i), link) + if err != nil { + return nil, err + } + } + + return &GetKeyV1Response{Key: key, PublicKey: publicKey, SignatureChain: chain}, nil +} + +// Attest produces a versioned attestation over reportData, which must be +// between 1 and 64 bytes and is zero-padded on the right to 64. +// +// The sole CVM attestation entry point in v1: the attestation already carries +// the TDX quote and the event log, so there is no separate GetQuote. +// +// includeBoottimeGpuEvidence also returns the boot-time nvattest output, as +// GpuEvidenceBundle values -- the same shape AttestGpu returns, so one parser +// serves both. That evidence is not bound to reportData; see AttestV1Response. +func (c *DstackClientV1) Attest(ctx context.Context, reportData []byte, includeBoottimeGpuEvidence bool) (*AttestV1Response, error) { + if len(reportData) == 0 || len(reportData) > 64 { + return nil, fmt.Errorf("report data must be between 1 and 64 bytes, got %d", len(reportData)) + } + + payload := map[string]interface{}{ + "report_data": hex.EncodeToString(reportData), + "include_boottime_gpu_evidence": includeBoottimeGpuEvidence, + } + + data, err := c.sendRPCRequest(ctx, "/v1/Attest", payload) + if err != nil { + return nil, err + } + + var response struct { + Attestation string `json:"attestation"` + BoottimeGpuEvidence []gpuEvidenceBundleJSON `json:"boottime_gpu_evidence"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + + attestation, err := decodeHexField("attestation", response.Attestation) + if err != nil { + return nil, err + } + + boottimeGpuEvidence, err := decodeGpuEvidenceBundles("boottime_gpu_evidence", response.BoottimeGpuEvidence) + if err != nil { + return nil, err + } + + return &AttestV1Response{ + Attestation: attestation, + BoottimeGpuEvidence: boottimeGpuEvidence, + }, nil +} + +// AttestGpu collects GPU attestation evidence now, against a nonce the caller +// chooses. +// +// The nonce must be exactly 32 bytes: SPDM fixes the evidence nonce at that +// length and dstack passes it through verbatim, so these bytes can be compared +// directly against the eat_nonce claim. Hash a longer challenge yourself. +// +// This answers "is the device I can talk to right now a genuine CC-enabled +// GPU that signs my challenge". It still does not bind the GPU to this TD. +func (c *DstackClientV1) AttestGpu(ctx context.Context, nonce []byte) (*AttestGpuV1Response, error) { + if len(nonce) != 32 { + return nil, fmt.Errorf("nonce must be exactly 32 bytes, got %d", len(nonce)) + } + + payload := map[string]interface{}{"nonce": hex.EncodeToString(nonce)} + data, err := c.sendRPCRequest(ctx, "/v1/AttestGpu", payload) + if err != nil { + return nil, err + } + + var response struct { + Bundles []gpuEvidenceBundleJSON `json:"bundles"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + + bundles, err := decodeGpuEvidenceBundles("bundles", response.Bundles) + if err != nil { + return nil, err + } + + return &AttestGpuV1Response{Bundles: bundles}, nil +} + +// Info returns this application's identity and configuration. +// +// Flat, unlike v0: there is no tcb_info blob and no app_cert. The measurement +// registers and the event log live on the attestation Attest returns, which is +// the only place they are quote-backed. +func (c *DstackClientV1) Info(ctx context.Context) (*InfoV1Response, error) { + data, err := c.sendRPCRequest(ctx, "/v1/Info", map[string]interface{}{}) + if err != nil { + return nil, err + } + + var response struct { + AppID string `json:"app_id"` + AppName string `json:"app_name"` + ComposeHash string `json:"compose_hash"` + AppCompose string `json:"app_compose"` + InstanceID string `json:"instance_id"` + DeviceID string `json:"device_id"` + OsImageHash string `json:"os_image_hash"` + MrAggregated string `json:"mr_aggregated"` + VmConfig string `json:"vm_config"` + KeyProviderInfo string `json:"key_provider_info"` + CloudVendor string `json:"cloud_vendor"` + CloudProduct string `json:"cloud_product"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + + info := &InfoV1Response{ + AppName: response.AppName, + AppCompose: response.AppCompose, + VmConfig: response.VmConfig, + KeyProviderInfo: response.KeyProviderInfo, + CloudVendor: response.CloudVendor, + CloudProduct: response.CloudProduct, + } + + for _, field := range []struct { + name string + value string + into *[]byte + }{ + {"app_id", response.AppID, &info.AppID}, + {"compose_hash", response.ComposeHash, &info.ComposeHash}, + {"instance_id", response.InstanceID, &info.InstanceID}, + {"device_id", response.DeviceID, &info.DeviceID}, + {"os_image_hash", response.OsImageHash, &info.OsImageHash}, + {"mr_aggregated", response.MrAggregated, &info.MrAggregated}, + } { + decoded, err := decodeHexField(field.name, field.value) + if err != nil { + return nil, err + } + *field.into = decoded + } + + return info, nil +} + +// Version returns the guest agent version. +// +// The cheapest probe for whether an agent speaks v1 at all: it takes no +// arguments and touches nothing. An agent that predates v1 has no `/v1` mount +// and answers with a plain HTTP 404. +func (c *DstackClientV1) Version(ctx context.Context) (*VersionV1Response, error) { + data, err := c.sendRPCRequest(ctx, "/v1/Version", map[string]interface{}{}) + if err != nil { + return nil, err + } + + var response VersionV1Response + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/sdk/go/dstack/client_v1_test.go b/sdk/go/dstack/client_v1_test.go new file mode 100644 index 000000000..e6788a236 --- /dev/null +++ b/sdk/go/dstack/client_v1_test.go @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Exercises DstackClientV1 against the simulator, which serves the real v1 +// handlers -- so these tests pin the wire encoding, not a hand-written mock. + +package dstack_test + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) + +func TestV1Version(t *testing.T) { + client := dstack.NewDstackClientV1() + resp, err := client.Version(context.Background()) + if err != nil { + t.Fatal(err) + } + + if resp.Version == "" { + t.Error("expected version to not be empty") + } +} + +func TestV1GetKey(t *testing.T) { + client := dstack.NewDstackClientV1() + + for _, tc := range []struct { + algorithm string + pubKeyLen int + }{ + {"secp256k1", 33}, + {"ed25519", 32}, + } { + resp, err := client.GetKey(context.Background(), "storage-encryption", tc.algorithm) + if err != nil { + t.Fatalf("%s: %v", tc.algorithm, err) + } + + if len(resp.Key) != 32 { + t.Errorf("%s: expected a 32-byte key, got %d", tc.algorithm, len(resp.Key)) + } + if len(resp.PublicKey) != tc.pubKeyLen { + t.Errorf("%s: expected a %d-byte public key, got %d", tc.algorithm, tc.pubKeyLen, len(resp.PublicKey)) + } + // [0] the app root key over the v1 key claim, [1] the KMS root key over + // the app root public key. + if len(resp.SignatureChain) != 2 { + t.Errorf("%s: expected 2 chain links, got %d", tc.algorithm, len(resp.SignatureChain)) + } + for i, link := range resp.SignatureChain { + if len(link) != 65 { + t.Errorf("%s: chain link %d is %d bytes, want a 65-byte recoverable signature", + tc.algorithm, i, len(link)) + } + } + } +} + +// v1 binds the algorithm into the derivation, so the two curves no longer share +// one 32-byte secret the way they did on the frozen surface. +func TestV1GetKeyIsDomainSeparated(t *testing.T) { + client := dstack.NewDstackClientV1() + ctx := context.Background() + + k256, err := client.GetKey(ctx, "storage-encryption", "secp256k1") + if err != nil { + t.Fatal(err) + } + ed, err := client.GetKey(ctx, "storage-encryption", "ed25519") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(k256.Key, ed.Key) { + t.Error("expected the algorithm to be bound into the derivation") + } + + other, err := client.GetKey(ctx, "other", "secp256k1") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(k256.Key, other.Key) { + t.Error("expected two domains to yield unrelated keys") + } + + // Derivation is stable for a given (domain, algorithm). + again, err := client.GetKey(ctx, "storage-encryption", "secp256k1") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(k256.Key, again.Key) { + t.Error("expected the same domain and algorithm to derive the same key") + } +} + +// v1 keys are not v0 keys, so a caller cannot migrate a name across surfaces and +// expect the same material back. Nothing in the SDK smooths this over. +func TestV1GetKeyDiffersFromV0(t *testing.T) { + ctx := context.Background() + + v1Resp, err := dstack.NewDstackClientV1().GetKey(ctx, "storage-encryption", "secp256k1") + if err != nil { + t.Fatal(err) + } + v0Resp, err := dstack.NewDstackClientV0().GetKey(ctx, "storage-encryption", "", "secp256k1") + if err != nil { + t.Fatal(err) + } + v0Key, err := v0Resp.DecodeKey() + if err != nil { + t.Fatal(err) + } + + if bytes.Equal(v1Resp.Key, v0Key) { + t.Error("v1 must derive different key material than v0 for the same name") + } +} + +// The algorithm is required and has no alias: v1 refuses to guess what a caller +// meant rather than handing back a key of a type they did not ask for. +func TestV1GetKeyRejectsMissingOrUnknownAlgorithms(t *testing.T) { + client := dstack.NewDstackClientV1() + ctx := context.Background() + + _, err := client.GetKey(ctx, "storage-encryption", "") + if err == nil { + t.Fatal("expected an empty algorithm to be rejected") + } + if !strings.Contains(err.Error(), "algorithm is required") { + t.Errorf("expected the error to name the missing algorithm, got: %v", err) + } + + // Rejected client-side, so this never reaches the wire. + if strings.Contains(err.Error(), "unexpected status code") { + t.Error("expected the empty algorithm to be caught before the request was sent") + } + + for _, algorithm := range []string{"k256", "secp256k1_prehashed", "rsa"} { + if _, err := client.GetKey(ctx, "storage-encryption", algorithm); err == nil { + t.Errorf("expected algorithm %q to be rejected", algorithm) + } + } +} + +func TestV1Attest(t *testing.T) { + client := dstack.NewDstackClientV1() + resp, err := client.Attest(context.Background(), []byte("test"), false) + if err != nil { + t.Fatal(err) + } + + if len(resp.Attestation) == 0 { + t.Error("expected attestation to not be empty") + } +} + +// The simulator has no boot-time GPU output, which is the case worth pinning: +// absence is the empty slice, not a sentinel a caller has to test for. +func TestV1AttestBoottimeGpuEvidenceIsEmptyWithoutGpuOutput(t *testing.T) { + client := dstack.NewDstackClientV1() + resp, err := client.Attest(context.Background(), []byte("test"), true) + if err != nil { + t.Fatal(err) + } + + if len(resp.BoottimeGpuEvidence) != 0 { + t.Errorf("expected no boot-time evidence from the simulator, got %d bundles", len(resp.BoottimeGpuEvidence)) + } + // Same type as AttestGpu's bundles, so one parser serves both methods. + // This assignment is the assertion: it does not compile otherwise. + var bundles []dstack.GpuEvidenceBundle = resp.BoottimeGpuEvidence + bundles = (&dstack.AttestGpuV1Response{}).Bundles + _ = bundles +} + +// Boot-time evidence arrives in the same bundle shape AttestGpu returns, and +// its evidence is hex-decoded to the exact bytes nvattest wrote. +func TestV1AttestBoottimeGpuEvidenceBundles(t *testing.T) { + evidence := []byte(`{"result_code":0,"claims":[]}`) + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/Attest" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + 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") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "attestation": "deadbeef", + "boottime_gpu_evidence": []map[string]string{{ + "vendor": "nvidia", + "format": "nvidia-nvattest-boottime-json-v1", + "evidence": hex.EncodeToString(evidence), + }}, + }) + })) + defer server.Close() + + client := dstack.NewDstackClientV1(dstack.WithEndpoint(server.URL)) + resp, err := client.Attest(context.Background(), []byte("test"), true) + if err != nil { + t.Fatal(err) + } + + if payload["include_boottime_gpu_evidence"] != true { + t.Errorf("expected the request to ask for boot-time evidence, got: %v", payload["include_boottime_gpu_evidence"]) + } + if len(resp.BoottimeGpuEvidence) != 1 { + t.Fatalf("expected 1 bundle, got %d", len(resp.BoottimeGpuEvidence)) + } + + bundle := resp.BoottimeGpuEvidence[0] + // The format is what separates this from AttestGpu's on-demand evidence. + if bundle.Vendor != "nvidia" || bundle.Format != "nvidia-nvattest-boottime-json-v1" { + t.Errorf("unexpected bundle: %+v", bundle) + } + // sha256 of exactly these bytes is what the `gpu-attestation` event commits + // to, so the decode must be byte-for-byte, not a re-serialized parse. + if !bytes.Equal(bundle.Evidence, evidence) { + t.Errorf("expected the exact nvattest bytes, got %q", bundle.Evidence) + } +} + +func TestV1AttestRejectsBadReportDataLengths(t *testing.T) { + client := dstack.NewDstackClientV1() + for _, reportData := range [][]byte{nil, {}, bytes.Repeat([]byte("a"), 65)} { + if _, err := client.Attest(context.Background(), reportData, false); err == nil { + t.Errorf("expected %d bytes of report data to be rejected", len(reportData)) + } + } +} + +// The simulator has no GPU, so the round trip cannot succeed here. What can be +// pinned is the request encoding and the client-side nonce rule. +func TestV1AttestGpu(t *testing.T) { + nonce := bytes.Repeat([]byte{0xab}, 32) + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/AttestGpu" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + 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") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "bundles": []map[string]string{{ + "vendor": "nvidia", "format": "nvidia-nras-v1", "evidence": "deadbeef", + }}, + }) + })) + defer server.Close() + + client := dstack.NewDstackClientV1(dstack.WithEndpoint(server.URL)) + resp, err := client.AttestGpu(context.Background(), nonce) + if err != nil { + t.Fatal(err) + } + + // Passed through verbatim, so a caller can compare it against eat_nonce. + if payload["nonce"] != "abababababababababababababababababababababababababababababababab" { + t.Errorf("nonce was not forwarded verbatim, got: %v", payload["nonce"]) + } + if len(resp.Bundles) != 1 { + t.Fatalf("expected 1 bundle, got %d", len(resp.Bundles)) + } + if resp.Bundles[0].Vendor != "nvidia" || resp.Bundles[0].Format != "nvidia-nras-v1" { + t.Errorf("unexpected bundle: %+v", resp.Bundles[0]) + } + if !bytes.Equal(resp.Bundles[0].Evidence, []byte{0xde, 0xad, 0xbe, 0xef}) { + t.Errorf("expected hex-decoded evidence, got %x", resp.Bundles[0].Evidence) + } +} + +func TestV1AttestGpuRejectsWrongNonceLength(t *testing.T) { + client := dstack.NewDstackClientV1() + for _, nonce := range [][]byte{nil, bytes.Repeat([]byte{1}, 31), bytes.Repeat([]byte{1}, 33)} { + if _, err := client.AttestGpu(context.Background(), nonce); err == nil { + t.Errorf("expected a %d-byte nonce to be rejected", len(nonce)) + } + } +} + +func TestV1Info(t *testing.T) { + client := dstack.NewDstackClientV1() + resp, err := client.Info(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(resp.AppID) == 0 { + t.Error("expected app_id to not be empty") + } + if len(resp.InstanceID) == 0 { + t.Error("expected instance_id to not be empty") + } + if resp.AppName == "" { + t.Error("expected app_name to not be empty") + } + if len(resp.DeviceID) != 32 { + t.Errorf("expected a 32-byte device_id, got %d", len(resp.DeviceID)) + } + if len(resp.ComposeHash) != 32 { + t.Errorf("expected a 32-byte compose_hash, got %d", len(resp.ComposeHash)) + } + if len(resp.MrAggregated) != 32 { + t.Errorf("expected a 32-byte mr_aggregated, got %d", len(resp.MrAggregated)) + } + if len(resp.OsImageHash) != 32 { + t.Errorf("expected a 32-byte os_image_hash, got %d", len(resp.OsImageHash)) + } + + // The document fields are served directly, not nested in a JSON string. + var appCompose map[string]interface{} + if err := json.Unmarshal([]byte(resp.AppCompose), &appCompose); err != nil { + t.Errorf("expected app_compose to be a JSON document: %v", err) + } + var vmConfig map[string]interface{} + if err := json.Unmarshal([]byte(resp.VmConfig), &vmConfig); err != nil { + t.Errorf("expected vm_config to be a JSON document: %v", err) + } + var keyProviderInfo map[string]interface{} + if err := json.Unmarshal([]byte(resp.KeyProviderInfo), &keyProviderInfo); err != nil { + t.Errorf("expected key_provider_info to be a JSON document: %v", err) + } +} + +func TestV1IssueCert(t *testing.T) { + client := dstack.NewDstackClientV1() + resp, err := client.IssueCert( + context.Background(), + dstack.WithCertSubject("test-subject"), + dstack.WithCertAltNames([]string{"localhost"}), + dstack.WithCertUsageServerAuth(true), + dstack.WithCertUsageClientAuth(true), + ) + if err != nil { + t.Fatal(err) + } + + if resp.Key == "" { + t.Error("expected key to not be empty") + } + if len(resp.CertificateChain) == 0 { + t.Fatal("expected certificate chain to not be empty") + } + + cert, err := parseCertificate(resp.CertificateChain[0]) + if err != nil { + t.Fatalf("failed to parse certificate: %v", err) + } + if !strings.Contains(cert.Subject.String(), "test-subject") { + t.Errorf("expected subject to contain 'test-subject', got %s", cert.Subject.String()) + } + if len(cert.DNSNames) < 1 || cert.DNSNames[0] != "localhost" { + t.Errorf("expected DNS name 'localhost', got %v", cert.DNSNames) + } + + hasServerAuth := false + hasClientAuth := false + for _, usage := range cert.ExtKeyUsage { + if usage == x509.ExtKeyUsageServerAuth { + hasServerAuth = true + } + if usage == x509.ExtKeyUsageClientAuth { + hasClientAuth = true + } + } + if !hasServerAuth { + t.Error("expected ExtKeyUsageServerAuth to be set") + } + if !hasClientAuth { + t.Error("expected ExtKeyUsageClientAuth to be set") + } +} + +// The key is generated per call and is not derived from the app identity, so +// two identical requests must not return the same key. +func TestV1IssueCertKeyIsFreshPerCall(t *testing.T) { + client := dstack.NewDstackClientV1() + ctx := context.Background() + + first, err := client.IssueCert(ctx, dstack.WithCertSubject("same")) + if err != nil { + t.Fatal(err) + } + second, err := client.IssueCert(ctx, dstack.WithCertSubject("same")) + if err != nil { + t.Fatal(err) + } + if first.Key == second.Key { + t.Error("expected IssueCert to generate a fresh key on every call") + } +} + +// Version selection is by URL path alone: every v1 method must post under /v1. +func TestV1MethodsPostUnderTheV1Prefix(t *testing.T) { + paths := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths <- r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"bundles":[]}`)) + })) + defer server.Close() + + client := dstack.NewDstackClientV1(dstack.WithEndpoint(server.URL)) + ctx := context.Background() + + calls := []struct { + want string + call func() error + }{ + {"/v1/IssueCert", func() error { _, err := client.IssueCert(ctx); return err }}, + {"/v1/GetKey", func() error { _, err := client.GetKey(ctx, "d", "ed25519"); return err }}, + {"/v1/Attest", func() error { _, err := client.Attest(ctx, []byte("x"), false); return err }}, + {"/v1/AttestGpu", func() error { + _, err := client.AttestGpu(ctx, bytes.Repeat([]byte{0}, 32)) + return err + }}, + {"/v1/Info", func() error { _, err := client.Info(ctx); return err }}, + {"/v1/Version", func() error { _, err := client.Version(ctx); return err }}, + } + + for _, c := range calls { + if err := c.call(); err != nil { + t.Fatalf("%s: %v", c.want, err) + } + if got := <-paths; got != c.want { + t.Errorf("expected %s, got %s", c.want, got) + } + } +} + +// The unsuffixed names are v1. The assignments below are half the assertion -- +// they do not compile if DstackClient is anything else -- and the round trip is +// the other half: the default constructor must actually post under /v1. +func TestUnsuffixedClientIsV1(t *testing.T) { + var _ *dstack.DstackClientV1 = (*dstack.DstackClient)(nil) + + path := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path <- r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"0.6.0","rev":"test"}`)) + })) + defer server.Close() + + client := dstack.NewDstackClient(dstack.WithEndpoint(server.URL)) + if _, err := client.Version(context.Background()); err != nil { + t.Fatal(err) + } + if got := <-path; got != "/v1/Version" { + t.Errorf("expected the default client to speak v1, got %s", got) + } +} + +// An agent that predates v1 has no /v1 mount, so it answers with a plain 404 +// rather than a prpc error. The client surfaces that rather than masking it. +func TestV1AgainstAnAgentWithoutV1(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "404 Not Found", http.StatusNotFound) + })) + defer server.Close() + + client := dstack.NewDstackClientV1(dstack.WithEndpoint(server.URL)) + _, err := client.Version(context.Background()) + if err == nil { + t.Fatal("expected a 404 from an agent without a v1 mount to be an error") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("expected the status to be reported, got: %v", err) + } +} diff --git a/sdk/go/dstack/client_web3_test.go b/sdk/go/dstack/client_web3_test.go index 537e61808..c6dc6d60a 100644 --- a/sdk/go/dstack/client_web3_test.go +++ b/sdk/go/dstack/client_web3_test.go @@ -24,7 +24,7 @@ func TestGetKeySignatureVerification(t *testing.T) { expectedAppPubkey, _ := hex.DecodeString("02818494263695e8839122dbd88e281d7380622999df4e60a14befa0f2d096fc7c") expectedKmsPubkey, _ := hex.DecodeString("0321529e458424ab1f710a3a57ec4dad2fb195ddca572f7469242ba6c7563085b6") - client := dstack.NewDstackClient() + client := dstack.NewDstackClientV0() path := "/test/path" purpose := "test-purpose" resp, err := client.GetKey(context.Background(), path, purpose, "secp256k1") diff --git a/sdk/go/dstack/transport.go b/sdk/go/dstack/transport.go new file mode 100644 index 000000000..733cecae0 --- /dev/null +++ b/sdk/go/dstack/transport.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Endpoint resolution and JSON-over-prpc plumbing shared by both API versions. +// +// The guest agent serves the frozen v0.5.11 surface and `dstack.guest.v1` on the +// same unix socket, chosen by URL path alone. So the two clients differ only in +// the prefix they post to, and everything below that -- endpoint resolution, +// dialing, error shape -- is one implementation both embed. + +package dstack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "strings" +) + +// sdkVersion is reported in the User-Agent so an agent-side log can tell which +// SDK release a request came from. +const sdkVersion = "0.6.0" + +// clientOptions holds what a caller may set at construction. +// +// Every constructor -- NewDstackClient, NewDstackClientV1 and NewDstackClientV0 +// -- takes the same options, so a caller moving between surfaces changes the +// constructor and nothing else. +type clientOptions struct { + endpoint string + logger *slog.Logger +} + +// Functional option for configuring a dstack client. +type DstackClientOption func(*clientOptions) + +// Sets the endpoint for the client. +func WithEndpoint(endpoint string) DstackClientOption { + return func(o *clientOptions) { + o.endpoint = endpoint + } +} + +// Sets the logger for the client. +func WithLogger(logger *slog.Logger) DstackClientOption { + return func(o *clientOptions) { + o.logger = logger + } +} + +// transport carries the resolved endpoint and the HTTP plumbing. +type transport struct { + endpoint string + baseURL string + httpClient *http.Client + logger *slog.Logger +} + +// newTransport resolves the options into a ready-to-use transport. +func newTransport(opts []DstackClientOption) transport { + settings := &clientOptions{logger: slog.Default()} + for _, opt := range opts { + opt(settings) + } + + t := transport{ + endpoint: settings.endpoint, + httpClient: &http.Client{}, + logger: settings.logger, + } + t.endpoint = t.getEndpoint() + + if strings.HasPrefix(t.endpoint, "http://") || strings.HasPrefix(t.endpoint, "https://") { + t.baseURL = t.endpoint + } else { + endpoint := t.endpoint + t.baseURL = "http://localhost" + t.httpClient = &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", endpoint) + }, + }, + } + } + + return t +} + +// Returns the appropriate endpoint based on environment and input. If the +// endpoint is empty, it will use the simulator endpoint if it is set in the +// environment through DSTACK_SIMULATOR_ENDPOINT. Otherwise, it will try +// /var/run/dstack/dstack.sock first, falling back to /var/run/dstack.sock +// for backward compatibility. +func (t *transport) getEndpoint() string { + if t.endpoint != "" { + return t.endpoint + } + if simEndpoint, exists := os.LookupEnv("DSTACK_SIMULATOR_ENDPOINT"); exists { + t.logger.Info("using simulator endpoint", "endpoint", simEndpoint) + return simEndpoint + } + // Try paths in order: legacy paths first, then namespaced paths + socketPaths := []string{ + "/var/run/dstack.sock", + "/run/dstack.sock", + "/var/run/dstack/dstack.sock", + "/run/dstack/dstack.sock", + } + for _, path := range socketPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + // Default to new path even if not exists (will fail with clear error) + return socketPaths[0] +} + +// Sends an RPC request to the dstack service. +func (t *transport) sendRPCRequest(ctx context.Context, path string, payload interface{}) ([]byte, error) { + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.baseURL+path, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "dstack-sdk-go/"+sdkVersion) + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} diff --git a/sdk/go/dstack/verify.go b/sdk/go/dstack/verify.go deleted file mode 100644 index cacd1c315..000000000 --- a/sdk/go/dstack/verify.go +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -// Local signature and signature-chain verification. -// -// Verification needs no key material and no attestation, so it does not belong -// behind an RPC to the guest agent: the agent's answer arrives over the socket -// unattested, which is no better than a caller checking the signature itself. -// The `Verify` RPC these functions replace was removed in v0.6.0. -// -// Two levels are available: -// -// - VerifySignature checks one signature against a public key you already -// have. It is the direct replacement for the old RPC and, on its own, proves -// only that whoever holds that key signed the data. -// - VerifySignatureChain walks the full chain from a SignResponse back to a -// KMS root key **you supply**, which is what actually establishes that the -// signer was a dstack app under that KMS. - -package dstack - -import ( - "bytes" - "crypto/ed25519" - "crypto/sha256" - "encoding/hex" - "fmt" - - secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" - secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" -) - -// kmsIssuedPrefix is the domain-separation prefix the KMS signs app root keys under. -const kmsIssuedPrefix = "dstack-kms-issued" - -// Sign derives its key at this path with this purpose; both are fixed agent-side. -const ( - SignPath = "vms" - SignPurpose = "signing" -) - -// normalizeAlgorithm maps `k256` onto `secp256k1`; they name the same thing and -// the agent normalized these too. -func normalizeAlgorithm(algorithm string) string { - if algorithm == "k256" { - return "secp256k1" - } - return algorithm -} - -// parseK256Signature decodes a raw 64-byte `r ‖ s` signature. -func parseK256Signature(signature []byte) (*secp256k1ecdsa.Signature, error) { - if len(signature) != 64 { - return nil, fmt.Errorf("secp256k1 signature must be 64 bytes, but received %d", len(signature)) - } - - var r, s secp256k1.ModNScalar - if overflow := r.SetByteSlice(signature[:32]); overflow { - return nil, fmt.Errorf("invalid secp256k1 signature: r is not in the group order") - } - if overflow := s.SetByteSlice(signature[32:64]); overflow { - return nil, fmt.Errorf("invalid secp256k1 signature: s is not in the group order") - } - if r.IsZero() || s.IsZero() { - return nil, fmt.Errorf("invalid secp256k1 signature: r and s must both be non-zero") - } - // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. The Rust SDK's - // k256 backend rejects the high-S form, so we must too -- otherwise a - // signature stops being a unique identifier for a signed message, and this - // SDK would disagree with every other dstack component about whether a given - // blob is valid. The decred library accepts high-S, so the check is ours. - if s.IsOverHalfOrder() { - return nil, fmt.Errorf("non-canonical (high-S) secp256k1 signature") - } - return secp256k1ecdsa.NewSignature(&r, &s), nil -} - -// VerifySignature verifies one signature against publicKey. -// -// algorithm is `ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, -// where data is already a 32-byte digest. Returns (false, nil) when the inputs -// are well-formed but the signature does not check out, and a non-nil error when -// they are not well-formed at all (bad key encoding, wrong signature length, -// unknown algorithm) -- a malformed input is a caller bug, not a verdict. -func VerifySignature(algorithm string, data []byte, signature []byte, publicKey []byte) (bool, error) { - switch normalizeAlgorithm(algorithm) { - case "ed25519": - if len(publicKey) != ed25519.PublicKeySize { - return false, fmt.Errorf("ed25519 public key must be %d bytes, but received %d", - ed25519.PublicKeySize, len(publicKey)) - } - if len(signature) != ed25519.SignatureSize { - return false, fmt.Errorf("ed25519 signature must be %d bytes, but received %d", - ed25519.SignatureSize, len(signature)) - } - // Divergence from the Rust SDK, deliberate and harmless: ed25519_dalek - // rejects a non-canonical or low-order point encoding as a malformed - // key, where crypto/ed25519 offers no way to ask and simply reports - // such a key as a failed verification. Both refuse the signature; only - // the error-versus-verdict shape differs, and aligning it would mean - // taking on an extra dependency just to decode the point. - return ed25519.Verify(ed25519.PublicKey(publicKey), data, signature), nil - - case "secp256k1": - pubKey, err := secp256k1.ParsePubKey(publicKey) - if err != nil { - return false, fmt.Errorf("invalid secp256k1 public key: %w", err) - } - sig, err := parseK256Signature(signature) - if err != nil { - return false, err - } - // The agent signs with SHA-256, so verification must hash the same way. - digest := sha256.Sum256(data) - return sig.Verify(digest[:], pubKey), nil - - case "secp256k1_prehashed": - if len(data) != 32 { - return false, fmt.Errorf( - "pre-hashed verification requires a 32-byte digest, but received %d bytes", len(data)) - } - pubKey, err := secp256k1.ParsePubKey(publicKey) - if err != nil { - return false, fmt.Errorf("invalid secp256k1 public key: %w", err) - } - sig, err := parseK256Signature(signature) - if err != nil { - return false, err - } - return sig.Verify(data, pubKey), nil - - default: - return false, fmt.Errorf("unsupported algorithm: %s", algorithm) - } -} - -// recoverCompressed recovers the compressed public key that produced a 65-byte -// `r ‖ s ‖ recid` signature over keccak256(message). -func recoverCompressed(message []byte, signature []byte) ([]byte, error) { - if len(signature) != 65 { - return nil, fmt.Errorf("recoverable signature must be 65 bytes, but received %d", len(signature)) - } - // Applies the same canonicality rules as a plain signature, including high-S. - if _, err := parseK256Signature(signature[:64]); err != nil { - return nil, err - } - if signature[64] > 3 { - return nil, fmt.Errorf("invalid recovery id %d", signature[64]) - } - - recovered, err := recoverCompressedPublicKey(message, signature) - if err != nil { - return nil, fmt.Errorf("failed to recover public key: %w", err) - } - if recovered == nil { - return nil, fmt.Errorf("failed to recover public key") - } - // recoverCompressedPublicKey hands back `0x`-prefixed hex; the chain compares raw bytes. - raw, err := hex.DecodeString(string(recovered[2:])) - if err != nil { - return nil, fmt.Errorf("failed to decode the recovered public key: %w", err) - } - return raw, nil -} - -// SignatureChainInput carries the inputs to VerifySignatureChain. -// -// A struct rather than a positional argument list so that adding an input later -// does not break callers. -type SignatureChainInput struct { - // Algorithm the payload was signed with. - Algorithm string - // Data is the signed payload; a 32-byte digest for `secp256k1_prehashed`. - Data []byte - // PublicKey is SignResponse.PublicKey -- the key that signed Data. - PublicKey []byte - // SignatureChain is SignResponse.SignatureChain, exactly 3 elements. - SignatureChain [][]byte - // AppID is the 20-byte app identity to hold the chain to. - // - // This must be the app id you *expect*, not merely whatever InfoResponse - // echoed back -- that comes from the CVM being checked. Comparing a chain - // against an app id the same CVM supplied proves only that it is - // self-consistent. - AppID []byte - // KMSRootPubKey is the KMS root public key you already trust, compressed or - // uncompressed SEC1. - // - // Get it from the DstackKms contract (`kmsInfo().k256Pubkey`) or pin it. - // Reading it from the KMS you are verifying against proves nothing. - KMSRootPubKey []byte - // Purpose bound into the app-root link. Defaults to SignPurpose when empty, - // which is what the Sign RPC always uses. - Purpose string -} - -// VerifySignatureChain verifies a Sign signature chain end to end and returns -// the app root public key (compressed SEC1, 33 bytes). -// -// Three links, all of which must hold: -// -// 1. SignatureChain[0] is a signature over Data by PublicKey. -// 2. SignatureChain[1] is the app root key attesting "{purpose}:{hex(PublicKey)}". -// 3. SignatureChain[2] is KMSRootPubKey attesting that app root key for AppID. -// -// Link 3 is the one that matters. Without comparing against a KMS root key you -// independently trust, a chain is just three signatures an attacker could have -// produced with their own keys. -func VerifySignatureChain(input SignatureChainInput) ([]byte, error) { - if len(input.SignatureChain) != 3 { - return nil, fmt.Errorf("signature chain must have 3 elements, but received %d", - len(input.SignatureChain)) - } - if len(input.AppID) != 20 { - return nil, fmt.Errorf("app_id must be 20 bytes, but received %d", len(input.AppID)) - } - - purpose := input.Purpose - if purpose == "" { - purpose = SignPurpose - } - - // Link 1: the payload signature. SignatureChain[0] *is* that signature; what - // matters is that it checks out under PublicKey, which links 2 and 3 cover. - valid, err := VerifySignature(input.Algorithm, input.Data, input.SignatureChain[0], input.PublicKey) - if err != nil { - return nil, fmt.Errorf("failed to check the payload signature: %w", err) - } - if !valid { - return nil, fmt.Errorf("payload signature is not valid for the given public key") - } - - // Link 2: recover the app root key that vouched for the signing key. - message := fmt.Sprintf("%s:%s", purpose, hex.EncodeToString(input.PublicKey)) - appRootPubKey, err := recoverCompressed([]byte(message), input.SignatureChain[1]) - if err != nil { - return nil, fmt.Errorf("failed to recover the app root key: %w", err) - } - - // Link 3: recover the KMS root key that vouched for the app root key, and - // check it is the one we were told to trust. - kmsMessage := make([]byte, 0, len(kmsIssuedPrefix)+1+len(input.AppID)+len(appRootPubKey)) - kmsMessage = append(kmsMessage, kmsIssuedPrefix...) - kmsMessage = append(kmsMessage, ':') - kmsMessage = append(kmsMessage, input.AppID...) - kmsMessage = append(kmsMessage, appRootPubKey...) - recoveredKMS, err := recoverCompressed(kmsMessage, input.SignatureChain[2]) - if err != nil { - return nil, fmt.Errorf("failed to recover the KMS root key: %w", err) - } - - // Normalize the expected key so callers may pass either SEC1 encoding. - expectedKMS, err := secp256k1.ParsePubKey(input.KMSRootPubKey) - if err != nil { - return nil, fmt.Errorf("invalid KMS root public key: %w", err) - } - if !bytes.Equal(recoveredKMS, expectedKMS.SerializeCompressed()) { - return nil, fmt.Errorf("signature chain is not anchored at the expected KMS root key") - } - - return appRootPubKey, nil -} diff --git a/sdk/go/dstack/verify_test.go b/sdk/go/dstack/verify_test.go deleted file mode 100644 index b54323696..000000000 --- a/sdk/go/dstack/verify_test.go +++ /dev/null @@ -1,383 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -// Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. -// The Rust, Python and JavaScript suites assert against the same file, so any port -// that disagrees about the byte format fails here too. - -package dstack_test - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "os" - "strings" - "testing" - - secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" - - "github.com/Dstack-TEE/dstack/sdk/go/dstack" -) - -const vectorsPath = "../../tests/vectors/signature_chain.json" - -type vectorCase struct { - Algorithm string `json:"algorithm"` - Data string `json:"data"` - PublicKey string `json:"public_key"` - Signature string `json:"signature"` - SignatureChain []string `json:"signature_chain"` - Name string `json:"name"` - Reason string `json:"reason"` -} - -type vectorFile struct { - AppID string `json:"app_id"` - Purpose string `json:"purpose"` - Path string `json:"path"` - KMSRootPubKey string `json:"kms_root_pubkey"` - AppRootPubKey string `json:"app_root_pubkey"` - WrongKMSRootPubKey string `json:"wrong_kms_root_pubkey"` - Cases []vectorCase `json:"cases"` - InvalidCases []vectorCase `json:"invalid_cases"` -} - -func vectors(t *testing.T) vectorFile { - t.Helper() - raw, err := os.ReadFile(vectorsPath) - if err != nil { - t.Fatalf("read vectors: %v", err) - } - var v vectorFile - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("parse vectors: %v", err) - } - if len(v.Cases) == 0 || len(v.InvalidCases) == 0 { - t.Fatalf("vectors file has no cases") - } - return v -} - -func unhex(t *testing.T, s string) []byte { - t.Helper() - b, err := hex.DecodeString(s) - if err != nil { - t.Fatalf("invalid hex %q: %v", s, err) - } - return b -} - -func chainOf(t *testing.T, c vectorCase) [][]byte { - t.Helper() - chain := make([][]byte, len(c.SignatureChain)) - for i, s := range c.SignatureChain { - chain[i] = unhex(t, s) - } - return chain -} - -func caseWithAlgorithm(t *testing.T, v vectorFile, algorithm string) vectorCase { - t.Helper() - for _, c := range v.Cases { - if c.Algorithm == algorithm { - return c - } - } - t.Fatalf("no vector case for algorithm %q", algorithm) - return vectorCase{} -} - -func TestVerifySignatureValidVectors(t *testing.T) { - v := vectors(t) - for _, c := range v.Cases { - valid, err := dstack.VerifySignature( - c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) - if err != nil { - t.Fatalf("%s: %v", c.Algorithm, err) - } - if !valid { - t.Errorf("%s: valid signature was rejected", c.Algorithm) - } - } -} - -func TestVerifySignatureInvalidVectors(t *testing.T) { - v := vectors(t) - for _, c := range v.InvalidCases { - valid, err := dstack.VerifySignature( - c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) - // High-S is refused outright rather than reported false, because it is a - // malformed encoding rather than a legitimate signature that fails to match. - if err != nil { - if c.Name != "secp256k1_high_s" { - t.Errorf("%s: unexpected error %v", c.Name, err) - } - continue - } - if valid { - t.Errorf("%s: should not have verified (%s)", c.Name, c.Reason) - } - } -} - -func TestVerifySignatureHighSIsRejected(t *testing.T) { - v := vectors(t) - var found bool - for _, c := range v.InvalidCases { - if c.Name != "secp256k1_high_s" { - continue - } - found = true - valid, err := dstack.VerifySignature( - c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) - if err == nil { - t.Fatalf("high-S signature was accepted as an encoding (valid=%v)", valid) - } - if !strings.Contains(err.Error(), "high-S") { - t.Errorf("unexpected error for high-S: %v", err) - } - } - if !found { - t.Fatal("vectors file no longer pins the secp256k1_high_s case") - } -} - -func TestVerifySignatureK256IsAnAliasForSecp256k1(t *testing.T) { - v := vectors(t) - c := caseWithAlgorithm(t, v, "secp256k1") - valid, err := dstack.VerifySignature("k256", unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) - if err != nil { - t.Fatalf("k256 alias: %v", err) - } - if !valid { - t.Error("k256 alias did not verify a valid secp256k1 signature") - } -} - -func TestVerifySignatureMalformedInputsErrorRatherThanReportFalse(t *testing.T) { - if _, err := dstack.VerifySignature("rsa", []byte("x"), make([]byte, 64), make([]byte, 32)); err == nil { - t.Error("expected an error for an unknown algorithm") - } - if _, err := dstack.VerifySignature("ed25519", []byte("x"), make([]byte, 64), make([]byte, 31)); err == nil { - t.Error("expected an error for a 31-byte ed25519 public key") - } - if _, err := dstack.VerifySignature("ed25519", []byte("x"), make([]byte, 63), make([]byte, 32)); err == nil { - t.Error("expected an error for a 63-byte ed25519 signature") - } - - v := vectors(t) - c := caseWithAlgorithm(t, v, "secp256k1_prehashed") - // A prehashed digest must be exactly 32 bytes. - if _, err := dstack.VerifySignature( - "secp256k1_prehashed", []byte("short"), unhex(t, c.Signature), unhex(t, c.PublicKey)); err == nil { - t.Error("expected an error for a prehashed digest that is not 32 bytes") - } - // A secp256k1 signature must be raw 64-byte r||s, not DER. - if _, err := dstack.VerifySignature( - "secp256k1", []byte("x"), make([]byte, 70), unhex(t, c.PublicKey)); err == nil { - t.Error("expected an error for a 70-byte secp256k1 signature") - } - // The public key must be SEC1. - if _, err := dstack.VerifySignature( - "secp256k1", []byte("x"), unhex(t, c.Signature), make([]byte, 33)); err == nil { - t.Error("expected an error for a malformed secp256k1 public key") - } -} - -func TestVerifySignatureAcceptsUncompressedSecp256k1Keys(t *testing.T) { - v := vectors(t) - c := caseWithAlgorithm(t, v, "secp256k1") - compressed := unhex(t, c.PublicKey) - if len(compressed) != 33 { - t.Fatalf("expected a 33-byte compressed key in the vectors, got %d", len(compressed)) - } - parsed, err := secp256k1.ParsePubKey(compressed) - if err != nil { - t.Fatalf("parse compressed key: %v", err) - } - uncompressed := parsed.SerializeUncompressed() - valid, err := dstack.VerifySignature(c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), uncompressed) - if err != nil { - t.Fatalf("uncompressed key: %v", err) - } - if !valid { - t.Error("a valid signature was rejected under the uncompressed SEC1 key") - } -} - -func TestVerifySignatureChainVerifiesToTheKMSRoot(t *testing.T) { - v := vectors(t) - appID := unhex(t, v.AppID) - kmsRoot := unhex(t, v.KMSRootPubKey) - expectedAppRoot := unhex(t, v.AppRootPubKey) - - for _, c := range v.Cases { - appRoot, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: appID, - KMSRootPubKey: kmsRoot, - }) - if err != nil { - t.Fatalf("%s: %v", c.Algorithm, err) - } - if !bytes.Equal(appRoot, expectedAppRoot) { - t.Errorf("%s: recovered the wrong app root key: got %x, want %x", - c.Algorithm, appRoot, expectedAppRoot) - } - } -} - -func TestVerifySignatureChainExplicitPurposeMatchesTheDefault(t *testing.T) { - v := vectors(t) - c := v.Cases[0] - appRoot, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - Purpose: v.Purpose, - }) - if err != nil { - t.Fatalf("explicit purpose %q: %v", v.Purpose, err) - } - if !bytes.Equal(appRoot, unhex(t, v.AppRootPubKey)) { - t.Error("explicit purpose recovered a different app root key than the default") - } - - // A different purpose recovers some other key, which the KMS never signed. - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - Purpose: "encryption", - }); err == nil { - t.Error("expected a chain bound to a different purpose to be rejected") - } -} - -func TestVerifySignatureChainAnchoredAtAForeignKMSRootIsRejected(t *testing.T) { - v := vectors(t) - c := v.Cases[0] - _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.WrongKMSRootPubKey), - }) - if err == nil { - t.Fatal("a chain not anchored at our KMS root must be rejected") - } - if !strings.Contains(err.Error(), "not anchored") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestVerifySignatureChainForADifferentAppIDIsRejected(t *testing.T) { - v := vectors(t) - c := v.Cases[0] - appID := unhex(t, v.AppID) - appID[0] ^= 0xff - - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: appID, - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - }); err == nil { - t.Fatal("a chain issued for a different app_id must be rejected") - } -} - -func TestVerifySignatureChainTamperedPayloadBreaksTheChain(t *testing.T) { - v := vectors(t) - c := v.Cases[0] - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: []byte("a different payload entirely"), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chainOf(t, c), - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - }); err == nil { - t.Fatal("a chain over a tampered payload must be rejected") - } -} - -func TestVerifySignatureChainMalformedInputs(t *testing.T) { - v := vectors(t) - c := v.Cases[0] - chain := chainOf(t, c) - - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chain[:2], - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - }); err == nil { - t.Error("expected an error for a chain with fewer than 3 elements") - } - - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chain, - AppID: make([]byte, 19), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - }); err == nil { - t.Error("expected an error for an app_id that is not 20 bytes") - } - - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: chain, - AppID: unhex(t, v.AppID), - KMSRootPubKey: make([]byte, 33), - }); err == nil { - t.Error("expected an error for a malformed KMS root public key") - } - - // A recoverable link must be 65 bytes. - shortLink := [][]byte{chain[0], chain[1][:64], chain[2]} - if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ - Algorithm: c.Algorithm, - Data: unhex(t, c.Data), - PublicKey: unhex(t, c.PublicKey), - SignatureChain: shortLink, - AppID: unhex(t, v.AppID), - KMSRootPubKey: unhex(t, v.KMSRootPubKey), - }); err == nil { - t.Error("expected an error for a 64-byte recoverable signature") - } -} - -func TestVerifySignPurposeIsTheAgentSideConstant(t *testing.T) { - if dstack.SignPurpose != "signing" { - t.Errorf("SignPurpose = %q, want \"signing\"", dstack.SignPurpose) - } - if dstack.SignPath != "vms" { - t.Errorf("SignPath = %q, want \"vms\"", dstack.SignPath) - } - v := vectors(t) - if v.Purpose != dstack.SignPurpose || v.Path != dstack.SignPath { - t.Errorf("vectors disagree about the agent-side constants: purpose=%q path=%q", v.Purpose, v.Path) - } -} diff --git a/sdk/go/ratls/ratls.go b/sdk/go/ratls/ratls.go index bb7e28d4b..15a37d463 100644 --- a/sdk/go/ratls/ratls.go +++ b/sdk/go/ratls/ratls.go @@ -24,8 +24,8 @@ import ( // Phala RA-TLS OIDs for certificate extensions. var ( - oidTdxQuote = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62397, 1, 1} - oidEventLog = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62397, 1, 2} + oidTdxQuote = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62397, 1, 1} + oidEventLog = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 62397, 1, 2} ) // DefaultPCCSURL is the default PCCS server for collateral fetching. diff --git a/sdk/js/README.md b/sdk/js/README.md index 04a41cee7..6d010efb7 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -1,6 +1,6 @@ # @phala/dstack-sdk -JavaScript / TypeScript client for the dstack guest agent. Derive deterministic keys, generate TDX attestation quotes, issue TLS certificates, sign payloads, and encrypt environment variables for KMS-managed deployments — all against the guest agent socket inside a confidential VM (CVM). Signature verification runs locally in your process, not over the socket. +JavaScript / TypeScript client for the dstack guest agent. Derive deterministic keys, produce TDX attestations, issue TLS certificates, and encrypt environment variables for KMS-managed deployments — all against the guest agent socket inside a confidential VM (CVM). ## Installation @@ -8,7 +8,7 @@ JavaScript / TypeScript client for the dstack guest agent. Derive deterministic npm install @phala/dstack-sdk ``` -`@noble/hashes` and `@noble/curves` ship as regular dependencies — the core needs them for hashing and for local signature verification. Install the matching peer when you import a blockchain submodule: +`@noble/hashes` and `@noble/curves` ship as regular dependencies — the core needs them for hashing and for verifying the KMS env-encryption key. Install the matching peer when you import one of the v0-era chain submodules: | Import path | Extra peer dependency | | --- | --- | @@ -26,226 +26,144 @@ import { DstackClient } from '@phala/dstack-sdk' const client = new DstackClient() -const key = await client.getKey('wallet/eth') +const key = await client.getKey('storage-encryption', 'secp256k1') console.log(Buffer.from(key.key).toString('hex')) -const quote = await client.getQuote('app-state-snapshot') -console.log(quote.quote) -console.log(quote.event_log) +const { attestation } = await client.attest('app-state-snapshot') +console.log(attestation) ``` The constructor probes `/var/run/dstack.sock`, then `/run/dstack.sock`, then the `/var/run/dstack/` and `/run/dstack/` variants. Pass an explicit endpoint for HTTP or for a non-default socket: ```typescript -const client = new DstackClient('http://localhost:8090') // simulator -const client = new DstackClient('/run/dstack/dstack.sock') // custom path +const client = new DstackClient('http://localhost:8090') // simulator +const client = new DstackClient('/run/dstack/dstack.sock') // custom path ``` `DSTACK_SIMULATOR_ENDPOINT` overrides the default when set. -## Keys +An agent that predates v1 has no `/v1` mount at all, so it answers with a plain HTTP 404 page rather than a JSON error. `version()` is the cheapest probe for support. -### `getKey(path?, purpose?, algorithm?)` +## Two API surfaces -Derive a deterministic key. The same `(app_id, path)` returns the same raw key material; different apps deriving on the same path get different keys. +dstack 0.6.0 splits the guest agent API into two surfaces on the same socket, selected by URL path. This SDK mirrors both. -```typescript -const eth = await client.getKey('wallet/ethereum') // secp256k1 (default) -const sol = await client.getKey('wallet/solana', 'mainnet', 'ed25519') // ed25519 -``` +| Client | Paths | Status | +| --- | --- | --- | +| `DstackClient`, `DstackClientV1` | `/v1/` | **Current, and the default.** Six methods. Needs guest agent ≥ 0.6.0. | +| `DstackClientV0` | `/`, and equivalently `/v0/` | Deprecated. Frozen at the 0.5.11 shape; will not change again. | -Returns `{ key: Uint8Array, signature_chain: Uint8Array[] }`. The signature chain proves the key was derived inside a genuine TEE. +The unsuffixed `DstackClient` names v1. `DstackClientV1` is the same class under an explicit name — use whichever reads better; new code should not need `DstackClientV0` at all. -`purpose` is included in the signature-chain message and does not affect the private key bytes. `algorithm` selects how the derived 32-byte material is interpreted: `'secp256k1'` (default), `'k256'` (alias), or `'ed25519'`. It does not domain-separate the derivation, so use algorithm-specific paths such as `wallet/ethereum` and `wallet/solana` when those keys must be independent. ed25519 requires guest agent ≥ 0.5.7. +> **v1 keys are not v0 keys.** `getKey` on v1 derives under its own HKDF salt and binds the algorithm and a versioned context tag into the derivation. The same name yields **different key material** on the two surfaces, and under v1 secp256k1 and ed25519 no longer share one 32-byte secret. There is no compatibility mode and no migration path back — an app that has published v0-derived material must keep deriving it with `DstackClientV0`. `docs/guest-api-v1.md` pins the byte-level construction. -### `getTlsKey(options?)` +Code that used the unsuffixed client for v0 calls fails **loudly** on upgrade rather than silently deriving different keys, because the v1 method signatures differ and `getKey` requires `algorithm` explicitly. To stay on the frozen surface, switch to `DstackClientV0`. -Generate a fresh random TLS keypair plus certificate chain. Every call returns a new key — use `getKey` for deterministic material. +v1 also drops `sign`, `verify`, `getQuote`, `gpuInfo` and `emitEvent`. Those are not oversights: the agent holds two things a caller cannot get elsewhere — the app root key, and the platform's ability to attest — and v1 serves those two things only. Signing and verifying are pure computation over material `getKey` already hands you. + +## Client methods + +### `issueCert(options?)` + +Issue a certificate for this application. The agent generates a key, builds a CSR, signs it, and relays it to the KMS (or to the local CA when the app runs without one). ```typescript -const tls = await client.getTlsKey({ +const cert = await client.issueCert({ subject: 'api.example.com', altNames: ['localhost', '127.0.0.1'], - usageRaTls: true, // embed TDX quote in cert extension + usageRaTls: true, // embed the attestation quote in a cert extension }) +cert.key // PEM-encoded private key +cert.certificate_chain // PEM entries, leaf first +cert.asUint8Array(32) // the key as raw DER bytes ``` -Options: `subject`, `altNames`, `usageRaTls`, `usageServerAuth` (default `true`), `usageClientAuth` (default `false`), and — on guest agent ≥ 0.5.7 — `notBefore`, `notAfter` (Unix seconds), `withAppInfo`. The client probes `version()` before sending the new options and throws a clear error on older agents instead of silently dropping them. +Options: `subject`, `altNames`, `usageRaTls`, `usageServerAuth` (default `true`), `usageClientAuth` (default `false`), `withAppInfo`, `notBefore`, `notAfter` (Unix seconds). -Returns `{ key: string, certificate_chain: string[], asUint8Array(maxLength?) }`. `key` is PEM-encoded. +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. -## Attestation +### `getKey(domain, algorithm)` -### `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. +Derive a deterministic application key. ```typescript -const quote = await client.getQuote('user:alice:nonce123') -quote.quote // hex-encoded TDX quote -quote.event_log // JSON string of measured events +const enc = await client.getKey('storage-encryption', 'secp256k1') +const sig = await client.getKey('backup-signing', 'ed25519') // unrelated key material + +enc.key // Uint8Array, 32 bytes +enc.public_key // Uint8Array — SEC1 compressed (33) for secp256k1, raw (32) for ed25519 +enc.signature_chain // Uint8Array[], exactly 2 links ``` -### `attest(reportData)` +Both arguments are required. `algorithm` is exactly `'secp256k1'` or `'ed25519'` — there is no default and no `k256` alias, because v0's defaulting meant a typo silently produced a key of the wrong type under a name the caller thought meant something else. + +`domain` is an opaque domain-separation string, not a DNS name and not a path. Derivation is **flat**: `'a/b'` is a string that happens to contain a slash, not a child of `'a'`, and no key derived here can derive another. It replaces v0's `path` plus `purpose`; both `domain` and `algorithm` now feed the KDF. -Versioned dstack attestation that works across TDX / GCP / Nitro providers. Preferred for cross-platform verifiers. +`signature_chain` has two links: the app root key over the v1 key claim, then the KMS root key over the app root public key. `docs/guest-api-v1.md` is the normative spec for verifying it — it gives the claim encoding, the recovery steps, and, critically, why the KMS root key has to come from a source you trust independently of the agent you are checking. + +### `attest(reportData, includeBoottimeGpuEvidence?)` + +The only CVM attestation entry point. The versioned attestation already carries the TDX quote and the event log, so there is no separate `getQuote`. ```typescript const { attestation } = await client.attest('app-state-snapshot') ``` -Pass `true` as the second argument to also return the boot-time GPU attestation -evidence, so a verifier gets the quote and the GPU evidence in one round trip. +`reportData` is 1 to 64 bytes (string, Buffer, or Uint8Array), zero-padded on the right by the agent. Pass `true` as the second argument to also return the boot-time GPU evidence in `boottime_gpu_evidence`, so a verifier gets both in one round trip: ```typescript -const { attestation, boottime_gpu_evidence } = await client.attest('app-state-snapshot', true) +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()) +} ``` -The evidence is the same bytes ``gpuInfo()`` serves and is empty unless the flag was set -and boot-time GPU attestation output exists. It is not bound to `report_data`; verify -it with the measured `gpu-attestation` event digest as described under ``gpuInfo()``. +`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. ### `attestGpu(nonce)` -Collects vendor-native GPU evidence for a caller-chosen 32-byte nonce. +Collect GPU evidence now, against a 32-byte nonce you choose. This answers "is the device I can talk to right now a genuine CC-enabled GPU that signs my challenge", which boot-time evidence cannot. ```typescript const { bundles } = await client.attestGpu(crypto.randomBytes(32)) for (const bundle of bundles) { - console.log(bundle.vendor, bundle.format, bundle.evidence) + console.log(bundle.vendor, bundle.format, bundle.asUint8Array()) } ``` -Select a verifier using each bundle's `vendor` and `format`. The verifier must check -the evidence signature, certificate chain, measurements, and embedded nonce. Evidence -is opaque and hex-encoded by the JSON RPC. It does not by itself bind the GPU to this -CVM. - -### `gpuInfo()` +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. -Returns GPU information collected during boot. Currently, this includes the -complete NVIDIA `nvattest` JSON output. - -```typescript -const gpu = await client.gpuInfo() -console.log(gpu.attestation) -``` - -The `attestation` field is empty when no GPU attestation output is available. -The raw output is not trusted by itself; remote verifiers should compare its -digest with the measured `gpu-attestation` runtime event. +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. ### `info()` -App identity and TCB metadata. +App identity and configuration. Not attestation. ```typescript const info = await client.info() -info.app_id // application identifier -info.instance_id // CVM instance identifier -info.tcb_info // parsed { mrtd, rtmr0..3, event_log, ... } -info.compose_hash -info.cloud_vendor // e.g. "Google" (guest agent ≥ 0.5.7) -info.cloud_product // e.g. "Google Compute Engine" (guest agent ≥ 0.5.7) +info.app_id // hex +info.app_name +info.compose_hash // hex — sha256 over exactly the app_compose bytes +info.app_compose // the deployed document, verbatim +info.instance_id // hex +info.device_id // hex — identifies the host machine, not this instance +info.os_image_hash // hex +info.mr_aggregated // hex +info.vm_config // JSON owned by the VMM +info.key_provider_info // JSON owned by dstack-util +info.cloud_vendor // e.g. "Google" +info.cloud_product // e.g. "Google Compute Engine" ``` -### `version()` - -Returns `{ version, rev }` of the guest agent. Throws on agents older than 0.5.7 (the RPC didn't exist). - -## Sign and verify - -### `sign(algorithm, data)` - -Sign data with a derived key. The SDK rejects mismatched input early — `secp256k1_prehashed` requires a 32-byte digest. +Flat, with no `tcb_info` blob and no `app_cert`. The measurement registers and the event log are deliberately absent: they belong to `attest()`, which returns them quote-backed. Everything here arrives over a local socket with no quote behind it, so confirm anything you rely on against an attestation. -```typescript -const res = await client.sign('ed25519', 'hello dstack') -res.signature // Uint8Array -res.public_key // Uint8Array -res.signature_chain // Uint8Array[] — proves the signing key came from this TEE -``` +Do not parse and re-serialize `app_compose` before hashing it — key order, whitespace and unknown fields all change the digest, and that digest is what gets whitelisted on chain. -Algorithms: `ed25519`, `secp256k1`, `secp256k1_prehashed`. Requires guest agent ≥ 0.5.7. - -### `verifySignature(algorithm, data, signature, publicKey)` - -Verification needs no key material and no attestation, so it runs locally rather than through the agent — an agent's answer would arrive over the socket unattested anyway. The `Verify` RPC that used to back `client.verify()` was removed in dstack 0.6.0. - -```typescript -import { verifySignature } from '@phala/dstack-sdk' - -const data = new TextEncoder().encode('hello dstack') -verifySignature('ed25519', data, res.signature, res.public_key) // boolean -``` - -`data`, `signature` and `publicKey` are `Uint8Array`s. `secp256k1` (alias `k256`) takes a SEC1 public key — compressed or uncompressed — and a raw 64-byte `r || s` signature over SHA-256 of the data; `secp256k1_prehashed` takes the 32-byte digest directly. Malformed input (bad key length, wrong signature length, unknown algorithm, non-canonical high-S signature) throws; a well-formed signature that simply does not match returns `false`. - -### `verifySignatureChain(input)` - -On its own, `verifySignature` only proves that whoever holds that public key signed the data. `verifySignatureChain` walks the whole chain from a `sign()` response back to a KMS root key **you supply**, which is what establishes that the signer was a dstack app under that KMS. - -```typescript -import { verifySignatureChain } from '@phala/dstack-sdk' - -// Both anchors come from you, not from the CVM being checked. -const expectedAppId = Buffer.from('a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b', 'hex') -const kmsRootPubKey = Buffer.from('03...', 'hex') // pinned, or read from DstackKms - -const appRootPubKey = verifySignatureChain({ - algorithm: 'ed25519', - data, - publicKey: res.public_key, - signatureChain: res.signature_chain, - appId: expectedAppId, - kmsRootPubKey, -}) -``` - -Note what the example does *not* do: it never passes `info.app_id` from -`client.info()` straight through. That value is reported by the very CVM being -verified, so a chain checked against it proves only that the CVM is -self-consistent with itself. Use the app id you registered on chain, and if you -want `info` in the picture, compare it against that value rather than trusting -it. - -Returns the app root public key (compressed SEC1, 33 bytes) or throws. Get `kmsRootPubKey` from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it in your build — reading it from the KMS you are verifying against proves nothing. - -## Diagnostics - -### `isReachable()` - -Sub-500ms probe against `/Info`. Returns a boolean and never throws — useful for liveness checks. - -## Blockchain helpers - -### Ethereum - -```typescript -import { toViemAccountSecure } from '@phala/dstack-sdk/viem' -import { createWalletClient, http } from 'viem' -import { mainnet } from 'viem/chains' - -const key = await client.getKey('wallet/ethereum') -const account = toViemAccountSecure(key) - -const wallet = createWalletClient({ account, chain: mainnet, transport: http() }) -``` - -`toViemAccountSecure` hashes the derived key with SHA-256 before passing it to viem's `privateKeyToAccount`. The unhashed alternative `toViemAccount` is kept for migration only and emits a warning. - -### Solana - -```typescript -import { toKeypairSecure } from '@phala/dstack-sdk/solana' - -const key = await client.getKey('wallet/solana', 'mainnet', 'ed25519') -const keypair = toKeypairSecure(key) -console.log(keypair.publicKey.toBase58()) -``` +### `version()` -Same pattern as the Ethereum helper. `toKeypair` is the unhashed legacy variant. +Returns `{ version, rev }` of the guest agent. ## Compose hash @@ -313,12 +231,13 @@ Verify functions return the signer's compressed public key (hex) on success, or | Feature | Minimum guest agent | | --- | --- | -| `getKey`, `getTlsKey`, `getQuote`, `info` | 0.3.x | -| `attest`, `sign`, `version`, ed25519 keys, `info.cloud_vendor` / `cloud_product`, `getTlsKey` `notBefore` / `notAfter` / `withAppInfo` | 0.5.7 | +| `DstackClient` (v1), all six of its methods | 0.6.0 | +| `DstackClientV0`: `getKey`, `getTlsKey`, `getQuote`, `info` | 0.3.x | +| `DstackClientV0`: `attest`, `sign`, `verify`, `version`, ed25519 keys, `info.cloud_vendor` / `cloud_product`, `getTlsKey` `notBefore` / `notAfter` / `withAppInfo` | 0.5.7 | -`verifySignature` and `verifySignatureChain` run locally and have no guest agent requirement. They replace `client.verify()`, whose `Verify` RPC was removed in dstack 0.6.0. +`emitEvent` needed 0.5.0 and was removed in 0.6.0; a 0.6.0 agent fails every call. -The SDK's release versions track guest agent versions — `0.5.8-x` targets dstack 0.5.7+. +The SDK's release versions track guest agent versions — `0.6.0-x` targets dstack 0.6.0+, and still speaks the frozen v0 surface to older agents. ## Development @@ -331,21 +250,175 @@ cd dstack/sdk/simulator export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 ``` -Then point `new DstackClient()` at the simulator (it picks up `DSTACK_SIMULATOR_ENDPOINT` automatically). +Then point `new DstackClient()` at the simulator (it picks up `DSTACK_SIMULATOR_ENDPOINT` automatically). Both clients read the same variable, and the one simulator socket serves both surfaces. + +## Legacy (v0, frozen) + +Everything below is the deprecated `DstackClientV0` surface, frozen at the dstack 0.5.11 shape. It is kept for apps that already published v0-derived material and therefore cannot move. New code should use `DstackClient`. + +```typescript +import { DstackClientV0 } from '@phala/dstack-sdk' + +const client = new DstackClientV0() +``` + +### Keys + +#### `getKey(path?, purpose?, algorithm?)` + +Derive a deterministic key. The same `(app_id, path)` returns the same raw key material; different apps deriving on the same path get different keys. + +```typescript +const eth = await client.getKey('wallet/ethereum') // secp256k1 (default) +const sol = await client.getKey('wallet/solana', 'mainnet', 'ed25519') // ed25519 +``` + +Returns `{ key: Uint8Array, signature_chain: Uint8Array[] }`. The signature chain proves the key was derived inside a genuine TEE. + +`purpose` is included in the signature-chain message and does not affect the private key bytes. `algorithm` selects how the derived 32-byte material is interpreted: `'secp256k1'` (default), `'k256'` (alias), or `'ed25519'`. It does not domain-separate the derivation, so use algorithm-specific paths such as `wallet/ethereum` and `wallet/solana` when those keys must be independent. ed25519 requires guest agent ≥ 0.5.7. -## Migration from TappdClient +#### `getTlsKey(options?)` -`TappdClient` and its `deriveKey` / `tdxQuote` methods are deprecated but still exported. Replace them with `DstackClient` and the new methods: +Generate a fresh random TLS keypair plus certificate chain. Every call returns a new key — use `getKey` for deterministic material. + +```typescript +const tls = await client.getTlsKey({ + subject: 'api.example.com', + altNames: ['localhost', '127.0.0.1'], + usageRaTls: true, // embed TDX quote in cert extension +}) +``` + +Options: `subject`, `altNames`, `usageRaTls`, `usageServerAuth` (default `true`), `usageClientAuth` (default `false`), and — on guest agent ≥ 0.5.7 — `notBefore`, `notAfter` (Unix seconds), `withAppInfo`. The client probes `version()` before sending the new options and throws a clear error on older agents instead of silently dropping them. + +Returns `{ key: string, certificate_chain: string[], asUint8Array(maxLength?) }`. `key` is PEM-encoded. + +### Attestation + +#### `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') +quote.quote // hex-encoded TDX quote +quote.event_log // JSON string of measured events +``` + +#### `attest(reportData)` + +Versioned dstack attestation that works across TDX / GCP / Nitro providers. Preferred over `getQuote` for cross-platform verifiers. + +```typescript +const { attestation } = await client.attest('app-state-snapshot') +``` + +`reportData` only — GPU evidence is not available on this surface. Use `DstackClient.attest` or `DstackClient.attestGpu`. + +#### `info()` + +App identity and TCB metadata. + +```typescript +const info = await client.info() +info.app_id // application identifier +info.instance_id // CVM instance identifier +info.tcb_info // parsed { mrtd, rtmr0..3, event_log, ... } +info.compose_hash +info.cloud_vendor // e.g. "Google" (guest agent ≥ 0.5.7) +info.cloud_product // e.g. "Google Compute Engine" (guest agent ≥ 0.5.7) +``` + +#### `version()` + +Returns `{ version, rev }` of the guest agent. Throws on agents older than 0.5.7 (the RPC didn't exist). + +### Sign and verify + +#### `sign(algorithm, data)` + +Sign data with a derived key. The SDK rejects mismatched input early — `secp256k1_prehashed` requires a 32-byte digest. + +```typescript +const res = await client.sign('ed25519', 'hello dstack') +res.signature // Uint8Array +res.public_key // Uint8Array +res.signature_chain // Uint8Array[] — proves the signing key came from this TEE +``` + +Algorithms: `ed25519`, `secp256k1`, `secp256k1_prehashed`. Requires guest agent ≥ 0.5.7. + +#### `verify(algorithm, data, signature, publicKey)` + +Check a single signature through the agent. Returns `{ valid: boolean }`; throws when the agent rejects the input as malformed. + +```typescript +const { valid } = await client.verify('ed25519', 'hello dstack', res.signature, res.public_key) +``` + +This only proves that whoever holds `publicKey` signed the data. Establishing that the signer was a dstack app under a KMS you trust means walking the whole signature chain, and that is not something the agent can tell you — its answer arrives over the socket unattested, so it is worth no more than checking it yourself. + +The SDK no longer ships `verifySignature` and `verifySignatureChain`. For a v1 chain, `docs/guest-api-v1.md` is the normative spec: it gives the claim encoding, the recovery steps, and why the KMS root key must come from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or a pinned value rather than from the agent under test. + +One thing that spec insists on and that is easy to get wrong: never anchor a chain against `info.app_id` read from the same CVM. That value is reported by the very thing being verified, so a chain checked against it proves only that the CVM is self-consistent with itself. Use the app id you registered on chain. + +#### `emitEvent(event, payload)` + +Removed in dstack 0.6.0 — runtime RTMR3 events became system-owned, so a 0.6.0 agent fails every call and this surfaces the agent's own message. Bind application data through `report_data` on `attest()` instead. + +### Diagnostics + +#### `isReachable()` + +Sub-500ms probe against `/Info`. Returns a boolean and never throws — useful for liveness checks. + +### Blockchain helpers + +The chain adapters are v0-era and stay that way: `toViemAccountSecure` and `toKeypairSecure` take a v0 `GetKeyResponse` or `GetTlsKeyResponse`. The v1 surface has no chain-related functionality — it returns key material, and what an application builds from those bytes is its own business. + +```typescript +import { toViemAccountSecure } from '@phala/dstack-sdk/viem' + +const key = await client.getKey('wallet/ethereum') +const account = toViemAccountSecure(key) +``` + +```typescript +import { toKeypairSecure } from '@phala/dstack-sdk/solana' + +const key = await client.getKey('wallet/solana', 'mainnet', 'ed25519') +const keypair = toKeypairSecure(key) +``` + +Given a `GetTlsKeyResponse` both helpers hash the PEM key with SHA-256 first, which is what makes them "secure" relative to `toViemAccount` and `toKeypair`; those unhashed variants are kept for migration only and emit a warning. + +### Migrating v0 to v1 + +| v0 | v1 | +| --- | --- | +| `new DstackClientV0()` | `new DstackClient()` | +| `client.getTlsKey({ subject })` | `client.issueCert({ subject })` | +| `client.getKey(path, purpose)` | `client.getKey(domain, algorithm)` — **different key material** | +| `client.getQuote(data)` | `client.attest(data)` | +| `client.sign(...)` / `client.verify(...)` | sign and verify locally with the key from `getKey` | +| `client.emitEvent(...)` | bind the data through `report_data` on `attest()` | +| `info.tcb_info.*` | `attest()`, which returns measurements quote-backed | +| no equivalent | `client.attestGpu(nonce)`, GPU evidence against a fresh nonce | + +Migrate a surface at a time: both clients can talk to the same agent at once, so a v1 `attest()` is safe to adopt while `getKey` still runs on v0. What cannot be mixed is key derivation — see the warning under [Two API surfaces](#two-api-surfaces). + +### Migrating from TappdClient + +`TappdClient` and its `deriveKey` / `tdxQuote` methods are deprecated but still exported, and still extend `DstackClientV0` — the unsuffixed alias moving to v1 did not change what they inherit or what they send. | Old | New | | --- | --- | -| `new TappdClient()` | `new DstackClient()` | -| `client.deriveKey(path, subject)` | `client.getTlsKey({ subject })` | -| `client.tdxQuote(data)` | `client.getQuote(data)` | +| `new TappdClient()` | `new DstackClient()` — or `new DstackClientV0()` to keep the same key material | +| `client.deriveKey(path, subject)` | `client.issueCert({ subject })` | +| `client.tdxQuote(data)` | `client.attest(data)` | | `/var/run/tappd.sock` | `/var/run/dstack.sock` | -`toViemAccount` and `toKeypair` are kept for the same reason; prefer their `Secure` variants in new code. - ## License Apache-2.0 diff --git a/sdk/js/package.json b/sdk/js/package.json index 17d0ec253..c17d7fc7f 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -1,6 +1,6 @@ { "name": "@phala/dstack-sdk", - "version": "0.5.8", + "version": "0.6.0", "description": "dstack SDK", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -53,14 +53,6 @@ }, "import": "./dist/verify-env-encrypt-public-key.mjs", "require": "./dist/verify-env-encrypt-public-key.js" - }, - "./verify": { - "types": { - "import": "./dist/verify.d.mts", - "require": "./dist/verify.d.ts" - }, - "import": "./dist/verify.mjs", - "require": "./dist/verify.js" } }, "engines": { diff --git a/sdk/js/src/__tests__/index-v1.test.ts b/sdk/js/src/__tests__/index-v1.test.ts new file mode 100644 index 000000000..720b7f2dc --- /dev/null +++ b/sdk/js/src/__tests__/index-v1.test.ts @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +import { expect, describe, it } from 'vitest' +import http from 'http' +import type { AddressInfo } from 'net' +import { DstackClient, DstackClientV0, DstackClientV1 } from '../index' +import type { GpuEvidenceBundleV1 } from '../index' + +describe('DstackClientV1', () => { + it('should be what the unsuffixed DstackClient names, as value and as type', () => { + expect(DstackClient).toBe(DstackClientV1) + // Typed as the alias, constructed through the alias: this line fails to + // compile if either half of the export stops pointing at v1. + const client: DstackClient = new DstackClient() + expect(client).toBeInstanceOf(DstackClientV1) + expect(client).not.toBeInstanceOf(DstackClientV0) + }) + + it('should be able to get version', async () => { + const client = new DstackClientV1() + const result = await client.version() + expect(result).toHaveProperty('version') + expect(result).toHaveProperty('rev') + expect(result.version).not.toBe('') + }) + + describe('issueCert', () => { + it('should issue a certificate with a fresh key', async () => { + const client = new DstackClientV1() + const result = await client.issueCert({ + subject: 'test-subject', + altNames: ['localhost', '127.0.0.1'], + usageRaTls: true, + usageServerAuth: true, + usageClientAuth: true, + }) + expect(result.key).toContain('-----BEGIN PRIVATE KEY-----') + expect(result.certificate_chain.length).toBeGreaterThan(0) + expect(result.certificate_chain[0]).toContain('-----BEGIN CERTIFICATE-----') + }) + + it('should generate an unrelated key on every call', async () => { + const client = new DstackClientV1() + const first = await client.issueCert({ subject: 'test-subject' }) + const second = await client.issueCert({ subject: 'test-subject' }) + expect(first.key).not.toBe(second.key) + }) + + it('should expose the key as a uint8array of the requested length', async () => { + const client = new DstackClientV1() + const result = await client.issueCert() + const full = result.asUint8Array() + const truncated = result.asUint8Array(32) + expect(full).toBeInstanceOf(Uint8Array) + expect(truncated.length).toBe(32) + expect(truncated.length).not.toBe(full.length) + }) + + it('should reject a validity window that ends before it starts', async () => { + const client = new DstackClientV1() + const now = Math.floor(Date.now() / 1000) + await expect(() => client.issueCert({ notBefore: now + 3600, notAfter: now })).rejects.toThrow() + }) + }) + + describe('getKey', () => { + it('should derive a secp256k1 key with a public key and a two-link chain', async () => { + const client = new DstackClientV1() + const result = await client.getKey('storage-encryption', 'secp256k1') + expect(result.key).toBeInstanceOf(Uint8Array) + expect(result.key.length).toBe(32) + // SEC1 compressed, so the chain's first link commits to these exact bytes. + expect(result.public_key.length).toBe(33) + expect([0x02, 0x03]).toContain(result.public_key[0]) + expect(result.signature_chain.length).toBe(2) + for (const link of result.signature_chain) { + expect(link).toBeInstanceOf(Uint8Array) + expect(link.length).toBe(65) // recoverable r || s || v + } + }) + + it('should derive an ed25519 key with a 32-byte public key', async () => { + const client = new DstackClientV1() + const result = await client.getKey('storage-encryption', 'ed25519') + expect(result.key.length).toBe(32) + expect(result.public_key.length).toBe(32) + expect(result.signature_chain.length).toBe(2) + }) + + it('should be deterministic for the same domain and algorithm', async () => { + const client = new DstackClientV1() + const first = await client.getKey('storage-encryption', 'secp256k1') + const second = await client.getKey('storage-encryption', 'secp256k1') + expect(first.key).toEqual(second.key) + }) + + it('should separate the two curves, which v0 did not', async () => { + const client = new DstackClientV1() + const secp = await client.getKey('storage-encryption', 'secp256k1') + const ed = await client.getKey('storage-encryption', 'ed25519') + expect(secp.key).not.toEqual(ed.key) + }) + + it('should derive different material than v0 for the same name', async () => { + const v0 = await new DstackClientV0().getKey('storage-encryption', '', 'secp256k1') + const v1 = await new DstackClientV1().getKey('storage-encryption', 'secp256k1') + expect(v1.key).not.toEqual(v0.key) + }) + + it('should treat the domain as flat rather than a path', async () => { + const client = new DstackClientV1() + const parent = await client.getKey('a', 'secp256k1') + const child = await client.getKey('a/b', 'secp256k1') + expect(parent.key).not.toEqual(child.key) + }) + + it('should accept an empty domain', async () => { + const client = new DstackClientV1() + const result = await client.getKey('', 'secp256k1') + expect(result.key.length).toBe(32) + }) + + it('should reject an empty algorithm without a round trip', async () => { + const client = new DstackClientV1() + await expect(() => client.getKey('storage-encryption', '')).rejects.toThrow('algorithm is required') + }) + + it('should reject the v0 k256 alias', async () => { + const client = new DstackClientV1() + await expect(() => client.getKey('storage-encryption', 'k256')).rejects.toThrow() + }) + + it('should reject secp256k1_prehashed, which named a signing mode', async () => { + const client = new DstackClientV1() + await expect(() => client.getKey('storage-encryption', 'secp256k1_prehashed')).rejects.toThrow() + }) + }) + + describe('attest', () => { + it('should attest over report data', async () => { + const client = new DstackClientV1() + const result = await client.attest('test') + expect(result.attestation).not.toBe('') + expect(result.boottime_gpu_evidence).toEqual([]) + }) + + it('should accept the boot-time GPU evidence flag', async () => { + const client = new DstackClientV1() + const result = await client.attest('test', true) + expect(result.attestation).not.toBe('') + // Absence is the empty list, not a sentinel; the simulator has no GPU + // output, so this is empty here but must still be an array. + expect(Array.isArray(result.boottime_gpu_evidence)).toBe(true) + expect(result.boottime_gpu_evidence).toEqual([]) + }) + + it('should type boot-time evidence as the bundle list attestGpu returns', async () => { + const client = new DstackClientV1() + const result = await client.attest('test', true) + // Assigning one to the other is the assertion: one parser, both methods. + const bundles: GpuEvidenceBundleV1[] = result.boottime_gpu_evidence + for (const bundle of bundles) { + expect(bundle.asUint8Array()).toBeInstanceOf(Uint8Array) + } + }) + + it('should reject report data outside 1..64 bytes', async () => { + const client = new DstackClientV1() + await expect(() => client.attest('')).rejects.toThrow('must not be empty') + await expect(() => client.attest(Buffer.alloc(65))).rejects.toThrow('at most 64 bytes') + }) + }) + + describe('attestGpu', () => { + it('should reject a nonce that is not exactly 32 bytes', async () => { + const client = new DstackClientV1() + await expect(() => client.attestGpu(new Uint8Array(31))).rejects.toThrow('exactly 32 bytes') + await expect(() => client.attestGpu(new Uint8Array(33))).rejects.toThrow('exactly 32 bytes') + }) + + it('should surface the agent failure when there is no GPU', async () => { + const client = new DstackClientV1() + // The simulator ships no nvattest, so this must fail fast and clearly + // rather than hang for the attestation timeout. + await expect(() => client.attestGpu(new Uint8Array(32).fill(0xab))).rejects.toThrow( + 'GPU attestation' + ) + }) + }) + + describe('GPU evidence bundles', () => { + // The simulator ships no nvattest, so a stub agent is the only way to see a + // non-empty bundle -- and the decoding is what a verifier depends on. + const nvattest_output = '{"nonce": "00", "measurements": []}\n' + const evidence = Buffer.from(nvattest_output, 'utf8').toString('hex') + + async function withStubAgent(fn: (client: DstackClientV1) => Promise) { + const server = http.createServer((req, res) => { + const body = req.url === '/v1/Attest' + ? { + attestation: 'aabb', + boottime_gpu_evidence: [ + { vendor: 'nvidia', format: 'nvidia-nvattest-boottime-json-v1', evidence }, + ], + } + : { + bundles: [ + { vendor: 'nvidia', format: 'nvidia-nvattest-collect-evidence-json-v1', evidence }, + ], + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())) + try { + const { port } = server.address() as AddressInfo + await fn(new DstackClientV1(`http://127.0.0.1:${port}`)) + } finally { + await new Promise(resolve => server.close(() => resolve())) + } + } + + it('should decode boot-time evidence to the nvattest bytes verbatim', async () => { + await withStubAgent(async client => { + const result = await client.attest('test', true) + const [bundle] = result.boottime_gpu_evidence + expect(bundle.vendor).toBe('nvidia') + expect(bundle.format).toBe('nvidia-nvattest-boottime-json-v1') + // Byte-exact: sha256 over these bytes is what `evidence_sha256` in the + // measured `gpu-attestation` event commits to. + expect(Buffer.from(bundle.asUint8Array()).toString('utf8')).toBe(nvattest_output) + }) + }) + + it('should hand both methods the same bundle shape', async () => { + await withStubAgent(async client => { + const attested = await client.attest('test', true) + const collected = await client.attestGpu(new Uint8Array(32)) + const boottime: GpuEvidenceBundleV1 = attested.boottime_gpu_evidence[0] + const on_demand: GpuEvidenceBundleV1 = collected.bundles[0] + // Only `format` separates them, so one parser handles both. + expect(on_demand.format).toBe('nvidia-nvattest-collect-evidence-json-v1') + expect(on_demand.asUint8Array()).toEqual(boottime.asUint8Array()) + }) + }) + }) + + describe('info', () => { + it('should return the flat identity shape', async () => { + const client = new DstackClientV1() + const result = await client.info() + for (const field of [ + 'app_id', 'app_name', 'compose_hash', 'app_compose', 'instance_id', 'device_id', + 'os_image_hash', 'mr_aggregated', 'vm_config', 'key_provider_info', + 'cloud_vendor', 'cloud_product', + ]) { + expect(result).toHaveProperty(field) + } + expect(result.app_id).not.toBe('') + expect(result.instance_id).not.toBe('') + }) + + it('should not nest measurements in a tcb_info blob or mint an app_cert', async () => { + const client = new DstackClientV1() + const result = await client.info() as any + expect(result.tcb_info).toBeUndefined() + expect(result.app_cert).toBeUndefined() + }) + + it('should hex-encode the byte fields', async () => { + const client = new DstackClientV1() + const result = await client.info() + expect(result.app_id).toMatch(/^[0-9a-f]+$/) + expect(result.compose_hash).toMatch(/^[0-9a-f]{64}$/) + expect(result.mr_aggregated).toMatch(/^[0-9a-f]{64}$/) + }) + + it('should serve app_compose verbatim rather than nested in another JSON string', async () => { + const client = new DstackClientV1() + const result = await client.info() + // Handed over as the deployed bytes, so a caller can hash them directly. + // v0 reached this through a JSON string inside `tcb_info`. + expect(typeof result.app_compose).toBe('string') + expect(() => JSON.parse(result.app_compose)).not.toThrow() + expect(JSON.parse(result.app_compose)).toHaveProperty('manifest_version') + }) + }) + + it('should serve only the six v1 methods', () => { + const client = new DstackClientV1() as any + for (const absent of ['sign', 'verify', 'emitEvent', 'getQuote', 'gpuInfo', 'getTlsKey']) { + expect(client[absent]).toBeUndefined() + } + }) + + it('should throw when the unix socket file does not exist', () => { + const savedEnv = process.env.DSTACK_SIMULATOR_ENDPOINT + delete process.env.DSTACK_SIMULATOR_ENDPOINT + + expect(() => new DstackClientV1('/non/existent/socket')).toThrow( + 'Unix socket file /non/existent/socket does not exist' + ) + expect(() => new DstackClientV1('http://localhost:8080')).not.toThrow() + + if (savedEnv) { + process.env.DSTACK_SIMULATOR_ENDPOINT = savedEnv + } + }) +}) diff --git a/sdk/js/src/__tests__/index.test.ts b/sdk/js/src/__tests__/index.test.ts index 5ff413f8f..e4ca2ab39 100644 --- a/sdk/js/src/__tests__/index.test.ts +++ b/sdk/js/src/__tests__/index.test.ts @@ -4,9 +4,19 @@ import { expect, describe, it, vi } from 'vitest' import crypto from 'crypto' // Added for prehashed test -import { DstackClient, TappdClient, verifySignature } from '../index' +import { DstackClient, DstackClientV0, DstackClientV1, TappdClient } from '../index' + +describe('DstackClientV0', () => { + it('should only be reachable under its explicit name now', () => { + expect(DstackClient).not.toBe(DstackClientV0) + expect(new DstackClient()).not.toBeInstanceOf(DstackClientV0) + }) + + it('should stay the base of TappdClient even though the alias moved to v1', () => { + expect(new TappdClient()).toBeInstanceOf(DstackClientV0) + expect(new TappdClient()).not.toBeInstanceOf(DstackClientV1) + }) -describe('DstackClient', () => { it('should able to derive key in TappdClient', async () => { const client = new TappdClient() const result = await client.deriveKey('/', 'test') @@ -14,20 +24,20 @@ describe('DstackClient', () => { expect(result).toHaveProperty('certificate_chain') }) - it('should throws error in DstackClient', async () => { - const client = new DstackClient() + it('should throws error in DstackClientV0', async () => { + const client = new DstackClientV0() await expect(() => client.deriveKey('/', 'test')).rejects.toThrow('deriveKey is deprecated, please use getKey instead.') }) it('should able to get key', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.getKey('/', 'test') expect(result).toHaveProperty('key') expect(result).toHaveProperty('signature_chain') }) it('should able to get key with different algorithms', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const resultSecp = await client.getKey('/secp', 'test', 'secp256k1') expect(resultSecp.key).toBeInstanceOf(Uint8Array) expect(resultSecp.key.length).toBe(32) // secp256k1 private key size @@ -39,7 +49,7 @@ describe('DstackClient', () => { it('should able to request tdx quote', async () => { - const client = new DstackClient() + const client = new DstackClientV0() // You can put computation result as report data to tdxQuote. NOTE: it should serializable by JSON.stringify const result = await client.getQuote('some data or anything can be call by toJSON') expect(result).toHaveProperty('quote') @@ -48,44 +58,27 @@ describe('DstackClient', () => { expect(() => JSON.parse(result.event_log)).not.toThrowError() }) - it('should reject an attestGpu nonce that is not 32 bytes', async () => { - const client = new DstackClient() - await expect(() => client.attestGpu(new Uint8Array(31))).rejects.toThrow() - await expect(() => client.attestGpu(new Uint8Array(33))).rejects.toThrow() - }) - - it('should surface an attestGpu failure when the simulator has no GPU', async () => { - const client = new DstackClient() - // The simulator ships no nvattest, so this must fail fast and clearly - // rather than hang for the attestation timeout. - await expect(() => client.attestGpu(new Uint8Array(32).fill(0xab))).rejects.toThrow() - }) - it('should be able to attest', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.attest('test') expect(result).toHaveProperty('attestation') expect(result.attestation).not.toBe('') - expect(result.boottime_gpu_evidence).toBe('') }) - it('should be able to attest with gpu evidence', async () => { - const client = new DstackClient() - const result = await client.attest('test', true) - expect(result).toHaveProperty('attestation') - expect(result.attestation).not.toBe('') - // Whether evidence exists depends on the host; assert the field is present. - expect(result).toHaveProperty('boottime_gpu_evidence') + it('should not carry the GPU methods, which this surface never served', () => { + const client = new DstackClientV0() as any + expect(client.attestGpu).toBeUndefined() + expect(client.gpuInfo).toBeUndefined() }) it('should able to get derive key result as uint8array', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.getKey('/', 'test') expect(result.key).toBeInstanceOf(Uint8Array) }) it('should able to get derive key result as uint8array with specified length', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.getTlsKey() const full = result.asUint8Array() const key = result.asUint8Array(32) @@ -96,33 +89,33 @@ describe('DstackClient', () => { }) it('should be able to get quote', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.getQuote('pure string') }) it('should throw error on report_data large then 64 characters', async () => { - const client = new DstackClient() + const client = new DstackClientV0() await expect(() => client.getQuote('0'.padEnd(65, 'x'))).rejects.toThrow() }) it('should throw error on report_data large then 64 bytes', async () => { - const client = new DstackClient() + const client = new DstackClientV0() await expect(() => client.getQuote(Buffer.alloc(65))).rejects.toThrow() }) it('should throw error on report_data large then 128 bytes', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const input = new Uint8Array(65).fill(0) await expect(() => client.getQuote(input)).rejects.toThrow() }) it('should throw error on attest report_data larger than 64 bytes', async () => { - const client = new DstackClient() + const client = new DstackClientV0() await expect(() => client.attest(Buffer.alloc(65))).rejects.toThrow() }) it('should be able to get info', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.info() expect(result).toHaveProperty('app_id') expect(result).toHaveProperty('instance_id') @@ -138,7 +131,7 @@ describe('DstackClient', () => { }) it('should be able to decode tcb info', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.info() const tcbInfo = result.tcb_info expect(tcbInfo).toHaveProperty('rtmr0') @@ -154,7 +147,7 @@ describe('DstackClient', () => { }) it('should be able to get TLS key with alt names', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const altNames = ['localhost', '127.0.0.1'] const result = await client.getTlsKey({ subject: 'test-subject', @@ -174,7 +167,7 @@ describe('DstackClient', () => { const savedEnv = process.env.DSTACK_SIMULATOR_ENDPOINT delete process.env.DSTACK_SIMULATOR_ENDPOINT - expect(() => new DstackClient('/non/existent/socket')).toThrow('Unix socket file /non/existent/socket does not exist') + expect(() => new DstackClientV0('/non/existent/socket')).toThrow('Unix socket file /non/existent/socket does not exist') // Restore environment variable if (savedEnv) { @@ -187,8 +180,8 @@ describe('DstackClient', () => { const savedEnv = process.env.DSTACK_SIMULATOR_ENDPOINT delete process.env.DSTACK_SIMULATOR_ENDPOINT - expect(() => new DstackClient('http://localhost:8080')).not.toThrow() - expect(() => new DstackClient('https://example.com')).not.toThrow() + expect(() => new DstackClientV0('http://localhost:8080')).not.toThrow() + expect(() => new DstackClientV0('https://example.com')).not.toThrow() // Restore environment variable if (savedEnv) { @@ -197,18 +190,17 @@ describe('DstackClient', () => { }) it('should be able to check if service is reachable', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const isReachable = await client.isReachable() expect(typeof isReachable).toBe('boolean') }) describe('Sign and Verify Methods', () => { - const client = new DstackClient() + const client = new DstackClientV0() const testData = 'Test message for signing' const badData = 'This is not the original message' - const encode = (text: string) => new TextEncoder().encode(text) - it('should sign with ed25519 and verify locally', async () => { + it('should sign with ed25519 and verify', async () => { const algorithm = 'ed25519' const signResp = await client.sign(algorithm, testData) @@ -220,14 +212,15 @@ describe('DstackClient', () => { expect(signResp.signature_chain.length).toBeGreaterThan(0) // Should have at least the signature itself expect(signResp.signature_chain[0]).toBeInstanceOf(Uint8Array) - // Verification is local: it needs no key material, so there is no RPC for it. - expect(verifySignature(algorithm, encode(testData), signResp.signature, signResp.public_key)).toBe(true) + const verifyResp = await client.verify(algorithm, testData, signResp.signature, signResp.public_key) + expect(verifyResp.valid).toBe(true) // Verify failure (bad data) - expect(verifySignature(algorithm, encode(badData), signResp.signature, signResp.public_key)).toBe(false) + const badResp = await client.verify(algorithm, badData, signResp.signature, signResp.public_key) + expect(badResp.valid).toBe(false) }) - it('should sign with secp256k1 and verify locally', async () => { + it('should sign with secp256k1 and verify', async () => { const algorithm = 'secp256k1' const signResp = await client.sign(algorithm, testData) @@ -235,11 +228,11 @@ describe('DstackClient', () => { expect(signResp.public_key).toBeInstanceOf(Uint8Array) expect(signResp.signature_chain.length).toBeGreaterThan(0) - expect(verifySignature(algorithm, encode(testData), signResp.signature, signResp.public_key)).toBe(true) - expect(verifySignature(algorithm, encode(badData), signResp.signature, signResp.public_key)).toBe(false) + expect((await client.verify(algorithm, testData, signResp.signature, signResp.public_key)).valid).toBe(true) + expect((await client.verify(algorithm, badData, signResp.signature, signResp.public_key)).valid).toBe(false) }) - it('should sign with secp256k1_prehashed and verify locally', async () => { + it('should sign with secp256k1_prehashed and verify', async () => { const algorithm = 'secp256k1_prehashed' const digest = new Uint8Array(crypto.createHash('sha256').update(testData).digest()) expect(digest.length).toBe(32) // Ensure it's 32 bytes @@ -249,11 +242,11 @@ describe('DstackClient', () => { expect(signResp.signature).toBeInstanceOf(Uint8Array) expect(signResp.public_key).toBeInstanceOf(Uint8Array) - expect(verifySignature(algorithm, digest, signResp.signature, signResp.public_key)).toBe(true) + expect((await client.verify(algorithm, digest, signResp.signature, signResp.public_key)).valid).toBe(true) // Verify failure (bad digest) const badDigest = new Uint8Array(crypto.createHash('sha256').update(badData).digest()) - expect(verifySignature(algorithm, badDigest, signResp.signature, signResp.public_key)).toBe(false) + expect((await client.verify(algorithm, badDigest, signResp.signature, signResp.public_key)).valid).toBe(false) }) it('should throw error when signing secp256k1_prehashed with incorrect data length', async () => { @@ -271,22 +264,38 @@ describe('DstackClient', () => { }) }) + describe('emitEvent', () => { + it('should reject an empty event name before reaching the agent', async () => { + const client = new DstackClientV0() + await expect(() => client.emitEvent('', 'payload')).rejects.toThrow('Event name cannot be empty') + }) + + it('should surface the agent removal message instead of resolving silently', async () => { + const client = new DstackClientV0() + // The 0.6.0 agent always fails this. A caller that gets a resolved promise + // would believe the event was measured, which is the one wrong answer here. + await expect(() => client.emitEvent('test-event', 'payload')).rejects.toThrow( + 'EmitEvent was removed in dstack 0.6.0' + ) + }) + }) + it('should be able to get version', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const result = await client.version() expect(result).toHaveProperty('version') expect(result.version).not.toBe('') }) it('should get key with k256 alias producing same result as secp256k1', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const resultK256 = await client.getKey('/test', 'purpose', 'k256') const resultSecp = await client.getKey('/test', 'purpose', 'secp256k1') expect(resultK256.key).toEqual(resultSecp.key) }) it('should reject secp256k1_prehashed in getKey', async () => { - const client = new DstackClient() + const client = new DstackClientV0() await expect(() => client.getKey('/test', 'purpose', 'secp256k1_prehashed')).rejects.toThrow() }) @@ -328,9 +337,9 @@ describe('DstackClient', () => { }) }) - describe('deprecated methods with DstackClient', () => { + describe('deprecated methods with DstackClientV0', () => { it('should throws error in deriveKey method', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(() => client.deriveKey('/', 'test')).rejects.toThrow('deriveKey is deprecated, please use getKey instead.') @@ -339,7 +348,7 @@ describe('DstackClient', () => { }) it('should throws error in tdxQuote method without hash algorithm parameter', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(() => client.tdxQuote('test data')).rejects.toThrow('tdxQuote only supports raw hash algorithm.') @@ -348,7 +357,7 @@ describe('DstackClient', () => { }) it("should throws error in tdxQuote method with hash algorithm parameter other than raw", async () => { - const client = new DstackClient() + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(() => client.tdxQuote('test data', 'sha256')).rejects.toThrow('tdxQuote only supports raw hash algorithm.') @@ -357,7 +366,7 @@ describe('DstackClient', () => { }) it('should able to get quote with plain report_data in tdxQuote method with warning', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = await client.tdxQuote('test data', "raw") @@ -369,7 +378,7 @@ describe('DstackClient', () => { }) it('should throws error in tdxQuote with hash algorithm parameter', async () => { - const client = new DstackClient() + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(() => client.tdxQuote('test data', 'sha256')).rejects.toThrow('tdxQuote only supports raw hash algorithm.') diff --git a/sdk/js/src/__tests__/solana.test.ts b/sdk/js/src/__tests__/solana.test.ts index ee908a900..87436b3bb 100644 --- a/sdk/js/src/__tests__/solana.test.ts +++ b/sdk/js/src/__tests__/solana.test.ts @@ -6,13 +6,13 @@ import { expect, describe, it, vi } from 'vitest' import { Keypair } from '@solana/web3.js' -import { DstackClient, TappdClient } from '../index' +import { DstackClientV0, TappdClient } from '../index' import { toKeypair, toKeypairSecure } from '../solana' describe('solana support', () => { describe('toKeypair (legacy)', () => { - it('should able to get keypair from getKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get keypair from getKey with DstackClientV0', async () => { + const client = new DstackClientV0() const result = await client.getKey('/', 'test') const keypair = toKeypair(result) expect(keypair).toBeInstanceOf(Keypair) @@ -32,8 +32,8 @@ describe('solana support', () => { consoleSpy.mockRestore() }) - it('should able to get keypair from getTlsKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get keypair from getTlsKey with DstackClientV0', async () => { + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = await client.getTlsKey() @@ -47,8 +47,8 @@ describe('solana support', () => { }) describe('toKeypairSecure', () => { - it('should able to get keypair from getKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get keypair from getKey with DstackClientV0', async () => { + const client = new DstackClientV0() const result = await client.getKey('/', 'test') const keypair = toKeypairSecure(result) expect(keypair).toBeInstanceOf(Keypair) @@ -68,8 +68,8 @@ describe('solana support', () => { consoleSpy.mockRestore() }) - it('should able to get keypair from getTlsKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get keypair from getTlsKey with DstackClientV0', async () => { + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = await client.getTlsKey() diff --git a/sdk/js/src/__tests__/verify.test.ts b/sdk/js/src/__tests__/verify.test.ts deleted file mode 100644 index 2b6ecb1fe..000000000 --- a/sdk/js/src/__tests__/verify.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -// Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. -// The Rust, Python and Go suites assert against the same file, so any port that -// disagrees about the byte format fails here too. - -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' -import { secp256k1 } from '@noble/curves/secp256k1' -import { expect, describe, it } from 'vitest' -import { verifySignature, verifySignatureChain, SIGN_PURPOSE } from '../verify' - -interface Case { - algorithm: string - data: string - public_key: string - signature: string - signature_chain: string[] -} - -interface InvalidCase { - name: string - reason: string - algorithm: string - data: string - public_key: string - signature: string -} - -interface Vectors { - app_id: string - purpose: string - path: string - kms_root_pubkey: string - app_root_pubkey: string - wrong_kms_root_pubkey: string - cases: Case[] - invalid_cases: InvalidCase[] -} - -const vectors: Vectors = JSON.parse( - readFileSync( - fileURLToPath( - new URL('../../../tests/vectors/signature_chain.json', import.meta.url), - ), - 'utf8', - ), -) - -function unhex(hex: string): Uint8Array { - return new Uint8Array(Buffer.from(hex, 'hex')) -} - -function hex(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('hex') -} - -function caseFor(algorithm: string): Case { - const found = vectors.cases.find((c) => c.algorithm === algorithm) - if (!found) throw new Error(`no vector for ${algorithm}`) - return found -} - -function chainOf(testCase: Case) { - return { - algorithm: testCase.algorithm, - data: unhex(testCase.data), - publicKey: unhex(testCase.public_key), - signatureChain: testCase.signature_chain.map(unhex), - appId: unhex(vectors.app_id), - kmsRootPubKey: unhex(vectors.kms_root_pubkey), - } -} - -describe('verifySignature', () => { - it('accepts every valid vector', () => { - expect(vectors.cases.length).toBeGreaterThan(0) - for (const c of vectors.cases) { - expect( - verifySignature( - c.algorithm, - unhex(c.data), - unhex(c.signature), - unhex(c.public_key), - ), - `${c.algorithm}: valid signature was rejected`, - ).toBe(true) - } - }) - - it('rejects every invalid vector', () => { - expect(vectors.invalid_cases.length).toBeGreaterThan(0) - for (const c of vectors.invalid_cases) { - const verify = () => - verifySignature( - c.algorithm, - unhex(c.data), - unhex(c.signature), - unhex(c.public_key), - ) - if (c.name === 'secp256k1_high_s') { - // High-S is refused outright rather than reported false, because it is a - // malformed encoding rather than a legitimate signature that fails to match. - expect(verify, c.name).toThrow(/high-S/) - } else { - expect(verify(), `${c.name}: should not have verified`).toBe(false) - } - } - }) - - it('treats k256 as an alias for secp256k1', () => { - const c = caseFor('secp256k1') - expect( - verifySignature( - 'k256', - unhex(c.data), - unhex(c.signature), - unhex(c.public_key), - ), - ).toBe(true) - }) - - it('accepts an uncompressed SEC1 public key', () => { - const c = caseFor('secp256k1') - // 0x03-prefixed compressed key from the vectors, expanded to 65 bytes. - const compressed = unhex(c.public_key) - expect(compressed.length).toBe(33) - const uncompressed = secp256k1.ProjectivePoint.fromHex(compressed).toRawBytes(false) - expect(uncompressed.length).toBe(65) - expect( - verifySignature( - c.algorithm, - unhex(c.data), - unhex(c.signature), - uncompressed, - ), - ).toBe(true) - }) - - it('throws on malformed inputs rather than reporting false', () => { - expect(() => - verifySignature('rsa', new Uint8Array([1]), new Uint8Array(64), new Uint8Array(32)), - ).toThrow(/unsupported algorithm/) - expect(() => - verifySignature('ed25519', new Uint8Array([1]), new Uint8Array(64), new Uint8Array(31)), - ).toThrow(/32 bytes/) - expect(() => - verifySignature('ed25519', new Uint8Array([1]), new Uint8Array(63), new Uint8Array(32)), - ).toThrow(/64 bytes/) - - // A prehashed digest must be exactly 32 bytes. - const prehashed = caseFor('secp256k1_prehashed') - expect(() => - verifySignature( - 'secp256k1_prehashed', - new TextEncoder().encode('short'), - unhex(prehashed.signature), - unhex(prehashed.public_key), - ), - ).toThrow(/32-byte digest/) - - // Raw 64-byte r || s only; DER is not accepted. - const secp = caseFor('secp256k1') - expect(() => - verifySignature( - 'secp256k1', - unhex(secp.data), - unhex(secp.signature).slice(0, 63), - unhex(secp.public_key), - ), - ).toThrow(/64 raw bytes/) - expect(() => - verifySignature( - 'secp256k1', - unhex(secp.data), - unhex(secp.signature), - unhex(secp.public_key).slice(0, 32), - ), - ).toThrow(/public key/) - }) -}) - -describe('verifySignatureChain', () => { - it('verifies every vector up to the KMS root', () => { - for (const c of vectors.cases) { - const appRoot = verifySignatureChain(chainOf(c)) - expect(appRoot.length).toBe(33) - expect(hex(appRoot), `${c.algorithm}: recovered the wrong app root key`).toBe( - vectors.app_root_pubkey, - ) - } - }) - - it('defaults purpose to the agent-side signing constant', () => { - expect(SIGN_PURPOSE).toBe('signing') - expect(vectors.purpose).toBe(SIGN_PURPOSE) - const c = vectors.cases[0] - expect(hex(verifySignatureChain({ ...chainOf(c), purpose: SIGN_PURPOSE }))).toBe( - vectors.app_root_pubkey, - ) - }) - - it('rejects a chain anchored at a foreign KMS root', () => { - const c = vectors.cases[0] - expect(() => - verifySignatureChain({ - ...chainOf(c), - kmsRootPubKey: unhex(vectors.wrong_kms_root_pubkey), - }), - ).toThrow(/not anchored/) - }) - - it('rejects a chain issued for a different app id', () => { - const c = vectors.cases[0] - const appId = unhex(vectors.app_id) - appId[0] ^= 0xff - expect(() => verifySignatureChain({ ...chainOf(c), appId })).toThrow() - }) - - it('rejects a tampered payload', () => { - const c = vectors.cases[0] - expect(() => - verifySignatureChain({ - ...chainOf(c), - data: new TextEncoder().encode('a different payload entirely'), - }), - ).toThrow() - }) - - it('rejects a tampered purpose', () => { - const c = vectors.cases[0] - expect(() => - verifySignatureChain({ ...chainOf(c), purpose: 'encryption' }), - ).toThrow(/not anchored/) - }) - - it('rejects malformed chain shapes', () => { - const c = vectors.cases[0] - expect(() => - verifySignatureChain({ - ...chainOf(c), - signatureChain: c.signature_chain.slice(0, 2).map(unhex), - }), - ).toThrow(/3 elements/) - expect(() => - verifySignatureChain({ ...chainOf(c), appId: unhex(vectors.app_id).slice(0, 19) }), - ).toThrow(/20 bytes/) - }) -}) diff --git a/sdk/js/src/__tests__/viem.test.ts b/sdk/js/src/__tests__/viem.test.ts index 307ef49d4..1dbd705de 100644 --- a/sdk/js/src/__tests__/viem.test.ts +++ b/sdk/js/src/__tests__/viem.test.ts @@ -4,13 +4,13 @@ import { expect, describe, it, vi } from 'vitest' -import { DstackClient, TappdClient } from '../index' +import { DstackClientV0, TappdClient } from '../index' import { toViemAccount, toViemAccountSecure } from '../viem' describe('viem support', () => { describe('toViemAccount (legacy)', () => { - it('should able to get account from getKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get account from getKey with DstackClientV0', async () => { + const client = new DstackClientV0() const result = await client.getKey('/', 'test') const account = toViemAccount(result) @@ -34,8 +34,8 @@ describe('viem support', () => { consoleSpy.mockRestore() }) - it('should able to get account from getTlsKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get account from getTlsKey with DstackClientV0', async () => { + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = await client.getTlsKey() @@ -51,8 +51,8 @@ describe('viem support', () => { }) describe('toViemAccountSecure', () => { - it('should able to get account from getKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get account from getKey with DstackClientV0', async () => { + const client = new DstackClientV0() const result = await client.getKey('/', 'test') const account = toViemAccountSecure(result) @@ -76,8 +76,8 @@ describe('viem support', () => { consoleSpy.mockRestore() }) - it('should able to get account from getTlsKey with DstackClient', async () => { - const client = new DstackClient() + it('should able to get account from getTlsKey with DstackClientV0', async () => { + const client = new DstackClientV0() const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = await client.getTlsKey() diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index f32167856..a7c3926a5 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -7,8 +7,6 @@ import { send_rpc_request } from './send-rpc-request' export { getComposeHash } from './get-compose-hash' export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' export type { VerifyOptions } from './verify-env-encrypt-public-key' -export { verifySignature, verifySignatureChain, SIGN_PATH, SIGN_PURPOSE } from './verify' -export type { SignatureChainInput } from './verify' export interface GetTlsKeyResponse { __name__: Readonly<'GetTlsKeyResponse'> @@ -34,6 +32,12 @@ export interface SignResponse { public_key: Uint8Array } +export interface VerifyResponse { + __name__: Readonly<'VerifyResponse'> + + valid: boolean +} + export type Hex = `${string}` @@ -101,38 +105,6 @@ export interface AttestResponse { __name__: Readonly<'AttestResponse'> attestation: Hex - - /** - * Complete JSON output produced by nvattest during guest boot. Empty unless the - * request set `include_boottime_gpu_evidence` and the guest has boot-time GPU attestation - * output. - * - * Not bound to `report_data`: verify it by replaying the runtime event log and - * comparing sha256 of these exact UTF-8 bytes against `evidence_sha256` in the - * `gpu-attestation` event. - */ - boottime_gpu_evidence: string -} - -/** - * Result of fresh, on-demand GPU evidence collection. - */ -export interface AttestGpuResponse { - __name__: Readonly<'AttestGpuResponse'> - bundles: GpuEvidenceBundle[] -} - -export interface GpuEvidenceBundle { - vendor: string - format: string - /** Hex-encoded opaque evidence bytes, as represented by the JSON RPC. */ - evidence: Hex -} - -export interface GpuInfoResponse { - __name__: Readonly<'GpuInfoResponse'> - - attestation: string } export interface VersionResponse { @@ -184,7 +156,54 @@ export interface TlsKeyOptions { const SECP256K1_ALGORITHMS = new Set(['secp256k1', 'k256', '']) -export class DstackClient { +/** Socket paths the clients probe, legacy first, then the namespaced variants. */ +const DSTACK_SOCKET_PATHS = [ + '/var/run/dstack.sock', + '/run/dstack.sock', + '/var/run/dstack/dstack.sock', + '/run/dstack/dstack.sock', +] + +/** + * A prpc handler reports failure in the response body rather than by refusing to + * answer, so every method has to look for it; an unchecked call would hand the + * caller a response object with every field missing. + */ +function throwOnRpcError(result: unknown): void { + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String((result as { error: unknown }).error)) + } +} + +/** + * Attach the byte accessor to the bundles a v1 RPC returned. + * + * Shared by `attest` and `attestGpu` so both hand back the same object shape, + * which is the point of the wire message being shared. + */ +function to_gpu_evidence_bundles( + bundles: Array> | undefined, +): GpuEvidenceBundleV1[] { + return (bundles ?? []).map(bundle => Object.freeze({ + ...bundle, + asUint8Array: () => new Uint8Array(Buffer.from(bundle.evidence, 'hex')), + })) +} + +/** + * Client for the frozen v0 guest agent surface, served at `/` and, + * since dstack 0.6.0, equivalently at `/v0/`. + * + * This surface is closed at the dstack 0.5.11 shape and will not change again. + * New capability lands in {@link DstackClientV1}, which derives *different* key + * material for the same inputs -- the two are separate derivation trees, not + * two spellings of one. + * + * @deprecated Legacy surface, kept for apps that already published v0-derived + * material and therefore cannot move. Use {@link DstackClientV1}, which the + * unsuffixed `DstackClient` now names, for anything new. + */ +export class DstackClientV0 { protected endpoint: string constructor(endpoint: string | undefined = undefined) { @@ -193,14 +212,7 @@ export class DstackClient { console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT } else { - // Try paths in order: legacy paths first, then namespaced paths - const socketPaths = [ - '/var/run/dstack.sock', - '/run/dstack.sock', - '/var/run/dstack/dstack.sock', - '/run/dstack/dstack.sock', - ] - endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] + endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] } } if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { @@ -314,52 +326,20 @@ export class DstackClient { /** * Requests a versioned attestation for the given report data. * - * Pass `include_boottime_gpu_evidence` to also return the boot-time GPU attestation - * evidence in `boottime_gpu_evidence`, so a verifier can check both in one round trip. + * GPU evidence is not available here: this surface is frozen at the 0.5.11 + * shape. Use {@link DstackClientV1.attest} or {@link DstackClientV1.attestGpu}. */ - async attest(report_data: string | Buffer | Uint8Array, include_boottime_gpu_evidence: boolean = false): Promise { + async attest(report_data: string | Buffer | Uint8Array): Promise { let hex = to_hex(report_data) if (hex.length > 128) { throw new Error(`Report data is too large, it should be less than 64 bytes.`) } - const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) - const result = await send_rpc_request<{ attestation: string, boottime_gpu_evidence?: string }>(this.endpoint, '/Attest', payload) - if ('error' in (result as any)) { - const err = (result as any)['error'] as string - throw new Error(err) - } + const payload = JSON.stringify({ report_data: hex }) + const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) + throwOnRpcError(result) return Object.freeze({ __name__: 'AttestResponse', attestation: result.attestation as Hex, - boottime_gpu_evidence: result.boottime_gpu_evidence ?? '', - }) - } - - /** - * Runs NVIDIA GPU attestation now, against a 32-byte nonce you choose. - * - * See {@link AttestGpuResponse} for what this does and does not prove. - */ - async attestGpu(nonce: Buffer | Uint8Array): Promise { - if (nonce.length !== 32) { - throw new Error(`Nonce must be exactly 32 bytes, got ${nonce.length}.`) - } - const payload = JSON.stringify({ nonce: to_hex(nonce) }) - const result = await send_rpc_request<{ bundles: GpuEvidenceBundle[] }>(this.endpoint, '/AttestGpu', payload) - if ('error' in (result as any)) { - throw new Error((result as any)['error'] as string) - } - return Object.freeze({ - ...result, - __name__: 'AttestGpuResponse' as const, - }) - } - - async gpuInfo(): Promise { - const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/GpuInfo', '{}') - return Object.freeze({ - ...result, - __name__: 'GpuInfoResponse', }) } @@ -395,6 +375,34 @@ export class DstackClient { } } + /** + * Emit an event. This extends the event to RTMR3 on TDX platform. + * + * Requires dstack OS 0.5.0 or later, and removed in 0.6.0: runtime RTMR3 + * events became system-owned, so a 0.6.0 agent answers every call with an + * error. It stays here because the frozen surface still carries the method, + * and the agent's own explanation is more useful than one invented here. + * + * @param event The event name + * @param payload The event data as string or Buffer or Uint8Array + */ + async emitEvent(event: string, payload: string | Buffer | Uint8Array): Promise { + if (!event) { + throw new Error('Event name cannot be empty') + } + + const hexPayload = to_hex(payload) + const result = await send_rpc_request( + this.endpoint, + '/EmitEvent', + JSON.stringify({ + event: event, + payload: hexPayload + }) + ) + throwOnRpcError(result) + } + /** * Signs a payload using a derived key. * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") @@ -422,6 +430,36 @@ export class DstackClient { }); } + /** + * Verifies a payload signature. + * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") + * @param data The data that was signed. + * @param signature The signature to verify. + * @param publicKey The public key to use for verification. + * @returns A VerifyResponse indicating if the signature is valid. + */ + async verify( + algorithm: string, + data: string | Buffer | Uint8Array, + signature: string | Buffer | Uint8Array, + publicKey: string | Buffer | Uint8Array + ): Promise { + const payload = JSON.stringify({ + algorithm: algorithm, + data: to_hex(data), + signature: to_hex(signature), + public_key: to_hex(publicKey) + }); + + const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); + throwOnRpcError(result) + + return Object.freeze({ + ...result, + __name__: 'VerifyResponse', + }); + } + // // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. // These methods don't mean fully compatible as past, but we keep them here until next major version. @@ -453,7 +491,9 @@ export class DstackClient { } } -export class TappdClient extends DstackClient { +// `TappdClient` names `DstackClientV0` rather than the `DstackClient` alias on +// purpose: the alias points at v1 now, and Tappd speaks the v0 wire surface. +export class TappdClient extends DstackClientV0 { constructor(endpoint: string | undefined = undefined) { if (endpoint === undefined) { if (process.env.TAPPD_SIMULATOR_ENDPOINT) { @@ -470,7 +510,7 @@ export class TappdClient extends DstackClient { endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] } } - console.warn('TappdClient is deprecated, please use DstackClient instead') + console.warn('TappdClient is deprecated, please use DstackClientV0 instead') super(endpoint) } @@ -533,3 +573,348 @@ export class TappdClient extends DstackClient { } } } + +// --------------------------------------------------------------------------- +// dstack.guest.v1 +// --------------------------------------------------------------------------- + +export interface IssueCertOptionsV1 { + subject?: string; + altNames?: string[]; + usageRaTls?: boolean; + usageServerAuth?: boolean; + usageClientAuth?: boolean; + withAppInfo?: boolean; + // Certificate validity start (seconds since UNIX epoch). + notBefore?: number; + // Certificate validity end (seconds since UNIX epoch). + notAfter?: number; +} + +export interface IssueCertResponseV1 { + __name__: Readonly<'IssueCertResponseV1'> + + /** The private key the agent generated for this certificate, PEM-encoded. */ + key: string + /** The certificate chain, leaf first, each entry PEM-encoded. */ + certificate_chain: string[] + + asUint8Array: (max_length?: number) => Uint8Array +} + +export interface GetKeyResponseV1 { + __name__: Readonly<'GetKeyResponseV1'> + + /** The derived private key: 32 raw bytes for both supported algorithms. */ + key: Uint8Array + /** SEC1 compressed (33 bytes) for secp256k1, raw (32 bytes) for ed25519. */ + public_key: Uint8Array + /** Two links: the app root key over the v1 key claim, then the KMS root key. */ + signature_chain: Uint8Array[] +} + +export interface AttestResponseV1 { + __name__: Readonly<'AttestResponseV1'> + + attestation: Hex + + /** + * The GPU evidence nvattest recorded at boot, in the same bundle shape + * {@link DstackClientV1.attestGpu} returns, so one parser serves both. Empty + * unless the request asked for it and the guest has boot-time output -- + * absence is the empty array, not a sentinel. + * + * Not bound to `report_data`: nvattest ran at boot against its own nonce. + * Bind it by replaying the runtime event log and comparing sha256 of the + * bytes `asUint8Array()` returns against `evidence_sha256` in the measured + * `gpu-attestation` event. + */ + boottime_gpu_evidence: GpuEvidenceBundleV1[] +} + +/** + * One vendor's GPU evidence, however it was obtained. + * + * Shared by {@link DstackClientV1.attestGpu} and + * {@link AttestResponseV1.boottime_gpu_evidence}; dispatch on `vendor` and + * `format`, because the two sources answer different questions and a verifier + * for one does not appraise the other: + * + * - `nvidia-nvattest-collect-evidence-json-v1` -- collected on demand by + * `attestGpu`, against the nonce you passed. + * - `nvidia-nvattest-boottime-json-v1` -- the record written at boot, carried + * by `attest`. + */ +export interface GpuEvidenceBundleV1 { + /** Stable GPU vendor identifier, for example `nvidia`. */ + vendor: string + /** Vendor-specific evidence format and version. */ + format: string + /** Opaque vendor-native evidence bytes, hex-encoded by the JSON RPC. */ + evidence: Hex + + /** + * The evidence as raw bytes, exactly as the vendor emitted it. + * + * Byte-exact by design: for a boot-time bundle the binding rule is sha256 + * over precisely these bytes, compared against `evidence_sha256` in the + * measured `gpu-attestation` event, so parsing and re-serialising the JSON + * breaks the comparison. + */ + asUint8Array: () => Uint8Array +} + +export interface AttestGpuResponseV1 { + __name__: Readonly<'AttestGpuResponseV1'> + + bundles: GpuEvidenceBundleV1[] +} + +/** + * Identity and configuration. Not attestation. + * + * The measurement registers and the event log are deliberately absent -- they + * belong to `attest()`, which returns them quote-backed. Nothing here arrives + * with a quote behind it, so confirm anything you rely on against an + * attestation. + * + * `app_id`, `compose_hash`, `instance_id`, `device_id`, `os_image_hash` and + * `mr_aggregated` are lowercase hex; the rest are plain strings, with the three + * document fields carrying JSON owned by someone else (see `docs/guest-api-v1.md`). + */ +export interface InfoResponseV1 { + __name__: Readonly<'InfoResponseV1'> + + app_id: Hex + app_name: string + compose_hash: Hex + /** + * The app-compose document, verbatim. `compose_hash` is sha256 over exactly + * these bytes, so do not parse and re-serialize before hashing: key order, + * whitespace and unknown fields all change the digest. + */ + app_compose: string + instance_id: Hex + /** Identifies the host machine, not this instance. */ + device_id: Hex + os_image_hash: Hex + mr_aggregated: Hex + vm_config: string + key_provider_info: string + cloud_vendor: string + cloud_product: string +} + +export interface VersionResponseV1 { + __name__: Readonly<'VersionResponseV1'> + + version: string + rev: string +} + +/** + * Client for `dstack.guest.v1`, served at `/v1/` by dstack 0.6.0 and later. + * + * Six methods, no more: v1 serves only what needs the TEE -- deriving keys from + * the app root key, and attesting. `sign`, `verify`, `getQuote`, `gpuInfo` and + * `emitEvent` are absent by design, not by oversight; see `docs/guest-api-v1.md`. + * + * A v1 key is NOT the v0 key of the same name. v1 derives under its own HKDF + * salt and binds the algorithm into the derivation, so `getKey('storage-encryption', + * 'secp256k1')` here returns different material than `DstackClientV0.getKey` + * ever did, and secp256k1 and ed25519 no longer share one secret. There is no + * compatibility mode. + * + * An agent that predates v1 has no `/v1` mount, so it answers with a plain + * HTTP 404 page rather than a JSON error. `version()` is the cheapest probe. + */ +export class DstackClientV1 { + protected endpoint: string + + constructor(endpoint: string | undefined = undefined) { + if (endpoint === undefined) { + if (process.env.DSTACK_SIMULATOR_ENDPOINT) { + console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) + endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT + } else { + endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] + } + } + if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { + throw new Error(`Unix socket file ${endpoint} does not exist`); + } + this.endpoint = endpoint + } + + /** + * Issue a certificate for this application. + * + * The key is freshly generated on every call and is not derived from the app + * identity: two identical requests produce two unrelated keys. Use + * {@link getKey} for stable, attestable material. + */ + async issueCert(options: IssueCertOptionsV1 = {}): Promise { + const { + subject = '', + altNames = [], + usageRaTls = false, + usageServerAuth = true, + usageClientAuth = false, + withAppInfo = false, + notBefore, + notAfter, + } = options; + + const raw: Record = { + subject, + usage_ra_tls: usageRaTls, + usage_server_auth: usageServerAuth, + usage_client_auth: usageClientAuth, + with_app_info: withAppInfo, + } + if (altNames && altNames.length) { + raw['alt_names'] = altNames + } + // Both are `optional` on the wire, so send them only when asked for rather + // than pinning a validity window the caller never chose. + if (notBefore !== undefined) { + raw['not_before'] = notBefore + } + if (notAfter !== undefined) { + raw['not_after'] = notAfter + } + const result = await send_rpc_request<{ key: string, certificate_chain: string[] }>( + this.endpoint, '/v1/IssueCert', JSON.stringify(raw)) + throwOnRpcError(result) + const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) + return Object.freeze({ + ...result, + asUint8Array, + __name__: 'IssueCertResponseV1' as const, + }) + } + + /** + * Derive an application key from `(domain, algorithm)`. + * + * `domain` is an opaque domain-separation string, not a DNS name and not a + * path: derivation is flat, so `a/b` is not a child of `a` and no key derived + * here can derive another. + * + * @param domain Caller-chosen domain-separation string. May be empty. + * @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias. + */ + async getKey(domain: string, algorithm: string): Promise { + // v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a + // key of the wrong type under a name the caller thought meant something else. + if (!algorithm) { + throw new Error('algorithm is required, use "secp256k1" or "ed25519"') + } + const payload = JSON.stringify({ domain, algorithm }) + const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>( + this.endpoint, '/v1/GetKey', payload) + throwOnRpcError(result) + return Object.freeze({ + key: new Uint8Array(Buffer.from(result.key, 'hex')), + public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + __name__: 'GetKeyResponseV1' as const, + }) + } + + /** + * Produce a versioned attestation over the given report data. + * + * The only CVM attestation entry point in v1: the attestation already carries + * the TDX quote and the event log, so there is no separate `getQuote`. + * + * @param report_data 1 to 64 bytes, zero-padded on the right to 64 by the agent. + * @param include_boottime_gpu_evidence Also return the boot-time GPU evidence, + * as the same {@link GpuEvidenceBundleV1} list `attestGpu` returns, so a + * verifier gets both in one round trip. It is not bound to `report_data`. + */ + async attest( + report_data: string | Buffer | Uint8Array, + include_boottime_gpu_evidence: boolean = false, + ): Promise { + const hex = to_hex(report_data) + if (hex.length === 0) { + throw new Error('report data must not be empty') + } + if (hex.length > 128) { + throw new Error(`report data must be at most 64 bytes, but received ${hex.length / 2}`) + } + const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) + const result = await send_rpc_request<{ + attestation: string, + boottime_gpu_evidence?: Array>, + }>(this.endpoint, '/v1/Attest', payload) + throwOnRpcError(result) + return Object.freeze({ + __name__: 'AttestResponseV1' as const, + attestation: result.attestation as Hex, + boottime_gpu_evidence: to_gpu_evidence_bundles(result.boottime_gpu_evidence), + }) + } + + /** + * Collect GPU attestation evidence now, against a nonce you choose. + * + * Returns vendor-native evidence, not a verdict: select a verifier from each + * bundle's `vendor` and `format`, then check the signature, certificate chain, + * measurements and the embedded nonce yourself. Evidence does not by itself + * bind the GPU to this CVM. + * + * @param nonce Exactly 32 bytes, passed to the GPU verbatim. SPDM fixes the + * length; hash a longer challenge yourself. + */ + async attestGpu(nonce: Buffer | Uint8Array): Promise { + if (nonce.length !== 32) { + throw new Error(`nonce must be exactly 32 bytes, but received ${nonce.length}`) + } + const payload = JSON.stringify({ nonce: to_hex(nonce) }) + const result = await send_rpc_request<{ + bundles?: Array>, + }>(this.endpoint, '/v1/AttestGpu', payload) + throwOnRpcError(result) + return Object.freeze({ + bundles: to_gpu_evidence_bundles(result.bundles), + __name__: 'AttestGpuResponseV1' as const, + }) + } + + /** Return this application's identity and configuration. */ + async info(): Promise { + const result = await send_rpc_request>(this.endpoint, '/v1/Info', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'InfoResponseV1' as const, + }) + } + + /** Return the guest agent version. Also the cheapest probe for v1 support. */ + async version(): Promise { + const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/v1/Version', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'VersionResponseV1' as const, + }) + } +} + +/** + * The recommended client: `dstack.guest.v1`. + * + * Declared here rather than beside {@link DstackClientV0} because a `const` + * cannot name a class that has not been evaluated yet. + * + * This alias used to mean {@link DstackClientV0}. Code that upgrades without + * changing the name fails loudly rather than quietly deriving different keys: + * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a + * v0 call site stops compiling (or throws) instead of returning wrong material. + * To stay on the frozen surface, name {@link DstackClientV0}. + */ +export const DstackClient = DstackClientV1 +export type DstackClient = DstackClientV1 diff --git a/sdk/js/src/send-rpc-request.ts b/sdk/js/src/send-rpc-request.ts index e831c418f..97805cfcb 100644 --- a/sdk/js/src/send-rpc-request.ts +++ b/sdk/js/src/send-rpc-request.ts @@ -6,7 +6,7 @@ import http from 'http' import https from 'https' import net from 'net' -export const __version__ = "0.5.6" +export const __version__ = "0.6.0" export function send_rpc_request(endpoint: string, path: string, payload: string, timeoutMs?: number): Promise { diff --git a/sdk/js/src/verify.ts b/sdk/js/src/verify.ts deleted file mode 100644 index 542bf0a95..000000000 --- a/sdk/js/src/verify.ts +++ /dev/null @@ -1,316 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -/** - * Local signature and signature-chain verification. - * - * Verification needs no key material and no attestation, so it does not belong - * behind an RPC to the guest agent: the agent's answer arrives over the socket - * unattested, which is no better than a caller checking the signature itself. - * The `Verify` RPC these functions replace was removed in v0.6.0. - * - * Two levels are available: - * - * - {@link verifySignature} checks one signature against a public key you - * already have. It is the direct replacement for the old RPC and, on its own, - * proves only that whoever holds that key signed the data. - * - {@link verifySignatureChain} walks the full chain from a `SignResponse` - * back to a KMS root key **you supply**, which is what actually establishes - * that the signer was a dstack app under that KMS. - */ - -import { ed25519 } from "@noble/curves/ed25519" -import { secp256k1 } from "@noble/curves/secp256k1" -import { sha256 } from "@noble/hashes/sha256" -import { keccak_256 } from "@noble/hashes/sha3" - -/** Domain-separation prefix the KMS signs app root keys under. */ -const KMS_ISSUED_PREFIX = "dstack-kms-issued:" - -/** `Sign` derives its key at this path with this purpose; both are fixed agent-side. */ -export const SIGN_PATH = "vms" -export const SIGN_PURPOSE = "signing" - -/** `k256` and `secp256k1` name the same thing; the agent normalized these too. */ -function normalizeAlgorithm(algorithm: string): string { - return algorithm === "k256" ? "secp256k1" : algorithm -} - -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") -} - -function concat(...parts: Uint8Array[]): Uint8Array { - const total = parts.reduce((n, p) => n + p.length, 0) - const out = new Uint8Array(total) - let offset = 0 - for (const part of parts) { - out.set(part, offset) - offset += part.length - } - return out -} - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -type K256Signature = ReturnType - -function parseK256Signature(signature: Uint8Array): K256Signature { - if (signature.length !== 64) { - throw new Error( - `invalid secp256k1 signature: expected 64 raw bytes (r || s), but received ${signature.length}`, - ) - } - let sig: K256Signature - try { - sig = secp256k1.Signature.fromCompact(signature) - } catch (error) { - throw new Error(`invalid secp256k1 signature: ${describe(error)}`) - } - // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. Rust's k256 - // rejects the high-S form, so we must too -- otherwise a signature stops - // being a unique identifier for a signed message, and this SDK would disagree - // with every other dstack component about whether a given blob is valid. - // A high-S signature is a malformed encoding rather than a signature that - // legitimately fails to match, so it throws instead of returning false. - if (sig.hasHighS()) { - throw new Error("non-canonical (high-S) secp256k1 signature") - } - return sig -} - -/** Parses a SEC1 public key, compressed (33 bytes) or uncompressed (65 bytes). */ -function parseK256PublicKey(publicKey: Uint8Array) { - if (publicKey.length !== 33 && publicKey.length !== 65) { - throw new Error( - `invalid secp256k1 public key: expected 33 or 65 SEC1 bytes, but received ${publicKey.length}`, - ) - } - try { - const point = secp256k1.ProjectivePoint.fromHex(publicKey) - point.assertValidity() - return point - } catch (error) { - throw new Error(`invalid secp256k1 public key: ${describe(error)}`) - } -} - -/** - * Verifies one signature against `publicKey`. - * - * `algorithm` is `ed25519`, `secp256k1` (alias `k256`), or - * `secp256k1_prehashed`, where `data` is already a 32-byte digest. Returns - * `false` when the inputs are well-formed but the signature does not check out, - * and throws when they are not well-formed at all (bad key encoding, wrong - * signature length, unknown algorithm) -- a malformed input is a caller bug, - * not a verdict. - */ -export function verifySignature( - algorithm: string, - data: Uint8Array, - signature: Uint8Array, - publicKey: Uint8Array, -): boolean { - switch (normalizeAlgorithm(algorithm)) { - case "ed25519": { - if (publicKey.length !== 32) { - throw new Error( - `ed25519 public key must be 32 bytes, but received ${publicKey.length}`, - ) - } - if (signature.length !== 64) { - throw new Error( - `ed25519 signature must be 64 bytes, but received ${signature.length}`, - ) - } - try { - ed25519.ExtendedPoint.fromHex(publicKey) - } catch (error) { - throw new Error(`invalid ed25519 public key: ${describe(error)}`) - } - try { - return ed25519.verify(signature, data, publicKey) - } catch { - // Past the encoding checks above, anything left is a failed match. - return false - } - } - case "secp256k1": { - const point = parseK256PublicKey(publicKey) - const sig = parseK256Signature(signature) - // The agent signs with k256's `sign`, which hashes with SHA-256, so - // verification must hash the payload the same way. - return verifyPrehashed(sha256(data), sig, point) - } - case "secp256k1_prehashed": { - if (data.length !== 32) { - throw new Error( - `pre-hashed verification requires a 32-byte digest, but received ${data.length} bytes`, - ) - } - const point = parseK256PublicKey(publicKey) - const sig = parseK256Signature(signature) - return verifyPrehashed(data, sig, point) - } - default: - throw new Error(`unsupported algorithm: ${algorithm}`) - } -} - -function verifyPrehashed( - digest: Uint8Array, - signature: K256Signature, - publicKey: ReturnType, -): boolean { - try { - // `lowS` is noble's default today, but state it explicitly: a future change - // to that default must not silently start accepting malleated signatures - // that Rust's k256 rejects. High-S has already thrown by this point; this - // keeps the two layers from drifting apart. - return secp256k1.verify( - signature.toCompactRawBytes(), - digest, - publicKey.toRawBytes(true), - { lowS: true }, - ) - } catch { - // Past the encoding checks above, anything left is a failed match. - return false - } -} - -/** - * Recovers the compressed public key that produced a 65-byte `r || s || recid` - * signature over `keccak256(message)`. - */ -function recoverCompressed( - message: Uint8Array, - signature: Uint8Array, -): Uint8Array { - if (signature.length !== 65) { - throw new Error( - `recoverable signature must be 65 bytes, but received ${signature.length}`, - ) - } - const sig = parseK256Signature(signature.slice(0, 64)) - const recid = signature[64] - // Raw recovery ids, not the +27 form Ethereum wire formats use. - if (recid > 3) { - throw new Error(`invalid recovery id ${recid}`) - } - try { - return sig - .addRecoveryBit(recid) - .recoverPublicKey(keccak_256(message)) - .toRawBytes(true) - } catch (error) { - throw new Error(`failed to recover public key: ${describe(error)}`) - } -} - -/** - * Inputs to {@link verifySignatureChain}. - * - * An options object rather than a positional argument list so that adding an - * input later does not break callers. - */ -export interface SignatureChainInput { - /** Algorithm the payload was signed with. */ - algorithm: string - /** The signed payload; a 32-byte digest for `secp256k1_prehashed`. */ - data: Uint8Array - /** `SignResponse.public_key` -- the key that signed `data`. */ - publicKey: Uint8Array - /** `SignResponse.signature_chain`, exactly 3 elements. */ - signatureChain: Uint8Array[] - /** - * The 20-byte app identity to hold the chain to. - * - * This must be the app id you *expect*, not merely whatever `InfoResponse` - * echoed back -- that comes from the CVM being checked. Comparing a chain - * against an app id the same CVM supplied proves only that it is - * self-consistent. - */ - appId: Uint8Array - /** - * The KMS root public key you already trust, compressed or uncompressed SEC1. - * - * Get it from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it. - * Reading it from the KMS you are verifying against proves nothing. - */ - kmsRootPubKey: Uint8Array - /** Purpose bound into the app-root link. Always {@link SIGN_PURPOSE} for `Sign`. */ - purpose?: string -} - -/** - * Verifies a `Sign` signature chain end to end. - * - * Three links, all of which must hold: - * - * 1. `signatureChain[0]` is a signature over `data` by `publicKey`. - * 2. `signatureChain[1]` is the app root key attesting `"{purpose}:{hex(publicKey)}"`. - * 3. `signatureChain[2]` is `kmsRootPubKey` attesting that app root key for `appId`. - * - * Link 3 is the one that matters. Without comparing against a KMS root key you - * independently trust, a chain is just three signatures an attacker could have - * produced with their own keys. - * - * @returns the app root public key, compressed SEC1 (33 bytes), recovered from - * the chain and confirmed to be the one this KMS root signed. - * @throws if any link fails. - */ -export function verifySignatureChain(input: SignatureChainInput): Uint8Array { - const { - algorithm, - data, - publicKey, - signatureChain, - appId, - kmsRootPubKey, - purpose = SIGN_PURPOSE, - } = input - - if (signatureChain.length !== 3) { - throw new Error( - `signature chain must have 3 elements, but received ${signatureChain.length}`, - ) - } - if (appId.length !== 20) { - throw new Error(`appId must be 20 bytes, but received ${appId.length}`) - } - - // Link 1: the payload signature. signatureChain[0] *is* that signature; what - // matters is that it checks out under `publicKey`, which links 2 and 3 cover. - if (!verifySignature(algorithm, data, signatureChain[0], publicKey)) { - throw new Error("payload signature is not valid for the given public key") - } - - // Link 2: recover the app root key that vouched for the signing key. - const message = new TextEncoder().encode( - `${purpose}:${bytesToHex(publicKey)}`, - ) - const appRootPubKey = recoverCompressed(message, signatureChain[1]) - - // Link 3: recover the KMS root key that vouched for the app root key, and - // check it is the one we were told to trust. - const kmsMessage = concat( - new TextEncoder().encode(KMS_ISSUED_PREFIX), - appId, - appRootPubKey, - ) - const recoveredKms = recoverCompressed(kmsMessage, signatureChain[2]) - - // Normalize the expected key so callers may pass either SEC1 encoding. - const expectedKms = parseK256PublicKey(kmsRootPubKey).toRawBytes(true) - if (bytesToHex(recoveredKms) !== bytesToHex(expectedKms)) { - throw new Error( - "signature chain is not anchored at the expected KMS root key", - ) - } - - return appRootPubKey -} diff --git a/sdk/js/test-outputs.js b/sdk/js/test-outputs.js index 37aa18238..1e9031347 100644 --- a/sdk/js/test-outputs.js +++ b/sdk/js/test-outputs.js @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -const { DstackClient, TappdClient, getComposeHash, verifyEnvEncryptPublicKey } = require('./dist/node/index.js'); +const { DstackClientV0, TappdClient, getComposeHash, verifyEnvEncryptPublicKey } = require('./dist/node/index.js'); const { toViemAccount, toViemAccountSecure } = require('./dist/node/viem.js'); const { toKeypair, toKeypairSecure } = require('./dist/node/solana.js'); @@ -12,8 +12,8 @@ async function main() { try { // Test client get_key - const client = new DstackClient(); - console.log("\n1. Testing DstackClient.getKey()"); + const client = new DstackClientV0(); + console.log("\n1. Testing DstackClientV0.getKey()"); const testPaths = [ { path: "test/wallet", purpose: "ethereum" }, @@ -95,7 +95,7 @@ async function main() { // Test quotes console.log("\n5. Testing Quote Methods"); - console.log("\n5.1 DstackClient.getQuote():"); + console.log("\n5.1 DstackClientV0.getQuote():"); const dstackQuote = await client.getQuote("test-data-for-quote"); console.log(` quote length: ${dstackQuote.quote.length}`); console.log(` event_log length: ${dstackQuote.event_log.length}`); diff --git a/sdk/js/tsup.config.ts b/sdk/js/tsup.config.ts index e72661034..189631ac7 100644 --- a/sdk/js/tsup.config.ts +++ b/sdk/js/tsup.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ "src/encrypt-env-vars.ts", "src/get-compose-hash.ts", "src/verify-env-encrypt-public-key.ts", - "src/verify.ts", ], format: ["cjs", "esm"], dts: true, diff --git a/sdk/python/README.md b/sdk/python/README.md index 52ad32e7a..12b94f57d 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,6 +1,40 @@ # dstack SDK for Python -Access TEE features from your Python application running inside dstack. Derive deterministic keys, generate attestation quotes, create TLS certificates, and sign data—all backed by hardware security. +Access TEE features from your Python application running inside dstack. Derive deterministic keys, request attestations, and issue TLS certificates—all backed by hardware security. + +## The default client speaks v1 + +dstack 0.6.0 split the guest agent API into two surfaces on one socket, and the +agent picks between them by URL path alone. This SDK mirrors both, and nothing +more — neither client translates calls to the other. + +| Client | Surface | Paths | What it is | +|---|---|---|---| +| `DstackClient` / `AsyncDstackClient` — same classes as `DstackClientV1` / `AsyncDstackClientV1` | `dstack.guest.v1` | `/v1/GetKey` | **The default.** Where every new capability lands | +| `DstackClientV0` / `AsyncDstackClientV0` | the frozen v0.5.11 API | `/GetKey`, equivalently `/v0/GetKey` | Legacy. Frozen: no new method, no new field, ever | + +Use the unsuffixed name in new code. The `V1` names are the same classes, spelled +out for code that wants the surface visible at a glance. + +> [!WARNING] +> **v1 derives different key material than v0.** `DstackClient.get_key('x', 'secp256k1')` +> and `DstackClientV0.get_key('x')` return different private keys. The v1 KDF binds +> the algorithm and its own context tag alongside the domain, which is the point of +> the new derivation, not a defect: v0 ignored the algorithm, so one secret served +> two curves. There is no compatibility mode and no flag that brings the old bytes +> back. An application that has published or committed to material derived from a v0 +> key must migrate deliberately — derive the v1 key, re-establish whatever depends on +> the old one under it, and only then cut over. +> +> Code that used the unsuffixed client for v0 calls fails **loudly** on upgrade +> rather than silently deriving different keys, because the v1 method signatures +> differ and `get_key` requires `algorithm` explicitly. To stay on the frozen +> surface, switch to `DstackClientV0`. + +The v1 surface serves only what genuinely needs the TEE, so it has no `sign`, no +`verify`, no `emit_event`, no `get_quote` and no `gpu_info`. See +[`docs/guest-api-v1.md`](../../docs/guest-api-v1.md) for the normative spec, and +[Legacy (v0, frozen)](#legacy-v0-frozen) for the surface those methods live on. ## Installation @@ -8,12 +42,13 @@ Access TEE features from your Python application running inside dstack. Derive d pip install dstack-sdk ``` -Blockchain helpers are optional extras: +Blockchain helpers are optional extras, needed only for the v0-era chain +adapters ([Blockchain helpers](#blockchain-helpers)): | Extra | Pulls in | Use when | |---|---|---| -| `dstack-sdk[ethereum]` | `eth-account` | You want `to_account` / `to_account_secure` for Ethereum signing | -| `dstack-sdk[solana]` | `solders` | You want `to_keypair` / `to_keypair_secure` for Solana signing | +| `dstack-sdk[ethereum]` | `eth-account` | You sign Ethereum transactions | +| `dstack-sdk[solana]` | `solders` | You sign Solana transactions | | `dstack-sdk[all]` | both | You need both | Aliases `[eth]` and `[sol]` are accepted for convenience. @@ -25,257 +60,149 @@ from dstack_sdk import DstackClient client = DstackClient() -# Derive a deterministic key for your wallet -key = client.get_key('wallet/eth') -print(key.key) # Same path always returns the same key - -# Generate an attestation quote -quote = client.get_quote(b'my-app-state') -print(quote.quote) +key = client.get_key('storage-encryption', 'secp256k1') # algorithm is required +attestation = client.attest(b'my-app-state') +info = client.info() ``` -The client automatically connects to `/var/run/dstack.sock`. For local development with the simulator: +The client automatically connects to `/var/run/dstack.sock`. For local +development with the simulator: ```python client = DstackClient('http://localhost:8090') # or export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 ``` -## Core API - -### Derive Keys - -`get_key()` derives deterministic keys bound to your application's identity (`app_id`). The same path always produces the same key for your app, but different apps get different keys even with the same path. +Every v1 method requires a 0.6.0+ guest agent: older agents have no `/v1` mount +and answer HTTP 404. -```python -# Derive keys by path -eth_key = client.get_key('wallet/ethereum') -btc_key = client.get_key('wallet/bitcoin') - -# Use path to separate keys -mainnet_key = client.get_key('wallet/eth/mainnet') -testnet_key = client.get_key('wallet/eth/testnet') +## Client -# Use a different signature algorithm (requires dstack OS >= 0.5.7) -ed_key = client.get_key('signing/key', algorithm='ed25519') -``` +`DstackClient` speaks `dstack.guest.v1` at `/v1/`. Six methods, and +deliberately no more — v1 serves only what genuinely needs the TEE. In the +examples below, `client = DstackClient()`. -**Parameters:** -- `path` (optional): Key derivation path. Defaults to `""` (root). -- `purpose` (optional): Included in the signature chain message; does not affect the derived key. -- `algorithm` (optional): `'secp256k1'` (default) or `'ed25519'`. For compatibility, this selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. Use algorithm-specific paths when independent keys are required. +### `get_key()` -**Returns:** `GetKeyResponse` -- `key`: Hex-encoded private key -- `signature_chain`: Signatures proving the key was derived in a genuine TEE -- `decode_key()` / `decode_signature_chain()`: Helpers that return `bytes` - -### 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. +Derives deterministic keys bound to your application's identity (`app_id`). The +same domain always produces the same key for your app, and different apps get +different keys for the same domain. ```python -quote = client.get_quote(b'user:alice:nonce123') -print(quote.event_log) +key = client.get_key('storage-encryption', 'secp256k1') +print(key.decode_key()) # 32 raw bytes +print(key.decode_public_key()) # SEC1 compressed (33 B), or 32 B for ed25519 +print(key.decode_signature_chain()) # two links: app root, then KMS root ``` **Parameters:** -- `report_data`: Up to 64 bytes (`bytes` or `str`). Shorter inputs are padded with zeros; longer inputs should be hashed first (e.g., SHA-256). +- `domain`: Any string. This replaces v0's `path` plus `purpose`; in v0 only `path` reached the KDF and `purpose` was merely echoed into the chain claim. Derivation is flat — two domains give unrelated keys, and `a/b` is not a child of `a`. +- `algorithm`: Exactly `'secp256k1'` or `'ed25519'`. **Required.** There is no default and no `k256` alias, because in v0 a typo silently produced a key of the wrong type under a name the caller thought meant something else. An empty value is rejected client-side. -**Returns:** `GetQuoteResponse` -- `quote`: Hex-encoded TDX quote -- `event_log`: JSON string of measured events -- `decode_quote()` / `decode_event_log()`: Helpers +**Returns:** `GetKeyResponseV1` with hex `key`, `public_key` and +`signature_chain`, plus the usual `decode_*` helpers. -### Versioned Attestation +### `attest()` -`attest()` returns a versioned attestation payload that newer verifier APIs can dispatch on without sniffing the quote format. +The sole CVM attestation entry point in v1. The dstack attestation format +already carries the quote and the event log, so v0's TDX-only `get_quote` has +nothing left to add. ```python result = client.attest(b'user:alice:nonce123') -print(result.attestation) # hex string -print(result.decode_attestation()) # bytes -``` - -Pass `include_boottime_gpu_evidence=True` to also return the boot-time GPU attestation -evidence, so a verifier gets the quote and the GPU evidence in one round trip. +print(result.decode_attestation()) -```python -result = client.attest(b'user:alice:nonce123', include_boottime_gpu_evidence=True) -print(result.boottime_gpu_evidence) +with_gpu = client.attest(b'user:alice:nonce123', include_boottime_gpu_evidence=True) +for bundle in with_gpu.boottime_gpu_evidence: + print(bundle.vendor, bundle.format, bundle.decode_evidence()) ``` -The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set -and boot-time GPU attestation output exists. It is not bound to `report_data`; verify -it with the measured `gpu-attestation` event digest as described under ``gpu_info()``. - -### On-demand GPU Attestation - -`attest_gpu(nonce)` collects vendor-native GPU evidence for a caller-chosen 32-byte -nonce. - -```python -result = client.attest_gpu(os.urandom(32)) -for bundle in result.bundles: - print(bundle.vendor, bundle.format, bundle.evidence) -``` +`report_data` is 1–64 bytes and is zero-padded on the right to 64. +`boottime_gpu_evidence` is a list of `GpuEvidenceBundleV1` — the same model +`attest_gpu()` returns, so one bundle parser serves both methods. It is empty +unless the flag was set and the guest has boot-time output; there is no +sentinel. Boot-time bundles carry `format='nvidia-nvattest-boottime-json-v1'`, +against `attest_gpu()`'s `'nvidia-nvattest-collect-evidence-json-v1'`. -Select a verifier using each bundle's `vendor` and `format`. The verifier must check -the evidence signature, certificate chain, measurements, and embedded nonce. Evidence -is opaque and hex-encoded by the JSON RPC. It does not by itself bind the GPU to this -CVM. +Boot-time evidence is *not* bound to `report_data` — nvattest ran at boot +against its own nonce. Bind it by replaying the runtime event log and comparing +sha256 of `decode_evidence()` against `evidence_sha256` in the measured +`gpu-attestation` event. `decode_evidence()` returns the nvattest output byte +for byte; do not parse and re-serialize before hashing, since key order and +whitespace change the digest. -### GPU Info +### `attest_gpu()` -`gpu_info()` returns GPU information collected during boot. Currently, this -includes the complete NVIDIA `nvattest` JSON output. +Samples the GPU *now*, against a nonce you choose — which is what +`boottime_gpu_evidence` cannot tell you, since that is a record written at boot. +Use it after anything that may have reinitialised the GPU. ```python -gpu = client.gpu_info() -print(gpu.attestation) -``` - -The `attestation` field is empty when no GPU attestation output is available. -The raw output is not trusted by itself; remote verifiers should compare its -digest with the measured `gpu-attestation` runtime event. - -### Get Instance Info - -```python -info = client.info() -print(info.app_id) -print(info.instance_id) -print(info.tcb_info) -print(info.cloud_vendor, info.cloud_product) # 0.5.7+ +result = client.attest_gpu(os.urandom(32)) # exactly 32 bytes +for bundle in result.bundles: + print(bundle.vendor, bundle.format, bundle.decode_evidence()) ``` -**Returns:** `InfoResponse` -- `app_id`, `instance_id`, `app_name`, `device_id` -- `tcb_info`: TCB measurements (MRTD, RTMRs, event log, compose hash, ...) -- `compose_hash`: Hash of the app configuration -- `app_cert`: Application certificate (PEM) -- `key_provider_info`: Key management configuration -- `cloud_vendor` / `cloud_product`: Cloud provider strings (empty on older OS) +Select a verifier using each bundle's `vendor` and `format`, then check the +signature, certificate chain, measurements, and the nonce embedded in the +evidence. `evidence` is opaque vendor-native bytes, hex-encoded on the wire — do +not assume UTF-8 or JSON. It does not by itself bind the GPU to this CVM; only +TDISP/TEE-IO device binding closes that gap. -### Generate TLS Certificates +### `issue_cert()` -`get_tls_key()` creates fresh TLS certificates. Unlike `get_key()`, each call generates a new random key. +v0 called this `get_tls_key()`, which named the by-product rather than the +request. The private key is freshly generated per call and is *not* derived from +the app identity: two calls with the same arguments return two unrelated keys. ```python -tls = client.get_tls_key( +cert = client.issue_cert( subject='api.example.com', alt_names=['localhost'], - usage_ra_tls=True, # Embed attestation in certificate - # 0.5.7+ options below: + usage_ra_tls=True, # Embed the attestation in the certificate + usage_server_auth=True, + usage_client_auth=False, + with_app_info=True, not_before=1700000000, # seconds since UNIX epoch not_after=1800000000, - with_app_info=True, -) -print(tls.key) # PEM private key -print(tls.certificate_chain) # Certificate chain -``` - -**Parameters:** -- `subject` (optional): Certificate Common Name (e.g., domain name) -- `alt_names` (optional): Subject Alternative Names -- `usage_ra_tls` (optional): Embed TDX quote in a certificate extension (default `False`) -- `usage_server_auth` (optional): Enable for server authentication (default `True`) -- `usage_client_auth` (optional): Enable for client authentication (default `False`) -- `not_before` / `not_after` (optional, kw-only): Validity window in seconds since UNIX epoch. Requires dstack OS >= 0.5.7. -- `with_app_info` (optional, kw-only): Embed app identity into the certificate. Requires dstack OS >= 0.5.7. - -When any of the 0.5.7-only options is set, the SDK probes `Version` first and raises `RuntimeError` on older guest agents that lack it. - -**Returns:** `GetTlsKeyResponse` -- `key`: PEM-encoded private key -- `certificate_chain`: List of PEM certificates -- `as_uint8array(max_length=None)`: Returns the DER-encoded private key bytes (handy when feeding key material into low-level crypto libraries) - -### Sign and Verify - -Signing happens in the TEE, because it needs a key only the TEE holds. Verifying -does not, so it runs locally in this SDK — the guest agent's `Verify` RPC was -removed in v0.6.0. Its answer arrived over the socket unattested, so trusting it -was never better than checking the signature yourself. - -```python -from dstack_sdk import verify_signature, verify_signature_chain - -result = client.sign('ed25519', b'message to sign') - -# Does this signature check out under this public key? -valid = verify_signature( - 'ed25519', - b'message to sign', - result.decode_signature(), - result.decode_public_key(), ) -assert valid is True +print(cert.key) # PEM private key +print(cert.certificate_chain) # PEM chain, leaf first ``` -**`sign()` Parameters:** -- `algorithm`: `'ed25519'`, `'secp256k1'` (alias `'k256'`), or `'secp256k1_prehashed'` -- `data`: Data to sign (`bytes` or `str`). For `secp256k1_prehashed`, must be a 32-byte digest. +**Returns:** `IssueCertResponseV1` with a PEM `key` and a leaf-first +`certificate_chain`. -**`sign()` Returns:** `SignResponse` -- `signature`: Hex-encoded signature -- `public_key`: Hex-encoded public key -- `signature_chain`: Three signatures linking the signing key back to the KMS root - -**`verify_signature()` Returns:** `bool` — `False` when a well-formed signature -does not match, and it *raises* `ValueError` when an input is malformed (bad key -length, wrong signature length, unknown algorithm, non-canonical high-S -signature). A malformed input is a caller bug, not a verdict. - -#### Verifying the whole chain - -`verify_signature` alone proves only that whoever holds that public key signed -the data. It says nothing about *whose* key it is. `verify_signature_chain` -walks all three links back to a KMS root key you supply: +### `info()` ```python -# Both anchors come from you, not from the CVM being checked. -expected_app_id = bytes.fromhex('a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b') -kms_root_pubkey = bytes.fromhex('03...') # pinned, or read from DstackKms - -app_root_pubkey = verify_signature_chain( - 'ed25519', - b'message to sign', - result.decode_public_key(), - result.decode_signature_chain(), - expected_app_id, - kms_root_pubkey, -) -print(app_root_pubkey.hex()) # compressed SEC1, 33 bytes +info = client.info() +print(info.app_id, info.app_name, info.compose_hash) +print(info.instance_id, info.device_id) +print(info.os_image_hash, info.mr_aggregated) +print(info.app_compose, info.vm_config, info.key_provider_info) +print(info.cloud_vendor, info.cloud_product) ``` -Note what the example does *not* do: it never passes `client.info().app_id` -straight through. That value is reported by the very CVM being verified, so a -chain checked against it proves only that the CVM is self-consistent with -itself. Use the app id you registered on chain, and if you want `AppInfo` in the -picture, compare it against that value rather than trusting it. +Identity and configuration — not attestation. Nothing here is evidence: it +arrives over a local socket with no quote behind it. That is why there is no +`tcb_info` and no `app_cert`. The measurement registers and the event log belong +to `attest()`, which returns them quote-backed. `compose_hash`, `os_image_hash` +and `mr_aggregated` are here because they identify *which* application and image +this is, and a relying party still confirms them against an attestation. -It returns the app root public key and raises `ValueError` on any failure. +`app_compose` is the verbatim deployed document and `compose_hash` is sha256 +over exactly those bytes — do not parse and re-serialize before hashing. -`kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` -contract's `kmsInfo().k256Pubkey`, or a value you pinned. Reading it from the -same KMS you are checking against proves nothing — an attacker who can answer -that query can also mint a self-consistent chain. This comparison is the entire -point of the chain; skip it and the other two links establish nothing. - -### Diagnostics +### `version()` ```python -client.version() # VersionResponse(version, rev) — raises on OS < 0.5.7 -client.is_reachable() # Quick connectivity probe; never raises +client.version() # VersionResponseV1(version, rev) ``` -## Async Client +### Async -For async applications, use `AsyncDstackClient`. The API surface is identical, but every method is a coroutine: +`AsyncDstackClient` has the identical surface, with every method a coroutine: ```python import asyncio @@ -285,50 +212,50 @@ async def main(): client = AsyncDstackClient() info = await client.info() - key = await client.get_key('wallet/eth') + key = await client.get_key('backup-signing', 'ed25519') # Run requests concurrently keys = await asyncio.gather( - client.get_key('user/alice'), - client.get_key('user/bob'), + client.get_key('user/alice', 'secp256k1'), + client.get_key('user/bob', 'secp256k1'), ) asyncio.run(main()) ``` -`AsyncDstackClient` accepts the same constructor as `DstackClient` plus `use_sync_http: bool = False` for callers that need to issue sync HTTP from within an async context. +`AsyncDstackClient` accepts the same constructor as `DstackClient` plus +`use_sync_http: bool = False` for callers that need to issue sync HTTP from +within an async context. The same holds for the v0 clients. -## Blockchain Integration +### Signing and verifying -### Ethereum +Neither is a v1 method. Signing happens wherever the key is, and `get_key()` +already hands you the key, so a round trip to the agent adds nothing; verifying +needs no key at all, and an agent's answer arrives over the socket unattested, +so a relying party gains nothing over checking the signature itself with a +standard library. ```python -from dstack_sdk.ethereum import to_account_secure +from cryptography.hazmat.primitives.asymmetric import ed25519 -key = client.get_key('wallet/ethereum') -account = to_account_secure(key) -print(account.address) +key = client.get_key('signing/messages', 'ed25519') +signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(key.decode_key()) +signature = signing_key.sign(b'message to sign') ``` -`to_account_secure(key)` hashes the full key material with SHA-256 before deriving the Ethereum private key. The legacy `to_account()` is kept for backward compatibility but uses raw key bytes—prefer the secure variant for new code. - -### Solana - -```python -from dstack_sdk.solana import to_keypair_secure - -key = client.get_key('wallet/solana', purpose='mainnet', algorithm='ed25519') -keypair = to_keypair_secure(key) -print(keypair.pubkey()) -``` - -Same pattern: `to_keypair_secure(key)` SHA-256-hashes the key material; `to_keypair()` is the legacy raw-bytes variant. +To verify a v1 signature chain, follow +[`docs/guest-api-v1.md`](../../docs/guest-api-v1.md), which is the normative +spec: it gives the claim encoding, the link order, and — the part that actually +establishes anything — the requirement that the KMS root public key come from +somewhere you already trust (the `DstackKms` contract's `kmsInfo().k256Pubkey`, +or a value you pinned), never from the CVM being checked. --- ## Deployment Utilities -These utilities are for deployment scripts, not runtime SDK operations. +These utilities are for deployment scripts, not runtime SDK operations, and are +the same for either client. ### Encrypted Environment Variables @@ -388,13 +315,14 @@ hash_value = get_compose_hash(app_compose_dict) | Feature | Required dstack OS | |---|---| -| `get_key`, `get_quote`, `get_tls_key` (legacy fields), `info` (legacy fields) | 0.3+ | -| `attest`, `sign`, `is_reachable` | 0.5.0+ (`sign` requires a server build with the feature) | -| `version`, `algorithm='ed25519'` on `get_key`, `info.cloud_vendor` / `cloud_product`, `not_before` / `not_after` / `with_app_info` on `get_tls_key` | 0.5.7+ | +| Every `DstackClient` (v1) method | 0.6.0+ — older agents have no `/v1` mount and answer HTTP 404 | +| V0 `get_key`, `get_quote`, `get_tls_key` (legacy fields), `info` (legacy fields) | 0.3+ | +| V0 `attest`, `sign`, `verify`, `is_reachable` | 0.5.0+ (`sign` requires a server build with the feature) | +| V0 `version`, `algorithm='ed25519'` on `get_key`, `info.cloud_vendor` / `cloud_product`, `not_before` / `not_after` / `with_app_info` on `get_tls_key` | 0.5.7+ | +| V0 `emit_event` | Removed in 0.6.0: the agent always fails it | | `verify_env_encrypt_public_key` (signature_v1 with timestamp) | Requires KMS build that emits `signature_v1`; legacy variant remains available | -| `verify_signature`, `verify_signature_chain` | Any — verification is local and needs no guest agent | -Calls that require 0.5.7-only fields probe the `Version` RPC first and raise a clear `RuntimeError` on older guest agents. +V0 calls that require 0.5.7-only fields probe the `Version` RPC first and raise a clear `RuntimeError` on older guest agents. The v1 client never probes: it requires a 0.6 agent outright. ## Development @@ -413,17 +341,252 @@ Then set the endpoint: export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 ``` -Install dev dependencies and run tests with PDM: +Install dev dependencies, then run the tests and the format/lint checks with PDM: ```bash cd sdk/python -make install -make test +pdm install --dev +pdm run test +pdm run check +``` + +`make install` / `make test` wrap the same commands and additionally assert that +`DSTACK_SIMULATOR_ENDPOINT` and `TAPPD_SIMULATOR_ENDPOINT` are set. + +--- + +# Legacy (v0, frozen) + +Everything below is the frozen v0.5.11 surface. It still works and is still +served, but it gains no method and no field. Reach for it when you must keep the +v0 key derivation, or when you need `sign` / `verify`, which v1 does not serve. + +Import it by its explicit name — the unsuffixed `DstackClient` now means v1: + +```python +from dstack_sdk import DstackClientV0, AsyncDstackClientV0 + +v0 = DstackClientV0() +``` + +## V0 Client + +In the examples below, `v0 = DstackClientV0()`. + +### Derive Keys + +`get_key()` derives deterministic keys bound to your application's identity (`app_id`). The same path always produces the same key for your app, but different apps get different keys even with the same path. + +```python +# Derive keys by path +eth_key = v0.get_key('wallet/ethereum') +btc_key = v0.get_key('wallet/bitcoin') + +# Use path to separate keys +mainnet_key = v0.get_key('wallet/eth/mainnet') +testnet_key = v0.get_key('wallet/eth/testnet') + +# Use a different signature algorithm (requires dstack OS >= 0.5.7) +ed_key = v0.get_key('signing/key', algorithm='ed25519') ``` +**Parameters:** +- `path` (optional): Key derivation path. Defaults to `""` (root). +- `purpose` (optional): Included in the signature chain message; does not affect the derived key. +- `algorithm` (optional): `'secp256k1'` (default) or `'ed25519'`. For compatibility, this selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. Use algorithm-specific paths when independent keys are required. + +**Returns:** `GetKeyResponse` +- `key`: Hex-encoded private key +- `signature_chain`: Signatures proving the key was derived in a genuine TEE +- `decode_key()` / `decode_signature_chain()`: Helpers that return `bytes` + +### 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 = v0.get_quote(b'user:alice:nonce123') +print(quote.event_log) +``` + +**Parameters:** +- `report_data`: Up to 64 bytes (`bytes` or `str`). Shorter inputs are padded with zeros; longer inputs should be hashed first (e.g., SHA-256). + +**Returns:** `GetQuoteResponse` +- `quote`: Hex-encoded TDX quote +- `event_log`: JSON string of measured events +- `decode_quote()` / `decode_event_log()`: Helpers + +### Versioned Attestation + +`attest()` returns a versioned attestation payload that newer verifier APIs can dispatch on without sniffing the quote format. + +```python +result = v0.attest(b'user:alice:nonce123') +print(result.attestation) # hex string +print(result.decode_attestation()) # bytes +``` + +`report_data` is the only argument. GPU evidence — boot-time and on-demand alike — +is a v1 capability; see [`attest()`](#attest) and [`attest_gpu()`](#attest_gpu). + +### Get Instance Info + +```python +info = v0.info() +print(info.app_id) +print(info.instance_id) +print(info.tcb_info) +print(info.cloud_vendor, info.cloud_product) # 0.5.7+ +``` + +**Returns:** `InfoResponse` +- `app_id`, `instance_id`, `app_name`, `device_id` +- `tcb_info`: TCB measurements (MRTD, RTMRs, event log, compose hash, ...) +- `compose_hash`: Hash of the app configuration +- `app_cert`: Application certificate (PEM) +- `key_provider_info`: Key management configuration +- `cloud_vendor` / `cloud_product`: Cloud provider strings (empty on older OS) + +### Generate TLS Certificates + +`get_tls_key()` creates fresh TLS certificates. Unlike `get_key()`, each call generates a new random key. + +```python +tls = v0.get_tls_key( + subject='api.example.com', + alt_names=['localhost'], + usage_ra_tls=True, # Embed attestation in certificate + # 0.5.7+ options below: + not_before=1700000000, # seconds since UNIX epoch + not_after=1800000000, + with_app_info=True, +) +print(tls.key) # PEM private key +print(tls.certificate_chain) # Certificate chain +``` + +**Parameters:** +- `subject` (optional): Certificate Common Name (e.g., domain name) +- `alt_names` (optional): Subject Alternative Names +- `usage_ra_tls` (optional): Embed TDX quote in a certificate extension (default `False`) +- `usage_server_auth` (optional): Enable for server authentication (default `True`) +- `usage_client_auth` (optional): Enable for client authentication (default `False`) +- `not_before` / `not_after` (optional, kw-only): Validity window in seconds since UNIX epoch. Requires dstack OS >= 0.5.7. +- `with_app_info` (optional, kw-only): Embed app identity into the certificate. Requires dstack OS >= 0.5.7. + +When any of the 0.5.7-only options is set, the SDK probes `Version` first and raises `RuntimeError` on older guest agents that lack it. + +**Returns:** `GetTlsKeyResponse` +- `key`: PEM-encoded private key +- `certificate_chain`: List of PEM certificates +- `as_uint8array(max_length=None)`: Returns the DER-encoded private key bytes (handy when feeding key material into low-level crypto libraries) + +### Sign and Verify + +Both are frozen v0 RPCs and neither has a v1 counterpart. + +```python +result = v0.sign('ed25519', b'message to sign') + +verdict = v0.verify( + 'ed25519', + b'message to sign', + result.decode_signature(), + result.decode_public_key(), +) +assert verdict.valid is True +``` + +**`sign()` Parameters:** +- `algorithm`: `'ed25519'`, `'secp256k1'` (alias `'k256'`), or `'secp256k1_prehashed'` +- `data`: Data to sign (`bytes` or `str`). For `secp256k1_prehashed`, must be a 32-byte digest. + +**`sign()` Returns:** `SignResponse` +- `signature`: Hex-encoded signature +- `public_key`: Hex-encoded public key +- `signature_chain`: Three signatures linking the signing key back to the KMS root + +**`verify()` Returns:** `VerifyResponse` with a single `valid: bool`. It reports +only whether that one signature matches that one public key — it says nothing +about *whose* key it is, and it does not walk the signature chain. + +Earlier drafts of this SDK shipped local `verify_signature` and +`verify_signature_chain` helpers. They are gone; verify locally per +[`docs/guest-api-v1.md`](../../docs/guest-api-v1.md). + +### Diagnostics + +```python +v0.version() # VersionResponse(version, rev) — raises on OS < 0.5.7 +v0.is_reachable() # Quick connectivity probe; never raises +``` + +### Async + +`AsyncDstackClientV0` has the identical surface, with every method a coroutine: + +```python +import asyncio +from dstack_sdk import AsyncDstackClientV0 + +async def main(): + v0 = AsyncDstackClientV0() + + info = await v0.info() + key = await v0.get_key('wallet/eth') + + # Run requests concurrently + keys = await asyncio.gather( + v0.get_key('user/alice'), + v0.get_key('user/bob'), + ) + +asyncio.run(main()) +``` + +### Removed in 0.6.0 + +`emit_event()` is still on the client, but the agent now fails every call: +runtime RTMR3 events are system-owned and an app can no longer extend them. The +method remains so that a caller written against 0.5.x gets the agent's own +explanation rather than a 404. `attest_gpu()` and `gpu_info()` never shipped on +this surface and are not here; they live on the v1 client. + +## Blockchain helpers + +The chain adapters are v0-era. `dstack_sdk.ethereum` and `dstack_sdk.solana` +take a v0 `GetKeyResponse` or `GetTlsKeyResponse`, and that is the only shape +they take: v1 has no chain-related surface. `get_key()` returns key material, +and what an application builds out of those bytes is its own business. + +```python +from dstack_sdk import DstackClientV0 +from dstack_sdk.ethereum import to_account_secure +from dstack_sdk.solana import to_keypair_secure + +v0 = DstackClientV0() + +account = to_account_secure(v0.get_key('wallet/ethereum')) +print(account.address) + +keypair = to_keypair_secure( + v0.get_key('wallet/solana', purpose='mainnet', algorithm='ed25519') +) +print(keypair.pubkey()) +``` + +`to_account_secure` / `to_keypair_secure` hash the full key material with +SHA-256 before deriving. The legacy `to_account()` / `to_keypair()` use raw key +bytes and are kept only for backward compatibility. + ## Migration from TappdClient -Replace `TappdClient` with `DstackClient`: +`TappdClient` only ever spoke v0, so replace it with `DstackClientV0` — +not with the unsuffixed name, which is now v1 and derives different keys: ```python # Before @@ -431,8 +594,8 @@ from dstack_sdk import TappdClient client = TappdClient() # After -from dstack_sdk import DstackClient -client = DstackClient() +from dstack_sdk import DstackClientV0 +v0 = DstackClientV0() ``` Method changes: @@ -440,6 +603,23 @@ Method changes: - `tdx_quote()` → `get_quote()` (raw data only, no hash algorithms) - Socket path: `/var/run/tappd.sock` → `/var/run/dstack.sock` +## Migration from v0 to v1 + +v1 is a separate surface, not an upgrade path — read the key warning at the top +before switching anything that holds state. + +| v0 | v1 | Note | +|---|---|---| +| `get_tls_key()` | `issue_cert()` | Renamed; same behaviour | +| `get_key(path, purpose)` | `get_key(domain, algorithm)` | Merged into one KDF input; `algorithm` is now required | +| `get_quote()` | `attest()` | The TDX-only channel is subsumed | +| `info().tcb_info` | — | Measurements are typed fields; the rest belongs to `attest()` | +| `info().app_cert` | — | A dashboard artifact; it proved nothing | +| `sign()` | — | Sign locally with the key `get_key()` returns | +| `verify()` | — | Verify locally, per `docs/guest-api-v1.md` | +| `emit_event()` | — | RTMR3 is system-owned as of 0.6.0 | +| `gpu_info()` | `attest()` with `include_boottime_gpu_evidence=True` | Same bytes, now on the attestation call, in `attest_gpu()`'s bundle shape | + ## License Apache License 2.0 diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 76b528f01..d67994ef7 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "dstack-sdk" -version = "0.5.4" +version = "0.6.0" description = "dstack SDK for Python" authors = [ {name = "Leechael Yim", email = "yanleech@gmail.com"}, diff --git a/sdk/python/src/dstack_sdk/__init__.py b/sdk/python/src/dstack_sdk/__init__.py index 5052772b2..4fee0ebdd 100644 --- a/sdk/python/src/dstack_sdk/__init__.py +++ b/sdk/python/src/dstack_sdk/__init__.py @@ -2,22 +2,31 @@ # # SPDX-License-Identifier: Apache-2.0 -from .dstack_client import AsyncDstackClient +from .dstack_client import AsyncDstackClientV0 from .dstack_client import AsyncTappdClient -from .dstack_client import AttestGpuResponse from .dstack_client import AttestResponse -from .dstack_client import DstackClient +from .dstack_client import DstackClientV0 from .dstack_client import EventLog from .dstack_client import GetKeyResponse from .dstack_client import GetQuoteResponse from .dstack_client import GetTlsKeyResponse -from .dstack_client import GpuEvidenceBundle -from .dstack_client import GpuInfoResponse from .dstack_client import InfoResponse from .dstack_client import SignResponse from .dstack_client import TappdClient from .dstack_client import TcbInfo +from .dstack_client import VerifyResponse from .dstack_client import VersionResponse +from .dstack_client_v1 import AsyncDstackClient +from .dstack_client_v1 import AsyncDstackClientV1 +from .dstack_client_v1 import AttestGpuResponseV1 +from .dstack_client_v1 import AttestResponseV1 +from .dstack_client_v1 import DstackClient +from .dstack_client_v1 import DstackClientV1 +from .dstack_client_v1 import GetKeyResponseV1 +from .dstack_client_v1 import GpuEvidenceBundleV1 +from .dstack_client_v1 import InfoResponseV1 +from .dstack_client_v1 import IssueCertResponseV1 +from .dstack_client_v1 import VersionResponseV1 from .encrypt_env_vars import EnvVar from .encrypt_env_vars import encrypt_env_vars from .encrypt_env_vars import encrypt_env_vars_sync @@ -25,29 +34,40 @@ from .get_compose_hash import DockerConfig from .get_compose_hash import Requirements from .get_compose_hash import get_compose_hash -from .verify import verify_signature -from .verify import verify_signature_chain from .verify_env_encrypt_public_key import verify_env_encrypt_public_key from .verify_env_encrypt_public_key import verify_env_encrypt_public_key_legacy __all__ = [ - # Core clients + # The default clients: unsuffixed means dstack.guest.v1 "DstackClient", "AsyncDstackClient", + # The same classes under their explicit names + "DstackClientV1", + "AsyncDstackClientV1", + # Legacy clients for the frozen v0.5.11 surface + "DstackClientV0", + "AsyncDstackClientV0", "AsyncTappdClient", "TappdClient", - # Response types + # v0 response types "GetKeyResponse", "GetTlsKeyResponse", "AttestResponse", - "AttestGpuResponse", - "GpuEvidenceBundle", - "GpuInfoResponse", "GetQuoteResponse", "InfoResponse", "TcbInfo", "EventLog", + "SignResponse", + "VerifyResponse", "VersionResponse", + # v1 response types + "IssueCertResponseV1", + "GetKeyResponseV1", + "AttestResponseV1", + "AttestGpuResponseV1", + "GpuEvidenceBundleV1", + "InfoResponseV1", + "VersionResponseV1", # Utility functions "encrypt_env_vars_sync", "encrypt_env_vars", @@ -56,8 +76,6 @@ "AppCompose", "DockerConfig", "Requirements", - "verify_signature", - "verify_signature_chain", "verify_env_encrypt_public_key", "verify_env_encrypt_public_key_legacy", ] diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index aa5919d1b..b854cee0d 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -152,33 +152,11 @@ def decode_event_log(self) -> "List[EventLog]": class AttestResponse(BaseModel): attestation: str - # Complete JSON output produced by nvattest during guest boot. Empty unless - # the request set include_boottime_gpu_evidence and the guest has boot-time GPU - # attestation output. Not bound to report_data: verify it by replaying the - # runtime event log and comparing sha256 of these exact UTF-8 bytes against - # evidence_sha256 in the `gpu-attestation` event. - boottime_gpu_evidence: str = "" def decode_attestation(self) -> bytes: return bytes.fromhex(self.attestation) -class GpuEvidenceBundle(BaseModel): - vendor: str - format: str - evidence: str - - -class AttestGpuResponse(BaseModel): - """Result of fresh, on-demand GPU evidence collection.""" - - bundles: list[GpuEvidenceBundle] - - -class GpuInfoResponse(BaseModel): - attestation: str - - class SignResponse(BaseModel): signature: str signature_chain: List[str] @@ -194,6 +172,10 @@ def decode_public_key(self) -> bytes: return bytes.fromhex(self.public_key) +class VerifyResponse(BaseModel): + valid: bool + + class VersionResponse(BaseModel): version: str rev: str @@ -280,7 +262,43 @@ class BaseClient: pass -class AsyncDstackClient(BaseClient): +def raise_for_status(response: httpx.Response) -> None: + """Raise on an error status, carrying the guest agent's message. + + prpc reports "no such method" and "the handler failed" alike as an HTTP + 400 with the reason in the JSON body, so the status line on its own cannot + tell a removed method from a rejected argument. httpx's own + ``raise_for_status`` shows only the status line, which would hide exactly + the text a caller needs -- for instance the one ``EmitEvent`` returns + naming its removal. + """ + try: + response.raise_for_status() + return + except httpx.HTTPStatusError as exc: + message = "" + try: + body = response.json() + if isinstance(body, dict): + message = str(body.get("error", "")) + except Exception: + message = response.text.strip() + if not message: + raise + raise httpx.HTTPStatusError( + f"{exc.args[0]}\nguest agent said: {message}", + request=exc.request, + response=response, + ) from None + + +class AsyncBaseClient(BaseClient): + """Transport shared by every async client, whatever surface it speaks. + + Subclasses differ only in ``PATH_PREFIX``: the guest agent selects the API + version from the URL path alone, never from a header. + """ + PATH_PREFIX = "/" def __init__( @@ -353,14 +371,12 @@ async def _send_rpc_request( # Use sync HTTP client - works from any context sync_client: httpx.Client = self._get_sync_client() response = sync_client.post(path, json=payload, headers=headers) - response.raise_for_status() - return cast(Dict[str, Any], response.json()) else: # Use async HTTP client - traditional async behavior async_client: httpx.AsyncClient = self._get_client() response = await async_client.post(path, json=payload, headers=headers) - response.raise_for_status() - return cast(Dict[str, Any], response.json()) + raise_for_status(response) + return cast(Dict[str, Any], response.json()) async def __aenter__(self): self._client_ref_count += 1 @@ -381,6 +397,22 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): self._sync_client.close() self._sync_client = None + +class AsyncDstackClientV0(AsyncBaseClient): + """Legacy async client for the frozen v0.5.11 guest agent API. + + .. deprecated:: 0.6.0 + Prefer ``AsyncDstackClient`` (which is ``AsyncDstackClientV1``). This + class stays for code that must keep the v0 key derivation or needs + ``sign`` / ``verify``, which v1 does not serve. + + Served at the historical unversioned paths (``/GetKey``) and, since 0.6.0, + equivalently at ``/v0/GetKey``. The surface is frozen: it gains no method + and no field. New capabilities live on ``AsyncDstackClientV1``. + """ + + PATH_PREFIX = "/" + async def _ensure_algorithm_supported(self, algorithm: str) -> None: """Check OS version when a non-secp256k1 algorithm is requested.""" if algorithm in ("secp256k1", "k256", ""): @@ -445,13 +477,8 @@ async def get_quote( async def attest( self, report_data: str | bytes, - include_boottime_gpu_evidence: bool = False, ) -> AttestResponse: - """Request a versioned attestation for the provided report data. - - Set include_boottime_gpu_evidence to also return the boot-time GPU attestation - evidence in AttestResponse.boottime_gpu_evidence. - """ + """Request a versioned attestation for the provided report data.""" if not report_data or not isinstance(report_data, (bytes, str)): raise ValueError("report_data can not be empty") report_bytes: bytes = ( @@ -460,38 +487,35 @@ async def attest( if len(report_bytes) > 64: raise ValueError("report_data must be less than 64 bytes") hex = binascii.hexlify(report_bytes).decode() - result = await self._send_rpc_request( - "Attest", - { - "report_data": hex, - "include_boottime_gpu_evidence": include_boottime_gpu_evidence, - }, - ) + result = await self._send_rpc_request("Attest", {"report_data": hex}) return AttestResponse(**result) - async def attest_gpu(self, nonce: bytes) -> AttestGpuResponse: - """Collect vendor-native GPU evidence for a 32-byte nonce. - - Select a verifier using each bundle's vendor and format, and verify the - signature, certificate chain, measurements, and embedded nonce. - """ - if not isinstance(nonce, (bytes, bytearray)) or len(nonce) != 32: - raise ValueError("nonce must be exactly 32 bytes") - result = await self._send_rpc_request( - "AttestGpu", {"nonce": binascii.hexlify(bytes(nonce)).decode()} - ) - return AttestGpuResponse(**result) - - async def gpu_info(self) -> GpuInfoResponse: - """Return GPU information collected during boot.""" - result = await self._send_rpc_request("GpuInfo", {}) - return GpuInfoResponse(**result) - async def info(self) -> InfoResponse[TcbInfo]: """Fetch service information including parsed TCB info.""" result = await self._send_rpc_request("Info", {}) return InfoResponse.parse_response(result, TcbInfoV05x) + async def emit_event( + self, + event: str, + payload: str | bytes, + ) -> None: + """Emit an event that extends RTMR3 on TDX platforms. + + Removed in dstack 0.6.0: runtime RTMR3 events are system-owned, and the + agent now fails every call. The method stays so that a caller written + against 0.5.x gets the agent's own explanation rather than a 404. + """ + if not event: + raise ValueError("event name cannot be empty") + + payload_bytes: bytes = payload.encode() if isinstance(payload, str) else payload + hex_payload = binascii.hexlify(payload_bytes).decode() + await self._send_rpc_request( + "EmitEvent", {"event": event, "payload": hex_payload} + ) + return None + async def get_tls_key( self, subject: str | None = None, @@ -552,6 +576,27 @@ async def sign(self, algorithm: str, data: str | bytes) -> SignResponse: result = await self._send_rpc_request("Sign", payload) return SignResponse(**result) + async def verify( + self, + algorithm: str, + data: str | bytes, + signature: str | bytes, + public_key: str | bytes, + ) -> VerifyResponse: + """Verify a signature.""" + data_bytes = data.encode() if isinstance(data, str) else data + sig_bytes = signature.encode() if isinstance(signature, str) else signature + pk_bytes = public_key.encode() if isinstance(public_key, str) else public_key + + payload = { + "algorithm": algorithm, + "data": binascii.hexlify(data_bytes).decode(), + "signature": binascii.hexlify(sig_bytes).decode(), + "public_key": binascii.hexlify(pk_bytes).decode(), + } + result = await self._send_rpc_request("Verify", payload) + return VerifyResponse(**result) + async def version(self) -> VersionResponse: """Query the guest-agent version. @@ -570,7 +615,16 @@ async def is_reachable(self) -> bool: return False -class DstackClient(BaseClient): +class DstackClientV0(BaseClient): + """Legacy sync client for the frozen v0.5.11 guest agent API. + + .. deprecated:: 0.6.0 + Prefer ``DstackClient`` (which is ``DstackClientV1``); see + ``AsyncDstackClientV0`` for when staying on v0 is the right call. + + Every method here is the blocking twin of ``AsyncDstackClientV0``'s. + """ + PATH_PREFIX = "/" def __init__(self, endpoint: str | None = None, *, timeout: float = 3): @@ -579,7 +633,7 @@ def __init__(self, endpoint: str | None = None, *, timeout: float = 3): If a non-HTTP(S) endpoint is provided, it is treated as a Unix socket path and validated for existence. """ - self.async_client = AsyncDstackClient( + self.async_client = AsyncDstackClientV0( endpoint, use_sync_http=True, timeout=timeout ) @@ -611,32 +665,25 @@ def get_quote( def attest( self, report_data: str | bytes, - include_boottime_gpu_evidence: bool = False, ) -> AttestResponse: - """Request a versioned attestation for the provided report data. - - Set include_boottime_gpu_evidence to also return the boot-time GPU attestation - evidence in AttestResponse.boottime_gpu_evidence. - """ + """Request a versioned attestation for the provided report data.""" raise NotImplementedError @call_async - def attest_gpu(self, nonce: bytes) -> AttestGpuResponse: - """Collect vendor-native GPU evidence for a 32-byte nonce. - - Select a verifier using each bundle's vendor and format, and verify the - signature, certificate chain, measurements, and embedded nonce. - """ + def info(self) -> InfoResponse[TcbInfo]: + """Fetch service information including parsed TCB info.""" raise NotImplementedError @call_async - def gpu_info(self) -> GpuInfoResponse: - """Return GPU information collected during boot.""" - raise NotImplementedError + def emit_event( + self, + event: str, + payload: str | bytes, + ) -> None: + """Emit an event that extends RTMR3 on TDX platforms. - @call_async - def info(self) -> InfoResponse[TcbInfo]: - """Fetch service information including parsed TCB info.""" + Removed in dstack 0.6.0; see ``AsyncDstackClientV0.emit_event``. + """ raise NotImplementedError @call_async @@ -660,6 +707,17 @@ def sign(self, algorithm: str, data: str | bytes) -> SignResponse: """Signs data using a derived key.""" raise NotImplementedError + @call_async + def verify( + self, + algorithm: str, + data: str | bytes, + signature: str | bytes, + public_key: str | bytes, + ) -> VerifyResponse: + """Verify a signature.""" + raise NotImplementedError + @call_async def version(self) -> VersionResponse: """Query the guest-agent version.""" @@ -679,10 +737,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise NotImplementedError -class AsyncTappdClient(AsyncDstackClient): +class AsyncTappdClient(AsyncDstackClientV0): """Deprecated async client kept for backward compatibility. - DEPRECATED: Use ``AsyncDstackClient`` instead. + DEPRECATED: Use ``AsyncDstackClientV0`` instead. It is named explicitly + here because tappd only ever spoke v0, and the unsuffixed + ``AsyncDstackClient`` now means v1, which derives different keys. """ def __init__( @@ -696,7 +756,7 @@ def __init__( if not use_sync_http: # Already warned in TappdClient.__init__ emit_deprecation_warning( - "AsyncTappdClient is deprecated, please use AsyncDstackClient instead" + "AsyncTappdClient is deprecated, please use AsyncDstackClientV0 instead" ) endpoint = get_tappd_endpoint(endpoint) @@ -764,16 +824,16 @@ async def info(self) -> InfoResponse[TcbInfo]: return InfoResponse.parse_response(result, TcbInfoV03x) -class TappdClient(DstackClient): +class TappdClient(DstackClientV0): """Deprecated client kept for backward compatibility. - DEPRECATED: Use ``DstackClient`` instead. + DEPRECATED: Use ``DstackClientV0`` instead; see ``AsyncTappdClient``. """ def __init__(self, endpoint: str | None = None, timeout: float = 3): """Initialize deprecated tappd client wrapper.""" emit_deprecation_warning( - "TappdClient is deprecated, please use DstackClient instead" + "TappdClient is deprecated, please use DstackClientV0 instead" ) endpoint = get_tappd_endpoint(endpoint) self.async_client = AsyncTappdClient( diff --git a/sdk/python/src/dstack_sdk/dstack_client_v1.py b/sdk/python/src/dstack_sdk/dstack_client_v1.py new file mode 100644 index 000000000..4fff04afb --- /dev/null +++ b/sdk/python/src/dstack_sdk/dstack_client_v1.py @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +"""Client for the ``dstack.guest.v1`` guest agent API. + +v1 is a separate surface, not a newer dialect of the frozen one: it is served +at ``/v1/`` on the same socket, and the guest agent picks the version +from the URL path alone. This client mirrors that surface and nothing else. It +does not translate calls to v0, and it has no ``Sign``, ``Verify``, +``EmitEvent``, ``GetQuote`` or ``GpuInfo``, because v1 has none of them. + +Keys are the one thing that must not be assumed to carry over: the v1 KDF binds +the algorithm and its own context tag alongside the domain, so ``get_key`` here +returns different key material than ``DstackClientV0.get_key`` does for the same +name. There is no compatibility mode. See ``docs/guest-api-v1.md``. +""" + +import binascii +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from pydantic import BaseModel + +from .dstack_client import AsyncBaseClient +from .dstack_client import BaseClient +from .dstack_client import call_async + + +class IssueCertResponseV1(BaseModel): + # PEM-encoded private key, freshly generated for this call. It is not + # derived from the app identity: two calls with the same arguments return + # two unrelated keys. get_key is the method that derives a stable one. + key: str + # Leaf first, each entry PEM-encoded, exactly as the signer returned it. + certificate_chain: List[str] + + +class GetKeyResponseV1(BaseModel): + key: str + public_key: str + signature_chain: List[str] + + def decode_key(self) -> bytes: + return bytes.fromhex(self.key) + + def decode_public_key(self) -> bytes: + return bytes.fromhex(self.public_key) + + def decode_signature_chain(self) -> List[bytes]: + return [bytes.fromhex(chain) for chain in self.signature_chain] + + +class GpuEvidenceBundleV1(BaseModel): + """One vendor's GPU evidence, however it was obtained. + + The same bundle shape carries both fresh ``attest_gpu()`` output and the + boot-time record in ``AttestResponseV1.boottime_gpu_evidence``, so a caller + writes one parser and switches on ``format``: + + - ``nvidia-nvattest-collect-evidence-json-v1`` -- collected on demand, + against the nonce passed to ``attest_gpu()``. + - ``nvidia-nvattest-boottime-json-v1`` -- the record nvattest wrote at guest + boot, against its own nonce. + """ + + vendor: str + format: str + # Opaque vendor-native evidence, hex-encoded on the wire. Do not assume + # UTF-8 or JSON. + evidence: str + + def decode_evidence(self) -> bytes: + """Return the evidence as raw bytes, exactly as the vendor emitted it. + + The exactness matters for the boot-time format: the binding rule is + sha256 over precisely these bytes, compared against ``evidence_sha256`` + in the measured ``gpu-attestation`` event. Parsing and re-serializing + the JSON changes key order and whitespace, and so changes the digest. + """ + return bytes.fromhex(self.evidence) + + +class AttestResponseV1(BaseModel): + attestation: str + # The GPU evidence nvattest recorded during guest boot, in the same bundle + # shape attest_gpu returns. Empty unless the request set + # include_boottime_gpu_evidence and the guest has boot-time output. Not + # bound to report_data: verify each bundle by replaying the runtime event + # log and comparing sha256 of decode_evidence() against evidence_sha256 in + # the `gpu-attestation` event. + boottime_gpu_evidence: List[GpuEvidenceBundleV1] = [] + + def decode_attestation(self) -> bytes: + return bytes.fromhex(self.attestation) + + +class AttestGpuResponseV1(BaseModel): + """Result of fresh, on-demand GPU evidence collection.""" + + bundles: List[GpuEvidenceBundleV1] + + +class VersionResponseV1(BaseModel): + version: str + rev: str + + +class InfoResponseV1(BaseModel): + """Identity and configuration. Not attestation. + + Nothing here is evidence: it arrives over a local socket with no quote + behind it. The measurement registers and the event log are deliberately + absent -- they belong to ``attest()``, which returns them quote-backed. + ``compose_hash``, ``os_image_hash`` and ``mr_aggregated`` are here because + they identify *which* application and image this is, and a relying party + still confirms them against an attestation. + + The identity fields are hex strings, matching how the v0 ``InfoResponse`` + exposes the same values. + """ + + app_id: str + app_name: str = "" + compose_hash: str + # Verbatim deployed bytes; compose_hash is sha256 over exactly these. Do + # not parse and re-serialize before hashing -- key order, whitespace and + # unknown fields all change the digest, and that digest is what gets + # whitelisted on chain. + app_compose: str = "" + instance_id: str + device_id: str + os_image_hash: str + mr_aggregated: str + vm_config: str = "" + key_provider_info: str = "" + cloud_vendor: str = "" + cloud_product: str = "" + + +class AsyncDstackClientV1(AsyncBaseClient): + """Async client for the ``dstack.guest.v1`` API, served at ``/v1``. + + Construction, endpoint resolution and timeouts match + ``AsyncDstackClientV0``; only the path prefix and the method set differ. + """ + + PATH_PREFIX = "/v1/" + + async def issue_cert( + self, + subject: str | None = None, + alt_names: List[str] | None = None, + usage_ra_tls: bool = False, + usage_server_auth: bool = True, + usage_client_auth: bool = False, + *, + with_app_info: bool = False, + not_before: Optional[int] = None, + not_after: Optional[int] = None, + ) -> IssueCertResponseV1: + """Issue a certificate for this application. + + v0 called this ``get_tls_key``, which named the by-product rather than + the request. ``not_before`` / ``not_after`` are seconds since the UNIX + epoch, and the agent rejects a ``not_before`` that is not earlier than + ``not_after``. + """ + data: Dict[str, Any] = { + "subject": subject or "", + "usage_ra_tls": usage_ra_tls, + "usage_server_auth": usage_server_auth, + "usage_client_auth": usage_client_auth, + "with_app_info": with_app_info, + } + if alt_names: + data["alt_names"] = list(alt_names) + if not_before is not None: + data["not_before"] = not_before + if not_after is not None: + data["not_after"] = not_after + + result = await self._send_rpc_request("IssueCert", data) + return IssueCertResponseV1(**result) + + async def get_key(self, domain: str, algorithm: str) -> GetKeyResponseV1: + """Derive an application key from ``(domain, algorithm)``. + + Both arguments are required. Derivation is flat: two domains yield + unrelated keys, and ``a/b`` is not a child of ``a``. ``algorithm`` is + exactly ``secp256k1`` or ``ed25519`` -- there is no default and no + ``k256`` alias, because in v0 a typo silently produced a key of the + wrong type under a name the caller thought meant something else. + """ + if not algorithm: + raise ValueError("algorithm is required, use `secp256k1` or `ed25519`") + data: Dict[str, Any] = {"domain": domain, "algorithm": algorithm} + result = await self._send_rpc_request("GetKey", data) + return GetKeyResponseV1(**result) + + async def attest( + self, + report_data: str | bytes, + include_boottime_gpu_evidence: bool = False, + ) -> AttestResponseV1: + """Produce a versioned attestation over the given report data. + + The sole CVM attestation entry point in v1: the dstack attestation + format already carries the quote and the event log, so v0's TDX-only + ``get_quote`` has nothing left to add. + + Set include_boottime_gpu_evidence to also return the boot-time GPU + attestation evidence in ``AttestResponseV1.boottime_gpu_evidence``, as + the same ``GpuEvidenceBundleV1`` list ``attest_gpu`` returns. A guest + with no boot-time output returns an empty list. + """ + if not report_data or not isinstance(report_data, (bytes, str)): + raise ValueError("report_data can not be empty") + report_bytes: bytes = ( + report_data.encode() if isinstance(report_data, str) else report_data + ) + if len(report_bytes) > 64: + raise ValueError("report_data must be at most 64 bytes") + data: Dict[str, Any] = { + "report_data": binascii.hexlify(report_bytes).decode(), + "include_boottime_gpu_evidence": include_boottime_gpu_evidence, + } + result = await self._send_rpc_request("Attest", data) + return AttestResponseV1(**result) + + async def attest_gpu(self, nonce: bytes) -> AttestGpuResponseV1: + """Collect vendor-native GPU evidence now, against a 32-byte nonce. + + Select a verifier using each bundle's vendor and format, then check the + signature, certificate chain, measurements, and the nonce embedded in + the evidence. The nonce is passed to the GPU verbatim, so it can be + compared directly against the ``eat_nonce`` claim; to bind a longer + challenge, hash it yourself. + """ + if not isinstance(nonce, (bytes, bytearray)) or len(nonce) != 32: + raise ValueError("nonce must be exactly 32 bytes") + result = await self._send_rpc_request( + "AttestGpu", {"nonce": binascii.hexlify(bytes(nonce)).decode()} + ) + return AttestGpuResponseV1(**result) + + async def info(self) -> InfoResponseV1: + """Return this application's identity and measurements.""" + result = await self._send_rpc_request("Info", {}) + return InfoResponseV1(**result) + + async def version(self) -> VersionResponseV1: + """Return the guest agent version.""" + result = await self._send_rpc_request("Version", {}) + return VersionResponseV1(**result) + + +class DstackClientV1(BaseClient): + """Sync client for the ``dstack.guest.v1`` API, served at ``/v1``. + + See ``AsyncDstackClientV1``; every method here is its blocking twin. + """ + + PATH_PREFIX = "/v1/" + + def __init__(self, endpoint: str | None = None, *, timeout: float = 3): + """Initialize client with HTTP or Unix-socket transport. + + If a non-HTTP(S) endpoint is provided, it is treated as a Unix socket + path and validated for existence. + """ + self.async_client = AsyncDstackClientV1( + endpoint, use_sync_http=True, timeout=timeout + ) + + @call_async + def issue_cert( + self, + subject: str | None = None, + alt_names: List[str] | None = None, + usage_ra_tls: bool = False, + usage_server_auth: bool = True, + usage_client_auth: bool = False, + *, + with_app_info: bool = False, + not_before: Optional[int] = None, + not_after: Optional[int] = None, + ) -> IssueCertResponseV1: + """Issue a certificate for this application.""" + raise NotImplementedError + + @call_async + def get_key(self, domain: str, algorithm: str) -> GetKeyResponseV1: + """Derive an application key from ``(domain, algorithm)``.""" + raise NotImplementedError + + @call_async + def attest( + self, + report_data: str | bytes, + include_boottime_gpu_evidence: bool = False, + ) -> AttestResponseV1: + """Produce a versioned attestation over the given report data.""" + raise NotImplementedError + + @call_async + def attest_gpu(self, nonce: bytes) -> AttestGpuResponseV1: + """Collect vendor-native GPU evidence now, against a 32-byte nonce.""" + raise NotImplementedError + + @call_async + def info(self) -> InfoResponseV1: + """Return this application's identity and measurements.""" + raise NotImplementedError + + @call_async + def version(self) -> VersionResponseV1: + """Return the guest agent version.""" + raise NotImplementedError + + @call_async + def __enter__(self): + raise NotImplementedError + + @call_async + def __exit__(self, exc_type, exc_val, exc_tb): + raise NotImplementedError + + +#: The recommended client: unsuffixed means v1, the surface that gains +#: capabilities. Code written against the pre-0.6 unsuffixed name -- which then +#: meant v0 -- breaks loudly here rather than quietly deriving other keys, +#: because the v1 methods have different signatures and ``get_key`` refuses to +#: guess an algorithm. Pin such code to ``DstackClientV0`` to keep the frozen +#: surface. +DstackClient = DstackClientV1 + +#: The recommended async client; see ``DstackClient``. +AsyncDstackClient = AsyncDstackClientV1 diff --git a/sdk/python/src/dstack_sdk/ethereum.py b/sdk/python/src/dstack_sdk/ethereum.py index 9475f8370..6315f67c2 100644 --- a/sdk/python/src/dstack_sdk/ethereum.py +++ b/sdk/python/src/dstack_sdk/ethereum.py @@ -4,8 +4,10 @@ """Ethereum helpers for deriving accounts from dstack keys. -Use with ``dstack_sdk.DstackClient`` responses to create ``eth_account`` -objects for signing and transacting. +Use with ``dstack_sdk.DstackClientV0`` responses to create ``eth_account`` +objects for signing and transacting. These helpers take the v0 response models; +for a v1 key, hand ``GetKeyResponseV1.decode_key()`` to ``Account.from_key`` +yourself. """ import hashlib @@ -19,7 +21,7 @@ def to_account(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> LocalAccount: - """Create an Ethereum account from DstackClient key response. + """Create an Ethereum account from a DstackClientV0 key response. DEPRECATED: Use to_account_secure instead. This method has security concerns. Current implementation uses raw key material without proper hashing. diff --git a/sdk/python/src/dstack_sdk/solana.py b/sdk/python/src/dstack_sdk/solana.py index ba8c1b153..058bb3e83 100644 --- a/sdk/python/src/dstack_sdk/solana.py +++ b/sdk/python/src/dstack_sdk/solana.py @@ -4,8 +4,10 @@ """Solana helpers for deriving keypairs from dstack keys. -Use with ``dstack_sdk.DstackClient`` responses to create ``solders.Keypair`` -objects for signing transactions on Solana. +Use with ``dstack_sdk.DstackClientV0`` responses to create ``solders.Keypair`` +objects for signing transactions on Solana. These helpers take the v0 response +models; for a v1 key, hand ``GetKeyResponseV1.decode_key()`` to +``Keypair.from_seed`` yourself. """ import hashlib @@ -18,7 +20,7 @@ def to_keypair(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> Keypair: - """Create a Solana Keypair from DstackClient key response. + """Create a Solana Keypair from a DstackClientV0 key response. DEPRECATED: Use to_keypair_secure instead. This method has security concerns. Current implementation uses raw key material without proper hashing. diff --git a/sdk/python/src/dstack_sdk/verify.py b/sdk/python/src/dstack_sdk/verify.py deleted file mode 100644 index a278ab6aa..000000000 --- a/sdk/python/src/dstack_sdk/verify.py +++ /dev/null @@ -1,241 +0,0 @@ -# SPDX-FileCopyrightText: © 2026 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -"""Local signature and signature-chain verification. - -Verification needs no key material and no attestation, so it does not belong -behind an RPC to the guest agent: the agent's answer arrives over the socket -unattested, which is no better than a caller checking the signature itself. The -``Verify`` RPC these functions replace was removed in v0.6.0. - -Two levels are available: - -* :func:`verify_signature` checks one signature against a public key you - already have. It is the direct replacement for the old RPC and, on its own, - proves only that whoever holds that key signed the data. -* :func:`verify_signature_chain` walks the full chain from a ``SignResponse`` - back to a KMS root key **you supply**, which is what actually establishes - that the signer was a dstack app under that KMS. -""" - -from typing import Sequence - -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric import utils -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -from cryptography.hazmat.primitives.serialization import Encoding -from cryptography.hazmat.primitives.serialization import PublicFormat -from eth_keys import keys -from eth_utils import keccak - -__all__ = ["verify_signature", "verify_signature_chain", "SIGN_PATH", "SIGN_PURPOSE"] - -#: Domain-separation prefix the KMS signs app root keys under. -_KMS_ISSUED_PREFIX = b"dstack-kms-issued" -_SEPARATOR = b":" - -#: ``Sign`` derives its key at this path with this purpose; both are fixed agent-side. -SIGN_PATH = "vms" -SIGN_PURPOSE = "signing" - -#: Order of the secp256k1 group. Signatures with ``s`` above half of this are -#: the malleable "high-S" form. -_SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 -_SECP256K1_HALF_ORDER = _SECP256K1_ORDER // 2 - - -def _normalize_algorithm(algorithm: str) -> str: - """``k256`` and ``secp256k1`` name the same thing; the agent normalized these too.""" - return "secp256k1" if algorithm == "k256" else algorithm - - -def _parse_k256_signature(signature: bytes) -> tuple[int, int]: - """Split a raw 64-byte ``r || s`` signature, rejecting the high-S form. - - ECDSA is malleable: ``(r, n - s)`` verifies wherever ``(r, s)`` does. The - Rust SDK's k256 backend rejects the high-S form, so we must too -- otherwise - a signature stops being a unique identifier for a signed message, and this - SDK would disagree with every other dstack component about whether a given - blob is valid. ``cryptography`` accepts high-S happily, hence the explicit - check here. - """ - if len(signature) != 64: - raise ValueError( - f"secp256k1 signature must be 64 raw bytes (r || s), but received {len(signature)}" - ) - r = int.from_bytes(signature[:32], "big") - s = int.from_bytes(signature[32:], "big") - if r == 0 or r >= _SECP256K1_ORDER or s == 0 or s >= _SECP256K1_ORDER: - raise ValueError("invalid secp256k1 signature: r or s out of range") - if s > _SECP256K1_HALF_ORDER: - raise ValueError("non-canonical (high-S) secp256k1 signature") - return r, s - - -def _load_k256_public_key(public_key: bytes) -> ec.EllipticCurvePublicKey: - """Load a SEC1 secp256k1 key, compressed (33 bytes) or uncompressed (65).""" - try: - return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key) - except ValueError as exc: - raise ValueError(f"invalid secp256k1 public key: {exc}") from exc - - -def _compress(public_key: ec.EllipticCurvePublicKey) -> bytes: - return public_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint) - - -def verify_signature( - algorithm: str, - data: bytes, - signature: bytes, - public_key: bytes, -) -> bool: - """Verify one signature against ``public_key``. - - ``algorithm`` is ``ed25519``, ``secp256k1`` (alias ``k256``), or - ``secp256k1_prehashed``, where ``data`` is already a 32-byte digest. - - Returns ``False`` when the inputs are well-formed but the signature does not - check out, and raises when they are not well-formed at all (bad key - encoding, wrong signature length, unknown algorithm) -- a malformed input is - a caller bug, not a verdict. - """ - normalized = _normalize_algorithm(algorithm) - - if normalized == "ed25519": - if len(public_key) != 32: - raise ValueError( - f"ed25519 public key must be 32 bytes, but received {len(public_key)}" - ) - if len(signature) != 64: - raise ValueError( - f"ed25519 signature must be 64 bytes, but received {len(signature)}" - ) - try: - ed_key = Ed25519PublicKey.from_public_bytes(public_key) - except Exception as exc: # noqa: BLE001 - re-raised as a caller-facing error - raise ValueError(f"invalid ed25519 public key: {exc}") from exc - try: - ed_key.verify(signature, data) - return True - except InvalidSignature: - return False - - if normalized in ("secp256k1", "secp256k1_prehashed"): - prehashed = normalized == "secp256k1_prehashed" - if prehashed and len(data) != 32: - raise ValueError( - "pre-hashed verification requires a 32-byte digest, " - f"but received {len(data)} bytes" - ) - k256_key = _load_k256_public_key(public_key) - r, s = _parse_k256_signature(signature) - # k256's `sign` hashes with SHA-256, so verification must too. - algo = ( - ec.ECDSA(utils.Prehashed(hashes.SHA256())) - if prehashed - else ec.ECDSA(hashes.SHA256()) - ) - try: - k256_key.verify(utils.encode_dss_signature(r, s), data, algo) - return True - except InvalidSignature: - return False - - raise ValueError(f"unsupported algorithm: {algorithm}") - - -def _recover_compressed(message: bytes, signature: bytes) -> bytes: - """Recover the public key behind a recoverable signature, compressed. - - The signature is 65 bytes, ``r || s || recid``, over ``keccak256(message)``. - """ - if len(signature) != 65: - raise ValueError( - f"recoverable signature must be 65 bytes, but received {len(signature)}" - ) - # Rejects high-S and out-of-range r/s before we hand anything to eth_keys. - _parse_k256_signature(signature[:64]) - recid = signature[64] - if recid > 3: - raise ValueError(f"invalid recovery id {recid}") - if recid > 1: - # eth_keys only models v in {0, 1}; recid 2 and 3 mean r overflowed the - # curve order, which dstack signers never produce. Say so plainly rather - # than letting eth_keys fail with a validation error about `vrs`. - raise ValueError(f"unsupported recovery id {recid}: only 0 and 1 are supported") - try: - recovered = keys.Signature( - signature_bytes=signature - ).recover_public_key_from_msg_hash(keccak(message)) - except Exception as exc: # noqa: BLE001 - re-raised as a caller-facing error - raise ValueError(f"failed to recover public key: {exc}") from exc - return recovered.to_compressed_bytes() - - -def verify_signature_chain( - algorithm: str, - data: bytes, - public_key: bytes, - signature_chain: Sequence[bytes], - app_id: bytes, - kms_root_pubkey: bytes, - purpose: str = SIGN_PURPOSE, -) -> bytes: - """Verify a ``Sign`` signature chain end to end. - - Three links, all of which must hold: - - 1. ``signature_chain[0]`` is a signature over ``data`` by ``public_key``. - 2. ``signature_chain[1]`` is the app root key attesting - ``"{purpose}:{hex(public_key)}"``. - 3. ``signature_chain[2]`` is ``kms_root_pubkey`` attesting that app root key - for ``app_id``. - - Link 3 is the one that matters. Without comparing against a KMS root key you - independently trust, a chain is just three signatures an attacker could have - produced with their own keys. Get that key from the ``DstackKms`` contract - (``kmsInfo().k256Pubkey``) or pin it; reading it from the KMS you are - verifying against proves nothing. - - ``app_id`` must likewise be the app id you *expect*, not merely whatever - ``AppInfo`` echoed back -- that comes from the CVM being checked. Comparing a - chain against an app id the same CVM supplied proves only that it is - self-consistent. - - Returns the app root public key (compressed SEC1, 33 bytes) on success, and - raises on any failure. - """ - if len(signature_chain) != 3: - raise ValueError( - f"signature chain must have 3 elements, but received {len(signature_chain)}" - ) - if len(app_id) != 20: - raise ValueError(f"app_id must be 20 bytes, but received {len(app_id)}") - - # Link 1: the payload signature. chain[0] *is* that signature; what matters - # is that it checks out under `public_key`, which links 2 and 3 then cover. - if not verify_signature(algorithm, data, signature_chain[0], public_key): - raise ValueError("payload signature is not valid for the given public key") - - # Link 2: recover the app root key that vouched for the signing key. - message = f"{purpose}:{public_key.hex()}".encode() - app_root_pubkey = _recover_compressed(message, signature_chain[1]) - - # Link 3: recover the KMS root key that vouched for the app root key, and - # check it is the one we were told to trust. - kms_message = _KMS_ISSUED_PREFIX + _SEPARATOR + app_id + app_root_pubkey - recovered_kms = _recover_compressed(kms_message, signature_chain[2]) - - # Normalize the expected key so callers may pass either SEC1 encoding. - try: - expected_kms = _compress(_load_k256_public_key(kms_root_pubkey)) - except ValueError as exc: - raise ValueError(f"invalid KMS root public key: {exc}") from exc - if recovered_kms != expected_kms: - raise ValueError("signature chain is not anchored at the expected KMS root key") - - return app_root_pubkey diff --git a/sdk/python/test_outputs.py b/sdk/python/test_outputs.py index 169b291e9..0cfaf4c20 100644 --- a/sdk/python/test_outputs.py +++ b/sdk/python/test_outputs.py @@ -7,9 +7,9 @@ import asyncio import sys -from dstack_sdk import AsyncDstackClient +from dstack_sdk import AsyncDstackClientV0 from dstack_sdk import AsyncTappdClient -from dstack_sdk import DstackClient +from dstack_sdk import DstackClientV0 from dstack_sdk import TappdClient from dstack_sdk import get_compose_hash from dstack_sdk import verify_env_encrypt_public_key @@ -20,8 +20,8 @@ async def main(): # noqa: D103 try: # Test client get_key - client = DstackClient() - print("\n1. Testing DstackClient.get_key()") + client = DstackClientV0() + print("\n1. Testing DstackClientV0.get_key()") test_paths = [ {"path": "test/wallet", "purpose": "ethereum"}, @@ -141,13 +141,13 @@ async def main(): # noqa: D103 # Test quotes print("\n5. Testing Quote Methods") - print("\n5.1 DstackClient.get_quote():") + print("\n5.1 DstackClientV0.get_quote():") dstack_quote = client.get_quote("test-data-for-quote") print(f" quote length: {len(dstack_quote.quote)}") print(f" event_log length: {len(dstack_quote.event_log)}") - print("\n5.2 AsyncDstackClient.get_quote():") - async_client = AsyncDstackClient() + print("\n5.2 AsyncDstackClientV0.get_quote():") + async_client = AsyncDstackClientV0() async_dstack_quote = await async_client.get_quote("test-data-for-quote") print(f" quote length: {len(async_dstack_quote.quote)}") print(f" event_log length: {len(async_dstack_quote.event_log)}") diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 5fc7805e6..18f355841 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -9,25 +9,23 @@ from evidence_api.tdx.quote import TdxQuote import pytest -from dstack_sdk import AsyncDstackClient +from dstack_sdk import AsyncDstackClientV0 from dstack_sdk import AsyncTappdClient -from dstack_sdk import AttestGpuResponse from dstack_sdk import AttestResponse -from dstack_sdk import DstackClient +from dstack_sdk import DstackClientV0 from dstack_sdk import GetKeyResponse from dstack_sdk import GetQuoteResponse from dstack_sdk import GetTlsKeyResponse -from dstack_sdk import GpuInfoResponse from dstack_sdk import SignResponse from dstack_sdk import TappdClient +from dstack_sdk import VerifyResponse from dstack_sdk import VersionResponse -from dstack_sdk import verify_signature from dstack_sdk.dstack_client import InfoResponse from dstack_sdk.dstack_client import TcbInfo def test_sync_client_get_key(): - client = DstackClient() + client = DstackClientV0() result = client.get_key() # Test default algorithm (secp256k1) assert isinstance(result, GetKeyResponse) assert isinstance(result.decode_key(), bytes) @@ -43,20 +41,20 @@ def test_sync_client_get_key(): def test_sync_client_get_quote(): - client = DstackClient() + client = DstackClientV0() result = client.get_quote("test") assert isinstance(result, GetQuoteResponse) def test_sync_client_attest(): - client = DstackClient() + client = DstackClientV0() result = client.attest("test") assert isinstance(result, AttestResponse) assert len(result.attestation) > 0 def test_sync_client_get_tls_key(): - client = DstackClient() + client = DstackClientV0() result = client.get_tls_key() assert isinstance(result, GetTlsKeyResponse) assert isinstance(result.key, str) @@ -65,7 +63,7 @@ def test_sync_client_get_tls_key(): def test_sync_client_get_info(): - client = DstackClient() + client = DstackClientV0() result = client.info() check_info_response(result) @@ -92,7 +90,7 @@ def check_info_response(result: InfoResponse): @pytest.mark.asyncio async def test_async_client_get_key(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_key() # Test default algorithm (secp256k1) assert isinstance(result, GetKeyResponse) assert isinstance(result.decode_key(), bytes) @@ -109,87 +107,59 @@ async def test_async_client_get_key(): @pytest.mark.asyncio async def test_async_client_get_quote(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_quote("test") assert isinstance(result, GetQuoteResponse) @pytest.mark.asyncio async def test_async_client_attest(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.attest("test") assert isinstance(result, AttestResponse) assert len(result.attestation) > 0 -@pytest.mark.asyncio -async def test_async_client_attest_boottime_gpu_evidence(monkeypatch): - evidence = '{"result_code":0,"claims":[]}' - - async def fake_send(self, method, payload): - assert method == "Attest" - assert payload["include_boottime_gpu_evidence"] is True - return {"attestation": "deadbeef", "boottime_gpu_evidence": evidence} - - monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - result = await AsyncDstackClient().attest( - "test", include_boottime_gpu_evidence=True - ) - assert isinstance(result, AttestResponse) - assert result.boottime_gpu_evidence == evidence +def test_v0_surface_has_no_gpu_or_v1_methods(): + """The frozen surface never gained the GPU methods; the agent 404s them.""" + for name in ["attest_gpu", "gpu_info", "issue_cert"]: + assert not hasattr(DstackClientV0, name) + assert not hasattr(AsyncDstackClientV0, name) -@pytest.mark.asyncio -async def test_async_client_attest_gpu(monkeypatch): - evidence = '[{"arch":"HOPPER","evidence":"BASE64","certificate":"BASE64"}]' - nonce = bytes([0xAB]) * 32 +def test_sync_client_attest_takes_report_data_only(): + """The frozen Attest has one field; a GPU flag belongs to v1.""" + client = DstackClientV0() + with pytest.raises(TypeError): + client.attest("test", include_boottime_gpu_evidence=True) - async def fake_send(self, method, payload): - assert method == "AttestGpu" - assert payload == {"nonce": nonce.hex()} - return { - "bundles": [ - {"vendor": "nvidia", "format": "nvidia-test-v1", "evidence": evidence} - ] - } - monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - result = await AsyncDstackClient().attest_gpu(nonce) - assert isinstance(result, AttestGpuResponse) - assert len(result.bundles) == 1 - assert result.bundles[0].vendor == "nvidia" - assert result.bundles[0].evidence == evidence +def test_sync_client_emit_event_reports_its_removal(): + """The agent always fails EmitEvent now; surface its message, do not swallow it.""" + client = DstackClientV0() + with pytest.raises(Exception) as excinfo: + client.emit_event("test-event", b"payload") + assert "EmitEvent was removed" in str(excinfo.value) @pytest.mark.asyncio -async def test_async_client_attest_gpu_rejects_wrong_nonce_length(): - client = AsyncDstackClient() - for bad in [b"", bytes(31), bytes(33), "not-bytes"]: - with pytest.raises(ValueError): - await client.attest_gpu(bad) +async def test_async_client_emit_event_reports_its_removal(): + client = AsyncDstackClientV0() + with pytest.raises(Exception) as excinfo: + await client.emit_event("test-event", b"payload") + assert "EmitEvent was removed" in str(excinfo.value) @pytest.mark.asyncio -async def test_async_client_gpu_info(monkeypatch): - attestation = '{"result_code":0,"claims":[]}' - - async def fake_send(self, method, payload): - assert method == "GpuInfo" - assert payload == {} - return {"attestation": attestation} - - monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - result = await AsyncDstackClient().gpu_info() - assert isinstance(result, GpuInfoResponse) - assert result.attestation == attestation +async def test_async_client_emit_event_rejects_empty_name(): + client = AsyncDstackClientV0() + with pytest.raises(ValueError): + await client.emit_event("", b"payload") @pytest.mark.asyncio async def test_async_client_get_tls_key(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_tls_key() assert isinstance(result, GetTlsKeyResponse) assert isinstance(result.key, str) @@ -199,7 +169,7 @@ async def test_async_client_get_tls_key(): @pytest.mark.asyncio async def test_async_client_get_info(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.info() check_info_response(result) @@ -207,7 +177,7 @@ async def test_async_client_get_info(): @pytest.mark.asyncio async def test_tls_key_uniqueness(): """Test that TLS keys are unique across multiple calls.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() result1 = await client.get_tls_key() result2 = await client.get_tls_key() # TLS keys should be unique for each call @@ -217,11 +187,11 @@ async def test_tls_key_uniqueness(): @pytest.mark.asyncio async def test_get_quote_raw_hash_error(): with pytest.raises(ValueError) as excinfo: - client = AsyncDstackClient() + client = AsyncDstackClientV0() await client.get_quote("0" * 65) assert "64 bytes" in str(excinfo.value) with pytest.raises(ValueError) as excinfo: - client = AsyncDstackClient() + client = AsyncDstackClientV0() await client.get_quote(b"0" * 129) assert "64 bytes" in str(excinfo.value) @@ -229,7 +199,7 @@ async def test_get_quote_raw_hash_error(): @pytest.mark.asyncio async def test_report_data(): reportdata = "test" - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_quote(reportdata) tdxQuote = TdxQuote(bytearray(result.decode_quote())) reportdata = reportdata.encode("utf-8") + b"\x00" * (64 - len(reportdata)) @@ -238,7 +208,7 @@ async def test_report_data(): def test_sync_client_is_reachable(): """Test that sync client can check if service is reachable.""" - client = DstackClient() + client = DstackClientV0() is_reachable = client.is_reachable() assert isinstance(is_reachable, bool) assert is_reachable @@ -247,7 +217,7 @@ def test_sync_client_is_reachable(): @pytest.mark.asyncio async def test_async_client_is_reachable(): """Test that async client can check if service is reachable.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() is_reachable = await client.is_reachable() assert isinstance(is_reachable, bool) assert is_reachable @@ -255,7 +225,7 @@ async def test_async_client_is_reachable(): def test_tls_key_as_uint8array(): """Test that TLS key can be converted to bytes with as_uint8array method.""" - client = DstackClient() + client = DstackClientV0() result = client.get_tls_key() # Test full length @@ -272,7 +242,7 @@ def test_tls_key_as_uint8array(): def test_tls_key_with_alt_names(): """Test TLS key generation with alt names.""" - client = DstackClient() + client = DstackClientV0() alt_names = ["localhost", "127.0.0.1"] result = client.get_tls_key( subject="test-subject", @@ -295,7 +265,7 @@ def test_unix_socket_file_not_exist(): try: with pytest.raises(FileNotFoundError) as exc_info: - DstackClient("/non/existent/socket") + DstackClientV0("/non/existent/socket") assert "Unix socket file /non/existent/socket does not exist" in str( exc_info.value ) @@ -313,8 +283,8 @@ def test_non_unix_socket_endpoints(): try: # These should not raise errors - client1 = DstackClient("http://localhost:8080") - client2 = DstackClient("https://example.com") + client1 = DstackClientV0("http://localhost:8080") + client2 = DstackClientV0("https://example.com") assert client1 is not None assert client2 is not None finally: @@ -327,8 +297,8 @@ def test_non_unix_socket_endpoints(): SIGN_BAD_DATA = b"This is not the original message" -def test_sync_sign_then_verify_locally_ed25519(): - client = DstackClient() +def test_sync_sign_then_verify_ed25519(): + client = DstackClientV0() algo = "ed25519" sign_resp = client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) @@ -338,12 +308,14 @@ def test_sync_sign_then_verify_locally_ed25519(): signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True - assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False + good = client.verify(algo, SIGN_TEST_DATA, signature, public_key) + assert isinstance(good, VerifyResponse) + assert good.valid is True + assert client.verify(algo, SIGN_BAD_DATA, signature, public_key).valid is False -def test_sync_sign_then_verify_locally_secp256k1(): - client = DstackClient() +def test_sync_sign_then_verify_secp256k1(): + client = DstackClientV0() algo = "secp256k1" sign_resp = client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) @@ -351,12 +323,12 @@ def test_sync_sign_then_verify_locally_secp256k1(): signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True - assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False + assert client.verify(algo, SIGN_TEST_DATA, signature, public_key).valid is True + assert client.verify(algo, SIGN_BAD_DATA, signature, public_key).valid is False -def test_sync_sign_then_verify_locally_secp256k1_prehashed(): - client = DstackClient() +def test_sync_sign_then_verify_secp256k1_prehashed(): + client = DstackClientV0() algo = "secp256k1_prehashed" digest = hashlib.sha256(SIGN_TEST_DATA).digest() assert len(digest) == 32 @@ -367,14 +339,14 @@ def test_sync_sign_then_verify_locally_secp256k1_prehashed(): signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, digest, signature, public_key) is True + assert client.verify(algo, digest, signature, public_key).valid is True bad_digest = hashlib.sha256(SIGN_BAD_DATA).digest() - assert verify_signature(algo, bad_digest, signature, public_key) is False + assert client.verify(algo, bad_digest, signature, public_key).valid is False def test_sync_sign_prehashed_length_error(): - client = DstackClient() + client = DstackClientV0() algo = "secp256k1_prehashed" with pytest.raises(ValueError) as excinfo: client.sign(algo, b"too short") @@ -382,8 +354,8 @@ def test_sync_sign_prehashed_length_error(): @pytest.mark.asyncio -async def test_async_sign_then_verify_locally_ed25519(): - client = AsyncDstackClient() +async def test_async_sign_then_verify_ed25519(): + client = AsyncDstackClientV0() algo = "ed25519" sign_resp = await client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) @@ -392,26 +364,30 @@ async def test_async_sign_then_verify_locally_ed25519(): signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True - assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False + good = await client.verify(algo, SIGN_TEST_DATA, signature, public_key) + assert good.valid is True + bad = await client.verify(algo, SIGN_BAD_DATA, signature, public_key) + assert bad.valid is False @pytest.mark.asyncio -async def test_async_sign_then_verify_locally_secp256k1(): - client = AsyncDstackClient() +async def test_async_sign_then_verify_secp256k1(): + client = AsyncDstackClientV0() algo = "secp256k1" sign_resp = await client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True - assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False + good = await client.verify(algo, SIGN_TEST_DATA, signature, public_key) + assert good.valid is True + bad = await client.verify(algo, SIGN_BAD_DATA, signature, public_key) + assert bad.valid is False @pytest.mark.asyncio -async def test_async_sign_then_verify_locally_secp256k1_prehashed(): - client = AsyncDstackClient() +async def test_async_sign_then_verify_secp256k1_prehashed(): + client = AsyncDstackClientV0() algo = "secp256k1_prehashed" digest = hashlib.sha256(SIGN_TEST_DATA).digest() @@ -420,15 +396,17 @@ async def test_async_sign_then_verify_locally_secp256k1_prehashed(): signature = sign_resp.decode_signature() public_key = sign_resp.decode_public_key() - assert verify_signature(algo, digest, signature, public_key) is True + good = await client.verify(algo, digest, signature, public_key) + assert good.valid is True bad_digest = hashlib.sha256(SIGN_BAD_DATA).digest() - assert verify_signature(algo, bad_digest, signature, public_key) is False + bad = await client.verify(algo, bad_digest, signature, public_key) + assert bad.valid is False @pytest.mark.asyncio async def test_async_sign_prehashed_length_error(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() algo = "secp256k1_prehashed" with pytest.raises(ValueError) as excinfo: await client.sign(algo, b"too short") @@ -528,7 +506,7 @@ async def test_async_tappd_client_tdx_quote_deprecated(): def test_sync_client_version(): - client = DstackClient() + client = DstackClientV0() result = client.version() assert isinstance(result, VersionResponse) assert result.version != "" @@ -536,14 +514,14 @@ def test_sync_client_version(): @pytest.mark.asyncio async def test_async_client_version(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.version() assert isinstance(result, VersionResponse) assert result.version != "" def test_sync_client_get_key_k256_alias(): - client = DstackClient() + client = DstackClientV0() result_k256 = client.get_key(path="/test", purpose="p", algorithm="k256") result_secp = client.get_key(path="/test", purpose="p", algorithm="secp256k1") # k256 is an alias for secp256k1, should produce the same key @@ -552,21 +530,21 @@ def test_sync_client_get_key_k256_alias(): @pytest.mark.asyncio async def test_async_client_get_key_k256_alias(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result_k256 = await client.get_key(path="/test", purpose="p", algorithm="k256") result_secp = await client.get_key(path="/test", purpose="p", algorithm="secp256k1") assert result_k256.decode_key() == result_secp.decode_key() def test_sync_client_get_key_secp256k1_prehashed_rejected(): - client = DstackClient() + client = DstackClientV0() with pytest.raises(Exception): client.get_key(algorithm="secp256k1_prehashed") @pytest.mark.asyncio async def test_async_client_get_key_secp256k1_prehashed_rejected(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() with pytest.raises(Exception): await client.get_key(algorithm="secp256k1_prehashed") @@ -584,7 +562,7 @@ async def test_async_tappd_client_is_reachable(): @pytest.mark.asyncio async def test_sync_client_in_async_context_get_key(): """Test that sync client works when called from async context.""" - client = DstackClient() + client = DstackClientV0() result = client.get_key() assert isinstance(result, GetKeyResponse) assert isinstance(result.decode_key(), bytes) @@ -594,7 +572,7 @@ async def test_sync_client_in_async_context_get_key(): @pytest.mark.asyncio async def test_sync_client_in_async_context_get_info(): """Test that sync client info works when called from async context.""" - client = DstackClient() + client = DstackClientV0() result = client.info() check_info_response(result) @@ -602,8 +580,8 @@ async def test_sync_client_in_async_context_get_info(): @pytest.mark.asyncio async def test_mixed_sync_async_calls(): """Test mixing sync and async client calls in the same async context.""" - sync_client = DstackClient() - async_client = AsyncDstackClient() + sync_client = DstackClientV0() + async_client = AsyncDstackClientV0() # Call sync client from async context sync_result = sync_client.get_key() @@ -630,8 +608,8 @@ async def fake_send(self, method, payload): return {"key": "k", "certificate_chain": []} monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - client = AsyncDstackClient() + monkeypatch.setattr(AsyncDstackClientV0, "_send_rpc_request", fake_send) + client = AsyncDstackClientV0() result = await client.get_tls_key( subject="api.example.com", not_before=1_700_000_000, @@ -656,8 +634,8 @@ async def fake_send(self, method, payload): return {"key": "k", "certificate_chain": []} monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - client = AsyncDstackClient() + monkeypatch.setattr(AsyncDstackClientV0, "_send_rpc_request", fake_send) + client = AsyncDstackClientV0() await client.get_tls_key(subject="api.example.com") assert [c[0] for c in calls] == ["GetTlsKey"] payload = calls[0][1] @@ -676,7 +654,7 @@ async def fake_send(self, method, payload): return {"key": "k", "certificate_chain": []} monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") - monkeypatch.setattr(AsyncDstackClient, "_send_rpc_request", fake_send) - client = AsyncDstackClient() + monkeypatch.setattr(AsyncDstackClientV0, "_send_rpc_request", fake_send) + client = AsyncDstackClientV0() with pytest.raises(RuntimeError, match="TLS key options"): await client.get_tls_key(with_app_info=False) diff --git a/sdk/python/tests/test_client_v1.py b/sdk/python/tests/test_client_v1.py new file mode 100644 index 000000000..cc0d051b0 --- /dev/null +++ b/sdk/python/tests/test_client_v1.py @@ -0,0 +1,331 @@ +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +from typing import List + +import pytest + +from dstack_sdk import AsyncDstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import AsyncDstackClientV1 +from dstack_sdk import AttestGpuResponseV1 +from dstack_sdk import AttestResponseV1 +from dstack_sdk import DstackClient +from dstack_sdk import DstackClientV0 +from dstack_sdk import DstackClientV1 +from dstack_sdk import GetKeyResponseV1 +from dstack_sdk import GpuEvidenceBundleV1 +from dstack_sdk import InfoResponseV1 +from dstack_sdk import IssueCertResponseV1 +from dstack_sdk import VersionResponseV1 + +NONCE = bytes([0xAB]) * 32 + + +def test_v1_posts_to_the_v1_path(): + """The agent picks the version from the URL path alone.""" + assert AsyncDstackClientV1.PATH_PREFIX == "/v1/" + assert AsyncDstackClientV0.PATH_PREFIX == "/" + + +def test_unsuffixed_names_are_the_v1_clients(): + """Unsuffixed means v1: it is the default, and the V1 names are the same class.""" + assert DstackClient is DstackClientV1 + assert AsyncDstackClient is AsyncDstackClientV1 + + +def test_v0_call_shapes_fail_loudly_on_the_default_client(): + """Pre-0.6 code aimed at the unsuffixed name breaks, rather than deriving other keys. + + Both v0 spellings of get_key are rejected before a request goes out, so an + upgraded caller sees a TypeError instead of a key it did not ask for. + """ + client = DstackClient() + with pytest.raises(TypeError): + client.get_key(path="storage-encryption", purpose="mainnet") + with pytest.raises(TypeError): + client.get_key("storage-encryption") + for name in ["sign", "verify", "emit_event", "get_quote", "get_tls_key"]: + assert not hasattr(client, name) + + +def test_v1_surface_is_exactly_six_methods(): + """v1 serves only what needs the TEE: no sign, verify, emit_event, quote or GPU info.""" + for name in [ + "sign", + "verify", + "emit_event", + "get_quote", + "gpu_info", + "get_tls_key", + ]: + assert not hasattr(DstackClientV1, name) + assert not hasattr(AsyncDstackClientV1, name) + for name in [ + "issue_cert", + "get_key", + "attest", + "attest_gpu", + "info", + "version", + ]: + assert hasattr(DstackClientV1, name) + assert hasattr(AsyncDstackClientV1, name) + + +def test_sync_v1_version(): + result = DstackClientV1().version() + assert isinstance(result, VersionResponseV1) + assert result.version != "" + + +@pytest.mark.asyncio +async def test_async_v1_version(): + result = await AsyncDstackClientV1().version() + assert isinstance(result, VersionResponseV1) + assert result.version != "" + + +def test_sync_v1_info(): + result = DstackClientV1().info() + check_info_response(result) + + +@pytest.mark.asyncio +async def test_async_v1_info(): + result = await AsyncDstackClientV1().info() + check_info_response(result) + + +def check_info_response(result: InfoResponseV1): + assert isinstance(result, InfoResponseV1) + assert len(result.app_id) == 40 + assert len(result.compose_hash) == 64 + assert len(result.instance_id) == 40 + assert len(result.device_id) == 64 + assert len(result.os_image_hash) in (0, 64) + assert len(result.mr_aggregated) == 64 + assert len(result.app_compose) > 0 + # The measurement registers and the event log belong to attest(), which + # returns them quote-backed. They must not reappear here. + assert not hasattr(result, "tcb_info") + assert not hasattr(result, "app_cert") + + +def test_sync_v1_get_key(): + client = DstackClientV1() + result = client.get_key("storage-encryption", "secp256k1") + assert isinstance(result, GetKeyResponseV1) + assert len(result.decode_key()) == 32 + # secp256k1 public keys are SEC1 compressed, and the chain's first link + # commits to exactly these bytes. + assert len(result.decode_public_key()) == 33 + assert len(result.decode_signature_chain()) == 2 + + ed = client.get_key("storage-encryption", "ed25519") + assert len(ed.decode_key()) == 32 + assert len(ed.decode_public_key()) == 32 + # The v1 KDF binds the algorithm, so one name no longer serves two curves. + assert ed.decode_key() != result.decode_key() + + +@pytest.mark.asyncio +async def test_async_v1_get_key_is_deterministic_per_domain(): + client = AsyncDstackClientV1() + first = await client.get_key("a", "secp256k1") + again = await client.get_key("a", "secp256k1") + other = await client.get_key("a/b", "secp256k1") + assert first.key == again.key + # Derivation is flat: `a/b` is not a child of `a`. + assert other.key != first.key + + +def test_v1_get_key_requires_an_algorithm(): + client = DstackClientV1() + with pytest.raises(ValueError, match="algorithm is required"): + client.get_key("storage-encryption", "") + + +@pytest.mark.asyncio +async def test_async_v1_get_key_rejects_the_v0_k256_alias(): + """v0 accepted `k256`; v1 refuses rather than guess what the caller meant.""" + client = AsyncDstackClientV1() + with pytest.raises(Exception) as excinfo: + await client.get_key("storage-encryption", "k256") + assert "k256" in str(excinfo.value) + + +def test_v1_keys_differ_from_v0_keys(): + """No compatibility mode: the same name yields different key material.""" + v0 = DstackClientV0().get_key("storage-encryption", "") + v1 = DstackClientV1().get_key("storage-encryption", "secp256k1") + assert v1.decode_key() != v0.decode_key() + + +def test_sync_v1_attest(): + result = DstackClientV1().attest(b"user:alice:nonce123") + assert isinstance(result, AttestResponseV1) + assert len(result.decode_attestation()) > 0 + + +@pytest.mark.asyncio +async def test_async_v1_attest_boottime_gpu_evidence(monkeypatch): + """Boot-time evidence arrives in the same bundle shape attest_gpu returns.""" + evidence = b'{"result_code":0,"claims":[]}' + + async def fake_send(self, method, payload): + assert method == "Attest" + assert payload["include_boottime_gpu_evidence"] is True + return { + "attestation": "deadbeef", + "boottime_gpu_evidence": [ + { + "vendor": "nvidia", + "format": "nvidia-nvattest-boottime-json-v1", + "evidence": evidence.hex(), + } + ], + } + + monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") + monkeypatch.setattr(AsyncDstackClientV1, "_send_rpc_request", fake_send) + result = await AsyncDstackClientV1().attest( + "test", include_boottime_gpu_evidence=True + ) + assert isinstance(result, AttestResponseV1) + bundle = result.boottime_gpu_evidence[0] + assert isinstance(bundle, GpuEvidenceBundleV1) + assert bundle.format == "nvidia-nvattest-boottime-json-v1" + # sha256 of exactly these bytes is what the `gpu-attestation` event commits + # to, so the decode must be byte-for-byte, not a re-serialized parse. + assert bundle.decode_evidence() == evidence + + +def test_sync_v1_attest_boottime_gpu_evidence_defaults_to_empty(): + """No GPU output in the simulator: absence is an empty list, not a sentinel.""" + result = DstackClientV1().attest( + b"user:alice:nonce123", include_boottime_gpu_evidence=True + ) + assert result.boottime_gpu_evidence == [] + # Same model as attest_gpu's bundles, so one parser serves both methods. + bundles = List[GpuEvidenceBundleV1] + assert AttestResponseV1.model_fields["boottime_gpu_evidence"].annotation == bundles + assert AttestGpuResponseV1.model_fields["bundles"].annotation == bundles + + +@pytest.mark.asyncio +async def test_async_v1_attest_report_data_bounds(): + client = AsyncDstackClientV1() + with pytest.raises(ValueError): + await client.attest(b"") + with pytest.raises(ValueError, match="64 bytes"): + await client.attest(b"0" * 65) + # 64 bytes is the maximum, not one past it. + assert len((await client.attest(b"0" * 64)).decode_attestation()) > 0 + + +@pytest.mark.asyncio +async def test_async_v1_attest_gpu(monkeypatch): + evidence = b"\x01\x02\x03opaque" + + async def fake_send(self, method, payload): + assert method == "AttestGpu" + assert payload == {"nonce": NONCE.hex()} + return { + "bundles": [ + { + "vendor": "nvidia", + "format": "nvidia-test-v1", + "evidence": evidence.hex(), + } + ] + } + + monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") + monkeypatch.setattr(AsyncDstackClientV1, "_send_rpc_request", fake_send) + result = await AsyncDstackClientV1().attest_gpu(NONCE) + assert isinstance(result, AttestGpuResponseV1) + assert len(result.bundles) == 1 + assert result.bundles[0].vendor == "nvidia" + assert result.bundles[0].decode_evidence() == evidence + + +@pytest.mark.asyncio +async def test_async_v1_attest_gpu_rejects_wrong_nonce_length(): + """SPDM fixes the evidence nonce at 32 bytes; catch it before the round trip.""" + client = AsyncDstackClientV1() + for bad in [b"", bytes(31), bytes(33), "not-bytes"]: + with pytest.raises(ValueError, match="32 bytes"): + await client.attest_gpu(bad) + + +def test_sync_v1_attest_gpu_reaches_the_agent(): + """No GPU in the simulator, so the agent's own refusal is the success signal.""" + with pytest.raises(Exception) as excinfo: + DstackClientV1().attest_gpu(NONCE) + assert "GPU attestation" in str(excinfo.value) + + +def test_sync_v1_issue_cert(): + result = DstackClientV1().issue_cert(subject="api.example.com") + assert isinstance(result, IssueCertResponseV1) + assert result.key.startswith("-----BEGIN PRIVATE KEY-----") + assert len(result.certificate_chain) > 0 + + +@pytest.mark.asyncio +async def test_async_v1_issue_cert_key_is_fresh_per_call(): + client = AsyncDstackClientV1() + first = await client.issue_cert(subject="api.example.com") + again = await client.issue_cert(subject="api.example.com") + assert first.key != again.key + + +@pytest.mark.asyncio +async def test_async_v1_issue_cert_payload(monkeypatch): + calls: list = [] + + async def fake_send(self, method, payload): + calls.append((method, payload)) + return {"key": "k", "certificate_chain": []} + + monkeypatch.setenv("DSTACK_SIMULATOR_ENDPOINT", "http://localhost:0") + monkeypatch.setattr(AsyncDstackClientV1, "_send_rpc_request", fake_send) + await AsyncDstackClientV1().issue_cert( + subject="api.example.com", + alt_names=["localhost"], + usage_ra_tls=True, + with_app_info=True, + not_before=1_700_000_000, + not_after=1_800_000_000, + ) + method, payload = calls[0] + # v1 requires a 0.6 agent, so unlike v0 it never probes Version first. + assert [c[0] for c in calls] == ["IssueCert"] + assert method == "IssueCert" + assert payload["subject"] == "api.example.com" + assert payload["alt_names"] == ["localhost"] + assert payload["usage_ra_tls"] is True + assert payload["with_app_info"] is True + assert payload["not_before"] == 1_700_000_000 + assert payload["not_after"] == 1_800_000_000 + + +@pytest.mark.asyncio +async def test_async_v1_client_context_manager(): + async with AsyncDstackClientV1() as client: + assert (await client.version()).version != "" + + +def test_sync_v1_client_context_manager(): + client = DstackClientV1() + with client: + assert client.version().version != "" + assert client.async_client._sync_client is None + + +def test_v1_unix_socket_file_not_exist(monkeypatch): + monkeypatch.delenv("DSTACK_SIMULATOR_ENDPOINT", raising=False) + with pytest.raises(FileNotFoundError): + DstackClientV1("/non/existent/socket") diff --git a/sdk/python/tests/test_connection_reuse.py b/sdk/python/tests/test_connection_reuse.py index 73799828a..9fbb3f88c 100644 --- a/sdk/python/tests/test_connection_reuse.py +++ b/sdk/python/tests/test_connection_reuse.py @@ -6,8 +6,8 @@ import pytest -from dstack_sdk import AsyncDstackClient -from dstack_sdk import DstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import DstackClientV0 class TestConnectionReuse: @@ -16,7 +16,7 @@ class TestConnectionReuse: @pytest.mark.asyncio async def test_async_context_manager_reuses_client(self): """Test that async context manager creates and reuses a single client.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() # Verify client is None initially assert client._client is None @@ -48,7 +48,7 @@ async def test_async_context_manager_reuses_client(self): def test_sync_context_manager_reuses_client(self): """Test that sync context manager creates and reuses a single client.""" - client = DstackClient() + client = DstackClientV0() # Verify sync client is None initially assert client.async_client._sync_client is None @@ -83,7 +83,7 @@ def test_sync_context_manager_reuses_client(self): @pytest.mark.asyncio async def test_async_without_context_manager_reuses_client(self): """Test that without context manager, clients are still reused.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() with unittest.mock.patch("httpx.AsyncClient") as mock_async_client_class: # Mock the context manager behavior @@ -105,7 +105,7 @@ async def test_async_without_context_manager_reuses_client(self): def test_sync_without_context_manager_reuses_client(self): """Test that without context manager, clients are still reused.""" - client = DstackClient() + client = DstackClientV0() with unittest.mock.patch("httpx.Client") as mock_client_class: # Mock the context manager behavior @@ -126,7 +126,7 @@ def test_sync_without_context_manager_reuses_client(self): @pytest.mark.asyncio async def test_async_context_manager_with_real_requests(self): """Test async context manager with real requests to ensure connection reuse.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() async with client: # Make multiple requests - these should reuse the same connection @@ -146,7 +146,7 @@ async def test_async_context_manager_with_real_requests(self): def test_sync_context_manager_with_real_requests(self): """Test sync context manager with real requests to ensure connection reuse.""" - client = DstackClient() + client = DstackClientV0() with client: # Make multiple requests - these should reuse the same connection @@ -167,7 +167,7 @@ def test_sync_context_manager_with_real_requests(self): @pytest.mark.asyncio async def test_async_nested_context_managers(self): """Test that nested async context managers work correctly.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() async with client: first_client = client._client @@ -185,7 +185,7 @@ async def test_async_nested_context_managers(self): def test_sync_nested_context_managers(self): """Test that nested sync context managers work correctly.""" - client = DstackClient() + client = DstackClientV0() with client: first_client = client.async_client._sync_client diff --git a/sdk/python/tests/test_mypy_check.py b/sdk/python/tests/test_mypy_check.py index 4067bf424..806b40fd4 100644 --- a/sdk/python/tests/test_mypy_check.py +++ b/sdk/python/tests/test_mypy_check.py @@ -15,8 +15,8 @@ import pytest -from dstack_sdk import AsyncDstackClient -from dstack_sdk import DstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import DstackClientV0 def test_sync_client_types(): @@ -36,7 +36,7 @@ def test_sync_client_types(): } mock_post.return_value = mock_response - client = DstackClient(endpoint) + client = DstackClientV0(endpoint) # Test get_tls_key - this should be GetTlsKeyResponse, not Coroutine tls_result = client.get_tls_key() @@ -78,7 +78,7 @@ async def test_async_client_types(): } mock_post.return_value = mock_response - client = AsyncDstackClient(endpoint) + client = AsyncDstackClientV0(endpoint) # Test get_tls_key - this should be GetTlsKeyResponse tls_result = await client.get_tls_key() diff --git a/sdk/python/tests/test_solana.py b/sdk/python/tests/test_solana.py index b263184fd..27cb9bf30 100644 --- a/sdk/python/tests/test_solana.py +++ b/sdk/python/tests/test_solana.py @@ -7,8 +7,8 @@ import pytest from solders.keypair import Keypair -from dstack_sdk import AsyncDstackClient -from dstack_sdk import DstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import DstackClientV0 from dstack_sdk import GetKeyResponse from dstack_sdk.solana import to_keypair from dstack_sdk.solana import to_keypair_secure @@ -16,7 +16,7 @@ @pytest.mark.asyncio async def test_async_to_keypair(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_key("test") assert isinstance(result, GetKeyResponse) keypair = to_keypair(result) @@ -24,7 +24,7 @@ async def test_async_to_keypair(): def test_sync_to_keypair(): - client = DstackClient() + client = DstackClientV0() result = client.get_key("test") assert isinstance(result, GetKeyResponse) keypair = to_keypair(result) @@ -33,7 +33,7 @@ def test_sync_to_keypair(): @pytest.mark.asyncio async def test_async_to_keypair_secure(): - client = AsyncDstackClient() + client = AsyncDstackClientV0() result = await client.get_key("test") assert isinstance(result, GetKeyResponse) keypair = to_keypair_secure(result) @@ -41,7 +41,7 @@ async def test_async_to_keypair_secure(): def test_sync_to_keypair_secure(): - client = DstackClient() + client = DstackClientV0() result = client.get_key("test") assert isinstance(result, GetKeyResponse) keypair = to_keypair_secure(result) @@ -50,7 +50,7 @@ def test_sync_to_keypair_secure(): def test_to_keypair_with_tls_key(): """Test to_keypair with TLS key response (should show warning).""" - client = DstackClient() + client = DstackClientV0() result = client.get_tls_key() with warnings.catch_warnings(record=True) as w: @@ -65,7 +65,7 @@ def test_to_keypair_with_tls_key(): def test_to_keypair_secure_with_tls_key(): """Test to_keypair_secure with TLS key response (should show warning).""" - client = DstackClient() + client = DstackClientV0() result = client.get_tls_key() with warnings.catch_warnings(record=True) as w: diff --git a/sdk/python/tests/test_tcp_connection_validation.py b/sdk/python/tests/test_tcp_connection_validation.py index c77e59c1e..008529b29 100644 --- a/sdk/python/tests/test_tcp_connection_validation.py +++ b/sdk/python/tests/test_tcp_connection_validation.py @@ -6,8 +6,8 @@ import pytest -from dstack_sdk import AsyncDstackClient -from dstack_sdk import DstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import DstackClientV0 class TestTCPConnectionValidation: @@ -16,7 +16,7 @@ class TestTCPConnectionValidation: @pytest.mark.asyncio async def test_async_client_connection_object_reuse(self): """Test that the actual httpx client object is reused in async context manager.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() async with client: first_client_obj = client._client @@ -46,7 +46,7 @@ async def test_async_client_connection_object_reuse(self): def test_sync_client_connection_object_reuse(self): """Test that the actual httpx client object is reused in sync context manager.""" - client = DstackClient() + client = DstackClientV0() with client: # For sync clients, check _sync_client instead of _client @@ -79,7 +79,7 @@ def test_sync_client_connection_object_reuse(self): async def test_async_transport_configuration_preserved(self): """Test that transport configuration is preserved when using context manager.""" # Test with HTTP endpoint - http_client = AsyncDstackClient(endpoint="http://localhost:8080") + http_client = AsyncDstackClientV0(endpoint="http://localhost:8080") async with http_client: assert http_client._client is not None @@ -91,7 +91,7 @@ async def test_async_transport_configuration_preserved(self): def test_sync_transport_configuration_preserved(self): """Test that transport configuration is preserved when using context manager.""" # Test with HTTP endpoint - http_client = DstackClient(endpoint="http://localhost:8080") + http_client = DstackClientV0(endpoint="http://localhost:8080") with http_client: # For sync clients, check _sync_client instead of _client @@ -108,7 +108,7 @@ def test_sync_transport_configuration_preserved(self): @pytest.mark.asyncio async def test_reference_counting_behavior(self): """Test that reference counting works correctly for nested contexts.""" - client = AsyncDstackClient() + client = AsyncDstackClientV0() # Initially no client and ref count is 0 assert client._client is None @@ -138,7 +138,7 @@ async def test_reference_counting_behavior(self): def test_sync_reference_counting_behavior(self): """Test that reference counting works correctly for nested sync contexts.""" - client = DstackClient() + client = DstackClientV0() # Initially no client and ref count is 0 assert client.async_client._sync_client is None diff --git a/sdk/python/tests/test_typing.py b/sdk/python/tests/test_typing.py index 4aa38e469..9f7cbc997 100644 --- a/sdk/python/tests/test_typing.py +++ b/sdk/python/tests/test_typing.py @@ -7,12 +7,11 @@ import inspect from typing import get_type_hints -from dstack_sdk import AsyncDstackClient -from dstack_sdk import DstackClient +from dstack_sdk import AsyncDstackClientV0 +from dstack_sdk import DstackClientV0 from dstack_sdk import GetKeyResponse from dstack_sdk import GetQuoteResponse from dstack_sdk import GetTlsKeyResponse -from dstack_sdk import GpuInfoResponse from dstack_sdk.dstack_client import InfoResponse # Use a test endpoint to avoid socket file not found errors @@ -21,7 +20,7 @@ def test_sync_method_type_annotations(): """Test that sync methods have correct type annotations, not Coroutine.""" - client = DstackClient(TEST_ENDPOINT) + client = DstackClientV0(TEST_ENDPOINT) # Check get_tls_key method get_tls_key_method = getattr(client, "get_tls_key") @@ -49,11 +48,10 @@ def test_sync_method_type_annotations(): def test_all_sync_method_types(): """Test all sync business methods have correct type annotations.""" - client = DstackClient(TEST_ENDPOINT) + client = DstackClientV0(TEST_ENDPOINT) expected_types = { "get_key": GetKeyResponse, - "gpu_info": GpuInfoResponse, "get_quote": GetQuoteResponse, "get_tls_key": GetTlsKeyResponse, "info": InfoResponse, @@ -91,11 +89,10 @@ def test_all_sync_method_types(): def test_async_method_types(): """Test that async methods have correct type annotations.""" - client = AsyncDstackClient(TEST_ENDPOINT) + client = AsyncDstackClientV0(TEST_ENDPOINT) expected_types = { "get_key": GetKeyResponse, - "gpu_info": GpuInfoResponse, "get_quote": GetQuoteResponse, "get_tls_key": GetTlsKeyResponse, "info": InfoResponse, @@ -124,12 +121,11 @@ def test_async_method_types(): def test_method_signature_comparison(): """Compare method signatures between sync and async versions.""" - sync_client = DstackClient(TEST_ENDPOINT) - async_client = AsyncDstackClient(TEST_ENDPOINT) + sync_client = DstackClientV0(TEST_ENDPOINT) + async_client = AsyncDstackClientV0(TEST_ENDPOINT) methods_to_check = [ "get_key", - "gpu_info", "get_quote", "get_tls_key", "info", diff --git a/sdk/python/tests/test_verify.py b/sdk/python/tests/test_verify.py deleted file mode 100644 index 76f93d993..000000000 --- a/sdk/python/tests/test_verify.py +++ /dev/null @@ -1,246 +0,0 @@ -# SPDX-FileCopyrightText: © 2026 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -"""Drives the shared cross-SDK vectors in ``sdk/tests/vectors/signature_chain.json``. - -The Rust, Go and JavaScript suites assert against the same file, so any port that -disagrees about the byte format fails here too. -""" - -import json -from pathlib import Path - -import pytest - -from dstack_sdk import verify_signature -from dstack_sdk import verify_signature_chain -from dstack_sdk.verify import SIGN_PURPOSE - -VECTORS_PATH = ( - Path(__file__).resolve().parents[2] / "tests" / "vectors" / "signature_chain.json" -) - - -def _vectors() -> dict: - return json.loads(VECTORS_PATH.read_text()) - - -VECTORS = _vectors() -CASES = VECTORS["cases"] -INVALID_CASES = VECTORS["invalid_cases"] -APP_ID = bytes.fromhex(VECTORS["app_id"]) -KMS_ROOT = bytes.fromhex(VECTORS["kms_root_pubkey"]) -WRONG_KMS_ROOT = bytes.fromhex(VECTORS["wrong_kms_root_pubkey"]) -APP_ROOT = bytes.fromhex(VECTORS["app_root_pubkey"]) - - -def _case(algorithm: str) -> dict: - return next(c for c in CASES if c["algorithm"] == algorithm) - - -def _chain(case: dict) -> list[bytes]: - return [bytes.fromhex(sig) for sig in case["signature_chain"]] - - -@pytest.mark.parametrize("case", CASES, ids=lambda c: c["algorithm"]) -def test_valid_signatures_verify(case): - assert ( - verify_signature( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["signature"]), - bytes.fromhex(case["public_key"]), - ) - is True - ) - - -@pytest.mark.parametrize("case", INVALID_CASES, ids=lambda c: c["name"]) -def test_invalid_signatures_are_rejected(case): - args = ( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["signature"]), - bytes.fromhex(case["public_key"]), - ) - if case["name"] == "secp256k1_high_s": - # High-S is refused outright rather than reported false, because it is a - # malformed encoding rather than a legitimate signature that fails to match. - with pytest.raises(ValueError, match="high-S"): - verify_signature(*args) - else: - assert verify_signature(*args) is False - - -def test_k256_is_an_alias_for_secp256k1(): - case = _case("secp256k1") - assert ( - verify_signature( - "k256", - bytes.fromhex(case["data"]), - bytes.fromhex(case["signature"]), - bytes.fromhex(case["public_key"]), - ) - is True - ) - - -@pytest.mark.parametrize("case", CASES, ids=lambda c: c["algorithm"]) -def test_full_chain_verifies_to_the_kms_root(case): - app_root = verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case), - APP_ID, - KMS_ROOT, - ) - assert app_root == APP_ROOT - assert len(app_root) == 33 - - -def test_chain_accepts_an_uncompressed_kms_root(): - """Callers may pass either SEC1 encoding of the key they pinned.""" - from cryptography.hazmat.primitives.asymmetric import ec - from cryptography.hazmat.primitives.serialization import Encoding - from cryptography.hazmat.primitives.serialization import PublicFormat - - uncompressed = ec.EllipticCurvePublicKey.from_encoded_point( - ec.SECP256K1(), KMS_ROOT - ).public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) - assert len(uncompressed) == 65 - - case = CASES[0] - assert ( - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case), - APP_ID, - uncompressed, - ) - == APP_ROOT - ) - - -def test_chain_anchored_at_a_foreign_kms_root_is_rejected(): - case = CASES[0] - with pytest.raises(ValueError, match="not anchored"): - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case), - APP_ID, - WRONG_KMS_ROOT, - ) - - -def test_chain_for_a_different_app_id_is_rejected(): - case = CASES[0] - tampered_app_id = bytes([APP_ID[0] ^ 0xFF]) + APP_ID[1:] - with pytest.raises(ValueError): - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case), - tampered_app_id, - KMS_ROOT, - ) - - -def test_tampered_payload_breaks_the_chain(): - case = CASES[0] - with pytest.raises(ValueError): - verify_signature_chain( - case["algorithm"], - b"a different payload entirely", - bytes.fromhex(case["public_key"]), - _chain(case), - APP_ID, - KMS_ROOT, - ) - - -def test_tampered_public_key_breaks_the_chain(): - """Swapping the signing key invalidates link 1 and the app-root link's message.""" - case = _case("secp256k1") - public_key = bytearray(bytes.fromhex(case["public_key"])) - public_key[-1] ^= 0xFF - with pytest.raises(ValueError): - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes(public_key), - _chain(case), - APP_ID, - KMS_ROOT, - ) - - -def test_chain_requires_three_links(): - case = CASES[0] - with pytest.raises(ValueError, match="3 elements"): - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case)[:2], - APP_ID, - KMS_ROOT, - ) - - -def test_chain_requires_a_20_byte_app_id(): - case = CASES[0] - with pytest.raises(ValueError, match="20 bytes"): - verify_signature_chain( - case["algorithm"], - bytes.fromhex(case["data"]), - bytes.fromhex(case["public_key"]), - _chain(case), - APP_ID[:19], - KMS_ROOT, - ) - - -def test_malformed_inputs_error_rather_than_report_false(): - with pytest.raises(ValueError): - verify_signature("rsa", b"x", bytes(64), bytes(32)) - with pytest.raises(ValueError): - verify_signature("ed25519", b"x", bytes(64), bytes(31)) - with pytest.raises(ValueError): - verify_signature("ed25519", b"x", bytes(63), bytes(32)) - # A prehashed digest must be exactly 32 bytes. - case = _case("secp256k1_prehashed") - with pytest.raises(ValueError, match="32-byte digest"): - verify_signature( - "secp256k1_prehashed", - b"short", - bytes.fromhex(case["signature"]), - bytes.fromhex(case["public_key"]), - ) - # A raw r || s signature is 64 bytes; DER or truncated blobs are caller bugs. - secp = _case("secp256k1") - with pytest.raises(ValueError, match="64 raw bytes"): - verify_signature( - "secp256k1", - bytes.fromhex(secp["data"]), - bytes.fromhex(secp["signature"])[:63], - bytes.fromhex(secp["public_key"]), - ) - with pytest.raises(ValueError, match="public key"): - verify_signature( - "secp256k1", - bytes.fromhex(secp["data"]), - bytes.fromhex(secp["signature"]), - bytes(33), - ) - - -def test_sign_purpose_is_the_agent_side_constant(): - assert SIGN_PURPOSE == "signing" - assert VECTORS["purpose"] == SIGN_PURPOSE diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index bbc64a639..e088cfe5d 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "dstack-sdk" -version = "0.1.3" +version = "0.6.0" dependencies = [ "alloy", "anyhow", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "dstack-sdk-types" -version = "0.1.3" +version = "0.6.0" dependencies = [ "anyhow", "bon", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 0c73affec..79f425885 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -26,11 +26,11 @@ tokio = { version = "1.46.1" } alloy = { version = "1.0.32", default-features = false } http = "1.3.1" x509-parser = "0.16.0" -dstack-sdk-types = { path = "types", version = "0.1.3", default-features = false } +dstack-sdk-types = { path = "types", version = "0.6.0", default-features = false } [package] name = "dstack-sdk" -version = "0.1.3" +version = "0.6.0" edition = "2021" license = "MIT" description = "This crate provides a rust client for communicating with dstack" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 59c61a768..3de94903f 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -3,6 +3,36 @@ Access TEE features from your Rust application running inside dstack. Derive deterministic keys, generate attestation quotes, create TLS certificates, and sign data—all backed by hardware security. This directory is a **standalone Cargo workspace** (`dstack-sdk`, `dstack-sdk-types`, and a `no_std` check crate). It does not join the `dstack/` core workspace. +## Two API surfaces + +The guest agent serves two surfaces on the same socket, selected by URL path, +and this SDK mirrors both: + +| Client | Surface | Paths | +|---|---|---| +| `DstackClient` (= `DstackClientV1`) | `dstack.guest.v1` | `/v1/GetKey` | +| `DstackClientV0` | the frozen v0.5.11 API | `/GetKey`, also served at `/v0/GetKey` | + +`DstackClient` is the recommended default and names the v1 surface. +`DstackClientV0` is legacy: that surface is closed, gains no methods, and exists +so a v0.5.x program keeps working unchanged. + +> **The `DstackClient` alias flipped in 0.6.0.** It used to mean the v0 client. +> Code that called v0 methods through it now **fails to compile** rather than +> silently deriving different key material -- the v1 signatures differ, and +> `get_key` requires an explicit `algorithm`. To stay on the frozen surface, +> name `DstackClientV0`. + +> **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 there is no compatibility mode. An application holding +> assets under a v0 key must migrate them with a transaction signed by the old +> key before cutting over. See [`docs/guest-api-v1.md`](../../docs/guest-api-v1.md). + +The clients 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. ## Installation @@ -14,33 +44,191 @@ dstack-sdk = { git = "https://github.com/Dstack-TEE/dstack.git" } ## Quick Start ```rust -use dstack_sdk::dstack_client::DstackClient; +use dstack_sdk::DstackClient; #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> anyhow::Result<()> { let client = DstackClient::new(None); - // Derive a deterministic key for your wallet - let key = client.get_key(Some("wallet/eth".to_string()), None).await?; - println!("{}", key.key); // Same path always returns the same key - - // Generate an attestation quote - let resp = client.attest(b"my-app-state".to_vec()).await?; - println!("{}", resp.attestation); + // Derive an application key. `domain` is a caller-chosen + // domain-separation string; `algorithm` is required. + let key = client.get_key("storage-encryption", "secp256k1").await?; + println!("public key: {}", key.public_key); + let info = client.info().await?; + println!("app: {} ({})", info.app_name, info.app_id); Ok(()) } ``` -The client automatically connects to `/var/run/dstack.sock`. For local development with the simulator: +Point it somewhere else with `DstackClient::new(Some("/custom/dstack.sock"))` +or an `http://` URL. + +## API + + +```rust +use dstack_sdk::DstackClient; +use dstack_sdk::dstack_client_v1::IssueCertConfig; + +let client = DstackClient::new(None); +``` + +#### `get_key(domain: &str, algorithm: &str) -> GetKeyResponse` + +```rust +let key = client.get_key("storage-encryption", "secp256k1").await?; +let private_key = key.decode_key()?; // 32 bytes +let public_key = key.decode_public_key()?; // SEC1 compressed, or 32 raw for ed25519 +``` + +`domain` is a caller-chosen domain-separation string -- not a DNS name and not a +path. Derivation is **flat**: `a/b` is unrelated to `a`, and no key derives +another. `algorithm` is required and must be `secp256k1` or `ed25519`; there is +no default and no `k256` alias, so a typo is an error rather than a key of the +wrong type. + +`signature_chain` has two links: the app root key's signature over the v1 key +claim, then the KMS root key's signature over the app root public key. + +#### `attest(report_data: Vec, include_boottime_gpu_evidence: bool) -> AttestResponse` + +v1's only CVM attestation entry point. The attestation already carries the TDX +quote and the event log, and unlike v0's `get_quote` it answers on every +supported platform. + +```rust +let result = client.attest(b"custom data".to_vec(), true).await?; +let attestation = result.decode_attestation()?; + +// Boot-time GPU evidence uses the same bundle shape `attest_gpu` returns, so +// one parser handles both. Empty when the flag was not set or the guest has no +// GPU output -- absence is the empty list, not a sentinel. +for bundle in &result.boottime_gpu_evidence { + assert_eq!(bundle.format, dstack_sdk::dstack_client_v1::FORMAT_BOOTTIME); + let nvattest_output = bundle.decode_evidence()?; // exact bytes from disk +} +``` + +Dispatch on `format`: `nvidia-nvattest-boottime-json-v1` is the record written +at boot, `nvidia-nvattest-collect-evidence-json-v1` is collected on demand +against a nonce you choose. A verifier for one does not appraise the other. + +That evidence is **not** bound to `report_data` -- nvattest ran at boot against +its own nonce. Bind it by replaying the runtime event log and comparing sha256 +of the bundle's **exact** decoded bytes against `evidence_sha256` in the +`gpu-attestation` event. Parsing and re-serializing the JSON first changes the +digest and breaks the comparison. + +#### `attest_gpu(nonce: Vec) -> AttestGpuResponse` + +Collects GPU evidence *now*, against a caller-chosen 32-byte nonce -- which the +boot-time evidence cannot answer, since it is a record written at boot. + +```rust +let result = client.attest_gpu(vec![0xab; 32]).await?; +for bundle in &result.bundles { + println!("{} / {}", bundle.vendor, bundle.format); +} +``` + +Returns vendor-native evidence, not a verdict: select a verifier by `vendor` and +`format`, then check the signature, certificate chain, measurements, and the +nonce embedded in the evidence. + +#### `issue_cert(config: IssueCertConfig) -> IssueCertResponse` + +Certificate issuance -- what v0 called `get_tls_key`, which named the by-product +rather than the request. ```rust -let client = DstackClient::new(Some("http://localhost:8090".to_string())); +let cert = client + .issue_cert( + IssueCertConfig::builder() + .subject("example.com") + .usage_server_auth(true) + .build(), + ) + .await?; ``` -## Core API +The returned private key is freshly generated per call and is **not** derived +from the app identity: two identical requests produce two unrelated keys. +`get_key` is the method that derives a stable, attestable key. + +#### `info() -> InfoResponse` + +Identity and configuration, never attestation. The measurement registers and the +event log are deliberately absent -- they are attestation data, and this +response arrives over a local socket with nothing vouching for it. Ask `attest` +and verify. -### Derive Keys +`app_compose` is served directly here, rather than nested inside a `tcb_info` +JSON string as v0 did, and `compose_hash` is sha256 over its verbatim bytes. + +#### `version() -> VersionResponse` + +Also the cheapest probe for whether an agent serves v1 at all. + +## Development + +For local development without TDX hardware, use the simulator: + +```bash +git clone https://github.com/Dstack-TEE/dstack.git +cd dstack/sdk/simulator +./build.sh +./dstack-simulator +``` + +Then set the endpoint: + +```bash +export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 +``` + +Run examples: + +```bash +cargo run --example dstack_client_usage +``` + +--- + +## Migration from TappdClient + +`TappdClient` is superseded. For new code use `DstackClient` (v1); for a +like-for-like swap that keeps the old semantics, use `DstackClientV0`: + +```rust +// Before +use dstack_sdk::tappd_client::TappdClient; +let client = TappdClient::new(None); + +// After +use dstack_sdk::dstack_client::DstackClientV0; +let client = DstackClientV0::new(None); +``` + +Method changes: +- `derive_key()` → `get_tls_key()` for TLS certificates +- Socket path: `/var/run/tappd.sock` → `/var/run/dstack.sock` + +## Legacy (v0, frozen) + +Everything below is the **frozen v0.5.11 surface**, reached through +`DstackClientV0`. It is closed: no new methods, no behaviour changes. Use it +only if you need something v1 does not carry -- `sign`, `verify`, `emit_event`, +`get_quote` -- or to keep existing code working while you migrate. + +```rust +use dstack_sdk::dstack_client::DstackClientV0; + +let client = DstackClientV0::new(None); +``` + + +### Derive Keys (v0) `get_key()` derives deterministic keys bound to your application's identity (`app_id`). The same path always produces the same key for your app, but different apps get different keys even with the same path. @@ -103,54 +291,9 @@ println!("{}", info.tcb_info); #### `attest(report_data: Vec) -> AttestResponse` Generates a versioned attestation with a custom 64-byte payload. - `attestation`: Hex-encoded attestation -- `boottime_gpu_evidence`: Boot-time GPU attestation evidence, empty unless requested - -#### `attest_with(config: AttestConfig) -> AttestResponse` -Same, with options. Set `include_boottime_gpu_evidence` to also return the boot-time GPU -attestation evidence, so a verifier gets the quote and the GPU evidence in one round trip. - -```rust -let config = AttestConfig::builder() - .report_data(hex::encode(b"user:alice:nonce123")) - .include_boottime_gpu_evidence(true) - .build(); -let result = client.attest_with(config).await?; -println!("{}", result.boottime_gpu_evidence); -``` - -The evidence is the same bytes ``gpu_info()`` serves and is empty unless the flag was set -and boot-time GPU attestation output exists. It is not bound to `report_data`; verify -it with the measured `gpu-attestation` event digest as described under ``gpu_info()``. -#### `attest_gpu(nonce: Vec) -> AttestGpuResponse` - -Collects vendor-native GPU evidence for a caller-chosen 32-byte nonce. - -```rust -let result = client.attest_gpu(nonce.to_vec()).await?; -for bundle in result.bundles { - println!("{} {} {}", bundle.vendor, bundle.format, bundle.evidence); -} -``` - -Select a verifier using each bundle's `vendor` and `format`. The verifier must check -the evidence signature, certificate chain, measurements, and embedded nonce. Evidence -is opaque and hex-encoded by the JSON RPC. It does not by itself bind the GPU to this -CVM. - -#### `gpu_info() -> GpuInfoResponse` - -Returns GPU information collected during boot. Currently, this includes the -complete NVIDIA `nvattest` JSON output. - -```rust -let gpu = client.gpu_info().await?; -println!("{}", gpu.attestation); -``` - -The `attestation` field is empty when no GPU attestation output is available. -The raw output is not trusted by itself; remote verifiers should compare its -digest with the measured `gpu-attestation` runtime event. +No GPU-evidence flag on v0: that field is reserved on this surface, and only +`DstackClientV1::attest` honours it. ### Generate TLS Certificates @@ -183,131 +326,49 @@ println!("{:?}", tls.certificate_chain); // Certificate chain - `key`: PEM-encoded private key - `certificate_chain`: List of PEM certificates -### Sign and Verify +### Sign and Verify (v0 only) -Signing happens in the TEE, because it needs a key only the TEE holds. Verifying -does not, so it runs locally in this SDK -- the guest agent's `Verify` RPC was -removed in v0.6.0. Its answer arrived over the socket unattested, so trusting it -was never better than checking the signature yourself. +`Sign` and `Verify` are v0 RPCs. v1 has neither: any caller that can reach the +socket can ask `get_key` for the private key and do both locally, so an RPC for +them would add a round trip without adding a capability. ```rust -use dstack_sdk::verify::{verify_signature, verify_signature_chain, SignatureChain}; - -let result = client.sign("ed25519", b"message to sign".to_vec()).await?; - -// Does this signature check out under this public key? -let valid = verify_signature( - "ed25519", - b"message to sign", - &result.decode_signature()?, - &result.decode_public_key()?, -)?; -assert!(valid); +let sign_resp = client.sign("secp256k1", b"my message".to_vec()).await?; +let signature = sign_resp.decode_signature()?; +let public_key = sign_resp.decode_public_key()?; + +let verified = client + .verify("secp256k1", b"my message".to_vec(), signature, public_key) + .await?; +assert!(verified.valid); ``` -**`sign()` Parameters:** -- `algorithm`: `"ed25519"`, `"secp256k1"` (alias `"k256"`), or `"secp256k1_prehashed"` -- `data`: Data to sign (a 32-byte digest for `secp256k1_prehashed`) +`verify()` needs no key material and no attestation, and the agent's answer +arrives over the socket unattested -- a caller gains nothing over checking the +signature itself. It is kept because it is part of the frozen v0 surface. -**`sign()` Returns:** `SignResponse` -- `signature`: Signature bytes -- `public_key`: Public key bytes -- `signature_chain`: Three signatures linking the signing key back to the KMS root +To verify a **v1 signature chain**, do it yourself. +[`docs/guest-api-v1.md`](../../docs/guest-api-v1.md) specifies the rules +normatively: the claim encoding, the recovery step, and the trust anchor the +chain has to terminate at. This SDK deliberately ships no verification helper -- +it mirrors an API surface, and verifying is the relying party's job. -**`verify_signature()` Returns** `Result` -- `Ok(false)` when a well-formed -signature does not match, and `Err` when an input is malformed (bad key length, -unknown algorithm, non-canonical high-S signature). A malformed input is a caller -bug, not a verdict. +### Blockchain adapters (v0-era) -#### Verifying the whole chain - -`verify_signature` alone proves only that whoever holds that public key signed the -data. It says nothing about *whose* key it is. `verify_signature_chain` walks all -three links back to a KMS root key you supply: +`ethereum::to_account` is typed against the v0 `GetKeyResponse` and stays that +way. The v1 surface has no chain-related functionality: it returns key +material, and what an application builds from those bytes is its own business. ```rust -// Both anchors come from you, not from the CVM being checked. -let expected_app_id = hex::decode("a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b")?; -let kms_root_pubkey = hex::decode("03...")?; // pinned, or read from DstackKms - -let verified = verify_signature_chain(&SignatureChain::from_sign_response( - "ed25519", - b"message to sign", - &result.decode_public_key()?, - &result.decode_signature_chain()?, - &expected_app_id, - &kms_root_pubkey, -)?; -println!("app root key: {}", hex::encode(verified.app_root_pubkey)); -``` - -Note what the example does *not* do: it never passes `client.info().app_id` -straight through. That value is reported by the very CVM being verified, so a -chain checked against it proves only that the CVM is self-consistent with -itself. Use the app id you registered on chain, and if you want `AppInfo` in the -picture, compare it against that value rather than trusting it. - -`kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` -contract's `kmsInfo().k256Pubkey`, or a value you pinned. Reading it from the same -KMS you are checking against proves nothing -- an attacker who can answer that -query can also mint a self-consistent chain. This comparison is the entire point -of the chain; skip it and the other two links establish nothing. - -## Blockchain Integration - -### Ethereum with Alloy - -```rust -use dstack_sdk::dstack_client::DstackClient; +use dstack_sdk::dstack_client::DstackClientV0; use dstack_sdk::ethereum::to_account; +let client = DstackClientV0::new(None); let key = client.get_key(Some("wallet/ethereum".to_string()), None).await?; let signer = to_account(&key)?; println!("Ethereum address: {}", signer.address()); ``` -## Development - -For local development without TDX hardware, use the simulator: - -```bash -git clone https://github.com/Dstack-TEE/dstack.git -cd dstack/sdk/simulator -./build.sh -./dstack-simulator -``` - -Then set the endpoint: - -```bash -export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 -``` - -Run examples: - -```bash -cargo run --example dstack_client_usage -``` - ---- - -## Migration from TappdClient - -Replace `TappdClient` with `DstackClient`: - -```rust -// Before -use dstack_sdk::tappd_client::TappdClient; -let client = TappdClient::new(None); - -// After -use dstack_sdk::dstack_client::DstackClient; -let client = DstackClient::new(None); -``` - -Method changes: -- `derive_key()` → `get_tls_key()` for TLS certificates -- Socket path: `/var/run/tappd.sock` → `/var/run/dstack.sock` ## License diff --git a/sdk/rust/examples/dstack_client_usage.rs b/sdk/rust/examples/dstack_client_usage.rs index 7e0d46188..5b3141531 100644 --- a/sdk/rust/examples/dstack_client_usage.rs +++ b/sdk/rust/examples/dstack_client_usage.rs @@ -3,22 +3,21 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClient; -use dstack_sdk::verify::verify_signature; +use dstack_sdk::dstack_client::DstackClientV0; use dstack_sdk_types::dstack::TlsKeyConfig; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Create a DstackClient with default endpoint (/var/run/dstack.sock) - let client = DstackClient::new(None); + // Create a DstackClientV0 with default endpoint (/var/run/dstack.sock) + let client = DstackClientV0::new(None); // Or create with a custom endpoint - // let client = DstackClient::new(Some("/custom/path/dstack.sock")); + // let client = DstackClientV0::new(Some("/custom/path/dstack.sock")); // Or create with HTTP endpoint for simulator - // let client = DstackClient::new(Some("http://localhost:8000")); + // let client = DstackClientV0::new(Some("http://localhost:8000")); - println!("DstackClient created successfully!"); + println!("DstackClientV0 created successfully!"); // Example usage (these will fail without a running dstack service): @@ -112,8 +111,11 @@ async fn main() -> anyhow::Result<()> { let sig_bytes = sign_resp.decode_signature()?; let pub_key_bytes = sign_resp.decode_public_key()?; - // Verification is local -- it needs no key material and no round trip. - let valid = verify_signature(algorithm, &data_to_sign, &sig_bytes, &pub_key_bytes)?; - println!(" Verification successful: {valid}"); + // Sign and Verify are both v0 RPCs, so this round trip stays on the frozen + // surface. v1 has neither; see `docs/guest-api-v1.md`. + let verified = client + .verify(algorithm, data_to_sign, sig_bytes, pub_key_bytes) + .await?; + println!(" Verification successful: {}", verified.valid); Ok(()) } diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client.rs index e95ed5516..a0acdd9a0 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -4,7 +4,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use anyhow::{Context, Result}; +use anyhow::Result; use hex::encode as hex_encode; use http_client_unix_domain_socket::{ClientUnix, Method}; use reqwest::Client; @@ -21,7 +21,15 @@ struct SignRequest<'a> { data: String, } -fn get_endpoint(endpoint: Option<&str>) -> String { +#[derive(Debug, Serialize)] +struct VerifyRequest<'a> { + algorithm: &'a str, + data: String, + signature: String, + public_key: String, +} + +pub(crate) fn get_endpoint(endpoint: Option<&str>) -> String { if let Some(e) = endpoint { return e.to_string(); } @@ -52,8 +60,22 @@ pub enum ClientKind { pub trait BaseClient {} -/// The main client for interacting with the dstack service -pub struct DstackClient { +/// Client for the frozen v0 guest-agent surface. +/// +/// **Legacy.** New code should use [`crate::dstack_client_v1::DstackClientV1`], +/// which is what the unsuffixed `DstackClient` now names. This client stays for +/// applications that need the v0.5.11 surface -- `sign`, `verify`, +/// `emit_event`, `get_quote` -- which v1 does not carry. +/// +/// Speaks the unversioned paths (`/GetKey`), which the agent also serves at +/// `/v0`. That surface is closed at exactly what dstack v0.5.11 shipped: it +/// gains no methods and changes no behaviour, so this client keeps working +/// against a 0.6 agent unchanged. +/// +/// For anything new, use [`crate::dstack_client_v1::DstackClientV1`]. Note that +/// **v1 derives different key material than v0 for the same inputs** -- see +/// `docs/guest-api-v1.md` for the migration. +pub struct DstackClientV0 { /// The base URL for HTTP requests base_url: String, /// The endpoint for Unix domain socket communication @@ -62,9 +84,9 @@ pub struct DstackClient { client: ClientKind, } -impl BaseClient for DstackClient {} +impl BaseClient for DstackClientV0 {} -impl DstackClient { +impl DstackClientV0 { pub fn new(endpoint: Option<&str>) -> Self { let endpoint = get_endpoint(endpoint); let (base_url, client) = match endpoint { @@ -74,7 +96,7 @@ impl DstackClient { _ => ("http://localhost".to_string(), ClientKind::Unix), }; - DstackClient { + DstackClientV0 { base_url, endpoint, client, @@ -153,47 +175,16 @@ impl DstackClient { } /// Requests a versioned attestation for the given report data. + /// + /// No GPU-evidence flag: that field is reserved on this surface and only + /// `/v1/Attest` honours it. pub async fn attest(&self, report_data: Vec) -> Result { - self.attest_with( - AttestConfig::builder() - .report_data(hex_encode(&report_data)) - .build(), - ) - .await - } - - /// Requests a versioned attestation, optionally bundling the boot-time GPU - /// attestation evidence so a verifier can check both in one round trip. - pub async fn attest_with(&self, config: AttestConfig) -> Result { - let report_data = - hex::decode(&config.report_data).context("Invalid report data encoding")?; if report_data.is_empty() || report_data.len() > 64 { anyhow::bail!("Invalid report data length") } - let response = self.send_rpc_request("/Attest", &config).await?; - let response = serde_json::from_value::(response)?; - - Ok(response) - } - - /// Collects vendor-native GPU evidence for a caller-chosen 32-byte nonce. - /// - /// Select a verifier using each bundle's vendor and format. The verifier must - /// check the signature, certificate chain, measurements, and embedded nonce. - pub async fn attest_gpu(&self, nonce: Vec) -> Result { - if nonce.len() != 32 { - anyhow::bail!("Nonce must be exactly 32 bytes") - } - let data = json!({ "nonce": hex_encode(nonce) }); - let response = self.send_rpc_request("/AttestGpu", &data).await?; - Ok(serde_json::from_value::(response)?) - } - - /// Returns GPU information collected during boot. - pub async fn gpu_info(&self) -> Result { - let response = self.send_rpc_request("/GpuInfo", &json!({})).await?; - let response = serde_json::from_value::(response)?; - Ok(response) + let data = json!({ "report_data": hex_encode(report_data) }); + let response = self.send_rpc_request("/Attest", &data).await?; + Ok(serde_json::from_value::(response)?) } pub async fn info(&self) -> Result { @@ -211,6 +202,22 @@ impl DstackClient { Ok(response) } + /// Emit a runtime event. + /// + /// Always fails against a 0.6 agent: runtime RTMR3 events became + /// system-owned, and the method is kept only so a caller learns that from + /// the error rather than from an unexplained failure. The agent's message + /// is surfaced verbatim. + pub async fn emit_event(&self, event: String, payload: Vec) -> Result<()> { + if event.is_empty() { + anyhow::bail!("Event name cannot be empty") + } + let hex_payload = hex_encode(payload); + let data = json!({ "event": event, "payload": hex_payload }); + self.send_rpc_request::<_, ()>("/EmitEvent", &data).await?; + Ok(()) + } + pub async fn get_tls_key(&self, tls_key_config: TlsKeyConfig) -> Result { let response = self.send_rpc_request("/GetTlsKey", &tls_key_config).await?; let response = serde_json::from_value::(response)?; @@ -228,4 +235,29 @@ impl DstackClient { let response = serde_json::from_value::(response)?; Ok(response) } + + /// Verifies a payload signature through the agent. + /// + /// Part of the v0 surface and kept for callers that already depend on it. + /// It needs no key material and no attestation, and the answer arrives over + /// the socket unattested, so a caller gains nothing over checking the + /// signature itself. v1 has no counterpart; `docs/guest-api-v1.md` + /// specifies verification for relying parties. + pub async fn verify( + &self, + algorithm: &str, + data: Vec, + signature: Vec, + public_key: Vec, + ) -> Result { + let payload = VerifyRequest { + algorithm, + data: hex_encode(data), + signature: hex_encode(signature), + public_key: hex_encode(public_key), + }; + let response = self.send_rpc_request("/Verify", &payload).await?; + let response = serde_json::from_value::(response)?; + Ok(response) + } } diff --git a/sdk/rust/src/dstack_client_v1.rs b/sdk/rust/src/dstack_client_v1.rs new file mode 100644 index 000000000..3afa9edb2 --- /dev/null +++ b/sdk/rust/src/dstack_client_v1.rs @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Client for the `dstack.guest.v1` guest-agent surface. +//! +//! A transport mirror of the six methods the agent serves at `/v1`, and nothing +//! more. There is no `sign` and no `verify` here because v1 has neither: any +//! caller that can reach this socket can ask [`DstackClientV1::get_key`] for the +//! private key and do both itself, so an RPC for it would add a round trip and +//! an entry point without adding a capability. +//! +//! Verifying a signature chain is likewise not this SDK's job. It needs no +//! client and no connection; `docs/guest-api-v1.md` specifies the rules +//! normatively, down to the trust anchor. + +use anyhow::{Context, Result}; +use hex::encode as hex_encode; +use http_client_unix_domain_socket::{ClientUnix, Method}; +use reqwest::Client; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; + +pub use dstack_sdk_types::dstack_v1::*; + +use crate::dstack_client::{get_endpoint, BaseClient, ClientKind}; + +/// Client for the v1 guest-agent surface. **This is the default client**, and +/// what the unsuffixed [`crate::DstackClient`] names. +/// +/// **v1 keys are not v0 keys.** Deriving under the same name here as on +/// [`crate::dstack_client::DstackClientV0`] returns different key material, by +/// design: the v0 KDF ignored the algorithm, so one secret served both curves. +/// There is no compatibility mode. An application holding assets under a v0 key +/// migrates them deliberately -- see `docs/guest-api-v1.md`. +pub struct DstackClientV1 { + base_url: String, + endpoint: String, + client: ClientKind, +} + +impl BaseClient for DstackClientV1 {} + +impl DstackClientV1 { + pub fn new(endpoint: Option<&str>) -> Self { + let endpoint = get_endpoint(endpoint); + let (base_url, client) = match endpoint { + ref e if e.starts_with("http://") || e.starts_with("https://") => { + (e.to_string(), ClientKind::Http) + } + _ => ("http://localhost".to_string(), ClientKind::Unix), + }; + + DstackClientV1 { + base_url, + endpoint, + client, + } + } + + /// Send to `/v1/`. + /// + /// The version prefix lives here rather than at each call site so no method + /// can be added against the wrong surface by copying a neighbour. + async fn send_rpc_request( + &self, + method: &str, + payload: &S, + ) -> Result { + let path = format!("/v1/{method}"); + match &self.client { + ClientKind::Http => { + let client = Client::new(); + let url = format!("{}{}", self.base_url.trim_end_matches('/'), path); + let res = client + .post(&url) + .json(payload) + .header("Content-Type", "application/json") + .send() + .await? + .error_for_status()?; + Ok(res.json().await?) + } + ClientKind::Unix => { + let mut unix_client = ClientUnix::try_new(&self.endpoint).await?; + let res = unix_client + .send_request_json::<_, _, Value>( + &path, + Method::POST, + &[("Content-Type", "application/json"), ("Host", "dstack")], + Some(&payload), + ) + .await?; + Ok(res.1) + } + } + } + + /// Issue a certificate for this application. + /// + /// The agent builds a CSR, signs it with the certificate's own key, and + /// relays it to the KMS. The private key comes back with the chain and is + /// freshly generated per call -- it is not derived from the app identity, + /// and two identical requests produce two unrelated keys. [`Self::get_key`] + /// is the method that derives a stable, attestable key. + pub async fn issue_cert(&self, config: IssueCertConfig) -> Result { + let response = self.send_rpc_request("IssueCert", &config).await?; + Ok(serde_json::from_value::(response)?) + } + + /// Derive an application key from `(domain, algorithm)`. + /// + /// `domain` is a caller-chosen domain-separation string, not a DNS name and + /// not a path: derivation is flat, so `a/b` is unrelated to `a` and no key + /// derives another. + /// + /// `algorithm` is required and must be `secp256k1` or `ed25519`. v1 has no + /// default and no `k256` alias -- a typo is an error rather than a key of + /// the wrong type under a name the caller misread. + pub async fn get_key(&self, domain: &str, algorithm: &str) -> Result { + if algorithm.is_empty() { + anyhow::bail!("algorithm is required, use `secp256k1` or `ed25519`") + } + let data = json!({ "domain": domain, "algorithm": algorithm }); + let response = self.send_rpc_request("GetKey", &data).await?; + Ok(serde_json::from_value::(response)?) + } + + /// Produce a versioned attestation over `report_data`. + /// + /// v1's only CVM attestation entry point: the attestation already carries + /// the TDX quote and event log, and unlike v0's `GetQuote` it answers on + /// every supported platform. + pub async fn attest( + &self, + report_data: Vec, + include_boottime_gpu_evidence: bool, + ) -> Result { + if report_data.is_empty() || report_data.len() > 64 { + anyhow::bail!("report data must be 1 to 64 bytes") + } + let config = AttestConfig::builder() + .report_data(hex_encode(&report_data)) + .include_boottime_gpu_evidence(include_boottime_gpu_evidence) + .build(); + let response = self.send_rpc_request("Attest", &config).await?; + Ok(serde_json::from_value::(response)?) + } + + /// Collect GPU evidence now, against a caller-chosen 32-byte nonce. + /// + /// Returns vendor-native evidence rather than a verdict: select a verifier + /// by vendor and format, then check the signature, certificate chain, + /// measurements and the nonce embedded in the evidence. + pub async fn attest_gpu(&self, nonce: Vec) -> Result { + // SPDM fixes the evidence nonce at 32 bytes and the agent passes it + // through verbatim, so a shorter one is a caller bug worth catching + // before the round trip. + if nonce.len() != 32 { + anyhow::bail!("nonce must be exactly 32 bytes") + } + let data = json!({ "nonce": hex_encode(nonce) }); + let response = self.send_rpc_request("AttestGpu", &data).await?; + Ok(serde_json::from_value::(response)?) + } + + /// Return this application's identity and configuration. + /// + /// Identity and configuration only. Nothing here is attestation, and + /// nothing here should be trusted on its own -- it arrives over a local + /// socket with no quote behind it. Use [`Self::attest`] for evidence. + pub async fn info(&self) -> Result { + let response = self.send_rpc_request("Info", &json!({})).await?; + Ok(serde_json::from_value::(response)?) + } + + /// Return the guest agent version. + /// + /// Also the cheapest probe for whether an agent serves v1 at all: it takes + /// no arguments and touches nothing. + pub async fn version(&self) -> Result { + let response = self.send_rpc_request("Version", &json!({})).await?; + serde_json::from_value::(response).context("failed to decode the response") + } +} + +/// The recommended client. +/// +/// Names the v1 surface. This alias flipped in 0.6.0: it used to mean the v0 +/// client, and code that was calling v0 methods through it stops compiling +/// rather than silently deriving different key material -- the v1 signatures +/// differ, and `get_key` requires an explicit `algorithm`. To stay on the +/// frozen surface, name [`crate::dstack_client::DstackClientV0`] explicitly. +pub type DstackClient = DstackClientV1; diff --git a/sdk/rust/src/ethereum.rs b/sdk/rust/src/ethereum.rs index 1f8df0c46..f53af5e4b 100644 --- a/sdk/rust/src/ethereum.rs +++ b/sdk/rust/src/ethereum.rs @@ -6,6 +6,12 @@ use alloy::signers::local::PrivateKeySigner; use dstack_sdk_types::dstack::GetKeyResponse; +/// Build a signer from a `get_key` response. +/// +/// A v0-era adapter, and deliberately still typed against the v0 response: the +/// v1 surface has no chain-related functionality. v1's story ends at "`GetKey` +/// returns key material"; what an application builds from those bytes is its +/// own business, not something this SDK models. pub fn to_account( get_key_response: &GetKeyResponse, ) -> Result> { diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 4ce9dc767..80417eb17 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -4,6 +4,11 @@ // SPDX-License-Identifier: Apache-2.0 pub mod dstack_client; +pub mod dstack_client_v1; + +/// The recommended client: the v1 guest-agent surface. +/// +/// See [`dstack_client_v1::DstackClient`] for what changed in 0.6.0. +pub use dstack_client_v1::{DstackClient, DstackClientV1}; pub mod ethereum; pub mod tappd_client; -pub mod verify; diff --git a/sdk/rust/src/verify.rs b/sdk/rust/src/verify.rs deleted file mode 100644 index bdc048c6f..000000000 --- a/sdk/rust/src/verify.rs +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -//! Local signature and signature-chain verification. -//! -//! Verification needs no key material and no attestation, so it does not belong -//! behind an RPC to the guest agent: the agent's answer arrives over the socket -//! unattested, which is no better than a caller checking the signature itself. -//! The `Verify` RPC these functions replace was removed in v0.6.0. -//! -//! Two levels are available: -//! -//! * [`verify_signature`] checks one signature against a public key you already -//! have. It is the direct replacement for the old RPC and, on its own, proves -//! only that whoever holds that key signed the data. -//! * [`verify_signature_chain`] walks the full chain from a [`SignResponse`] -//! back to a KMS root key **you supply**, which is what actually establishes -//! that the signer was a dstack app under that KMS. - -use anyhow::{bail, Context, Result}; -use k256::ecdsa::signature::hazmat::PrehashVerifier; -use k256::ecdsa::{RecoveryId, Signature as K256Signature, VerifyingKey}; -use sha3::Keccak256; - -/// Domain-separation prefix the KMS signs app root keys under. -const KMS_ISSUED_PREFIX: &[u8] = b"dstack-kms-issued"; -/// `Sign` derives its key at this path with this purpose; both are fixed agent-side. -pub const SIGN_PATH: &str = "vms"; -pub const SIGN_PURPOSE: &str = "signing"; - -/// `k256` and `ed25519` name the same thing; the agent normalized these too. -fn normalize_algorithm(algorithm: &str) -> &str { - match algorithm { - "k256" => "secp256k1", - other => other, - } -} - -fn parse_k256_signature(signature: &[u8]) -> Result { - let sig = K256Signature::from_slice(signature).context("invalid secp256k1 signature")?; - // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. k256 rejects the - // high-S form, so we must too -- otherwise a signature stops being a unique - // identifier for a signed message, and this SDK would disagree with every - // other dstack component about whether a given blob is valid. - if sig.normalize_s().is_some() { - bail!("non-canonical (high-S) secp256k1 signature"); - } - Ok(sig) -} - -/// Verifies one signature against `public_key`. -/// -/// `algorithm` is `ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, -/// where `data` is already a 32-byte digest. Returns `Ok(false)` when the inputs -/// are well-formed but the signature does not check out, and `Err` when they are -/// not well-formed at all (bad key encoding, wrong signature length, unknown -/// algorithm) -- a malformed input is a caller bug, not a verdict. -pub fn verify_signature( - algorithm: &str, - data: &[u8], - signature: &[u8], - public_key: &[u8], -) -> Result { - match normalize_algorithm(algorithm) { - "ed25519" => { - let key_bytes: [u8; 32] = public_key - .try_into() - .ok() - .context("ed25519 public key must be 32 bytes")?; - let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&key_bytes) - .context("invalid ed25519 public key")?; - let signature = ed25519_dalek::Signature::from_slice(signature) - .context("invalid ed25519 signature")?; - Ok(ed25519_dalek::Verifier::verify(&verifying_key, data, &signature).is_ok()) - } - "secp256k1" => { - let verifying_key = VerifyingKey::from_sec1_bytes(public_key) - .context("invalid secp256k1 public key")?; - let signature = parse_k256_signature(signature)?; - // k256's `sign` hashes with SHA-256, so verification must too. - Ok(k256::ecdsa::signature::Verifier::verify(&verifying_key, data, &signature).is_ok()) - } - "secp256k1_prehashed" => { - if data.len() != 32 { - bail!( - "pre-hashed verification requires a 32-byte digest, but received {} bytes", - data.len() - ); - } - let verifying_key = VerifyingKey::from_sec1_bytes(public_key) - .context("invalid secp256k1 public key")?; - let signature = parse_k256_signature(signature)?; - Ok(verifying_key.verify_prehash(data, &signature).is_ok()) - } - other => bail!("unsupported algorithm: {other}"), - } -} - -/// Recovers the compressed public key that produced a 65-byte `r ‖ s ‖ recid` -/// signature over `keccak256(message)`. -fn recover_compressed(message: &[u8], signature: &[u8]) -> Result> { - if signature.len() != 65 { - bail!( - "recoverable signature must be 65 bytes, but received {}", - signature.len() - ); - } - let sig = parse_k256_signature(&signature[..64])?; - let recid = RecoveryId::from_byte(signature[64]) - .with_context(|| format!("invalid recovery id {}", signature[64]))?; - let digest = ::new_with_prefix(message); - let recovered = VerifyingKey::recover_from_digest(digest, &sig, recid) - .context("failed to recover public key")?; - Ok(recovered.to_sec1_bytes().to_vec()) -} - -/// What a verified chain establishes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VerifiedChain { - /// The app root public key, recovered from the chain and confirmed to be the - /// one this KMS root signed. Compressed SEC1, 33 bytes. - pub app_root_pubkey: Vec, -} - -/// Inputs to [`verify_signature_chain`]. -/// -/// A struct rather than a positional argument list so that adding an input later -/// does not break callers. -#[derive(Debug, Clone)] -pub struct SignatureChain<'a> { - /// Algorithm the payload was signed with. - pub algorithm: &'a str, - /// The signed payload; a 32-byte digest for `secp256k1_prehashed`. - pub data: &'a [u8], - /// `SignResponse::public_key` -- the key that signed `data`. - pub public_key: &'a [u8], - /// `SignResponse::signature_chain`, exactly 3 elements. - pub signature_chain: &'a [Vec], - /// The 20-byte app identity to hold the chain to. - /// - /// This must be the app id you *expect*, not merely whatever `AppInfo` - /// echoed back -- that comes from the CVM being checked. Comparing a chain - /// against an app id the same CVM supplied proves only that it is - /// self-consistent. - pub app_id: &'a [u8], - /// The KMS root public key you already trust, compressed or uncompressed SEC1. - /// - /// Get it from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it. - /// Reading it from the KMS you are verifying against proves nothing. - pub kms_root_pubkey: &'a [u8], - /// Purpose bound into the app-root link. Always [`SIGN_PURPOSE`] for `Sign`. - pub purpose: &'a str, -} - -impl<'a> SignatureChain<'a> { - /// A chain as produced by the `Sign` RPC, which fixes purpose to `signing`. - pub fn from_sign_response( - algorithm: &'a str, - data: &'a [u8], - public_key: &'a [u8], - signature_chain: &'a [Vec], - app_id: &'a [u8], - kms_root_pubkey: &'a [u8], - ) -> Self { - Self { - algorithm, - data, - public_key, - signature_chain, - app_id, - kms_root_pubkey, - purpose: SIGN_PURPOSE, - } - } -} - -/// Verifies a `Sign` signature chain end to end. -/// -/// Three links, all of which must hold: -/// -/// 1. `chain[0]` is a signature over `data` by `public_key`. -/// 2. `chain[1]` is the app root key attesting `"{purpose}:{hex(public_key)}"`. -/// 3. `chain[2]` is `kms_root_pubkey` attesting that app root key for `app_id`. -/// -/// Link 3 is the one that matters. Without comparing against a KMS root key you -/// independently trust, a chain is just three signatures an attacker could have -/// produced with their own keys. -pub fn verify_signature_chain(chain: &SignatureChain<'_>) -> Result { - if chain.signature_chain.len() != 3 { - bail!( - "signature chain must have 3 elements, but received {}", - chain.signature_chain.len() - ); - } - if chain.app_id.len() != 20 { - bail!( - "app_id must be 20 bytes, but received {}", - chain.app_id.len() - ); - } - - // Link 1: the payload signature. chain[0] *is* that signature; what matters - // is that it checks out under `public_key`, which links 2 and 3 then cover. - if !verify_signature( - chain.algorithm, - chain.data, - &chain.signature_chain[0], - chain.public_key, - ) - .context("failed to check the payload signature")? - { - bail!("payload signature is not valid for the given public key"); - } - - // Link 2: recover the app root key that vouched for the signing key. - let message = format!("{}:{}", chain.purpose, hex::encode(chain.public_key)); - let app_root_pubkey = recover_compressed(message.as_bytes(), &chain.signature_chain[1]) - .context("failed to recover the app root key")?; - - // Link 3: recover the KMS root key that vouched for the app root key, and - // check it is the one we were told to trust. - let kms_message = [ - KMS_ISSUED_PREFIX, - b":", - chain.app_id, - app_root_pubkey.as_slice(), - ] - .concat(); - let recovered_kms = recover_compressed(&kms_message, &chain.signature_chain[2]) - .context("failed to recover the KMS root key")?; - - // Normalize the expected key so callers may pass either SEC1 encoding. - let expected_kms = VerifyingKey::from_sec1_bytes(chain.kms_root_pubkey) - .context("invalid KMS root public key")? - .to_sec1_bytes() - .to_vec(); - if recovered_kms != expected_kms { - bail!("signature chain is not anchored at the expected KMS root key"); - } - - Ok(VerifiedChain { app_root_pubkey }) -} diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 144fc64df..bb84697dd 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -6,8 +6,7 @@ // SPDX-License-Identifier: Apache-2.0 use dcap_qvl::quote::Quote; -use dstack_sdk::dstack_client::{AttestConfig, DstackClient as AsyncDstackClient}; -use dstack_sdk::verify::verify_signature; +use dstack_sdk::dstack_client::DstackClientV0 as AsyncDstackClient; use sha2::{Digest, Sha256}; #[tokio::test] @@ -25,54 +24,6 @@ async fn test_async_client_get_quote() { assert!(!result.quote.is_empty()); } -#[tokio::test] -async fn test_async_client_attest_gpu_validates_nonce_length() { - let client = AsyncDstackClient::new(None); - for len in [0, 31, 33] { - assert!( - client.attest_gpu(vec![0u8; len]).await.is_err(), - "a {len}-byte nonce must be rejected" - ); - } - // The simulator ships no nvattest, so a well-formed request must still fail - // fast with an error rather than hang for the attestation timeout. - assert!(client.attest_gpu(vec![0xab; 32]).await.is_err()); -} - -#[tokio::test] -async fn test_async_client_attest() { - let client = AsyncDstackClient::new(None); - let result = client.attest(b"test".to_vec()).await.unwrap(); - let attestation = result.decode_attestation().unwrap(); - assert!(!attestation.is_empty()); - assert!(result.boottime_gpu_evidence.is_empty()); - - let too_large = client.attest(vec![0_u8; 65]).await; - assert!(too_large.is_err()); -} - -#[tokio::test] -async fn test_async_client_attest_with_boottime_gpu_evidence() { - let client = AsyncDstackClient::new(None); - let config = AttestConfig::builder() - .report_data(hex::encode(b"test")) - .include_boottime_gpu_evidence(true) - .build(); - let result = client.attest_with(config).await.unwrap(); - assert!(!result.decode_attestation().unwrap().is_empty()); - // Whether evidence exists depends on the host, so assert the request - // round-trips and the field is populated from the same source as GpuInfo. - assert_eq!( - result.boottime_gpu_evidence, - client.gpu_info().await.unwrap().attestation - ); - - let too_large = AttestConfig::builder() - .report_data(hex::encode([0_u8; 65])) - .build(); - assert!(client.attest_with(too_large).await.is_err()); -} - #[tokio::test] async fn test_async_client_get_tls_key() { let client = AsyncDstackClient::new(None); @@ -165,11 +116,12 @@ async fn test_async_client_sign_k256_alias() { assert_eq!(resp_k256.public_key, resp_secp.public_key); } -// The Sign RPC is still server-side; only the checking of its result moved into -// the SDK. These replace the round trips that used to call the removed Verify RPC. +// Sign and Verify are both v0 RPCs, so the round trip stays entirely on the +// frozen surface. v1 has neither: an app there signs locally with the key +// `get_key` returns, and a relying party verifies per `docs/guest-api-v1.md`. #[tokio::test] -async fn test_sign_then_verify_locally_ed25519() { +async fn test_sign_then_verify_ed25519() { let client = AsyncDstackClient::new(None); let data = b"test message for ed25519".to_vec(); let resp = client.sign("ed25519", data.clone()).await.unwrap(); @@ -177,12 +129,24 @@ async fn test_sign_then_verify_locally_ed25519() { let public_key = resp.decode_public_key().unwrap(); assert_eq!(resp.signature_chain.len(), 3); - assert!(verify_signature("ed25519", &data, &signature, &public_key).unwrap()); - assert!(!verify_signature("ed25519", b"wrong message", &signature, &public_key).unwrap()); + assert!( + client + .verify("ed25519", data, signature.clone(), public_key.clone()) + .await + .unwrap() + .valid + ); + assert!( + !client + .verify("ed25519", b"wrong message".to_vec(), signature, public_key) + .await + .unwrap() + .valid + ); } #[tokio::test] -async fn test_sign_then_verify_locally_secp256k1() { +async fn test_sign_then_verify_secp256k1() { let client = AsyncDstackClient::new(None); let data = b"test message for secp256k1".to_vec(); let resp = client.sign("secp256k1", data.clone()).await.unwrap(); @@ -190,12 +154,29 @@ async fn test_sign_then_verify_locally_secp256k1() { let public_key = resp.decode_public_key().unwrap(); assert_eq!(resp.signature_chain.len(), 3); - assert!(verify_signature("secp256k1", &data, &signature, &public_key).unwrap()); - assert!(!verify_signature("secp256k1", b"wrong message", &signature, &public_key).unwrap()); + assert!( + client + .verify("secp256k1", data, signature.clone(), public_key.clone()) + .await + .unwrap() + .valid + ); + assert!( + !client + .verify( + "secp256k1", + b"wrong message".to_vec(), + signature, + public_key + ) + .await + .unwrap() + .valid + ); } #[tokio::test] -async fn test_sign_then_verify_locally_secp256k1_prehashed() { +async fn test_sign_then_verify_secp256k1_prehashed() { let client = AsyncDstackClient::new(None); let digest = Sha256::digest(b"test message for prehashed").to_vec(); let resp = client @@ -206,5 +187,11 @@ async fn test_sign_then_verify_locally_secp256k1_prehashed() { let public_key = resp.decode_public_key().unwrap(); assert_eq!(resp.signature_chain.len(), 3); - assert!(verify_signature("secp256k1_prehashed", &digest, &signature, &public_key).unwrap()); + assert!( + client + .verify("secp256k1_prehashed", digest, signature, public_key) + .await + .unwrap() + .valid + ); } diff --git a/sdk/rust/tests/test_client_v1.rs b/sdk/rust/tests/test_client_v1.rs new file mode 100644 index 000000000..6cd00d28e --- /dev/null +++ b/sdk/rust/tests/test_client_v1.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! `DstackClientV1` against the guest-agent simulator. + +use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v1::{DstackClientV1, IssueCertConfig}; + +fn client() -> DstackClientV1 { + DstackClientV1::new(None) +} + +#[tokio::test] +async fn version_answers_on_the_v1_surface() { + let response = client().version().await.unwrap(); + assert!(!response.version.is_empty()); +} + +#[tokio::test] +async fn get_key_returns_a_key_public_key_and_two_link_chain() { + for algorithm in ["secp256k1", "ed25519"] { + let response = client().get_key("storage-encryption", algorithm).await.unwrap(); + + // 32 raw bytes for both algorithms, hex-encoded on the wire. + assert_eq!(response.decode_key().unwrap().len(), 32); + // The chain is the key's chain and nothing else: the claim link and the + // KMS link. v0's `Sign` prepended the payload signature to its list, so + // the real chain there started at index 1. + assert_eq!(response.signature_chain.len(), 2); + assert_eq!(response.decode_signature_chain().unwrap()[0].len(), 65); + } +} + +#[tokio::test] +async fn get_key_public_key_lengths_are_the_specified_ones() { + let secp = client().get_key("storage-encryption", "secp256k1").await.unwrap(); + assert_eq!( + secp.decode_public_key().unwrap().len(), + 33, + "SEC1 compressed" + ); + + let ed = client().get_key("storage-encryption", "ed25519").await.unwrap(); + assert_eq!(ed.decode_public_key().unwrap().len(), 32); +} + +/// v1 has no default algorithm and no `k256` alias, so a caller cannot ask for +/// nothing in particular and get a key. +#[tokio::test] +async fn get_key_rejects_an_empty_or_unknown_algorithm() { + assert!(client().get_key("storage-encryption", "").await.is_err()); + for algorithm in ["k256", "rsa", "secp256k1_prehashed"] { + assert!( + client().get_key("storage-encryption", algorithm).await.is_err(), + "v1 accepted algorithm {algorithm:?}" + ); + } +} + +/// Derivation is flat: a domain that looks like a path is just a domain. +#[tokio::test] +async fn different_domains_yield_different_keys() { + let a = client().get_key("a", "secp256k1").await.unwrap(); + let b = client().get_key("a/b", "secp256k1").await.unwrap(); + assert_ne!(a.key, b.key); +} + +/// The two curves no longer share one secret, which is the whole reason the v1 +/// KDF exists. +#[tokio::test] +async fn the_two_algorithms_never_share_key_material() { + let secp = client().get_key("storage-encryption", "secp256k1").await.unwrap(); + let ed = client().get_key("storage-encryption", "ed25519").await.unwrap(); + assert_ne!(secp.key, ed.key); +} + +/// **v1 keys are not v0 keys.** This is the migration contract, asserted rather +/// than merely documented: an app that reuses its v0 path as a v1 domain gets +/// different key material. +#[tokio::test] +async fn v1_keys_differ_from_v0_keys_for_the_same_name() { + let v1 = client().get_key("test", "secp256k1").await.unwrap(); + let v0 = DstackClientV0::new(None) + .get_key(Some("test".to_string()), Some("signing".to_string())) + .await + .unwrap(); + assert_ne!(v1.key, v0.key); +} + +#[tokio::test] +async fn attest_returns_an_attestation() { + let response = client().attest(b"test".to_vec(), false).await.unwrap(); + assert!(!response.decode_attestation().unwrap().is_empty()); + assert!(response.boottime_gpu_evidence.is_empty()); +} + +/// Boot-time GPU evidence comes back in the same bundle shape `attest_gpu` +/// uses, so a consumer needs one parser rather than two. Absence is the empty +/// list, not a sentinel. +#[tokio::test] +async fn attest_can_ask_for_the_boot_time_gpu_evidence() { + // The simulator has no GPU output, so the list comes back empty. What is + // under test is that the flag is accepted on this surface at all -- it is + // reserved on v0 -- and that the field decodes as a bundle list. + let response = client().attest(b"test".to_vec(), true).await.unwrap(); + assert!(!response.decode_attestation().unwrap().is_empty()); + + let bundles: &Vec = + &response.boottime_gpu_evidence; + assert!(bundles.is_empty(), "the simulator has no GPU output"); + for bundle in bundles { + assert_eq!(bundle.vendor, "nvidia"); + assert_eq!(bundle.format, dstack_sdk::dstack_client_v1::FORMAT_BOOTTIME); + assert!(bundle.decode_evidence().is_ok()); + } +} + +/// The two sources must stay distinguishable: a verifier for the live +/// measurement does not appraise the boot record. +#[test] +fn the_two_gpu_evidence_formats_are_distinct() { + use dstack_sdk::dstack_client_v1::{FORMAT_BOOTTIME, FORMAT_ON_DEMAND}; + assert_eq!(FORMAT_BOOTTIME, "nvidia-nvattest-boottime-json-v1"); + assert_eq!(FORMAT_ON_DEMAND, "nvidia-nvattest-collect-evidence-json-v1"); + assert_ne!(FORMAT_BOOTTIME, FORMAT_ON_DEMAND); +} + +#[tokio::test] +async fn attest_rejects_report_data_outside_1_to_64_bytes() { + assert!(client().attest(vec![], false).await.is_err()); + assert!(client().attest(vec![0u8; 65], false).await.is_err()); +} + +#[tokio::test] +async fn attest_gpu_validates_the_nonce_length() { + for len in [0usize, 16, 31, 33, 64] { + assert!( + client().attest_gpu(vec![0u8; len]).await.is_err(), + "a {len}-byte nonce must be rejected" + ); + } + // A correctly sized nonce gets past the client and fails at the simulator, + // which has no GPU to attest. + assert!(client().attest_gpu(vec![0xab; 32]).await.is_err()); +} + +#[tokio::test] +async fn info_reports_identity_and_configuration() { + let info = client().info().await.unwrap(); + + assert!(!info.decode_app_id().unwrap().is_empty()); + assert!(!info.decode_instance_id().unwrap().is_empty()); + assert_eq!(info.decode_compose_hash().unwrap().len(), 32); + // The app-compose document is served directly rather than nested inside a + // `tcb_info` JSON string, which is what v0 did. + assert!(info.app_compose.starts_with('{')); +} + +#[tokio::test] +async fn issue_cert_returns_a_key_and_a_chain() { + let response = client() + .issue_cert( + IssueCertConfig::builder() + .subject("example.com") + .usage_server_auth(true) + .build(), + ) + .await + .unwrap(); + + assert!(response.key.contains("BEGIN"), "expected a PEM key"); + assert!(!response.certificate_chain.is_empty()); +} + +/// Committed v1 key vectors, as the agent produces them from the simulator's +/// fixed app root key (`sdk/simulator/appkeys.json`). +/// +/// The SDK derives nothing itself -- it is a transport mirror -- so what this +/// pins is the pair of things it can actually get wrong or notice: that the +/// client decodes the v1 wire format correctly, and that the agent's KDF has +/// not moved under it. The construction these bytes come from is specified in +/// `docs/guest-api-v1.md` and implemented once in `ra_tls::api_v1`, which +/// is the source of truth; the vectors there pin the primitive directly. +/// +/// A diff here is a change to deployed key material. Fix the derivation, do not +/// update the vector. +#[tokio::test] +async fn derives_the_committed_key_vectors() { + let vectors = [ + ( + "storage-encryption", + "secp256k1", + "b9fa657a9b12a35468341fe9204cad53d393b35f05184546fbc5c329a526cf79", + "0380a54b49c2ad61341d7ade1f41df5061783f8be45911bed81a8048bed2a60b36", + ), + ( + "storage-encryption", + "ed25519", + "4330ca9a8816f4e2be49b6b1c54a619940d70263429e9efe1fa5c3e269ef2786", + "ec766df0797ac4be0e85af6cd48cf26c834527ec0156550cbd4e68c9934748b7", + ), + ( + "", + "secp256k1", + "1405a12f0670bad157ae87f2da4f29e531bde7d05ff17a070e5191120557613a", + "02b3634254d8ec857b5149237c7232c9c9355a3186b20d3e873029f7a161a50284", + ), + ]; + for (domain, algorithm, expected_key, expected_public_key) in vectors { + let response = client().get_key(domain, algorithm).await.unwrap(); + assert_eq!( + response.key, expected_key, + "v1 key vector changed for ({domain:?}, {algorithm})" + ); + assert_eq!( + response.public_key, expected_public_key, + "v1 public key vector changed for ({domain:?}, {algorithm})" + ); + } +} + +/// The private key `issue_cert` returns is not derived from the app identity: +/// two identical requests produce two unrelated keys. +#[tokio::test] +async fn issue_cert_generates_a_fresh_key_per_call() { + let config = || IssueCertConfig::builder().subject("example.com").build(); + let first = client().issue_cert(config()).await.unwrap(); + let second = client().issue_cert(config()).await.unwrap(); + assert_ne!(first.key, second.key); +} diff --git a/sdk/rust/tests/test_eth.rs b/sdk/rust/tests/test_eth.rs index 4078fe5d4..2b6bea755 100644 --- a/sdk/rust/tests/test_eth.rs +++ b/sdk/rust/tests/test_eth.rs @@ -4,13 +4,13 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClient; +use dstack_sdk::dstack_client::DstackClientV0; use dstack_sdk::ethereum::to_account; use dstack_sdk_types::dstack::GetKeyResponse; #[tokio::test] async fn test_async_to_keypair() { - let client = DstackClient::new(None); + let client = DstackClientV0::new(None); let result = client .get_key(Some("test".to_string()), None) .await diff --git a/sdk/rust/tests/test_verify.rs b/sdk/rust/tests/test_verify.rs deleted file mode 100644 index f36d7f48b..000000000 --- a/sdk/rust/tests/test_verify.rs +++ /dev/null @@ -1,210 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -//! Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. -//! The Python, Go and JavaScript suites assert against the same file, so any port -//! that disagrees about the byte format fails here too. - -use dstack_sdk::verify::{verify_signature, verify_signature_chain, SignatureChain, SIGN_PURPOSE}; -use serde_json::Value; - -fn vectors() -> Value { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../tests/vectors/signature_chain.json" - ); - serde_json::from_str(&std::fs::read_to_string(path).expect("read vectors")).expect("parse") -} - -fn unhex(v: &Value) -> Vec { - hex::decode(v.as_str().expect("hex string")).expect("valid hex") -} - -#[test] -fn valid_signatures_verify() { - let v = vectors(); - for case in v["cases"].as_array().unwrap() { - let algorithm = case["algorithm"].as_str().unwrap(); - assert!( - verify_signature( - algorithm, - &unhex(&case["data"]), - &unhex(&case["signature"]), - &unhex(&case["public_key"]), - ) - .unwrap_or_else(|e| panic!("{algorithm}: {e}")), - "{algorithm}: valid signature was rejected" - ); - } -} - -#[test] -fn invalid_signatures_are_rejected() { - let v = vectors(); - for case in v["invalid_cases"].as_array().unwrap() { - let name = case["name"].as_str().unwrap(); - let verdict = verify_signature( - case["algorithm"].as_str().unwrap(), - &unhex(&case["data"]), - &unhex(&case["signature"]), - &unhex(&case["public_key"]), - ); - // High-S is refused outright rather than reported false, because it is a - // malformed encoding rather than a legitimate signature that fails to match. - match verdict { - Ok(valid) => assert!(!valid, "{name}: should not have verified"), - Err(_) if name == "secp256k1_high_s" => {} - Err(e) => panic!("{name}: unexpected error {e}"), - } - } -} - -#[test] -fn k256_is_an_alias_for_secp256k1() { - let v = vectors(); - let case = v["cases"] - .as_array() - .unwrap() - .iter() - .find(|c| c["algorithm"] == "secp256k1") - .unwrap(); - assert!(verify_signature( - "k256", - &unhex(&case["data"]), - &unhex(&case["signature"]), - &unhex(&case["public_key"]), - ) - .unwrap()); -} - -fn chain_of(case: &Value) -> Vec> { - case["signature_chain"] - .as_array() - .unwrap() - .iter() - .map(unhex) - .collect() -} - -#[test] -fn full_chain_verifies_to_the_kms_root() { - let v = vectors(); - let app_id = unhex(&v["app_id"]); - let kms_root = unhex(&v["kms_root_pubkey"]); - let expected_app_root = unhex(&v["app_root_pubkey"]); - - for case in v["cases"].as_array().unwrap() { - let algorithm = case["algorithm"].as_str().unwrap(); - let data = unhex(&case["data"]); - let public_key = unhex(&case["public_key"]); - let chain = chain_of(case); - let verified = verify_signature_chain(&SignatureChain::from_sign_response( - algorithm, - &data, - &public_key, - &chain, - &app_id, - &kms_root, - )) - .unwrap_or_else(|e| panic!("{algorithm}: {e}")); - assert_eq!( - verified.app_root_pubkey, expected_app_root, - "{algorithm}: recovered the wrong app root key" - ); - } -} - -#[test] -fn chain_anchored_at_a_foreign_kms_root_is_rejected() { - let v = vectors(); - let app_id = unhex(&v["app_id"]); - let wrong_root = unhex(&v["wrong_kms_root_pubkey"]); - let case = &v["cases"].as_array().unwrap()[0]; - let data = unhex(&case["data"]); - let public_key = unhex(&case["public_key"]); - let chain = chain_of(case); - - let err = verify_signature_chain(&SignatureChain::from_sign_response( - case["algorithm"].as_str().unwrap(), - &data, - &public_key, - &chain, - &app_id, - &wrong_root, - )) - .expect_err("a chain not anchored at our KMS root must be rejected"); - assert!( - err.to_string().contains("not anchored"), - "unexpected error: {err}" - ); -} - -#[test] -fn chain_for_a_different_app_id_is_rejected() { - let v = vectors(); - let kms_root = unhex(&v["kms_root_pubkey"]); - let case = &v["cases"].as_array().unwrap()[0]; - let data = unhex(&case["data"]); - let public_key = unhex(&case["public_key"]); - let chain = chain_of(case); - let mut app_id = unhex(&v["app_id"]); - app_id[0] ^= 0xff; - - assert!(verify_signature_chain(&SignatureChain::from_sign_response( - case["algorithm"].as_str().unwrap(), - &data, - &public_key, - &chain, - &app_id, - &kms_root, - )) - .is_err()); -} - -#[test] -fn tampered_payload_breaks_the_chain() { - let v = vectors(); - let app_id = unhex(&v["app_id"]); - let kms_root = unhex(&v["kms_root_pubkey"]); - let case = &v["cases"].as_array().unwrap()[0]; - let public_key = unhex(&case["public_key"]); - let chain = chain_of(case); - let tampered = b"a different payload entirely".to_vec(); - - assert!(verify_signature_chain(&SignatureChain::from_sign_response( - case["algorithm"].as_str().unwrap(), - &tampered, - &public_key, - &chain, - &app_id, - &kms_root, - )) - .is_err()); -} - -#[test] -fn malformed_inputs_error_rather_than_report_false() { - assert!(verify_signature("rsa", b"x", &[0; 64], &[0; 32]).is_err()); - assert!(verify_signature("ed25519", b"x", &[0; 64], &[0; 31]).is_err()); - // A prehashed digest must be exactly 32 bytes. - let v = vectors(); - let case = v["cases"] - .as_array() - .unwrap() - .iter() - .find(|c| c["algorithm"] == "secp256k1_prehashed") - .unwrap(); - assert!(verify_signature( - "secp256k1_prehashed", - b"short", - &unhex(&case["signature"]), - &unhex(&case["public_key"]), - ) - .is_err()); -} - -#[test] -fn sign_purpose_is_the_agent_side_constant() { - assert_eq!(SIGN_PURPOSE, "signing"); -} diff --git a/sdk/rust/types/Cargo.toml b/sdk/rust/types/Cargo.toml index 21c1f4228..d10d9f3ed 100644 --- a/sdk/rust/types/Cargo.toml +++ b/sdk/rust/types/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "dstack-sdk-types" -version = "0.1.3" +version = "0.6.0" edition = "2021" license = "MIT" description = "This crate provides rust types for communication with dstack" diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index e0f76c5e3..8a449d968 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -96,58 +96,6 @@ pub struct GetQuoteResponse { pub struct AttestResponse { /// The attestation in hexadecimal format pub attestation: String, - /// Complete JSON output produced by nvattest during guest boot. Empty - /// unless the request set `include_boottime_gpu_evidence` and the guest has - /// boot-time GPU attestation output. - /// - /// Not bound to `report_data`: verify it by replaying the runtime event log - /// and comparing sha256 of these exact UTF-8 bytes against - /// `evidence_sha256` in the `gpu-attestation` event. - #[serde(default)] - pub boottime_gpu_evidence: String, -} - -/// Configuration for a versioned attestation request -#[derive(Debug, bon::Builder, Serialize, Deserialize)] -#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] -#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] -pub struct AttestConfig { - /// The report data in hexadecimal format, at most 64 bytes once decoded - #[builder(into)] - pub report_data: String, - /// Also return the boot-time GPU attestation evidence in `boottime_gpu_evidence` - #[builder(default = false)] - pub include_boottime_gpu_evidence: bool, -} - -/// Response from fresh, on-demand GPU evidence collection. -/// -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] -#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] -pub struct AttestGpuResponse { - pub bundles: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] -#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] -pub struct GpuEvidenceBundle { - /// Stable GPU vendor identifier. - pub vendor: String, - /// Vendor-specific evidence format and version. - pub format: String, - /// Hex-encoded opaque evidence bytes, as represented by the JSON RPC. - pub evidence: String, -} - -/// Response containing the complete NVIDIA GPU attestation output. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] -#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] -pub struct GpuInfoResponse { - /// Complete JSON output produced by nvattest during guest boot. - pub attestation: String, } impl AttestResponse { @@ -278,6 +226,15 @@ impl SignResponse { } } +/// Response from a Verify request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct VerifyResponse { + /// Whether the signature is valid + pub valid: bool, +} + /// Response from a Version request #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] diff --git a/sdk/rust/types/src/dstack_v1.rs b/sdk/rust/types/src/dstack_v1.rs new file mode 100644 index 000000000..2c59c908d --- /dev/null +++ b/sdk/rust/types/src/dstack_v1.rs @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Types for the `dstack.guest.v1` API surface. +//! +//! Separate from [`crate::dstack`] because the two surfaces are separate +//! contracts, not versions of one type: v1's `GetKeyResponse` carries a public +//! key the v0 one has no field for, and its `InfoResponse` is flat where the v0 +//! one nests a `tcb_info` document. Sharing a type between them would mean one +//! of the two lying about what the agent sent. +//! +//! Wire encoding follows the v0 convention: every protobuf `bytes` field is a +//! lowercase hex string in JSON, with a `decode_*` helper next to it. Fields +//! carrying JSON documents (`app_compose`, `vm_config`, `key_provider_info`, +//! `boottime_gpu_evidence`) are plain strings and are passed through unparsed. + +use alloc::{string::String, vec::Vec}; +use hex::FromHexError; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "borsh_schema")] +use borsh::BorshSchema; +#[cfg(feature = "borsh")] +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Configuration for a certificate issuance request. +#[derive(Debug, bon::Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct IssueCertConfig { + /// The subject name for the certificate + #[builder(into, default = String::new())] + pub subject: String, + /// Alternative names for the certificate + #[builder(default = Vec::new())] + pub alt_names: Vec, + /// Include the attestation quote in the certificate (RA-TLS) + #[builder(default = false)] + pub usage_ra_tls: bool, + /// Whether the certificate may be used for server authentication + #[builder(default = true)] + pub usage_server_auth: bool, + /// Whether the certificate may be used for client authentication + #[builder(default = false)] + pub usage_client_auth: bool, + /// Include app info in the certificate + #[builder(default = false)] + pub with_app_info: bool, + /// Validity start, seconds since the UNIX epoch + #[serde(skip_serializing_if = "Option::is_none")] + pub not_before: Option, + /// Validity end, seconds since the UNIX epoch + #[serde(skip_serializing_if = "Option::is_none")] + pub not_after: Option, +} + +/// A freshly issued certificate and the key that backs it. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct IssueCertResponse { + /// The private key the agent generated for this certificate, PEM-encoded. + /// + /// Fresh per call and not derived from the app identity: two identical + /// requests produce two unrelated keys. [`super::dstack_v1`]'s `get_key` is + /// the method that derives a stable, attestable key. + pub key: String, + /// The certificate chain, leaf first, each entry PEM-encoded + pub certificate_chain: Vec, +} + +/// A derived application key with its signature chain. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct GetKeyResponse { + /// The derived private key, hex-encoded. 32 bytes for both algorithms. + pub key: String, + /// The corresponding public key, hex-encoded. SEC1 compressed (33 bytes) + /// for secp256k1, raw (32 bytes) for ed25519. + /// + /// This is the exact byte string the chain's first link commits to, so a + /// relying party never has to re-derive it from `key`. + pub public_key: String, + /// Two links, hex-encoded: the app root key's signature over the v1 key + /// claim, then the KMS root key's signature over the app root public key. + /// + /// `docs/guest-api-v1.md` specifies the claim encoding and the verification + /// steps. Verifying is the relying party's job; this SDK does not do it. + pub signature_chain: Vec, +} + +impl GetKeyResponse { + pub fn decode_key(&self) -> Result, FromHexError> { + hex::decode(&self.key) + } + + pub fn decode_public_key(&self) -> Result, FromHexError> { + hex::decode(&self.public_key) + } + + pub fn decode_signature_chain(&self) -> Result>, FromHexError> { + self.signature_chain.iter().map(hex::decode).collect() + } +} + +/// Configuration for a v1 attestation request. +#[derive(Debug, bon::Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct AttestConfig { + /// The report data in hexadecimal format, at most 64 bytes once decoded + #[builder(into)] + pub report_data: String, + /// Also return the boot-time GPU attestation evidence + #[builder(default = false)] + pub include_boottime_gpu_evidence: bool, +} + +/// A versioned attestation. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct AttestResponse { + /// The attestation, hex-encoded + pub attestation: String, + /// Boot-time GPU attestation evidence. Empty unless the request asked for + /// it and boot-time output exists, so absence is just the empty list. + /// + /// The same [`GpuEvidenceBundle`] shape `attest_gpu` returns, so one parser + /// handles both. Dispatch on `format`: [`FORMAT_BOOTTIME`] is the record + /// written at boot, [`FORMAT_ON_DEMAND`] is collected against a caller's + /// nonce, and a verifier for one does not appraise the other. + /// + /// Each bundle's `evidence` decodes to the nvattest output byte for byte. + /// That exactness is the contract: the only thing binding this evidence to + /// the boot is sha256 over precisely those bytes, compared against + /// `evidence_sha256` in the measured `gpu-attestation` event after + /// replaying the runtime event log. Re-serializing the JSON first changes + /// the digest. + /// + /// Not bound to `report_data`: nvattest ran at boot against its own nonce. + #[serde(default)] + pub boottime_gpu_evidence: Vec, +} + +impl AttestResponse { + pub fn decode_attestation(&self) -> Result, FromHexError> { + hex::decode(&self.attestation) + } +} + +/// Vendor-native GPU evidence collected on demand. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct AttestGpuResponse { + /// Select a verifier by vendor and format, then check the signature, + /// certificate chain, measurements, and the nonce embedded in the evidence. + pub bundles: Vec, +} + +/// Evidence collected on demand, against a caller-chosen nonce. +pub const FORMAT_ON_DEMAND: &str = "nvidia-nvattest-collect-evidence-json-v1"; + +/// The evidence record nvattest wrote at boot. +pub const FORMAT_BOOTTIME: &str = "nvidia-nvattest-boottime-json-v1"; + +/// One vendor's evidence. +/// +/// Shared by `attest_gpu` and `attest`'s boot-time evidence so a consumer +/// writes one parser for both; `(vendor, format)` says which is which. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct GpuEvidenceBundle { + /// Stable GPU vendor identifier, for example `nvidia` + pub vendor: String, + /// Vendor-specific evidence format and version + pub format: String, + /// Hex-encoded opaque vendor-native evidence bytes + pub evidence: String, +} + +impl GpuEvidenceBundle { + pub fn decode_evidence(&self) -> Result, FromHexError> { + hex::decode(&self.evidence) + } +} + +/// Application identity and configuration. +/// +/// Identity and configuration only, never attestation. The measurement +/// registers and the event log are deliberately absent: they are attestation +/// data, and this response arrives over a local socket with nothing vouching +/// for it. Ask `attest` and verify. +/// +/// `mr_aggregated`, `os_image_hash` and `compose_hash` are the exception -- +/// they identify *which* application and image this is, which is the question +/// `info` answers. They are still unattested. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct InfoResponse { + /// App ID, hex-encoded + pub app_id: String, + /// App name, from app-compose + pub app_name: String, + /// Compose hash, hex-encoded. sha256 over the verbatim bytes of + /// `app_compose`; do not re-serialize before hashing. + pub compose_hash: String, + /// The app-compose document, exactly as deployed. Empty on the external + /// surface unless the app set `public_tcbinfo`. + #[serde(default)] + pub app_compose: String, + /// App instance ID, hex-encoded + pub instance_id: String, + /// Device ID, hex-encoded. Identifies the host machine, not this instance. + pub device_id: String, + /// OS image hash, hex-encoded + #[serde(default)] + pub os_image_hash: String, + /// Aggregated measurement register value, hex-encoded + #[serde(default)] + pub mr_aggregated: String, + /// The VM's hardware configuration, as a JSON document produced by the VMM + #[serde(default)] + pub vm_config: String, + /// The key provider that supplied this app's keys, as a JSON document + #[serde(default)] + pub key_provider_info: String, + /// Cloud provider sys_vendor + #[serde(default)] + pub cloud_vendor: String, + /// Cloud provider product_name + #[serde(default)] + pub cloud_product: String, +} + +impl InfoResponse { + pub fn decode_app_id(&self) -> Result, FromHexError> { + hex::decode(&self.app_id) + } + + pub fn decode_instance_id(&self) -> Result, FromHexError> { + hex::decode(&self.instance_id) + } + + pub fn decode_compose_hash(&self) -> Result, FromHexError> { + hex::decode(&self.compose_hash) + } +} + +/// The guest agent version. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] +pub struct VersionResponse { + /// dstack version + pub version: String, + /// Git revision + pub rev: String, +} diff --git a/sdk/rust/types/src/lib.rs b/sdk/rust/types/src/lib.rs index 64befe356..1925a0218 100644 --- a/sdk/rust/types/src/lib.rs +++ b/sdk/rust/types/src/lib.rs @@ -7,4 +7,5 @@ extern crate alloc; pub mod dstack; +pub mod dstack_v1; pub mod tappd; diff --git a/sdk/tests/vectors/signature_chain.json b/sdk/tests/vectors/signature_chain.json deleted file mode 100644 index efb9fc662..000000000 --- a/sdk/tests/vectors/signature_chain.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "_comment": "Generated by dstack/guest-agent/tests/signature_chain_vectors.rs. Do not edit by hand; run with UPDATE_VECTORS=1 to regenerate.", - "app_id": "a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b", - "purpose": "signing", - "path": "vms", - "kms_root_pubkey": "0318f228673448772dd4835a82149a9d475db8095234469a8fcc85cfa18b85abbc", - "app_root_pubkey": "03f8669c2bf08c4deeb712b81d456cd1eadbf97872dfc5d5ca916edf605fc5b6fe", - "cases": [ - { - "algorithm": "ed25519", - "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", - "public_key": "f2c761dfee96542500c1ca5460bb0e327b4acb67d882f5846e21baa8d55d2f5b", - "signature": "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408", - "signature_chain": [ - "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408", - "181498cdcebe6cae41fe1474b3d8af53c7969a2fb20f9f20a5cdcc9d92f5596d500cf9605996ec2e55406666fee630d5882e5e97c2417e079c6f074ca6c7c57301", - "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" - ] - }, - { - "algorithm": "secp256k1", - "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", - "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", - "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", - "signature_chain": [ - "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", - "77ae5ea476f00df11938fd1df3a65a4fbd9f92b64c816b9f29ee79a1e4ae74ed43269a36376b4ed838460ef3b56ba553cefa6e63def4a4487386f783f14295a001", - "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" - ] - }, - { - "algorithm": "secp256k1_prehashed", - "data": "18779657704cffe138dcca907960ddc7c4586078c0a7a60b689de8ec0e9f558b", - "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", - "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", - "signature_chain": [ - "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", - "77ae5ea476f00df11938fd1df3a65a4fbd9f92b64c816b9f29ee79a1e4ae74ed43269a36376b4ed838460ef3b56ba553cefa6e63def4a4487386f783f14295a001", - "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" - ] - } - ], - "invalid_cases": [ - { - "name": "secp256k1_high_s", - "reason": "high-S form of an otherwise valid signature; must be rejected", - "algorithm": "secp256k1", - "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", - "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", - "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc2a37da37585da6583da902408a18b428b9287a4f66f845641d9aa95d78f94df4d" - }, - { - "name": "secp256k1_wrong_data", - "reason": "signature is valid, but not over this data", - "algorithm": "secp256k1", - "data": "6e6f74207468652064617461207468617420776173207369676e6564", - "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", - "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4" - }, - { - "name": "ed25519_wrong_data", - "reason": "signature is valid, but not over this data", - "algorithm": "ed25519", - "data": "6e6f74207468652064617461207468617420776173207369676e6564", - "public_key": "f2c761dfee96542500c1ca5460bb0e327b4acb67d882f5846e21baa8d55d2f5b", - "signature": "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408" - } - ], - "wrong_kms_root_pubkey": "029c5530e4385ebc41cdaf8257edf9a2baaf8506a4099103211e6ed7382103ed67" -}