Skip to content

Commit ab377e3

Browse files
committed
docs(guest-agent): correct the v0 spec where it did not match the code
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.
1 parent eac2663 commit ab377e3

2 files changed

Lines changed: 42 additions & 19 deletions

File tree

docs/guest-api-v0.md

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ semantic changes to what is already there. It exists so a v0.5.x client keeps
2323
working against a 0.6 agent unchanged. Every new capability goes to
2424
`dstack.guest.v1`.
2525

26-
Three services are frozen: `DstackGuest` and `Worker` (both in the
27-
`dstack_guest` proto package) and `Tappd`, which predates the scheme.
26+
Three services are frozen: `DstackGuest`, `Worker` and `Tappd`, all three in
27+
the `dstack_guest` proto package. `Tappd` predates the naming scheme the other
28+
two follow.
2829

2930
The freeze is mechanical, not a convention. `the_frozen_services_match_their_pinned_shape`
3031
in `dstack/guest-agent/rpc/tests/frozen_surface.rs` hashes each frozen service's
3132
descriptor against a pinned SHA-256. The descriptor covers the method list and
3233
the full field list of every message reachable from it, `reserved` ranges
3334
included.
3435
Any addition changes the digest and fails the test, including a wire-compatible
35-
one. That is deliberate: "frozen except for additions" is how three
36-
never-released methods accumulated on this surface between v0.5.11 and 0.6.0.
36+
one. That is deliberate: "frozen except for additions" is how `GpuInfo`,
37+
`AttestGpu` and `Worker.Health` accumulated on this surface between v0.5.11 and
38+
0.6.0 without ever being released.
3739
`the_frozen_services_expose_the_v0_5_11_methods` in the same file spells the
3840
method lists out so a failure is readable.
3941

@@ -187,18 +189,27 @@ encoding it is a 64-character hex string.
187189
`key` itself, and a verifier of the chain must reproduce the same encoding, as
188190
described below. (v1 added `public_key` to the response for exactly this reason.)
189191

190-
### One secret, two curves
192+
### One secret, three curves
191193

192194
The derivation ignores `algorithm`, so `GetKey(path, *, "secp256k1")` and
193195
`GetKey(path, *, "ed25519")` return **the same 32 bytes**. One secret is served
194196
in two representations: a secp256k1 scalar and an ed25519 seed.
195197

198+
`Tappd.DeriveKey` makes it three. It runs the identical construction --
199+
HKDF-SHA256, salt `RATLS`, `info` = `path` -- and reads the result as a **P-256**
200+
scalar, which it hands back as a PKCS#8 PEM on `/var/run/tappd.sock`. So
201+
`GetKey("x")` and `Tappd.DeriveKey("x")` are the same secret on a third curve,
202+
and the PEM is a more convenient form to walk off with than either.
203+
196204
This is a property callers must account for, not a bug in an individual call.
197-
Anyone who can reach the socket can request either interpretation, so the two
198-
keys are not independent: compromise of one is compromise of both, and a
199-
protocol that assumes a per-curve key does not get one here. Where independent
200-
keys across algorithms are required, encode the algorithm into `path`: for
201-
example `backup-signing/secp256k1` and `backup-signing/ed25519`.
205+
Anyone who can reach either socket can request any of the three interpretations,
206+
so the keys are not independent: compromise of one is compromise of all, and a
207+
protocol that assumes a per-curve key does not get one here. Encoding the
208+
algorithm into `path` -- `backup-signing/secp256k1` and `backup-signing/ed25519`
209+
-- separates the two `GetKey` curves from each other, but not from
210+
`Tappd.DeriveKey`, which will derive the very same secret from the very same
211+
string. The only real boundary is the socket: nothing inside this surface makes
212+
one derived key unreachable through another method.
202213

203214
This is one of the things v1 changed: the v1 KDF binds the canonical algorithm
204215
name and a version tag into `info` under its own salt, so the two curves never
@@ -323,8 +334,9 @@ c8a3dcf06c4e95bd78a5d7a1c8fcff171fc5848cfae804c6fc11bda4dc5d4062
323334
```
324335

325336
ECDSA here is deterministic (RFC 6979), so this vector pins the whole encoding
326-
including the recovery byte. It is the only committed vector for this surface;
327-
the values above are asserted on every test run.
337+
including the recovery byte. It is the only committed vector for a signature
338+
chain on this surface; the values above are asserted on every test run.
339+
[Report data](#report-data) has two of its own.
328340

329341
## Sign
330342

@@ -437,7 +449,9 @@ if you replace this call with a local check.
437449
asymmetry worth knowing about. `Sign` requires `data` to be exactly 32 bytes.
438450
`Verify` accepts any length from 16 bytes up: a shorter `data` is zero-padded on
439451
the left to 32 and a longer one is truncated to its leftmost 32 bytes, both
440-
silently. Pass exactly the 32-byte digest and the two agree.
452+
silently. Below 16 bytes it is not an error either -- the length rejection is
453+
swallowed and the answer is `200` with `valid: false`, like any other signature
454+
that does not check out. Pass exactly the 32-byte digest and the two agree.
441455

442456
A relying party that wants more than a single-signature check wants the chain
443457
verification in [Verifying a chain](#verifying-a-chain), which this method does
@@ -454,7 +468,9 @@ and the key is a by-product.
454468

455469
**The key is freshly generated on every call, not derived.** No request field
456470
feeds it, and two identical requests return two unrelated keys. It is a P-256
457-
key built from 32 bytes of `ring::rand::SystemRandom` output, returned as a
471+
key whose scalar comes from 32 bytes of `ring::rand::SystemRandom` output run
472+
through the same HKDF-SHA256/`RATLS` step the derived keys use -- with a random
473+
input, so the result is random and unreproducible either way -- returned as a
458474
PKCS#8 PEM string in `key`. `GetKey` is the method that returns a stable,
459475
re-derivable key.
460476

@@ -648,9 +664,13 @@ Every method maps onto a `DstackGuest` one, except where noted.
648664
| `RawQuote` | `TdxQuote` with `hash_algorithm = "raw"`; requires exactly 64 bytes and does not pad |
649665
| `DeriveKey` | *No `DstackGuest` equivalent.* See below |
650666

651-
`TdxQuote` builds report data as `hash(prefix || content)` rather than taking it
652-
raw. The default `hash_algorithm` is `sha512` and the default `prefix` is
653-
`app-data:`; the digest is left-aligned in the 64 bytes and the rest is zero.
667+
`TdxQuote` builds report data as `hash(prefix || ":" || content)` rather than
668+
taking it raw. **The agent supplies the colon**; `prefix` is the tag alone. The
669+
default is `app-data`, which the response echoes back without a colon, so
670+
`hash_algorithm = "sha512"` over `content` digests `app-data:` + `content`. A
671+
caller who passes `prefix = "app-data:"` because a quote is documented as
672+
`hash("app-data:" + content)` gets `app-data::` + `content` and a quote nothing
673+
will verify. The digest is left-aligned in the 64 bytes and the rest is zero.
654674
`hash_algorithm = "raw"` passes `report_data` through unchanged and then requires
655675
it to be exactly 64 bytes.
656676

dstack/guest-agent/rpc/proto/agent_rpc.proto

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,11 @@ message GetKeyResponse {
185185
}
186186

187187
// The request to get a TDX quote
188-
// The report data is prefixed with `app-data:` before hashing unless the algorithm is `raw`.
189-
// Final report data is hash(`app-data:` + report_data) if the algorithm is not `raw`.
188+
// Unless the algorithm is `raw`, the final report data is
189+
// hash(prefix + ":" + report_data), left-aligned in 64 bytes and zero-padded.
190+
// The agent supplies the colon, so `prefix` is the tag alone and its default is
191+
// `app-data` -- which is what the response echoes back. Passing `app-data:` as
192+
// the prefix produces hash(`app-data::` + report_data), not the default.
190193
message TdxQuoteArgs {
191194
// Report data
192195
bytes report_data = 1;

0 commit comments

Comments
 (0)