Skip to content

Commit 76cda6c

Browse files
committed
Add attested Consul admission
1 parent 16254d4 commit 76cda6c

13 files changed

Lines changed: 602 additions & 174 deletions

File tree

consul-postgres-ha/ATTESTATION.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Attestation Admission
2+
3+
This example can gate Consul client-agent join and Consul Connect
4+
service registration on dstack TEE attestation.
5+
6+
Enable it in `cluster-example/terraform.tfvars`:
7+
8+
```hcl
9+
enable_attestation_admission = true
10+
consul_management_token = "<consul-acl-management-token>"
11+
dstack_verifier_image = "dstacktee/dstack-verifier:0.5.9"
12+
```
13+
14+
Use a versioned verifier tag and pin it by digest for production.
15+
Do not rely on `dstacktee/dstack-verifier:latest`; the current
16+
release workflow publishes version tags and does not move `latest`.
17+
The verifier must support the `GetQuote` evidence shape used by
18+
current dstack guest agents: `quote`, `event_log`, and `vm_config`.
19+
20+
## Trust Flow
21+
22+
1. Terraform calls Phala preflight for the same app definitions it
23+
deploys and reads each expected `compose_hash`.
24+
2. Terraform renders `ADMISSION_POLICY_JSON` for coordinators. Policy
25+
rows are keyed by workload identity and optional `peer_id`;
26+
`compose_hash` is revision evidence, not the workload key.
27+
3. A worker starts `mesh-conn`, then requests a nonce from a
28+
coordinator admission broker.
29+
4. The worker builds a binding statement containing the claimed
30+
identity, peer id, dstack app id, instance id, and compose hash.
31+
5. The worker calls dstack `GetQuote(SHA-512(binding || nonce))` and
32+
sends `{identity, binding, nonce, quote, event_log, vm_config}` to
33+
the broker.
34+
6. The broker verifies the quote through `dstack-verifier`, checks
35+
report-data binding and nonce freshness, then matches identity,
36+
peer id, and compose hash against the Terraform policy.
37+
7. On success the broker mints a scoped Consul ACL token. Worker
38+
agents receive a node-identity token; service workloads receive
39+
service-identity tokens plus any declared Consul permissions.
40+
41+
## Runtime Checks
42+
43+
From a coordinator CVM, inspect Consul membership:
44+
45+
```sh
46+
docker exec dstack-sidecar-1 sh -lc \
47+
'CONSUL_HTTP_ADDR=http://127.0.0.1:8500 consul members -token "$CONSUL_MANAGEMENT_TOKEN"'
48+
```
49+
50+
Check Raft health:
51+
52+
```sh
53+
docker exec dstack-sidecar-1 sh -lc \
54+
'CONSUL_HTTP_ADDR=http://127.0.0.1:8500 consul operator raft list-peers -token "$CONSUL_MANAGEMENT_TOKEN"'
55+
```
56+
57+
Look for successful node and service admission in worker sidecar logs:
58+
59+
```sh
60+
phala logs --cvm-id <worker-app-id> dstack-sidecar-1 -n 700 |
61+
rg 'admission-client-(node|demo|webdemo).*admission accepted'
62+
```
63+
64+
Look for hard attestation failures:
65+
66+
```sh
67+
phala logs --cvm-id <worker-app-id> dstack-sidecar-1 -n 700 |
68+
rg 'QUOTE_INVALID|VERIFIER_FAILED|REPORT_DATA_MISMATCH|ADMISSION_REJECTED'
69+
```
70+
71+
No output from the failure grep is expected.
72+
73+
## Current Limits
74+
75+
- The low-level mesh rendezvous path still uses TURN HMAC admission.
76+
Attestation admission gates Consul/Connect identity, not rendezvous.
77+
- Broker-issued Consul tokens do not expire by default. Setting
78+
`ADMISSION_TOKEN_TTL` requires a renewal path for the Consul agent,
79+
Patroni DCS token, and Envoy xDS token.
80+
- The verifier downloads OS-image inputs and computes reference
81+
measurements on cache miss. Coordinators mount
82+
`/tmp/dstack-runtime/verifier-cache` into the verifier so image and
83+
measurement caches survive verifier container restarts on the same
84+
CVM.
85+
- The measured `compose_hash` comes from Phala's `app-compose.json`.
86+
Image binding should use digest-pinned image references in the
87+
Compose source. Environment variable values are not a sufficient
88+
image-binding policy.
89+
- Dynamic upgrade policy is future work. Phase 1 uses the static
90+
Terraform-generated allowlist; a future policy server can admit new
91+
compose hashes under a signed policy epoch.
92+
93+
## Verification Cost
94+
95+
Measured on 2026-05-18 with a live `tdx.small` worker quote and
96+
`dstacktee/dstack-verifier:0.5.9`:
97+
98+
| Mode | Time |
99+
|---|---:|
100+
| Oneshot, empty verifier cache | 2.1s |
101+
| Oneshot, image and measurement cache already populated | 1.5s |
102+
| Long-running HTTP server, warm cache | 1.1-1.2s |
103+
104+
The cold run downloaded a 20 MB OS-image bundle and computed the
105+
reference measurements. Larger OS images or slow network paths will
106+
increase the first request cost; the verifier download timeout is
107+
300s by default. After that, the verifier reuses the cached image and
108+
cached measurements for the same VM config. Quote verification still
109+
costs about one second, so the broker should treat verification as a
110+
startup/admission cost, not something to repeat for every leaf-cert
111+
rotation.

