This example can gate Consul client-agent join and Consul Connect service registration on dstack TEE attestation.
Enable it in clusters/patroni-demo/terraform.tfvars:
enable_attestation_admission = true
kms = "phala"
# admission_expected_kms_public_key defaults to the public key for the
# default phala KMS backend. Override it when changing kms.The Consul ACL management token is not a Terraform input. The
coordinators share one app_id, so bootstrap-secrets derives it
in-TEE via GetKey (identical on every coordinator) and writes it to
/run/secrets/consul-management-token. It never appears in
terraform.tfstate or on the deploy host.
The verifier image reference lives in the coordinator compose file.
Use a versioned verifier tag and pin it by digest for production. Do
not rely on dstacktee/dstack-verifier:latest; the current release
workflow publishes version tags and does not move latest. The
verifier must support the GetQuote evidence shape used by current
dstack guest agents: quote, event_log, and vm_config.
- Terraform calls Phala preflight for the same app definitions it
deploys and reads each expected
compose_hash. - Terraform renders
ADMISSION_POLICY_JSONfor coordinators. Policy rows are keyed by workload identity and optionalpeer_id;compose_hashis revision evidence, not the workload key. - A worker starts
mesh-conn, then requests a nonce from a coordinator admission broker. - The worker builds a binding statement containing the claimed identity, peer id, dstack app id, instance id, and compose hash.
- The worker calls dstack
GetQuote(SHA-256(binding || nonce))and sends{identity, binding, nonce, quote, event_log, vm_config}to the broker. - The broker verifies the quote through
dstack-verifier, checks report-data binding, nonce freshness, and KMS key-provider identity, then matches identity, peer id, and compose hash against the Terraform policy. - On success the broker mints a scoped Consul ACL token. Worker agents receive a node-identity token; service workloads receive service-identity tokens plus any declared Consul permissions.
report_data is the 64-byte TDX REPORTDATA field. The structured
admission data stays outside the quote in the binding statement.
The first 32 bytes are a SHA-256 digest; the remaining 32 bytes are
zero padding:
report_data = SHA-256(binding_statement_json || broker_nonce) || 32 zero bytes
The broker nonce is 32 random bytes encoded as hex. The binding statement is JSON generated by the workload sidecar:
{
"version": 1,
"identity": "spiffe://demo/postgres",
"cluster": "demo",
"peer_id": "worker-3",
"app_id": "<dstack app id>",
"instance_id": "<dstack instance id>",
"compose_hash": "<dstack compose hash>",
"issued_at": "2026-05-18T00:00:00Z"
}The client checks that dstack GetQuote() returns the same
report_data it requested. The broker independently recomputes the
digest from the submitted binding statement and nonce, then compares
that digest with the verifier-returned report_data.
peer_id is the mesh node label, not a secret and not a hardware
identity. In this example it is derived from the local role and
ordinal, such as worker-3, and reused as the Consul node name.
The current binding is:
peer_id -> binding statement -> SHA-256 report_data -> TDX quote
The broker also requires the peer_id to match the Terraform policy
row before it issues a node token or a service token scoped to that
node. This prevents one attested workload row from directly claiming
another row's Consul node identity.
For stronger production binding, the policy should also pin a
platform-stable workload instance field, such as a Phala-provided
instance id or app id when that can be supplied without introducing
a deployment cycle. Until then, peer_id is best understood as an
authorization namespace declared by Terraform and quoted by the CVM.
The broker treats dstack-verifier as the authority for quote and
event-log semantics. The broker should require verifier outputs such
as:
quote_verified == trueevent_log_verified == truereport_datapresent and equal to the broker-computed digestapp_info.compose_hashpresent and policy-allowedapp_info.key_provider_infopresent and equal to the policy-pinned key provider- when
production_profile = true,os_image_hash_verified == trueandos_image_hashequals the Terraform preflight hash
Key lifecycle should be policy-controlled. It answers three questions:
which KMS backend originates the key material, which app identity and
path scope the GetKey() output, and which CVMs can derive the same
role-local secret.
This example pins the long-term KMS public key in Terraform as
admission_expected_kms_public_key and checks it against the
verifier-returned app_info.key_provider_info. The verifier field is
a hex-encoded JSON payload:
{
"name": "kms",
"id": "<public-key-hex>"
}The pinned key must match the kms backend used in the Phala launch
configuration. The Terraform default matches the example's default
kms = "phala" backend; override it when changing KMS backend.
bootstrap-secrets derives role-local secrets inside the CVM with
dstack GetKey(). The output is scoped by KMS backend, app identity,
derivation path, and cluster name. Replicas in one phala_app share
one app_id, so they can derive the same role-local secret. Different
workload groups have different app_id values, so GetKey() does not
produce the same value across roles. This is why the Consul management
token and Patroni passwords can be derived in-TEE, while the current
cross-role gossip key is still a Terraform-generated workaround.
For production profiles, OS image identity should also be checked.
This example makes that an explicit profile: set
production_profile = true and an os_image name such as
dstack-0.5.7. Terraform uses that image in the Phala launch config,
reads the resulting
os_image_hash from preflight, and emits that SHA-256 hash into the
admission policy. The broker then requires dstack-verifier to prove
the same OS image hash before issuing Consul ACL tokens.
From a coordinator CVM, inspect Consul membership:
docker exec dstack-sidecar-1 sh -lc \
'CONSUL_HTTP_ADDR=http://127.0.0.1:8500 consul members -token "$(cat /run/secrets/consul-management-token)"'Check Raft health:
docker exec dstack-sidecar-1 sh -lc \
'CONSUL_HTTP_ADDR=http://127.0.0.1:8500 consul operator raft list-peers -token "$(cat /run/secrets/consul-management-token)"'Look for successful node and service admission in worker sidecar logs:
phala logs --cvm-id <worker-app-id> dstack-sidecar-1 -n 700 |
rg 'admission-client-.*admission accepted'Look for hard attestation failures:
phala logs --cvm-id <worker-app-id> dstack-sidecar-1 -n 700 |
rg 'QUOTE_INVALID|VERIFIER_FAILED|REPORT_DATA_MISMATCH|ADMISSION_REJECTED'No output from the failure grep is expected.
Production Phala OS images disable SSH access. For prod-profile runs,
use Phala instance metadata, phala ps, and public logs to validate
OS hash, container health, and admission success. Use a dev image only
when the test specifically requires in-CVM consul or patronictl
commands.
- The low-level mesh rendezvous path still uses TURN HMAC admission. Attestation admission gates Consul/Connect identity, not rendezvous.
- Broker-issued Consul tokens do not expire by default. Setting
ADMISSION_TOKEN_TTLrequires a renewal path for the Consul agent, Patroni DCS token, and Envoy xDS token. - The verifier downloads OS-image inputs and computes reference
measurements on cache miss. Coordinators mount
/tmp/dstack-runtime/verifier-cacheinto the verifier so image and measurement caches survive verifier container restarts on the same CVM. - The measured
compose_hashcomes from Phala'sapp-compose.json. Image binding should use digest-pinned image references in the Compose source. Environment variable values are not a sufficient image-binding policy. - Dynamic upgrade policy is future work. Phase 1 uses the static Terraform-generated allowlist; a future policy server can admit new compose hashes under a signed policy epoch.
Measured on 2026-05-18 with a live tdx.small worker quote and
dstacktee/dstack-verifier:0.5.9:
| Mode | Time |
|---|---|
| Oneshot, empty verifier cache | 2.1s |
| Oneshot, image and measurement cache already populated | 1.5s |
| Long-running HTTP server, warm cache | 1.1-1.2s |
The cold run downloaded a 20 MB OS-image bundle and computed the reference measurements. Larger OS images or slow network paths will increase the first request cost; the verifier download timeout is 300s by default. After that, the verifier reuses the cached image and cached measurements for the same VM config. Quote verification still costs about one second, so the broker should treat verification as a startup/admission cost, not something to repeat for every leaf-cert rotation.