package edge // The mesh key — housekeeper-minted, edge-mirrored, HMAC hop vocabulary // (spec docs/superpowers/specs/2026-08-22-multi-region-backhaul-design.md, // Part 3, Task 2). // // A multi-region deployment has more than one edge; Tasks 3/4 have one edge // forward a request to another (a "hop") when it does not own the region a // request landed in. That hop has to prove it came from a box inside this // deployment's own mesh — the writer map's answer is the same shape the // housekeeper's other duties already use: exactly ONE box mints a shared // secret, every box mirrors it, and no second election is invented. // // - mintMeshKey is the housekeeper duty (wired as its own loop, // internal/host/housekeeper.go's mintMeshKeyLoop — see that func's // comment for why it is NOT folded into retentionLoop's tick): 32 // random bytes, hex-encoded, written to control/edge-mesh-key with an // empty ifMatch — release.Store's create-only form. That IS the // compare-and-swap: two housekeepers racing the same tick (the // transient double-holder window housekeeper.go's poll cadence allows) // both attempt the Put, exactly one wins, and the loser's // release.ErrCASConflict is not a failure — it is proof a key already // exists, which is the only thing this func promises. Every later tick // calls it again and finds the same conflict, forever, for the // deployment's life: minting is a one-time event, not a duty that ever // "finishes" — and it stays SILENT after the first tick (see // mintMeshKey's own comment): the steady state of every tick but one, // forever, is not something worth a log line, exactly like the // housekeeper election's own contention. // // - signHop / verifyHop are the hop vocabulary Task 3/4 wire onto the // request path under the headers X-Carlos-Hop: 1 and X-Carlos-Hop-Auth: // :. The MAC binds the target host into the signature so one // hop's header cannot be replayed against another, and the timestamp // bounds replay in time (±hopSkew) independent of the host check. The // host is canonicalized (canonicalizeHopHost: lowercased, trailing dot // stripped, :port stripped) before it ever reaches the MAC, and the MAC // is always over the CONCRETE request host a hop is bound for — never a // wildcard map key (e.g. "*.example.com") — because Task 3 verifies // against the literal net/http request host and Task 4 signs the same; // canonicalizing in one place (hopMAC) is what keeps a signer's and a // verifier's idea of "host" from ever diverging on case, a trailing // root-label dot, or a stray port. // // - Rotation's READ side ships in this task, ahead of any code that // writes .next: control/edge-mesh-key.next mirrors a rotation // successor while one is in flight, loadMeshKeys reads both, // verifyHopEitherKey accepts a signature under either, and signingKey // is what a signer consults to prefer .next the instant it exists — // never waiting for a runbook step. // // What this removes is the PROMOTION window, not the PROPAGATION one. // A signer starts using .next as soon as its OWN box has picked it up; // a remote verifier accepts a .next-signed hop only once ITS OWN // mirror has too (Task 3/4's poll of the control/ prefix, up to ~30s // behind a write). Between those two moments a hop signed under .next // CAN be refused by a verifier that hasn't caught up — that is a real, // if short, window, and per Task 3's rules the cost of that refusal is // the request being served as an ordinary client (its X-Forwarded-For // stripped), not dropped: a degraded hop, not an outage. What // dual-accept actually buys is that the runbook's promotion step // itself (copy .next over control/edge-mesh-key, delete .next) needs // no synchronized cutover: because every verifier already accepts // EITHER key well before promotion happens, that write can land as an // ordinary, un-sequenced action with no box needing to be told "stop // signing with the old key" first. // // Single-box inertness: on a deployment with one platform box, this box is // always the housekeeper (housekeeper.go's housekeeperSole), so it mints its // own mesh key and nothing else ever reads it — Tasks 3/4 are the first // readers. Minting here is therefore inert today, by design: this task ships // the vocabulary and the mint, not a consumer. import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "log" "net" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/edgeproof" "github.com/carlosframework/platform/internal/release" ) // meshKeyKey is the control-plane object the housekeeper mints once per // deployment. Named beside the storedoor ACL tripwire // (TestDoorNeverServesCertMaterial), which refuses to serve it — a fleet // box authenticating through the store door must never read this key. const meshKeyKey = edgeproof.MeshKeyObject // meshKeyNextKey is the rotation successor, mirrored alongside meshKeyKey // only while a rotation is in flight. Promoting it (copy over meshKeyKey, // delete this object) is a runbook step, not code this task ships — see the // file header. const meshKeyNextKey = edgeproof.NextMeshKeyObject // meshKeyBytes is the random key length before hex encoding: 256 bits, // matched to the HMAC-SHA256 hop signature below. const meshKeyBytes = 32 // hopSkew bounds how far a hop signature's timestamp may drift from the // verifier's clock, in either direction: generous enough for ordinary box // clock skew and request latency, tight enough that a captured header stops // working well within a minute. const hopSkew = 30 * time.Second // mintMeshKey mints the deployment's mesh key if, and only if, nothing has // minted one yet. crypto/rand is required — this key authenticates hops // between platform boxes, so it gets the same random source every other // credential in this codebase does, never math/rand. // // A lost race (release.ErrCASConflict — a key already exists, from this // box's own earlier tick or another housekeeper that won the same race) is // deliberately SILENT, not logged even at "already minted": that outcome is // the steady state of every tick but the very first, forever, for the // deployment's whole life — one line every tick would be ~264 lines/day of // nothing happening, exactly the noise housekeeper.go's own election // contention refuses to log for the same reason. Only an actual, novel mint // gets a line. func mintMeshKey(ctx context.Context, s release.Store) error { buf := make([]byte, meshKeyBytes) if _, err := rand.Read(buf); err != nil { return fmt.Errorf("mesh key: read random bytes: %w", err) } body := []byte(hex.EncodeToString(buf)) if _, err := s.Put(ctx, meshKeyKey, body, ""); err != nil { if errors.Is(err, release.ErrCASConflict) { return nil } return fmt.Errorf("mesh key: put: %w", err) } log.Printf("edge: mesh key: minted") return nil } // MintMeshKey is mintMeshKey's exported wrapper — the seam // internal/host's housekeeper duty (mintMeshKeyLoop, housekeeper.go) calls // through, so the duty lives beside the housekeeper while the key's own // shape (its object name, its random-byte discipline) stays edge's to own. func MintMeshKey(ctx context.Context, s release.Store) error { return mintMeshKey(ctx, s) } // loadMeshKeys is the dual-accept read side's key material access: it reads // BOTH control/edge-mesh-key and control/edge-mesh-key.next. current is nil // only before the housekeeper has minted anything; next is nil the whole // rest of the time — nearly always, since a rotation is a deliberate, // short-lived operator action. // // The read itself lives in internal/edgeproof, because the console reads the // same pair to check the edge's forwarded-for proof and must not import this // package. Its failure rules are the ones this read always had: a bad .next // is logged and treated as no rotation in flight (it is written by hand, and // one bad upload must not take every hop down with the good current key); a // bad current key fails the call, since there is no safe fallback for the // live key being unreadable. func loadMeshKeys(ctx context.Context, s release.Store) (current, next []byte, err error) { return edgeproof.LoadKeys(ctx, s) } // signingKey is which of the two mirrored keys a signer should use: .next // the instant it exists, else current. A signer never waits for the // runbook's promotion step — verifyHopEitherKey's dual-accept on the reader // side is what makes that safe, so switching signers over early costs // nothing and shortens how long a rotation needs both keys mirrored. func signingKey(current, next []byte) []byte { if len(next) > 0 { return next } return current } // signHop produces the value of the X-Carlos-Hop-Auth header: an // HMAC-SHA256 over "host\nunix-timestamp", hex-encoded and prefixed with the // timestamp so a verifier does not have to guess it. host is bound into the // MAC so a signature minted for one hop cannot be replayed against another. // host must be the CONCRETE host a hop is bound for (a net/http request // host, e.g. from Task 4's forwarding decision) — never a wildcard home-map // key like "*.example.com" — because hopMAC canonicalizes it the same way a // verifier's literal request host is canonicalized, and a wildcard pattern // is not a request host under any canonicalization. func signHop(key []byte, host string, now time.Time) string { ts := strconv.FormatInt(now.Unix(), 10) return ts + ":" + hex.EncodeToString(hopMAC(key, host, ts)) } // verifyHop checks an X-Carlos-Hop-Auth header against a single key: the // timestamp must parse and fall within ±hopSkew of now, and the MAC must // match under constant-time comparison (hmac.Equal) — a hop signature is // exactly the kind of secret-derived value a timing side channel could leak, // so no byte-wise or string-equality shortcut is taken anywhere in this // path. func verifyHop(key []byte, host, header string, now time.Time) bool { ts, sig, ok := strings.Cut(header, ":") if !ok || ts == "" || sig == "" { return false } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } skew := now.Sub(time.Unix(sec, 0)) if skew < 0 { skew = -skew } if skew > hopSkew { return false } got, err := hex.DecodeString(sig) if err != nil { return false } return hmac.Equal(got, hopMAC(key, host, ts)) } // verifyHopEitherKey is the dual-accept read side of a rotation: it accepts // a signature made under either mirrored key, current or next, so neither a // straggling signer still on the old key nor one that has already switched // to .next (signingKey prefers it immediately) is ever refused while both // are mirrored. Task 3/4's request path calls this once it has loaded both // keys via loadMeshKeys. func verifyHopEitherKey(current, next []byte, host, header string, now time.Time) bool { if len(next) > 0 && verifyHop(next, host, header, now) { return true } if len(current) > 0 && verifyHop(current, host, header, now) { return true } return false } // hopMAC is the MAC signHop and verifyHop both compute — one place so the // two can never drift out of sync on what bytes are actually signed, and // the one choke point that canonicalizes host before it enters the MAC. func hopMAC(key []byte, host, ts string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(canonicalizeHopHost(host))) mac.Write([]byte("\n")) mac.Write([]byte(ts)) return mac.Sum(nil) } // canonicalizeHopHost normalizes a request host into the exact form the MAC // is computed over, so a signer and a verifier can never disagree on what // "host" means for the same request: // // - a :port is stripped via net.SplitHostPort when present — Task 3 // verifies against a literal net/http request Host, which carries a // port when the client set one (or the request came in over a // non-default port), while a signer's own idea of the host (e.g. a // home-map lookup key) never does; // - lowercased — HTTP host headers are case-insensitive, but bytes // entering an HMAC are not, so a client or intermediary that // capitalizes a label must not be able to produce a request this MAC // treats as a different host; // - a single trailing root-label dot is stripped — "example.com." and // "example.com" name the same host, and a client is free to send // either. // // host is returned unchanged (net.SplitHostPort's error path) when it // carries no port — the common case, and not a failure. func canonicalizeHopHost(host string) string { if h, _, err := net.SplitHostPort(host); err == nil { host = h } return strings.TrimSuffix(strings.ToLower(host), ".") }