| 1 | // Package edgeproof lets an app on the edge's own box tell a request the edge |
| 2 | // forwarded from one that only looks like it. |
| 3 | // |
| 4 | // Every request the edge proxies arrives from a loopback or box-local peer, |
| 5 | // so the peer address says nothing about who the client was; the edge's |
| 6 | // X-Forwarded-For does. But any process on the box can open the same |
| 7 | // loopback connection and write any X-Forwarded-For it likes. The console |
| 8 | // counts its per-IP login limits on that header, so a tenant process on the |
| 9 | // console's box could present a fresh "client" per request and walk past |
| 10 | // every one of them (discussion 214, item 5). |
| 11 | // |
| 12 | // The edge therefore signs the X-Forwarded-For chain it sends, with the |
| 13 | // deployment's mesh key, under Header. A verifier that holds the same key |
| 14 | // believes the chain only when the signature matches it. The signature binds |
| 15 | // the request's Host, the exact chain and a timestamp. Tenant apps see these |
| 16 | // headers on their own traffic, so the host is what stops one replaying a |
| 17 | // visitor's proof to the console on the same box: a proof minted for the |
| 18 | // tenant's hostname does not verify at the console's. Within one host, a |
| 19 | // lifted header vouches for that request's client and nothing else, for |
| 20 | // ±Skew. |
| 21 | // |
| 22 | // The proof is between an edge and the upstream it dials, never between |
| 23 | // edges: a backhauled request leaves without one, and the home edge signs |
| 24 | // what it sends on (internal/edge's backhaulProxy says why). |
| 25 | // |
| 26 | // The MAC is domain-separated from the hop MAC (internal/edge's hopMAC): |
| 27 | // both use the mesh key, and without the label a forwarded-proof value could |
| 28 | // be replayed as a hop's X-Carlos-Hop-Auth or the other way round. |
| 29 | // |
| 30 | // The key objects are named here, not in internal/edge, because the console |
| 31 | // reads them too and must not import the edge. |
| 32 | package edgeproof |
| 33 | |
| 34 | import ( |
| 35 | "context" |
| 36 | "crypto/hmac" |
| 37 | "crypto/sha256" |
| 38 | "encoding/hex" |
| 39 | "errors" |
| 40 | "fmt" |
| 41 | "log" |
| 42 | "net" |
| 43 | "os" |
| 44 | "strconv" |
| 45 | "strings" |
| 46 | "time" |
| 47 | |
| 48 | "github.com/carlosframework/platform/internal/release" |
| 49 | ) |
| 50 | |
| 51 | // Header carries the edge's signature over the request's X-Forwarded-For. |
| 52 | const Header = "X-Carlos-Forwarded-Proof" |
| 53 | |
| 54 | // MeshKeyObject is the control-plane object the housekeeper mints once per |
| 55 | // deployment (internal/edge's mintMeshKey). NextMeshKeyObject is its |
| 56 | // rotation successor, present only while a rotation is in flight. |
| 57 | const ( |
| 58 | MeshKeyObject = "control/edge-mesh-key" |
| 59 | NextMeshKeyObject = MeshKeyObject + ".next" |
| 60 | ) |
| 61 | |
| 62 | // Skew is how far a signature's timestamp may drift from the verifier's |
| 63 | // clock. The signer and the verifier this exists for share one box and one |
| 64 | // clock, so this is mostly request latency. |
| 65 | const Skew = 30 * time.Second |
| 66 | |
| 67 | // label separates this MAC from every other use of the mesh key. |
| 68 | const label = "carlos-forwarded-for\n" |
| 69 | |
| 70 | // Sign returns the Header value for a request to host carrying xff, its |
| 71 | // full X-Forwarded-For chain as one comma-joined string. |
| 72 | func Sign(key []byte, host, xff string, now time.Time) string { |
| 73 | ts := strconv.FormatInt(now.Unix(), 10) |
| 74 | return ts + ":" + hex.EncodeToString(mac(key, host, xff, ts)) |
| 75 | } |
| 76 | |
| 77 | // Verify reports whether header is a signature over host and xff under |
| 78 | // current or next, made within Skew of now. Either key is accepted so a |
| 79 | // rotation needs no cutover. The comparison is constant-time. |
| 80 | func Verify(current, next []byte, host, xff, header string, now time.Time) bool { |
| 81 | ts, sig, ok := strings.Cut(header, ":") |
| 82 | if !ok || ts == "" || sig == "" { |
| 83 | return false |
| 84 | } |
| 85 | sec, err := strconv.ParseInt(ts, 10, 64) |
| 86 | if err != nil { |
| 87 | return false |
| 88 | } |
| 89 | if d := now.Sub(time.Unix(sec, 0)); d > Skew || d < -Skew { |
| 90 | return false |
| 91 | } |
| 92 | got, err := hex.DecodeString(sig) |
| 93 | if err != nil { |
| 94 | return false |
| 95 | } |
| 96 | for _, k := range [][]byte{next, current} { |
| 97 | if len(k) > 0 && hmac.Equal(got, mac(k, host, xff, ts)) { |
| 98 | return true |
| 99 | } |
| 100 | } |
| 101 | return false |
| 102 | } |
| 103 | |
| 104 | func mac(key []byte, host, xff, ts string) []byte { |
| 105 | m := hmac.New(sha256.New, key) |
| 106 | m.Write([]byte(label)) |
| 107 | m.Write([]byte(canonicalHost(host))) |
| 108 | m.Write([]byte("\n")) |
| 109 | m.Write([]byte(xff)) |
| 110 | m.Write([]byte("\n")) |
| 111 | m.Write([]byte(ts)) |
| 112 | return m.Sum(nil) |
| 113 | } |
| 114 | |
| 115 | // canonicalHost is the host as both ends must agree on it: port stripped, |
| 116 | // lowercased, one trailing root dot dropped — the same rules as the hop |
| 117 | // MAC's canonicalizeHopHost. |
| 118 | func canonicalHost(host string) string { |
| 119 | if h, _, err := net.SplitHostPort(host); err == nil { |
| 120 | host = h |
| 121 | } |
| 122 | return strings.TrimSuffix(strings.ToLower(host), ".") |
| 123 | } |
| 124 | |
| 125 | // LoadKeys reads both mesh-key objects. current is nil only before the |
| 126 | // housekeeper has minted one; next is nil outside a rotation. |
| 127 | // |
| 128 | // A bad .next does not fail the call: it is written by hand during a |
| 129 | // rotation, and refusing the good current key alongside a bad upload would |
| 130 | // take every hop down. It is logged loudly instead. A bad current key DOES |
| 131 | // fail it: there is no safe fallback for the live key being unreadable. |
| 132 | func LoadKeys(ctx context.Context, s release.Store) (current, next []byte, err error) { |
| 133 | current, err = getKeyHex(ctx, s, MeshKeyObject) |
| 134 | if err != nil { |
| 135 | return nil, nil, err |
| 136 | } |
| 137 | next, nextErr := getKeyHex(ctx, s, NextMeshKeyObject) |
| 138 | if nextErr != nil { |
| 139 | log.Printf("mesh key: %s is unreadable, treating as no rotation in flight until fixed: %v", NextMeshKeyObject, nextErr) |
| 140 | next = nil |
| 141 | } |
| 142 | return current, next, nil |
| 143 | } |
| 144 | |
| 145 | // getKeyHex reads and hex-decodes one mesh-key object, folding "does not |
| 146 | // exist" into (nil, nil). |
| 147 | func getKeyHex(ctx context.Context, s release.Store, key string) ([]byte, error) { |
| 148 | body, _, err := s.Get(ctx, key) |
| 149 | if errors.Is(err, os.ErrNotExist) { |
| 150 | return nil, nil |
| 151 | } |
| 152 | if err != nil { |
| 153 | return nil, fmt.Errorf("mesh key: get %s: %w", key, err) |
| 154 | } |
| 155 | dec, err := hex.DecodeString(string(body)) |
| 156 | if err != nil { |
| 157 | return nil, fmt.Errorf("mesh key: %s: not valid hex: %w", key, err) |
| 158 | } |
| 159 | return dec, nil |
| 160 | } |
| 161 | |