|
| 1 | +# Design: TEE attestation as the mesh admission credential |
| 2 | + |
| 3 | +**Status**: not started. Largest of the three open architectural |
| 4 | +gaps. Worth starting with a design discussion (verify dstack SDK API |
| 5 | +shape, confirm policy choice) before writing code. Branch off |
| 6 | +`dstack-consul-ha-db`, PR back into it. |
| 7 | + |
| 8 | +## Why |
| 9 | + |
| 10 | +The whole point of running on dstack is that each CVM can produce a |
| 11 | +hardware-attested measurement of what's executing inside it. Right |
| 12 | +now mesh-conn doesn't *use* that — peer admission is gated by: |
| 13 | + |
| 14 | +- Holding the TURN HMAC secret (same on every peer in the cluster, |
| 15 | + derived from dstack KMS by `bootstrap-secrets`). |
| 16 | +- Completing pion/ICE handshake. |
| 17 | +- Completing QUIC TLS handshake (self-signed cert, no peer-cert |
| 18 | + verification — `InsecureSkipVerify: true`). |
| 19 | + |
| 20 | +A peer that **exfiltrates the TURN HMAC** can rejoin the mesh from |
| 21 | +anywhere. A peer running a **rolled-back or compromised image** can |
| 22 | +rejoin too — nobody asks "what are you running?" before admitting |
| 23 | +the connection. That's a meaningful gap for a TEE-rooted system. |
| 24 | + |
| 25 | +## Goal |
| 26 | + |
| 27 | +Each peer's mesh-conn admission decision is gated on a fresh dstack |
| 28 | +attestation that: |
| 29 | + |
| 30 | +1. **Signs a binding** between the peer's identity (peer-id, ICE |
| 31 | + credentials, QUIC cert public key, …) and the TEE measurement. |
| 32 | +2. **Chains to dstack's KMS root**, so we can verify off-chain that |
| 33 | + it really came from a dstack CVM. |
| 34 | +3. **Matches a policy** the cluster has agreed on (more on policy |
| 35 | + below). |
| 36 | + |
| 37 | +A peer that can't produce such an attestation is rejected at the |
| 38 | +QUIC handshake or first-stream layer, with a clear error. |
| 39 | + |
| 40 | +## Non-goals (for the first pass) |
| 41 | + |
| 42 | +- Replacing Consul Connect's mTLS at Layer 3. Consul intentions |
| 43 | + govern *service-to-service* auth and stay as-is. This work governs |
| 44 | + *peer-to-peer mesh admission* — a layer below. The two are |
| 45 | + orthogonal. |
| 46 | +- Replacing the TURN HMAC for *coturn auth*. Coturn still wants its |
| 47 | + shared-secret. We're adding a **second** check at mesh-conn admit |
| 48 | + time, not replacing the first. |
| 49 | +- Periodic re-attestation. Phase 1 is "fresh attestation at each new |
| 50 | + link establishment". A peer that rotates mid-session is out of |
| 51 | + scope until phase 2. |
| 52 | + |
| 53 | +## Where attestation flows in the protocol |
| 54 | + |
| 55 | +Two natural insertion points: |
| 56 | + |
| 57 | +| Where | Pros | Cons | |
| 58 | +|---|---|---| |
| 59 | +| **(a) During ICE auth exchange via signaling broker** | Earliest possible reject. No ICE NAT-mapping wasted on rejected peers. | Signaling broker is *public* — attestation is exposed to anyone polling the broker. May be acceptable if attestations don't reveal sensitive state. | |
| 60 | +| **(b) After QUIC handshake, as a "hello" message before the first user stream** | Private (encrypted under QUIC's TLS). Cleaner separation: ICE/QUIC stays oblivious, attestation is an application-layer concern. | A rejected peer wastes one ICE handshake + QUIC handshake. Fine in practice. | |
| 61 | + |
| 62 | +**Recommendation: (b).** The privacy benefit outweighs the |
| 63 | +extra-handshake cost. Concrete shape: |
| 64 | + |
| 65 | +1. Both sides establish QUIC (existing flow). |
| 66 | +2. Each side immediately opens a dedicated stream tagged |
| 67 | + `streamAttest = 0xAA` (next free tag after `streamUDP=0x55`, |
| 68 | + `streamTCP=0x33`). |
| 69 | +3. Each side writes its attestation (a length-prefixed blob) to that |
| 70 | + stream and closes its write half. |
| 71 | +4. Each side reads the peer's attestation, verifies it against the |
| 72 | + policy, and either: |
| 73 | + - On accept: starts the existing `runAcceptLoop` / |
| 74 | + `OpenStreamSync` flow. |
| 75 | + - On reject: closes the QUIC connection with a documented error |
| 76 | + code, and `runPeerLink` retries after backoff (no different |
| 77 | + from any other failed handshake). |
| 78 | + |
| 79 | +The 3-byte stream header + tagging convention extends naturally; |
| 80 | +nothing else in the wire format changes. |
| 81 | + |
| 82 | +## Policy choice — three candidates |
| 83 | + |
| 84 | +### (1) Per-image-digest allowlist |
| 85 | + |
| 86 | +The cluster admits peers running images whose digests match an |
| 87 | +allowlist hardcoded into `bootstrap-secrets` or pulled from Consul KV. |
| 88 | + |
| 89 | +Pro: tightest. A leaked TURN HMAC alone can't get you in. |
| 90 | + |
| 91 | +Con: rolling upgrades require careful sequencing. While CVM-A is on |
| 92 | +digest `D1` and CVM-B is on digest `D2`, they need to admit each |
| 93 | +other. Either the allowlist always carries N+M digests during the |
| 94 | +upgrade window, or the upgrade procedure pauses traffic between |
| 95 | +not-yet-upgraded peers — both annoying. |
| 96 | + |
| 97 | +### (2) Per-app-id signature |
| 98 | + |
| 99 | +The cluster admits any peer whose attestation binds to the **same |
| 100 | +dstack app-id** as our own. Identity = app-id; image-digest is not |
| 101 | +checked. |
| 102 | + |
| 103 | +Pro: rolling upgrades trivial — N+1 image is still under the same |
| 104 | +app-id, so peers admit each other unchanged. Simple to implement |
| 105 | +(app-id is already in `/run/instance/info.json`). |
| 106 | + |
| 107 | +Con: a malicious image deployed under the same app-id (by whoever |
| 108 | +controls the dstack-app deploy keys) can join. The TEE proves |
| 109 | +"running in this app" but not "running this *binary*". |
| 110 | + |
| 111 | +### (3) Consul-KV-rooted policy |
| 112 | + |
| 113 | +The admission policy is a signed document stored in Consul KV under |
| 114 | +e.g. `cluster/<name>/admission-policy`, signed by a key derived from |
| 115 | +dstack KMS at cluster bootstrap. The document lists allowed |
| 116 | +image-digests + a signature scheme for rotation. |
| 117 | + |
| 118 | +Pro: most expressive. Supports rolling upgrades (write a new policy |
| 119 | +listing both digests, peers re-evaluate, after upgrade the old digest |
| 120 | +is removed). Supports revocation (write a deny-list). |
| 121 | + |
| 122 | +Con: most complex. Bootstrapping the signing key safely is tricky |
| 123 | +(if an attacker reaches Consul KV they can rewrite the policy). |
| 124 | + |
| 125 | +### Recommendation |
| 126 | + |
| 127 | +**Phase 1: per-app-id (option 2).** It's the smallest delta from |
| 128 | +where we are, gives a meaningful security improvement (compromise of |
| 129 | +TURN HMAC alone no longer admits arbitrary outsiders — they'd have |
| 130 | +to also be inside *this* dstack-app), and doesn't fight rolling |
| 131 | +upgrades. Document explicitly that this is "trust the deploy key, |
| 132 | +not the image". |
| 133 | + |
| 134 | +**Phase 2: layer in image-digest verification with a policy doc in |
| 135 | +Consul KV** (option 3) once we have someone driving the |
| 136 | +deploy-time-signing story. |
| 137 | + |
| 138 | +Do **not** start with per-image-digest hardcoding (option 1) — the |
| 139 | +upgrade pain bites immediately and there's no path forward. |
| 140 | + |
| 141 | +## Implementation phases |
| 142 | + |
| 143 | +### Phase 0 — plumbing (no enforcement) |
| 144 | + |
| 145 | +- Each peer fetches its attestation at startup via the dstack SDK. |
| 146 | +- Add the attest-stream exchange (`streamAttest=0xAA` + length-prefix). |
| 147 | +- Both sides log "got peer attestation, would accept" but admit |
| 148 | + unconditionally. |
| 149 | +- Adds an observability foothold without breaking anything. |
| 150 | + |
| 151 | +### Phase 1 — per-app-id enforcement |
| 152 | + |
| 153 | +- Each peer's attestation includes its app-id. |
| 154 | +- Verify: signature chains to dstack KMS root, app-id matches our |
| 155 | + own. |
| 156 | +- Reject + log on mismatch. Add a regression test that constructs a |
| 157 | + fake attestation with a wrong app-id and asserts rejection. |
| 158 | + |
| 159 | +### Phase 2 — Consul-KV admission policy |
| 160 | + |
| 161 | +- Coordinator-side: a small tool that signs a policy doc and writes |
| 162 | + it to Consul KV. |
| 163 | +- Peer-side: pull the policy doc on link admission, verify |
| 164 | + signature, check peer's image-digest against the allowed list. |
| 165 | +- Rolling-upgrade story: operator writes a new policy listing both |
| 166 | + digests, applies cluster-wide image bump, then writes a policy |
| 167 | + removing the old digest. |
| 168 | + |
| 169 | +### Phase 3 — re-attestation on link redial |
| 170 | + |
| 171 | +- The stream-of-attestation exchange runs every time `dialAndPump` |
| 172 | + re-establishes a link, not just once at peer-id discovery. |
| 173 | +- Already implicit in phase 1 (the exchange is per-handshake), but |
| 174 | + worth listing because it means revocation propagates within |
| 175 | + ~minute timescales, not "until this connection drops naturally". |
| 176 | + |
| 177 | +## Open questions for the design discussion |
| 178 | + |
| 179 | +1. **What's the actual dstack SDK API for fetching an attestation / |
| 180 | + quote?** The user has worked with `dstack.NewDstackClient().Info()` |
| 181 | + and `client.GetKey()` — assume there's an analogous |
| 182 | + `client.GetQuote()` or `client.Attest()` but verify against the |
| 183 | + SDK source. Determines the binding shape (what the attestation |
| 184 | + commits to: nonce, peer-id, public key, …). |
| 185 | + |
| 186 | +2. **Attestation size + verification cost.** Dstack quotes are |
| 187 | + typically a few KB and Verify is a few ms. If both are larger than |
| 188 | + that, the attest exchange becomes a noticeable handshake-latency |
| 189 | + tax. Worth measuring early. |
| 190 | + |
| 191 | +3. **What does the attestation actually bind to?** Possible |
| 192 | + bindings: |
| 193 | + - peer-id (our string, e.g. `worker-3`) — easy to spoof on its own |
| 194 | + - QUIC cert public key — ties the attestation to *this* TLS |
| 195 | + handshake. Best. |
| 196 | + - Nonce from the peer — prevents replay across handshakes. Add to |
| 197 | + the dialer's auth blob and have the dialee bind to it. |
| 198 | + The right answer is probably "QUIC cert pubkey + a per-handshake |
| 199 | + nonce", binding both the identity and the freshness. |
| 200 | + |
| 201 | +4. **Bootstrap chicken-and-egg.** First peer to come up has nobody |
| 202 | + to attest to. How does the cluster bootstrap when *every* peer |
| 203 | + needs every other peer's attestation? Two answers: |
| 204 | + - Coordinators come up first; admit only if peer's |
| 205 | + attestation is valid; coordinators admit each other via a |
| 206 | + genesis attestation whose policy is "any peer in our app-id". |
| 207 | + - Or: same code, no special-case — just per-app-id from the |
| 208 | + start. |
| 209 | + |
| 210 | +5. **Failure observability.** What's the log shape when admission |
| 211 | + fails? Operators need to see "rejected peer X because |
| 212 | + app_id=Y didn't match expected=Z" — not just "link failed". |
| 213 | + New error type + structured log line. |
| 214 | + |
| 215 | +6. **Interaction with the planned single-sidecar consolidation |
| 216 | + (Gap 2).** Attestation lookup happens inside mesh-conn, so it |
| 217 | + stays with mesh-conn whether mesh-conn is a separate container or |
| 218 | + one process inside the consolidated sidecar. Gap 2 should land |
| 219 | + first; Gap 3 is easier when the platform plumbing lives in one |
| 220 | + place. |
| 221 | + |
| 222 | +## Success criteria |
| 223 | + |
| 224 | +- [ ] Each peer fetches a valid dstack attestation at startup. |
| 225 | +- [ ] Peer-pair handshake includes attest-stream exchange. |
| 226 | +- [ ] Verify signature chains to dstack KMS root. |
| 227 | +- [ ] Reject peer with mismatched app-id; admit peer with matching |
| 228 | + app-id. |
| 229 | +- [ ] Log shape clearly distinguishes admission-reject from other |
| 230 | + handshake errors. |
| 231 | +- [ ] Failover demos (FAILOVER.md) still pass — RTO unchanged within |
| 232 | + noise. |
| 233 | +- [ ] A new doc, `consul-postgres-ha/ATTESTATION.md`, explains the |
| 234 | + threat model + policy + how to inspect attestations on a |
| 235 | + running cluster. |
| 236 | + |
| 237 | +## Risks + mitigations |
| 238 | + |
| 239 | +| Risk | Mitigation | |
| 240 | +|---|---| |
| 241 | +| Attestation API not available on the dstack SDK we're using | Verify in the design-discussion phase before writing code. If missing, the right path is "land Gap 2 first, then file an SDK feature request, then revisit". | |
| 242 | +| Verification is slow enough to become a handshake bottleneck | Cache valid peer-attestations for the lifetime of the QUIC connection (don't re-verify on each stream). Measure once before deciding mitigation is needed. | |
| 243 | +| Per-app-id is too loose for the user's threat model | Document the limitation in `ATTESTATION.md` and ship Phase 2 (Consul-KV policy) as the next iteration. Don't perfect-is-the-enemy-of-good Phase 1. | |
| 244 | +| Bootstrap deadlock — every peer waits for every other | Per-app-id avoids this entirely (no shared trust root needed beyond dstack KMS, which every CVM has). Phase 2 needs explicit thought; not a Phase 1 concern. | |
| 245 | + |
| 246 | +## Hand-off |
| 247 | + |
| 248 | +Worth at least a design discussion before writing code (the user |
| 249 | +flagged this as "a large topic, breakout session"). Specifically: |
| 250 | +verify dstack SDK API, confirm per-app-id is the right Phase 1 |
| 251 | +policy, decide on (a) ICE-auth vs (b) post-QUIC stream as the |
| 252 | +exchange point. Then implementation is a focused ~300-LoC change in |
| 253 | +mesh-conn plus a new doc. |
0 commit comments