carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// The mesh key — housekeeper-minted, edge-mirrored, HMAC hop vocabulary
4// (spec docs/superpowers/specs/2026-08-22-multi-region-backhaul-design.md,
5// Part 3, Task 2).
6//
7// A multi-region deployment has more than one edge; Tasks 3/4 have one edge
8// forward a request to another (a "hop") when it does not own the region a
9// request landed in. That hop has to prove it came from a box inside this
10// deployment's own mesh — the writer map's answer is the same shape the
11// housekeeper's other duties already use: exactly ONE box mints a shared
12// secret, every box mirrors it, and no second election is invented.
13//
14// - mintMeshKey is the housekeeper duty (wired as its own loop,
15// internal/host/housekeeper.go's mintMeshKeyLoop — see that func's
16// comment for why it is NOT folded into retentionLoop's tick): 32
17// random bytes, hex-encoded, written to control/edge-mesh-key with an
18// empty ifMatch — release.Store's create-only form. That IS the
19// compare-and-swap: two housekeepers racing the same tick (the
20// transient double-holder window housekeeper.go's poll cadence allows)
21// both attempt the Put, exactly one wins, and the loser's
22// release.ErrCASConflict is not a failure — it is proof a key already
23// exists, which is the only thing this func promises. Every later tick
24// calls it again and finds the same conflict, forever, for the
25// deployment's life: minting is a one-time event, not a duty that ever
26// "finishes" — and it stays SILENT after the first tick (see
27// mintMeshKey's own comment): the steady state of every tick but one,
28// forever, is not something worth a log line, exactly like the
29// housekeeper election's own contention.
30//
31// - signHop / verifyHop are the hop vocabulary Task 3/4 wire onto the
32// request path under the headers X-Carlos-Hop: 1 and X-Carlos-Hop-Auth:
33// <unix>:<hex>. The MAC binds the target host into the signature so one
34// hop's header cannot be replayed against another, and the timestamp
35// bounds replay in time (±hopSkew) independent of the host check. The
36// host is canonicalized (canonicalizeHopHost: lowercased, trailing dot
37// stripped, :port stripped) before it ever reaches the MAC, and the MAC
38// is always over the CONCRETE request host a hop is bound for — never a
39// wildcard map key (e.g. "*.example.com") — because Task 3 verifies
40// against the literal net/http request host and Task 4 signs the same;
41// canonicalizing in one place (hopMAC) is what keeps a signer's and a
42// verifier's idea of "host" from ever diverging on case, a trailing
43// root-label dot, or a stray port.
44//
45// - Rotation's READ side ships in this task, ahead of any code that
46// writes .next: control/edge-mesh-key.next mirrors a rotation
47// successor while one is in flight, loadMeshKeys reads both,
48// verifyHopEitherKey accepts a signature under either, and signingKey
49// is what a signer consults to prefer .next the instant it exists —
50// never waiting for a runbook step.
51//
52// What this removes is the PROMOTION window, not the PROPAGATION one.
53// A signer starts using .next as soon as its OWN box has picked it up;
54// a remote verifier accepts a .next-signed hop only once ITS OWN
55// mirror has too (Task 3/4's poll of the control/ prefix, up to ~30s
56// behind a write). Between those two moments a hop signed under .next
57// CAN be refused by a verifier that hasn't caught up — that is a real,
58// if short, window, and per Task 3's rules the cost of that refusal is
59// the request being served as an ordinary client (its X-Forwarded-For
60// stripped), not dropped: a degraded hop, not an outage. What
61// dual-accept actually buys is that the runbook's promotion step
62// itself (copy .next over control/edge-mesh-key, delete .next) needs
63// no synchronized cutover: because every verifier already accepts
64// EITHER key well before promotion happens, that write can land as an
65// ordinary, un-sequenced action with no box needing to be told "stop
66// signing with the old key" first.
67//
68// Single-box inertness: on a deployment with one platform box, this box is
69// always the housekeeper (housekeeper.go's housekeeperSole), so it mints its
70// own mesh key and nothing else ever reads it — Tasks 3/4 are the first
71// readers. Minting here is therefore inert today, by design: this task ships
72// the vocabulary and the mint, not a consumer.
73import (
74 "context"
75 "crypto/hmac"
76 "crypto/rand"
77 "crypto/sha256"
78 "encoding/hex"
79 "errors"
80 "fmt"
81 "log"
82 "net"
83 "strconv"
84 "strings"
85 "time"
86
87 "github.com/carlosframework/platform/internal/edgeproof"
88 "github.com/carlosframework/platform/internal/release"
89)
90
91// meshKeyKey is the control-plane object the housekeeper mints once per
92// deployment. Named beside the storedoor ACL tripwire
93// (TestDoorNeverServesCertMaterial), which refuses to serve it — a fleet
94// box authenticating through the store door must never read this key.
95const meshKeyKey = edgeproof.MeshKeyObject
96
97// meshKeyNextKey is the rotation successor, mirrored alongside meshKeyKey
98// only while a rotation is in flight. Promoting it (copy over meshKeyKey,
99// delete this object) is a runbook step, not code this task ships — see the
100// file header.
101const meshKeyNextKey = edgeproof.NextMeshKeyObject
102
103// meshKeyBytes is the random key length before hex encoding: 256 bits,
104// matched to the HMAC-SHA256 hop signature below.
105const meshKeyBytes = 32
106
107// hopSkew bounds how far a hop signature's timestamp may drift from the
108// verifier's clock, in either direction: generous enough for ordinary box
109// clock skew and request latency, tight enough that a captured header stops
110// working well within a minute.
111const hopSkew = 30 * time.Second
112
113// mintMeshKey mints the deployment's mesh key if, and only if, nothing has
114// minted one yet. crypto/rand is required — this key authenticates hops
115// between platform boxes, so it gets the same random source every other
116// credential in this codebase does, never math/rand.
117//
118// A lost race (release.ErrCASConflict — a key already exists, from this
119// box's own earlier tick or another housekeeper that won the same race) is
120// deliberately SILENT, not logged even at "already minted": that outcome is
121// the steady state of every tick but the very first, forever, for the
122// deployment's whole life — one line every tick would be ~264 lines/day of
123// nothing happening, exactly the noise housekeeper.go's own election
124// contention refuses to log for the same reason. Only an actual, novel mint
125// gets a line.
126func mintMeshKey(ctx context.Context, s release.Store) error {
127 buf := make([]byte, meshKeyBytes)
128 if _, err := rand.Read(buf); err != nil {
129 return fmt.Errorf("mesh key: read random bytes: %w", err)
130 }
131 body := []byte(hex.EncodeToString(buf))
132 if _, err := s.Put(ctx, meshKeyKey, body, ""); err != nil {
133 if errors.Is(err, release.ErrCASConflict) {
134 return nil
135 }
136 return fmt.Errorf("mesh key: put: %w", err)
137 }
138 log.Printf("edge: mesh key: minted")
139 return nil
140}
141
142// MintMeshKey is mintMeshKey's exported wrapper — the seam
143// internal/host's housekeeper duty (mintMeshKeyLoop, housekeeper.go) calls
144// through, so the duty lives beside the housekeeper while the key's own
145// shape (its object name, its random-byte discipline) stays edge's to own.
146func MintMeshKey(ctx context.Context, s release.Store) error {
147 return mintMeshKey(ctx, s)
148}
149
150// loadMeshKeys is the dual-accept read side's key material access: it reads
151// BOTH control/edge-mesh-key and control/edge-mesh-key.next. current is nil
152// only before the housekeeper has minted anything; next is nil the whole
153// rest of the time — nearly always, since a rotation is a deliberate,
154// short-lived operator action.
155//
156// The read itself lives in internal/edgeproof, because the console reads the
157// same pair to check the edge's forwarded-for proof and must not import this
158// package. Its failure rules are the ones this read always had: a bad .next
159// is logged and treated as no rotation in flight (it is written by hand, and
160// one bad upload must not take every hop down with the good current key); a
161// bad current key fails the call, since there is no safe fallback for the
162// live key being unreadable.
163func loadMeshKeys(ctx context.Context, s release.Store) (current, next []byte, err error) {
164 return edgeproof.LoadKeys(ctx, s)
165}
166
167// signingKey is which of the two mirrored keys a signer should use: .next
168// the instant it exists, else current. A signer never waits for the
169// runbook's promotion step — verifyHopEitherKey's dual-accept on the reader
170// side is what makes that safe, so switching signers over early costs
171// nothing and shortens how long a rotation needs both keys mirrored.
172func signingKey(current, next []byte) []byte {
173 if len(next) > 0 {
174 return next
175 }
176 return current
177}
178
179// signHop produces the value of the X-Carlos-Hop-Auth header: an
180// HMAC-SHA256 over "host\nunix-timestamp", hex-encoded and prefixed with the
181// timestamp so a verifier does not have to guess it. host is bound into the
182// MAC so a signature minted for one hop cannot be replayed against another.
183// host must be the CONCRETE host a hop is bound for (a net/http request
184// host, e.g. from Task 4's forwarding decision) — never a wildcard home-map
185// key like "*.example.com" — because hopMAC canonicalizes it the same way a
186// verifier's literal request host is canonicalized, and a wildcard pattern
187// is not a request host under any canonicalization.
188func signHop(key []byte, host string, now time.Time) string {
189 ts := strconv.FormatInt(now.Unix(), 10)
190 return ts + ":" + hex.EncodeToString(hopMAC(key, host, ts))
191}
192
193// verifyHop checks an X-Carlos-Hop-Auth header against a single key: the
194// timestamp must parse and fall within ±hopSkew of now, and the MAC must
195// match under constant-time comparison (hmac.Equal) — a hop signature is
196// exactly the kind of secret-derived value a timing side channel could leak,
197// so no byte-wise or string-equality shortcut is taken anywhere in this
198// path.
199func verifyHop(key []byte, host, header string, now time.Time) bool {
200 ts, sig, ok := strings.Cut(header, ":")
201 if !ok || ts == "" || sig == "" {
202 return false
203 }
204 sec, err := strconv.ParseInt(ts, 10, 64)
205 if err != nil {
206 return false
207 }
208 skew := now.Sub(time.Unix(sec, 0))
209 if skew < 0 {
210 skew = -skew
211 }
212 if skew > hopSkew {
213 return false
214 }
215 got, err := hex.DecodeString(sig)
216 if err != nil {
217 return false
218 }
219 return hmac.Equal(got, hopMAC(key, host, ts))
220}
221
222// verifyHopEitherKey is the dual-accept read side of a rotation: it accepts
223// a signature made under either mirrored key, current or next, so neither a
224// straggling signer still on the old key nor one that has already switched
225// to .next (signingKey prefers it immediately) is ever refused while both
226// are mirrored. Task 3/4's request path calls this once it has loaded both
227// keys via loadMeshKeys.
228func verifyHopEitherKey(current, next []byte, host, header string, now time.Time) bool {
229 if len(next) > 0 && verifyHop(next, host, header, now) {
230 return true
231 }
232 if len(current) > 0 && verifyHop(current, host, header, now) {
233 return true
234 }
235 return false
236}
237
238// hopMAC is the MAC signHop and verifyHop both compute — one place so the
239// two can never drift out of sync on what bytes are actually signed, and
240// the one choke point that canonicalizes host before it enters the MAC.
241func hopMAC(key []byte, host, ts string) []byte {
242 mac := hmac.New(sha256.New, key)
243 mac.Write([]byte(canonicalizeHopHost(host)))
244 mac.Write([]byte("\n"))
245 mac.Write([]byte(ts))
246 return mac.Sum(nil)
247}
248
249// canonicalizeHopHost normalizes a request host into the exact form the MAC
250// is computed over, so a signer and a verifier can never disagree on what
251// "host" means for the same request:
252//
253// - a :port is stripped via net.SplitHostPort when present — Task 3
254// verifies against a literal net/http request Host, which carries a
255// port when the client set one (or the request came in over a
256// non-default port), while a signer's own idea of the host (e.g. a
257// home-map lookup key) never does;
258// - lowercased — HTTP host headers are case-insensitive, but bytes
259// entering an HMAC are not, so a client or intermediary that
260// capitalizes a label must not be able to produce a request this MAC
261// treats as a different host;
262// - a single trailing root-label dot is stripped — "example.com." and
263// "example.com" name the same host, and a client is free to send
264// either.
265//
266// host is returned unchanged (net.SplitHostPort's error path) when it
267// carries no port — the common case, and not a failure.
268func canonicalizeHopHost(host string) string {
269 if h, _, err := net.SplitHostPort(host); err == nil {
270 host = h
271 }
272 return strings.TrimSuffix(strings.ToLower(host), ".")
273}
274