consul-postgres-ha/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ peers' ICE candidates; no data ever passes through it once peers
2727
connect. Per-CVM and per-cluster secrets are split: the TURN HMAC is
2828
derived per-CVM from dstack KMS, while cluster-wide-identical
2929
material (gossip key, Patroni passwords) is generated in Terraform
30-
and broadcast via env until attestation-rooted admission lands.
30+
and broadcast via env until the later TEE-derived secret phase lands.
3131
Connect CA uses Consul's built-in CA provider — root in Raft, no
3232
external derivation needed.
3333

@@ -290,26 +290,29 @@ the platform plumbing untouched.
290290

291291
| | |
292292
|---|---|
293-
| In-place env updates | Yes — change image tags or env values, `terraform apply`, CVMs update without losing pgdata. Requires provider `phala-network/phala 0.2.0-beta.3+`. |
293+
| In-place env updates | Yes — change image tags or env values, `terraform apply`, CVMs update without losing pgdata. Requires provider `phala-network/phala 0.2.0-beta.4+`. |
294294
| Failover RTO | ~24s soft-kill, ~33s hard-kill (default Patroni `ttl=30`). See [`FAILOVER.md`](FAILOVER.md). |
295295
| Cheap rejoin | Yes — a recovered ex-leader replays local WAL and rejoins as a streaming replica without pg_basebackup. |
296296
| Disk-loss rejoin | Yes — Patroni detects empty pgdata and runs full pg_basebackup over the QUIC overlay. |
297297
| Build provenance | Sigstore-attested via GitHub Build Provenance on every published image. Verify with `gh attestation verify oci://... --repo Dstack-TEE/dstack-examples`. |
298+
| Attested Consul admission | Optional with `enable_attestation_admission = true`. See [`ATTESTATION.md`](ATTESTATION.md). |
298299

299300
## Known limitations
300301

