Skip to main content

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.

info

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:

  1. Real Intel hardware produced this report. Not a simulator, not a normal VM.
  2. The report describes a confidential VM whose memory the host machine's operator cannot read.
  3. 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.
  4. That software is a zs-node release we published, byte for byte, rather than a private build.
  5. 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.
  6. 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 checksHowWhat it costs you
Your browser, automaticallyThe 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 toolzs-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'sPhala'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:

FieldWhat it holds
MRTDThe measurement of the virtual firmware and initial memory — "this is a TDX guest, launched this way".
RTMR0–RTMR2Boot-chain measurements, extended as the guest starts.
RTMR3The runtime measurement register. dstack extends it with one entry per named property of the deployment. This is the interesting one.
REPORT_DATA64 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 RTMR3 covers only a hash. RTMR3 is 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 valueWhat it proves
os-image-hashWhich 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-hashThe digest of the deployment document — see the compose.
app-idThe dstack application identity. Stable across restarts and redeploys; this is the value Phala's trust center is keyed by.
instance-idThis 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:

InputWhere a verifier gets it
node_idthe node's on-chain record, never the bundle
the model listapp_models on GET /v1/zs/attestation
the postureposture on GET /v1/zs/attestation
noncethe 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_models makes 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​

StateOn the wireHow the digest was produced
measured1The node hashed local files itself, or took the digest from a runtime that reports what it loaded.
declared2An operator's pin the node never confirmed.
unverifiable3The 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:

StatusCodeMeaning
400invalid_nonceThe nonce is not 64 hex characters, is all zero, is repeated, or the query does not parse.
429attestation_rate_limitedThe node's challenge limit is spent, or two challenges are already being minted. Honor Retry-After.
501nonce_unsupportedThe node is in stub mode, which cannot mint on demand.
503attestation_unavailableThe 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_passthrough nodes 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​