// Package edgeproof lets an app on the edge's own box tell a request the edge // forwarded from one that only looks like it. // // Every request the edge proxies arrives from a loopback or box-local peer, // so the peer address says nothing about who the client was; the edge's // X-Forwarded-For does. But any process on the box can open the same // loopback connection and write any X-Forwarded-For it likes. The console // counts its per-IP login limits on that header, so a tenant process on the // console's box could present a fresh "client" per request and walk past // every one of them (discussion 214, item 5). // // The edge therefore signs the X-Forwarded-For chain it sends, with the // deployment's mesh key, under Header. A verifier that holds the same key // believes the chain only when the signature matches it. The signature binds // the request's Host, the exact chain and a timestamp. Tenant apps see these // headers on their own traffic, so the host is what stops one replaying a // visitor's proof to the console on the same box: a proof minted for the // tenant's hostname does not verify at the console's. Within one host, a // lifted header vouches for that request's client and nothing else, for // ±Skew. // // The proof is between an edge and the upstream it dials, never between // edges: a backhauled request leaves without one, and the home edge signs // what it sends on (internal/edge's backhaulProxy says why). // // The MAC is domain-separated from the hop MAC (internal/edge's hopMAC): // both use the mesh key, and without the label a forwarded-proof value could // be replayed as a hop's X-Carlos-Hop-Auth or the other way round. // // The key objects are named here, not in internal/edge, because the console // reads them too and must not import the edge. package edgeproof import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "log" "net" "os" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // Header carries the edge's signature over the request's X-Forwarded-For. const Header = "X-Carlos-Forwarded-Proof" // MeshKeyObject is the control-plane object the housekeeper mints once per // deployment (internal/edge's mintMeshKey). NextMeshKeyObject is its // rotation successor, present only while a rotation is in flight. const ( MeshKeyObject = "control/edge-mesh-key" NextMeshKeyObject = MeshKeyObject + ".next" ) // Skew is how far a signature's timestamp may drift from the verifier's // clock. The signer and the verifier this exists for share one box and one // clock, so this is mostly request latency. const Skew = 30 * time.Second // label separates this MAC from every other use of the mesh key. const label = "carlos-forwarded-for\n" // Sign returns the Header value for a request to host carrying xff, its // full X-Forwarded-For chain as one comma-joined string. func Sign(key []byte, host, xff string, now time.Time) string { ts := strconv.FormatInt(now.Unix(), 10) return ts + ":" + hex.EncodeToString(mac(key, host, xff, ts)) } // Verify reports whether header is a signature over host and xff under // current or next, made within Skew of now. Either key is accepted so a // rotation needs no cutover. The comparison is constant-time. func Verify(current, next []byte, host, xff, 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 } if d := now.Sub(time.Unix(sec, 0)); d > Skew || d < -Skew { return false } got, err := hex.DecodeString(sig) if err != nil { return false } for _, k := range [][]byte{next, current} { if len(k) > 0 && hmac.Equal(got, mac(k, host, xff, ts)) { return true } } return false } func mac(key []byte, host, xff, ts string) []byte { m := hmac.New(sha256.New, key) m.Write([]byte(label)) m.Write([]byte(canonicalHost(host))) m.Write([]byte("\n")) m.Write([]byte(xff)) m.Write([]byte("\n")) m.Write([]byte(ts)) return m.Sum(nil) } // canonicalHost is the host as both ends must agree on it: port stripped, // lowercased, one trailing root dot dropped — the same rules as the hop // MAC's canonicalizeHopHost. func canonicalHost(host string) string { if h, _, err := net.SplitHostPort(host); err == nil { host = h } return strings.TrimSuffix(strings.ToLower(host), ".") } // LoadKeys reads both mesh-key objects. current is nil only before the // housekeeper has minted one; next is nil outside a rotation. // // A bad .next does not fail the call: it is written by hand during a // rotation, and refusing the good current key alongside a bad upload would // take every hop down. It is logged loudly instead. A bad current key DOES // fail it: there is no safe fallback for the live key being unreadable. func LoadKeys(ctx context.Context, s release.Store) (current, next []byte, err error) { current, err = getKeyHex(ctx, s, MeshKeyObject) if err != nil { return nil, nil, err } next, nextErr := getKeyHex(ctx, s, NextMeshKeyObject) if nextErr != nil { log.Printf("mesh key: %s is unreadable, treating as no rotation in flight until fixed: %v", NextMeshKeyObject, nextErr) next = nil } return current, next, nil } // getKeyHex reads and hex-decodes one mesh-key object, folding "does not // exist" into (nil, nil). func getKeyHex(ctx context.Context, s release.Store, key string) ([]byte, error) { body, _, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("mesh key: get %s: %w", key, err) } dec, err := hex.DecodeString(string(body)) if err != nil { return nil, fmt.Errorf("mesh key: %s: not valid hex: %w", key, err) } return dec, nil }