Skip to content

Commit d204f50

Browse files
h4x3rotabclaude
andcommitted
docs(consul-postgres-ha): design briefs for single-sidecar + attestation admission
Two open architectural gaps surfaced during the consolidation pass on PR #95 — both deserve their own focused implementation passes rather than being squeezed into the mega PR. Capturing them as design docs so a future agent (or a future-me) can pick up either one cold and start. - design/single-sidecar.md: collapse the 5 platform-plumbing containers (keepalive, bootstrap-secrets, mesh-conn, consul, sidecar/Envoy) into one image with a shell-init multi-process supervisor. Per-CVM container count goes 8 → 3. - design/attestation-admission.md: replace the TURN-HMAC-only mesh admission with dstack TEE attestation as the credential. Phased plan: per-app-id check first (Phase 1, smallest delta, no rolling-upgrade pain), Consul-KV-rooted policy doc later (Phase 2). Recommends the post-QUIC-handshake-stream insertion point over the public signaling broker for privacy. Both docs include current state, approach, risks, open questions, and explicit hand-off instructions. Each is ~250-350 lines, written to be self-contained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d69c762 commit d204f50

3 files changed

Lines changed: 512 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Design docs — open work, intentionally separate from the user-facing docs
2+
3+
This directory holds design briefs for **planned but not-yet-implemented**
4+
work on `consul-postgres-ha`. Each doc is structured so an agent (or a
5+
person) can pick it up cold and start implementing.
6+
7+
The user-facing docs (`README.md`, `ARCHITECTURE.md`, `FAILOVER.md`,
8+
`PUBLISHING.md`, `ROBUSTNESS.md`) describe what's *shipping today*.
9+
This directory describes what's *next*. They're intentionally
10+
separated so a user landing on the example doesn't get a roadmap
11+
in their face.
12+
13+
| Doc | What |
14+
|---|---|
15+
| [`single-sidecar.md`](single-sidecar.md) | Collapse the 5 platform-plumbing containers (`keepalive`, `bootstrap-secrets`, `mesh-conn`, `consul`, `sidecar`/Envoy) into one image with a small shell-init multi-process supervisor. Per-CVM container count: 8 → 3. |
16+
| [`attestation-admission.md`](attestation-admission.md) | Use dstack TEE attestation as the mesh-conn admission credential, replacing/augmenting the shared TURN HMAC. Phased plan: per-app-id first, Consul-KV-rooted policy later. |
17+
18+
Each doc includes:
19+
20+
- The current state and why it falls short
21+
- What "done" looks like
22+
- A concrete approach with a code/structure sketch
23+
- Risks + mitigations
24+
- Open questions for the implementing agent
25+
- Success criteria
26+
- Hand-off instructions
27+
28+
When a doc's work lands, delete the doc (the implementation + the
29+
user-facing docs are the surviving artifacts).
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)