Skip to content

docs: give the frozen v0 guest API a written contract - #1123

Merged
kvinwang merged 2 commits into
nextfrom
docs/frozen-v0-spec
Aug 25, 2026
Merged

docs: give the frozen v0 guest API a written contract#1123
kvinwang merged 2 commits into
nextfrom
docs/frozen-v0-spec

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Closes two open items on the 0.6 prerelease checklist (#1094): publish a normative GetKey specification, and specify the exact Ed25519/secp256k1/prehashed Sign modes.

The gap

#1116 froze the unversioned guest-agent API at exactly what v0.5.11 served, and docs/guest-api-v1.md is normative for v1. That leaves the surface which is closed forever, and which every deployed 0.5.x client talks to, as the one without a written contract — sdk/curl/api.md says outright that it is "a curl-oriented tour, not the contract".

The frozen SignRequest message carried no doc comments on either field: nothing stated which algorithm values are accepted, that k256 is an alias, or that data means a raw message in two modes and a pre-computed digest in the third.

What the spec pins

Everything is derived from the code, not from existing prose.

GetKey, byte level: HKDF-SHA256(salt="RATLS", IKM=app root k256 key, info=path, L=32) — with info being the caller's path verbatim, no tag, separator, or length prefix. Which inputs reach the KDF and which do not: only path does; purpose is echoed into the chain claim and nothing else; algorithm selects how the derived 32 bytes are interpreted and does not domain-separate. The algorithm table read off normalize_algorithm, including that an empty string defaults to secp256k1 and that k256 is an alias. Output encodings per algorithm. The chain claim keccak256("{purpose}:{hex(public_key)}"), the KMS link's preimage, the 65-byte r || s || v envelope, and verification steps that mirror the v1 spec's — anchor, rebuild, recover, check, bind — including the same warning about where the anchor must come from.

The consequence of one KDF serving both curves is stated plainly rather than argued: derive at one path under both algorithms and you hold one secret in two representations. That is what v1's KDF changed, and a caller on this surface has to account for it.

Sign gets a definition it never had. The three modes differ in what is signed — raw message under SHA-256 for secp256k1, raw message for ed25519, and a caller-supplied 32-byte digest that the agent does not re-hash for secp256k1_prehashed — and all three derive the same key, at path vms with purpose signing, which was written down nowhere.

Verify, the remaining methods (GetTlsKey, GetQuote, Attest, EmitEvent, Info, Version, Worker.GetAttestationForAppKey with its dip1:: report-data construction), and a brief Tappd section that points at the DstackGuest equivalents rather than becoming a second spec.

No new test vectors were generated. The document reuses only values an existing test already asserts, and cites the test by name; where no committed vector exists, it states no value.

Proto change is comments only

SignRequest.algorithm and SignRequest.data get doc comments. No field, method, reserved, or declaration is touched — the frozen descriptor-digest test passes with unchanged digests, which is the check that proves it.

Two corrections that fell out of writing it down

  • sdk/curl/api.md claimed /Verify was "Removed in v0.6.0" and pointed callers at SDK helpers named verify_signature / verify_signature_chain. The RPC is alive on the frozen service, and those helpers exist nowhere in sdk/ — the CHANGELOG says explicitly that neither client ships one.
  • Tappd.RawQuote requires exactly 64 bytes of report data where GetQuote pads. No document distinguished them.

Also fixed two pre-existing broken anchors in sdk/curl/api.md (#7-attest#6-attest, #7-attest-gpu#7-attest-gpu-v1), since the file was already being edited.

Verification

cargo test -p dstack-guest-agent-rpc --test frozen_surface passes with unchanged digests; cargo build -p dstack-guest-agent-rpc succeeds; every relative link resolves.

Copilot AI lite review requested due to automatic review settings August 25, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

0.6.0 froze the unversioned surface at exactly what v0.5.11 served, and
`docs/guest-api-v1.md` is normative for v1 only. So the surface that is closed
forever, and that every deployed 0.5.x client talks to, is the one with no
specification -- `sdk/curl/api.md` says outright that it is a tour and not the
contract. Two items on the 0.6 prerelease checklist were open because of it.

`docs/guest-api-v0.md` states it from the code: the KDF down to the bytes
(HKDF-SHA256 under the "RATLS" salt, `info` the caller's `path` verbatim with
no tag, separator or length prefix), which inputs reach it and which do not
(`purpose` is echoed into the chain claim and nothing else; `algorithm` picks
how the derived 32 bytes are read and does not domain-separate), the algorithm
table including the `k256` alias and the empty-string default, the chain claim
and the KMS link's preimages, and the verification steps down to where the
trust anchor has to come from.

The consequence of one KDF serving two curves is stated plainly rather than
argued: derive at one path under both algorithms and you hold one secret in
two representations. That is what v1's KDF changed, and a caller on this
surface has to account for it.

`Sign` gets the definition it never had. Its three modes differ in what is
signed -- raw message under SHA-256 for secp256k1, raw message for ed25519,
and a caller-supplied 32-byte digest that the agent does not re-hash for
`secp256k1_prehashed` -- and all three derive the same key, at path `vms` with
purpose `signing`, which nothing said anywhere. The proto's `SignRequest`
carried no field comments at all; it does now. Comments only: the frozen
descriptor digests are unchanged, which is the test that proves it.

Two corrections fall out of writing it down. `sdk/curl/api.md` claimed
`/Verify` was removed in 0.6.0 and pointed at SDK helpers named
`verify_signature`/`verify_signature_chain`; the RPC is alive on the frozen
service and those helpers exist nowhere. And `Tappd.RawQuote` requires exactly
64 bytes of report data where `GetQuote` pads, which no document distinguished.
Six corrections from an adversarial pass over the spec against the
implementation. Two of them would have misled a verifier:

`TdxQuote` builds report data as hash(prefix || ":" || content), not
hash(prefix || content) -- the agent supplies the colon, so `prefix` is
the tag alone and the default is `app-data`, which is what the response
echoes back. The spec said the default was `app-data:`, so a caller who
set it explicitly would have digested `app-data::` and produced a quote
nothing verifies, and a verifier reading the formula would compute the
wrong 64 bytes for any custom prefix. The proto's own comment said the
same thing and is corrected with it; comments are outside the frozen
digest and `frozen_surface` still passes.

"One secret, two curves" was three. `Tappd.DeriveKey` runs the identical
HKDF -- same `RATLS` salt, same bare-`path` info -- and reads the result
as a P-256 scalar, handed back as a PKCS#8 PEM. The mitigation the
section offered (encode the algorithm into `path`) separates the two
`GetKey` curves from each other but not from `Tappd.DeriveKey`, which
derives the same secret from the same string. Say so.

The rest are smaller: the signature-chain vector is not the only
committed vector in the document (report data has two, cited 286 lines
later); `GetTlsKey`'s random seed goes through the same HKDF step before
it is a scalar; `Verify` with `data` under 16 bytes answers 200 with
`valid: false` rather than erroring; `Tappd` is in the `dstack_guest`
package like the other two; and the three never-released methods are
named instead of counted.
@kvinwang
kvinwang force-pushed the docs/frozen-v0-spec branch from c4d6fce to ab377e3 Compare August 25, 2026 03:01
@kvinwang
kvinwang merged commit 137ceca into next Aug 25, 2026
15 checks passed
@kvinwang
kvinwang deleted the docs/frozen-v0-spec branch August 25, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants