Verifying an attestation yourself
A confidential node makes two strong claims: the operator running this hardware cannot read your prompt, and cannot change the software that handles it. The attestation names every piece of software in the machine, so an operator cannot add a logger, swap in a modified build, or log in to watch traffic without the node failing verification. See what the operator cannot change.
You don't have to take that on our word. Every value that is measured can be checked, and there are three ways to check it, one of which involves neither our software nor our say-so.
One command checks the claim from the hardware to the exact container image:
zs-proxy tee inspect https://your-node.example.com --chain
That covers the first four steps of the claim.
The last two, the key binding and the model binding, need the node's on-chain
record, which the command does not look up. The chat app and zs-proxy check
both before they treat the node as attested.
Everything here applies to dstack-tdx, the mode that runs in production today:
Intel TDX confidential VMs managed by dstack,
on Phala Cloud or self-hosted. See Confidential compute
for the operator's side of it.
The claim, stated precisely
Attestation proves a chain of statements, each resting on the one before it:
- Real Intel hardware produced this report. Not a simulator, not a normal VM.
- The report describes a confidential VM whose memory the host machine's operator cannot read.
- This exact software, and nothing else, was measured into that VM at boot: a specific guest OS image, a specific set of containers by cryptographic digest, and the node's configuration.
- That software is a
zs-noderelease we published, byte for byte, rather than a private build. - The encryption key you seal your prompt to was generated inside that VM and is the one the operator identity registered on chain vouches for.
- The node's advertised model list, its posture and its on-chain node id are in the signed bytes. See the model binding.
Without steps 3 and 4, a valid quote proves only that someone rented a confidential VM, which anyone can do for a few dollars an hour.
Three ways to check, in increasing independence
| Who checks | How | What it costs you |
|---|---|---|
| Your browser, automatically | The chat app fetches the node's evidence and verifies it before routing anything there. Turn on Only use attested nodes in Settings → Model routing to refuse everything else. | Nothing. It already happens. |
| You, with our tool | zs-proxy tee inspect <url> --chain runs the hardware, guest-image and compose checks and prints each value, including the model list. It does not check the two REPORT_DATA bindings, which need the node's on-chain record. | One command. |
| You, with Phala's | Phala's trust center reports on the same CVM, from a party that is not us. | A browser. |
The first two run our verifier, so a bug or a deliberate weakening in it would affect both. The third is a different organization looking at the same hardware.
What the hardware actually signs
Intel TDX produces a quote: a block of bytes signed by a key that chains to Intel's own root certificate. Nothing in it is authored by the operator. The parts that matter here:
| Field | What it holds |
|---|---|
MRTD | The measurement of the virtual firmware and initial memory — "this is a TDX guest, launched this way". |
RTMR0–RTMR2 | Boot-chain measurements, extended as the guest starts. |
RTMR3 | The runtime measurement register. dstack extends it with one entry per named property of the deployment. This is the interesting one. |
REPORT_DATA | 64 bytes the software inside the VM chose, signed blindly by the hardware. |
- The hardware does not check
REPORT_DATA. It signs whatever the enclave hands it, so the value means something only because of what we put there and who can compute it. The first 32 bytes are the key binding; the last 32 are the model binding. - A signature over
RTMR3covers only a hash.RTMR3is a hash chain. Knowing it equals some 48-byte value tells you nothing until you can say which events produced it.
Where the Intel collateral comes from
Checking the quote's signature chain also takes Intel's current revocation lists
and the TCB status of the platform. zs-proxy fetches these from Intel's own
service. The chat app fetches them from Phala's mirror of it, because a browser
cannot call Intel's service directly. If the fetch fails, the verifier uses its
own cached copy, as long as that copy is not too old. Only when it has neither
does it use the copy the node carries in its bundle, which exists so that a
payer whose network cannot reach Intel can still verify the node.
The order matters. Intel's signature shows that Intel issued a document, but not
that it is the current one, so a node could hand over an older, still validly
signed version that hides a TCB downgrade or a revoked certificate. A verifier
therefore never uses carried collateral to overturn a refusal its own collateral
produced. tee inspect says which source it used.
From a signed number to names you can read
The node publishes its event log alongside the quote: the list of entries
dstack extended into RTMR3. A verifier replays that log: it recomputes the
hash chain from the entries and requires the result to equal the RTMR3 in the
signed quote.
event log (node-supplied) ──replay──▶ computed RTMR3
║ must be equal
quote (Intel-signed) ──────────▶ signed RTMR3
If they match, every named entry in that log is now covered by Intel's signature. If they don't, the log is a fabrication and the whole bundle is refused. A node cannot add, remove, or edit an entry without breaking the match, and it cannot forge the quote to fit, because it cannot sign as Intel.
Four names come out of a match:
| Recovered value | What it proves |
|---|---|
os-image-hash | Which guest OS image the CVM booted. Checked against a shipped list of production dstack images — this is what refuses a quote taken on a dstack development image, where every other check passes identically and the platform's hardening guarantees do not hold. |
compose-hash | The digest of the deployment document — see the compose. |
app-id | The dstack application identity. Stable across restarts and redeploys; this is the value Phala's trust center is keyed by. |
instance-id | This particular running CVM. Changes when the app is redeployed. |
Every value in that table is recovered from the replay; none is something the
node asserts about itself. The evidence format has no field such as
compose_hash, so there is no self-reported copy to read in place of the
replayed value.
The compose — what the deployment actually says
compose-hash on its own is only useful for comparing against a value you
already trust, so the node also publishes the document it is the digest of.
A verifier hashes it and requires the result to equal the measured value.
That document is the whole deployment description: the container image, the environment variable names, the storage and feature toggles, and the node's own configuration inlined as literal text. A verifier can therefore read it, not just compare it.
It has to be a release we published
The verifier lifts the operator-authored spans out of the document (the
image reference and the config block, plus on the GPU shape the
engine's own config block: the content: under the zs-engine-config:
entry, which sizes the runtime) and requires everything that remains, comments
included, to match a published zs-node release skeleton.
The engine block is the operator's to change, except for two keys refused
outright because either changes what the measured weights produce: adapters,
which would attach weights the engine never reports, and template, which would
replace the chat template. The structure around those spans is not free. So:
- A private build cannot pass. Its image digest is not on the list.
- Editing your own config is free (models, rates, capacity, timeouts), and does not strand payers.
- Editing anything else fails, silently from the operator's side: the node deploys, boots, and attests, but no payer counts it as attested, and anyone who requires attested hardware routes elsewhere.
Rules with a specific attack behind each
The document also has to satisfy content rules. These two catch what a valid quote would otherwise pass:
Root backdoor environment variables are refused outright. DSTACK_AUTHORIZED_KEYS,
DSTACK_ROOT_PUBLIC_KEY, DSTACK_ROOT_PASSWORD: any of these means the CVM was
provisioned with a way for a human to log into it as root, which makes the
memory-encryption guarantee irrelevant. An operator who can ssh into the
enclave can read the prompts inside it.
Phala's own phala deploy silently appends DSTACK_AUTHORIZED_KEYS from the
first public key it finds in ~/.ssh/, with no prompt and no output, unless
--no-dev-os is passed. An operator can back-door their own enclave by
accident, and the only visible trace is that name in the deployment document,
where a verifier can see it.
Key custody has to be the one that was attested. local_key_provider_enabled
must be off: the CVM takes its keys from Phala's KMS rather than deriving its
own. If a node turns it on, its key custody no longer matches what the rest of
the evidence describes.
Others cover the runner, the manifest version, the declared features, the storage filesystem, the pre-launch script's digest, and any environment variable name outside the declared set. A violation is named in the report rather than folded into a generic failure.
What is published, and what that means for secrets
The measured compose is served unauthenticated, since it is the preimage you
hash. dstack hashes the document before substituting ${VAR} references, so
an operator's API keys stay outside the measurement and outside the published
text, while their claims stay inside it. A secret pasted literally into the
compose is published, not merely measured.
What the operator cannot change
Together, the OS image, the compose and the release check fix everything that runs in the VM. An operator who changes any of it gets a node that still boots, but that no payer counts as attested, so it drops out of routing for anyone who requires attested hardware. So an operator cannot:
- Add a logger, proxy or any other container. Every container is in the compose, and an extra one breaks the match with the release skeleton.
- Run a modified node or engine. Both images are pinned by digest, and only digests from published releases pass.
- Log in to the VM. The settings that would give a person a root shell are refused (see rules with a specific attack behind each), and the guest OS image is fixed by its measured hash, so it cannot be swapped for one that allows it. The host machine cannot read the VM's memory either.
- Change the configuration after the fact. The configuration is inlined in the measured compose. The only environment variables an operator may set are credentials: upstream API keys, the algod token and the location of the node's key. None of them reaches a setting, so none can change what the node does.
- Turn on prompt logging. The node never writes prompt or response content to its logs, and it refuses to start in confidential mode with the one debugging option that would.
What the operator can change is the node's configuration: models, prices, capacity and timeouts. Changing it produces a different, published compose that anyone can read, and it cannot switch any of the protections above off.
One piece sits outside the measurement. On the GPU shape the inference engine loads a runtime library from a storage volume the measurement does not cover. The measured engine carries the library's expected digest and refuses to start if the volume holds anything else, so the library is pinned by digest rather than measured byte for byte.
The key binding
Everything above establishes which software is running. This last step ties it to the key you encrypt to.
Inside the CVM, the node generates its sealing key with the in-enclave random
number generator and asks the hardware to sign a report whose first 32 bytes of
REPORT_DATA are:
SHA-256( node_pubkey || be64(operator_id) )
where node_pubkey is the node's current sealing key (the ephemeral
recipient your prompt is actually encrypted to) and operator_id is the
operator's registered id. It is not the operator's on-chain signing key.
Attesting the identity key would prove only that the enclave controls the
operator's identity, and leave the step from that identity to the
sealing key resting on an ordinary signature that nothing proves was made inside
the CVM. An operator with a copy of the signing key outside the enclave could
then point you at a sealing key whose private half lives on the plain host, and
pass every check.
A verifier therefore works in two steps, neither of which takes the node's
word. It first checks the advertised sealing key on its own: the signature over
it must verify under the signing address registered on chain, over bytes that
name this operator and this node. Then it recomputes the hash from that verified
key and the chain-resolved operator id, never from the bundle's own copy of
either, and requires it to equal the signed REPORT_DATA.
Only code running inside the attested VM can get the hardware to sign anything. A matching value therefore means the software you just identified, running in that VM, holds this sealing key, and that key is the one the on-chain identity vouches for. An operator cannot attest a real CVM and then serve traffic on a key that CVM never held.
The sealing key rotates every ~20 minutes and is never written to disk. Because the evidence is bound to the current key, the node re-mints it on every rotation. A node that has rotated but not yet re-minted fails the check and comes back on the next probe.
The model binding
REPORT_DATA is 64 bytes and the key binding uses the first 32. Since protocol
9.10 the node fills the last 32 with a hash over four inputs:
| Input | Where a verifier gets it |
|---|---|
node_id | the node's on-chain record, never the bundle |
| the model list | app_models on GET /v1/zs/attestation |
| the posture | posture on GET /v1/zs/attestation |
nonce | the value you sent; otherwise the bundle's nonce, or 32 zero bytes if it has none |
Nodes on protocol 9.9 hash the first, second and fourth only, under the tag
zs-aux-v1\0 and without H_posture. A 9.10 verifier refuses them, and a 9.9
verifier refuses a 9.10 node.
The model list is the catalog the node advertises on /v1/zs/details. Each
entry is a model id, its source, its weights digest, and a weights_state
saying how that digest was obtained. A verifier hashes app_models itself and
refuses the node if the result differs from the signed bytes:
entry = str(model_id) || str(source) || str(weights_digest) || u8(weights_state)
H_app = SHA-256("zs-happ-v1\0" || be32(count) || entries, sorted by model_id bytes)
H_posture = SHA-256("zs-posture-v1\0" || u8(0)) no posture
= SHA-256("zs-posture-v1\0" || u8(1) || str(plaintext_terminates)
|| str(upstream_base_url) || u8(zero_retention)
|| u8(upstream_attested))
last 32 = SHA-256("zs-aux-v2\0" || be64(node_id) || H_app || H_posture || nonce)
where str(s) is be32(byte length) || UTF-8 bytes. posture is the
bundle's field of that name, and a missing field inside it counts as "" or
false.
The posture is where plaintext goes. Because it is part of the signed bytes,
the upstream a verifier checks is the one the node's own code derived from
its running configuration, not a label anything on the path could change.
For a named_upstream posture a verifier then checks the upstream itself. It
must be https with a plain host name, and it must either be xAI (the host
x.ai or a subdomain of it) with zero_retention set, or have
upstream_attested set and come with an upstream_attestation block whose
protocol is aci/1. upstream_attested is in the signed bytes and says the
node was configured to check its upstream's enclave. The block is not signed,
and says the node's latest check passed; the node leaves it out while that
check is failing or has expired.
- A relay or proxy that edits
app_modelsmakes the node fail verification. - A node running a published release signs exactly the list it publishes in
app_models, because both come from one read of its catalog. - The list covers every model on
/v1/zs/details, including models with no digest. - The node id stops an operator with several nodes from presenting one node's evidence as another's.
The three states
| State | On the wire | How the digest was produced |
|---|---|---|
measured | 1 | The node hashed local files itself, or took the digest from a runtime that reports what it loaded. |
declared | 2 | An operator's pin the node never confirmed. |
unverifiable | 3 | The node advertises no digest: the model runs on a remote engine, is an image model, or its files could not be hashed or verified. Normal for these models. |
node_supervised, runtime_attested and operator_declared become measured;
operator_pinned becomes declared. The tier itself is on /v1/zs/details and
is not signed. See
Model identity & integrity.
What it does and does not settle
The binding proves that a published release reported this list. It does not
prove the weights are the model the id names. A measured digest means code in
the VM hashed some files, and for operator_declared those need not be the
files the engine loads. Whether a digest matches the source repository is
weights_registry_match, which the quote does not cover.
The chat app's model card says whether the model you are viewing is in the
node's signed list. A missing model does not change the badge, because a model
added since the last mint is missing until the next one. Neither the chat app
nor zs-proxy routes on the list.
Freshness challenges
Without a challenge, freshness rests on the key rotation described above: a
bundle proves the enclave was live sometime in the current rotation. To prove it
is live now, request GET /v1/zs/attestation?nonce=HEX, where HEX is a random
32-byte value written as 64 hex characters (not all zero). The node mints a new
quote over that value and echoes it in nonce.
Reject the reply if its nonce is missing or differs from the value you sent.
Do not fall back to checking it as an unchallenged bundle. When the node cannot
answer a challenge it returns an error, never the cached bundle:
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_nonce | The nonce is not 64 hex characters, is all zero, is repeated, or the query does not parse. |
| 429 | attestation_rate_limited | The node's challenge limit is spent, or two challenges are already being minted. Honor Retry-After. |
| 501 | nonce_unsupported | The node is in stub mode, which cannot mint on demand. |
| 503 | attestation_unavailable | The mint failed. |
Neither tee inspect nor the chat app sends challenges, so checking one means
recomputing the hash above yourself.
Timing, and nodes that predate the change
A catalog change reaches the signed bytes within about 75 seconds, and a
restarted node lists every model as unverifiable until it finishes hashing
weights. The details are in
Confidential compute.
After that, a model stays unverifiable only if the node has no digest it can
stand behind. An empty list, which the node publishes at boot, verifies: its
hash is not zero, so no verifier mistakes it for a pre-9.9 node.
A node running software older than 9.9 leaves the last 32 bytes zero, and a 9.9
verifier refuses it with aux_binding_absent. The other refusal reasons, and
what an operator does about each, are in
Confidential compute.
Cross-checking with Phala's trust center
Every check so far runs code we wrote. Phala publishes its own view of the same
CVM, keyed by the app-id the replay recovered:
https://trust.phala.com/app/<app-id>
For the reference node that is
ab522d752817db3b2daa533315b8b1b97f179e6a.
zs-proxy tee inspect prints the app-id in its measurements block, so you can
go from a node URL to its trust page in two steps.
Use it as a second opinion, not as the root of trust. It comes from a different party: if our verifier said a node was fine and Phala's view disagreed, you would want to know, and neither of us can change the other's answer. But it is also a website reporting on infrastructure its operator runs. The guarantee rests on the Intel signature chain, which you can check yourself without asking anyone.
Take the app-id from the replay, never from something a node told you. An
app-id a node simply hands over is a self-report. A node could link you to
somebody else's trust page, which would show a genuinely attested, healthy CVM
that is not the machine you are about to send your prompt to.
The page exists only if the operator published the deployment. Phala's
--listed flag defaults to off, so an operator who never passed it has a real,
correctly attested CVM with no trust page. A missing page is not a failed check:
everything in the sections above still verifies, because none of it depends on
Phala publishing anything. The absence says little about the operator either.
--listed is one of the settings Phala cannot change in place,
so an operator who omitted it at first deploy has to rebuild the CVM to add it,
which moves the app id and the hostname. See
Confidential compute.
The node information page
The same deployment can also serve Phala's own status page, on the information port rather than the node's:
https://<app-id>-8090.<gateway-domain>/
It lists the containers actually running in the CVM and the TCB information for
the host, unauthenticated, straight from Phala. It is governed by the
--public-sysinfo, --public-tcbinfo, and --public-logs deploy flags, which
default on, so most deployments have it.
What is running inside the CVM is already settled by the compose you hashed. This page is Phala's report of the same thing from outside the node, so it does not depend on the node's own account. Like the trust page, it is a second opinion, not the root of trust.
What this does not prove
- Side channels are out of scope. Timing, power, and microarchitectural leakage are not addressed by attestation. TDX is defense against a host that reads memory, not against every possible inference.
- Metadata is still visible. Ticket ids, model names, token counts, and timing are not sealed; billing needs them.
- Liveness is not guaranteed. An operator can always refuse to serve you. They just cannot read what they do serve.
- How current the check is depends on Intel. A check against week-old cached collateral is not the same as one against Intel's live collateral; see where the Intel collateral comes from.
- Web search sends queries out of the enclave. A built-in web tool runs only when your request asks for it, and the chat app asks by default. While it is on, the queries the model writes from your prompt and the pages it reads go to outside services. Turn off Web search in Settings → Tools and nothing is searched or fetched.
attested_passthroughnodes forward your prompt. If the node's posture says it forwards to a named upstream, the attestation proves the operator cannot read your prompt, not that nobody can. That upstream can, under its zero-retention terms. Check the posture, not just the badge.- A signed model list is not a proof of the weights. See what the model binding does and does not settle.
- A trusted release is still software we wrote. Attestation proves you are running what we published. It is not a proof that what we published is correct.
Where this fits
- Confidential compute — the operator's side: the
hardware, the
tee:block, and how to deploy one. zs-proxy tee inspect— the command, its flags, and the four chain outcomes.- Routing preferences — requiring an attested node per request, or per model.
- Encryption & keys — the sealing that applies to every node, attested or not.