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.
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.
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.
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 | 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: <Method>"} |
The surface is mounted; it has no such method |
| 400 | {"error": "<message>"} |
The method ran and failed |
| 501 | {"error": "<message>"} |
AttestGpu on an image that ships no GPU attestation |
| other | {"error": "<message>"} |
A handler chose the status; the message says why |
A handler failure is a 400 with the error text in the body unless the handler
chose otherwise, and AttestGpu is the only v1 method that does. v1 does not
default, coerce, or truncate a malformed request into a well-formed one.
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: <Method>"}-- v1 is mounted and does not have that method.
/v1/Version is the cheapest probe: it takes no arguments and touches nothing.
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 specified in
dstack Guest Agent API v0, which is the normative
document for that surface.
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.
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.
Every request message is <Method>Request and every response is
<Method>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.
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.
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.
Every construction below builds its input from length-prefixed fields. Write
LP(x) for:
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.
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.
| 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.
Generated with an app root key of
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/ra-tls/src/api_v1.rs, which pins the
private-key column next to the primitives that produce it;
dstack/guest-agent/src/rpc_service_v1/keys.rs pins the public-key column
through the type the handler actually serves. They describe deployed key
material, so a diff against them is a bug in the derivation, not a stale fixture.
GetKey returns a two-element signature_chain alongside the key.
[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.
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":
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
5b6193729ce7976ec67863f21692d4b98c69832698aae8e001a7d33a6f818b6e
46ca950725b6e90e8ca9bcf394abd03ce264bf9b7eec1e91693247f9dd53c269
01
The signature is deterministic (RFC 6979), so this vector pins the whole encoding including the recovery byte.
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/ra-tls/src/api_v1.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.
Unchanged from v0 and reproduced here so this document is self-contained.
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.
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.
-
Anchor. Obtain the KMS root public key from a source you trust independently of the agent being checked: the
DstackKmscontract'skmsInfo().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. -
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, thendigest0 = keccak256(claim). -
Recover the app root key. Split
signature_chain[0]intor,s,vand recover the secp256k1 public key from(digest0, r, s, v). Reject a non-canonical high-Ss. Call the resultapp_root_pubkey, SEC1 compressed. -
Rebuild the KMS message. Compute
digest1 = keccak256("dstack-kms-issued" || ":" || app_id || app_root_pubkey). -
Check link 1. Verify
signature_chain[1]overdigest1against 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. -
Bind the application. Confirm the
app_idyou 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 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.
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:
- Derive the key:
GetKey(domain, algorithm)on the internal socket. - Commit to it: build
report_dataoverpublic_keyyourself. Prefix it so a verifier can tell what it is looking at -- see thedip1::convention the frozen method uses. - Attest it:
Attest(report_data)on the internal socket. - 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.
An app that never opted in answers healthy: true with an empty unhealthy
and no error, whatever its containers are doing: it declared itself
un-polled, so the agent computes no verdict and returns what the gateway would
have assumed. A caller other than the gateway must read that true as "nobody
asked me to know", not as a health check that passed.
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_composevm_configkey_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.
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.
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:
{ "version": u64,
"platform": { "kind": <tag>, "data": { ... } },
"stack": { "kind": <tag>, "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.
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 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,
which is the authoritative description.
| 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 |
nonce not exactly 32 bytes |
Error naming the required length |
| GPU attestation unavailable in this image | HTTP 501; AttestGpu has nothing to collect |
not_before not earlier than not_after |
Error |
| Unknown method on a mounted surface | HTTP 404, Service not found: <Method> |
/v1/... on an agent that predates v1 |
HTTP 404, no such mount |
Every row that does not name a status is an HTTP 400 with the message in the body. See Status codes for the full mapping. v1 does not default, coerce, or truncate a malformed request into a well-formed one.
The GPU row is a 501 rather than a 400 because it is a property of the image, not of the call: the request is well-formed, and no other request would succeed either. A client that reads 400 there retries with different arguments forever; one that reads 501 stops and falls back to whatever it does without a GPU. A malformed nonce is still the caller's fault and still a 400.
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, and they have their own
normative specification: dstack Guest Agent API v0 pins the
frozen KDF, the :-joined chain claim, and the per-algorithm Sign and Verify
modes. 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.
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 |
- dstack Guest Agent API v0, the normative spec for the frozen unversioned surface
- Attestation on Intel TDX
- Application health checks, for
/prpc/v1/Health - App Compose format, for the schema behind
InfoResponse.app_compose - On-chain governance, for the
DstackKmscontract that publishes the KMS root public key