carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
32package edgeproof
33
34import (
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.
52const 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.
57const (
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.
65const Skew = 30 * time.Second
66
67// label separates this MAC from every other use of the mesh key.
68const 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.
72func 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.
80func 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
104func 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.
118func 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.
132func 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).
147func 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