301302
* Each `terraform apply` that fans out more than 1 `phala_app` create
302303
in parallel hits
303304
[`phala-cloud#247`](https://github.com/Phala-Network/phala-cloud/issues/247)
304305
— use `-parallelism=1` for now (~5 min × N to bring-up).
305-
* The mesh-conn admission story is **shared-secret based today**
306-
(TURN HMAC), not attestation-based. Adding TEE attestation as the
307-
admission credential is the next architectural step.
306+
* The low-level mesh-conn rendezvous story is **shared-secret based
307+
today** (TURN HMAC), not attestation-based. Consul/Connect
308+
admission can be gated by TEE attestation with
309+
`enable_attestation_admission = true`; replacing rendezvous auth is
310+
separate work.
308311
* The cluster's gossip key, Patroni superuser password, and
309312
replication password are **generated in Terraform and broadcast
310313
via env to every CVM** — a workaround, because those bytes live
311314
in `terraform.tfstate` and pass through whoever runs `apply`. The
312-
attestation-rooted admission design
315+
attestation-rooted secret design
313316
([`design/attestation-admission.md`](design/attestation-admission.md))
314317
replaces this with TEE-derived material that no human ever sees.
315318

consul-postgres-ha/admission-broker/main.go

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const (
2525
defaultListenAddr = "127.0.0.1:8787"
2626
defaultVerifierURL = "http://127.0.0.1:8080/verify"
2727
defaultConsulURL = "http://127.0.0.1:8500"
28-
defaultTokenTTL = time.Hour
28+
defaultTokenTTL = 0
2929
)
3030

3131
func main() {
@@ -34,7 +34,7 @@ func main() {
3434
verifierURL = flag.String("verifier-url", envOr("DSTACK_VERIFIER_URL", defaultVerifierURL), "dstack-verifier /verify URL")
3535
consulURL = flag.String("consul-url", envOr("CONSUL_HTTP_ADDR", defaultConsulURL), "Consul HTTP API URL")
3636
policyPath = flag.String("policy", os.Getenv("ADMISSION_POLICY_FILE"), "path to admission policy JSON")
37-
tokenTTL = flag.Duration("token-ttl", durationEnvOr("ADMISSION_TOKEN_TTL", defaultTokenTTL), "issued Consul ACL token TTL")
37+
tokenTTL = flag.Duration("token-ttl", durationEnvOr("ADMISSION_TOKEN_TTL", defaultTokenTTL), "issued Consul ACL token TTL; 0 disables expiration")
3838
)
3939
flag.Parse()
4040

@@ -87,6 +87,7 @@ type Policy struct {
8787
type Workload struct {
8888
WorkloadID string `json:"workload_id"`
8989
Identity string `json:"identity"`
90+
PeerID string `json:"peer_id,omitempty"`
9091
ConsulService string `json:"consul_service"`
9192
ConsulPermissions ConsulPermissions `json:"consul_permissions,omitempty"`
9293
AllowedComposeHashes []string `json:"allowed_compose_hashes"`
@@ -95,9 +96,10 @@ type Workload struct {
9596
}
9697

9798
type ConsulPermissions struct {
98-
KeyPrefixes []string `json:"key_prefixes,omitempty"`
99-
SessionWrite bool `json:"session_write,omitempty"`
100-
AgentReadSelf bool `json:"agent_read_self,omitempty"`
99+
KeyPrefixes []string `json:"key_prefixes,omitempty"`
100+
SessionWrite bool `json:"session_write,omitempty"`
101+
AgentReadSelf bool `json:"agent_read_self,omitempty"`
102+
NodeIdentitySelf bool `json:"node_identity_self,omitempty"`
101103
}
102104

103105
func ParsePolicy(b []byte) (*Policy, error) {
@@ -133,8 +135,15 @@ func ParsePolicy(b []byte) (*Policy, error) {
133135
if strings.TrimSpace(w.Identity) == "" {
134136
return nil, fmt.Errorf("workloads[%d].identity is required", i)
135137
}
136-
if strings.TrimSpace(w.ConsulService) == "" {
137-
return nil, fmt.Errorf("workloads[%d].consul_service is required", i)
138+
w.PeerID = strings.TrimSpace(w.PeerID)
139+
if w.PeerID != "" {
140+
if err := validateConsulAgentName(w.PeerID); err != nil {
141+
return nil, fmt.Errorf("workloads[%d].peer_id: %w", i, err)
142+
}
143+
}
144+
w.ConsulService = strings.TrimSpace(w.ConsulService)
145+
if w.ConsulService == "" && !w.ConsulPermissions.hasGrant() {
146+
return nil, fmt.Errorf("workloads[%d] must define consul_service or consul_permissions", i)
138147
}
139148
for j, prefix := range w.ConsulPermissions.KeyPrefixes {
140149
prefix = strings.Trim(prefix, "/")
@@ -160,16 +169,20 @@ func ParsePolicy(b []byte) (*Policy, error) {
160169
return &p, nil
161170
}
162171

163-
func (p *Policy) Match(identity, composeHash string) (*Workload, error) {
172+
func (p *Policy) Match(identity, composeHash, peerID string) (*Workload, error) {
164173
normalized, err := normalizeComposeHash(composeHash)
165174
if err != nil {
166175
return nil, fmt.Errorf("invalid compose_hash from quote: %w", err)
167176
}
177+
peerID = strings.TrimSpace(peerID)
168178
for _, w := range p.byIdentity[identity] {
169-
if _, ok := w.allowedComposeHashSet[normalized]; ok {
179+
if _, ok := w.allowedComposeHashSet[normalized]; ok && (w.PeerID == "" || w.PeerID == peerID) {
170180
return &w, nil
171181
}
172182
}
183+
if peerID != "" {
184+
return nil, fmt.Errorf("identity %q is not allowed for compose_hash %s and peer_id %q", identity, normalized, peerID)
185+
}
173186
return nil, fmt.Errorf("identity %q is not allowed for compose_hash %s", identity, normalized)
174187
}
175188

@@ -227,11 +240,11 @@ type AttestRequest struct {
227240
}
228241

229242
type AttestResponse struct {
230-
ConsulACLToken string `json:"consul_acl_token"`
231-
ExpiresAt time.Time `json:"expires_at"`
232-
WorkloadID string `json:"workload_id"`
233-
ComposeHash string `json:"compose_hash"`
234-
PolicyEpoch int `json:"policy_epoch"`
243+
ConsulACLToken string `json:"consul_acl_token"`
244+
ExpiresAt *time.Time `json:"expires_at,omitempty"`
245+
WorkloadID string `json:"workload_id"`
246+
ComposeHash string `json:"compose_hash"`
247+
PolicyEpoch int `json:"policy_epoch"`
235248
}
236249

237250
func (s *Server) handleAttest(w http.ResponseWriter, r *http.Request) {
@@ -264,10 +277,9 @@ func (s *Server) handleAttest(w http.ResponseWriter, r *http.Request) {
264277
defer cancel()
265278

266279
verified, err := s.verifier.Verify(ctx, VerifyRequest{
267-
Attestation: req.Attestation,
268-
Quote: req.Quote,
269-
EventLog: rawJSONAsString(req.EventLog),
270-
VMConfig: req.VMConfig,
280+
Quote: firstNonEmpty(req.Quote, req.Attestation),
281+
EventLog: rawJSONAsString(req.EventLog),
282+
VMConfig: req.VMConfig,
271283
})
272284
if err != nil {
273285
writeError(w, http.StatusBadGateway, "VERIFIER_FAILED", err.Error())
@@ -282,17 +294,22 @@ func (s *Server) handleAttest(w http.ResponseWriter, r *http.Request) {
282294
return
283295
}
284296

285-
workload, err := s.policy.Match(req.Identity, verified.ComposeHash)
297+
workload, err := s.policy.Match(req.Identity, verified.ComposeHash, bindingStatement.PeerID)
286298
if err != nil {
287299
writeError(w, http.StatusForbidden, "ADMISSION_REJECTED", err.Error())
288300
return
289301
}
290302

291-
expiresAt := s.now().Add(s.tokenTTL)
303+
var expiresAt *time.Time
304+
if s.tokenTTL > 0 {
305+
t := s.now().Add(s.tokenTTL)
306+
expiresAt = &t
307+
}
292308
token, err := s.issuer.IssueToken(ctx, TokenRequest{
293309
Description: fmt.Sprintf("attested %s %s", workload.WorkloadID, verified.ComposeHash),
294310
ConsulService: workload.ConsulService,
295311
ConsulPermissions: workload.ConsulPermissions,
312+
Datacenter: s.policy.Cluster,
296313
PeerID: bindingStatement.PeerID,
297314
TTL: s.tokenTTL,
298315
})
@@ -413,10 +430,10 @@ func (s *NonceStore) gc(now time.Time) {
413430
}
414431

415432
type VerifyRequest struct {
416-
Attestation string `json:"attestation,omitempty"`
417-
Quote string `json:"quote"`
418-
EventLog string `json:"event_log"`
419-
VMConfig string `json:"vm_config"`
433+
Attestation *string `json:"attestation"`
434+
Quote string `json:"quote"`
435+
EventLog string `json:"event_log"`
436+
VMConfig string `json:"vm_config"`
420437
}
421438

422439
type VerifiedQuote struct {
@@ -518,6 +535,7 @@ type TokenRequest struct {
518535
Description string
519536
ConsulService string
520537
ConsulPermissions ConsulPermissions
538+
Datacenter string
521539
PeerID string
522540
TTL time.Duration
523541
}
@@ -533,20 +551,40 @@ type ConsulIssuer struct {
533551
}
534552

535553
func (i *ConsulIssuer) IssueToken(ctx context.Context, req TokenRequest) (string, error) {
536-
if req.ConsulService == "" {
537-
return "", errors.New("consul service is required")
554+
if req.TTL < 0 {
555+
return "", errors.New("token TTL must not be negative")
538556
}
539557
payload := map[string]any{
540-
"Description": req.Description,
541-
"ExpirationTTL": fmt.Sprintf("%ds", int(req.TTL.Seconds())),
542-
"ServiceIdentities": []map[string]string{
558+
"Description": req.Description,
559+
}
560+
if req.TTL > 0 {
561+
payload["ExpirationTTL"] = fmt.Sprintf("%ds", int(req.TTL.Seconds()))
562+
}
563+
if req.ConsulService != "" {
564+
payload["ServiceIdentities"] = []map[string]string{
543565
{"ServiceName": req.ConsulService},
544-
},
566+
}
567+
}
568+
if req.ConsulPermissions.NodeIdentitySelf {
569+
peerID := strings.TrimSpace(req.PeerID)
570+
if err := validateConsulAgentName(peerID); err != nil {
571+
return "", fmt.Errorf("node_identity_self requires attested peer_id: %w", err)
572+
}
573+
datacenter := strings.TrimSpace(req.Datacenter)
574+
if err := validateConsulAgentName(datacenter); err != nil {
575+
return "", fmt.Errorf("node_identity_self requires datacenter: %w", err)
576+
}
577+
payload["NodeIdentities"] = []map[string]string{
578+
{"NodeName": peerID, "Datacenter": datacenter},
579+
}
545580
}
546581
rules, err := consulACLRules(req.ConsulPermissions, req.PeerID)
547582
if err != nil {
548583
return "", err
549584
}
585+
if req.ConsulService == "" && !req.ConsulPermissions.NodeIdentitySelf && rules == "" {
586+
return "", errors.New("token request must define consul service identity, node identity, or ACL rules")
587+
}
550588
if rules != "" {
551589
policyName, err := i.EnsurePolicy(ctx, "attested-workload-"+shortSHA256(rules, 16), "Additional permissions for attested workloads", rules)
552590
if err != nil {
@@ -705,6 +743,10 @@ func consulACLRules(p ConsulPermissions, peerID string) (string, error) {
705743
return b.String(), nil
706744
}
707745

746+
func (p ConsulPermissions) hasGrant() bool {
747+
return len(p.KeyPrefixes) > 0 || p.SessionWrite || p.AgentReadSelf || p.NodeIdentitySelf
748+
}
749+
708750
func validateConsulAgentName(name string) error {
709751
if name == "" {
710752
return errors.New("peer_id is empty")

0 commit comments

Comments
 (0)