docs: give the frozen v0 guest API a written contract - #1123
Merged
Conversation
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
force-pushed
the
docs/frozen-v0-spec
branch
from
August 25, 2026 03:01
c4d6fce to
ab377e3
Compare
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes two open items on the 0.6 prerelease checklist (#1094): publish a normative
GetKeyspecification, and specify the exact Ed25519/secp256k1/prehashedSignmodes.The gap
#1116 froze the unversioned guest-agent API at exactly what v0.5.11 served, and
docs/guest-api-v1.mdis 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.mdsays outright that it is "a curl-oriented tour, not the contract".The frozen
SignRequestmessage carried no doc comments on either field: nothing stated whichalgorithmvalues are accepted, thatk256is an alias, or thatdatameans 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)— withinfobeing the caller'spathverbatim, no tag, separator, or length prefix. Which inputs reach the KDF and which do not: onlypathdoes;purposeis echoed into the chain claim and nothing else;algorithmselects how the derived 32 bytes are interpreted and does not domain-separate. The algorithm table read offnormalize_algorithm, including that an empty string defaults to secp256k1 and thatk256is an alias. Output encodings per algorithm. The chain claimkeccak256("{purpose}:{hex(public_key)}"), the KMS link's preimage, the 65-byter || s || venvelope, 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.
Signgets 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 forsecp256k1_prehashed— and all three derive the same key, at pathvmswith purposesigning, which was written down nowhere.Verify, the remaining methods (GetTlsKey,GetQuote,Attest,EmitEvent,Info,Version,Worker.GetAttestationForAppKeywith itsdip1::report-data construction), and a briefTappdsection 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.algorithmandSignRequest.dataget 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.mdclaimed/Verifywas "Removed in v0.6.0" and pointed callers at SDK helpers namedverify_signature/verify_signature_chain. The RPC is alive on the frozen service, and those helpers exist nowhere insdk/— the CHANGELOG says explicitly that neither client ships one.Tappd.RawQuoterequires exactly 64 bytes of report data whereGetQuotepads. 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_surfacepasses with unchanged digests;cargo build -p dstack-guest-agent-rpcsucceeds; every relative link resolves.