~bigbes/sr-ht-ecore

ref: 2e37ec7343344a5eea26c77cda89dfcc11b01e75 sr-ht-ecore/internalauth/internalauth.go -rw-r--r-- 16.9 KiB
2e37ec73 — Eugene Blikh apimeta: serve the api-meta.json a self-mounted /query owes the instance 3 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
// Package internalauth is the service-to-service authentication of a SourceHut
// instance: the "Authorization: Internal <fernet token>" header one service
// presents to another, and the check the receiving service runs on it.
//
// Both halves live here, and that is the package rather than a convenience.
// core-go implements the check unexported, inside auth.Middleware, so a service
// that wants the guard without the rest of that middleware — an endpoint whose
// subject comes from the request body, with no session to resolve and no cookie
// to fall back to — writes the thirty lines again. dolt.sr.ht did, in
// web/handlers_internal.go. The program that mints the header for it is not even
// in the same package: it is cmd/dolt-git-hook, git.sr.ht's post-update hook,
// which spells the payload out by hand. So the two ends of one protocol sat in
// two files that shared no type, no constant and no test. Change the payload
// shape, the header scheme or the expiry at one end and nothing fails to
// compile, nothing fails a test, and the symptom is that provisioning quietly
// stops happening on the next push. Here the two ends are two functions over one
// struct, and that change breaks the build on both of them.
//
// The other half of the argument is what a copy loses rather than what it drifts
// from. The check is two checks and both must pass: the source address must be
// inside [sr.ht]internal-ipnet, and the header must be a fernet token sealed
// with the shared [sr.ht]network-key and less than Expiry old. A copy that keeps
// only one of them, or that widens the window while someone is debugging, still
// works — it works for everybody — and nothing about it looks wrong until the
// endpoint is reached from outside. That is not a class of change a code review
// of the fourth copy is going to catch.
//
// Receiving side:
//
//	guard := internalauth.Guard("git.sr.ht", "dolt-git-hook", nil)
//	mux.Handle("/internal/repos", guard(http.HandlerFunc(a.handleInternalCreate)))
//
// Calling side:
//
//	header, err := internalauth.Authorization("git.sr.ht", "dolt-git-hook")
//	...
//	req.Header.Set("Authorization", header)
//
// Two process-global preconditions, both core-go's and neither checked here at
// request time. crypto.InitCrypto must have run, or there is no network key to
// seal or open a token with. config.LoadConfig must have run, or the internal
// network list is empty and every address on earth is external — which is the
// safe direction to fail in, but it fails as "source address is not internal"
// for callers that are, which is worth knowing before debugging one.
package internalauth

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// Scheme is the authorization scheme of this protocol. It is matched
// case-insensitively on the way in, as RFC 7235 requires, and spelled this way
// on the way out.
const Scheme = "Internal"

// Expiry is how old a token may be. It is core-go's 30 seconds, unchanged, and
// this is the one constant in the package worth not touching.
//
// The window has to cover the clock skew between two hosts of one instance plus
// the latency of a single request, and nothing else: the token is minted for one
// call and is never stored, so there is no legitimate reason for it to be
// presented a minute later. What the window costs is replay — fernet has no
// nonce and this package keeps no seen-token set, so anyone who can read a token
// off the wire can present it again until it ages out. Thirty seconds is short
// enough that this is only reachable by something already inside the internal
// network with the traffic in front of it, and long enough that a peer whose
// clock is a few seconds off still gets through.
//
// Fernet widens this at the other end and there is nothing here that can narrow
// it: its verifier also refuses a token dated more than 60 seconds in the
// future, and accepts everything below that. The real acceptance window is
// therefore [now-Expiry, now+60s], and shortening Expiry does not shorten the
// forward half.
const Expiry = 30 * time.Second

// The refusals. A caller distinguishes them with errors.Is, and the distinction
// that matters most is the first one against the last: ErrSourceIP means the
// request did not come from the instance at all, ErrPeer means it did, with a
// token this instance's own key sealed, but on behalf of a service this endpoint
// does not serve. The first is somebody knocking; the second is a provisioning
// bug, a stale deployment, or a service calling an endpoint it was not meant to,
// and it wants a different log line and probably a different alert.
var (
	// ErrSourceIP is a request from an address outside [sr.ht]internal-ipnet, or
	// from a RemoteAddr that does not parse as an address at all.
	ErrSourceIP = errors.New("internalauth: source address is not internal")

	// ErrMissing is a request with no Authorization header, or one that does not
	// carry the Internal scheme. It is deliberately not distinguished from a
	// Bearer or Basic header: to this endpoint they are all "no internal
	// authorization was presented".
	ErrMissing = errors.New("internalauth: Internal authorization is required")

	// ErrToken is a token that does not open with the network key: corrupt,
	// truncated, sealed with a different key, or older than Expiry. Fernet gives
	// one answer for all of those and this package does not invent more — a
	// forged token and an expired one are the same event from here, and telling
	// a caller which it was is telling an attacker whether they have the key.
	ErrToken = errors.New("internalauth: token does not open, or has expired")

	// ErrPayload is a token that opened but does not hold an Auth: not JSON, or
	// missing the client or node id. Only a holder of the network key can
	// produce one, so it means a peer that is minting the wrong shape, not an
	// attacker.
	ErrPayload = errors.New("internalauth: token payload is not an internal auth")

	// ErrPeer is a valid, unexpired token from the instance, naming a client or
	// node other than the one this endpoint accepts.
	ErrPeer = errors.New("internalauth: token names a different caller")

	// ErrNetworkKey is this process, not the request: crypto.InitCrypto has not
	// run, so there is no key to seal or open anything with. It is the only
	// refusal here that is a 500.
	ErrNetworkKey = errors.New("internalauth: network key is not initialised")
)

// Auth is the token payload — core-go's client.InternalAuth, wire-compatible
// field for field, because the peers on the other end of this are core-go
// services and the format is theirs.
//
// Name is the user the call is made on behalf of, empty for a call that has no
// user yet (core-go calls that anonymous internal auth and uses it for account
// registration and SSH key lookup). Nothing in this package resolves it: a
// custom service's user table is its own business, and the guard's job is to
// establish that the *caller* is a sibling service, not who they are acting for.
//
// ClientID names the calling service ("git.sr.ht") and NodeID the instance of it
// ("dolt-git-hook", or a hostname). Both are required — an internal call that
// cannot say who is making it is refused even when the seal is perfect, which is
// upstream's rule and the reason the mint side refuses to produce one.
//
// core-go's auth.InternalAuth carries a fourth field, oauth_client_id, honoured
// only by meta.sr.ht routes that resolve an OAuth client instead of a user. It
// is deliberately absent: no custom service can act on it, and a field that is
// minted but never read is a field that will one day be trusted by accident.
type Auth struct {
	Name     string `json:"name,omitempty"`
	ClientID string `json:"client_id"`
	NodeID   string `json:"node_id"`
}

// Guard refuses a request that is not a sibling service calling in, and passes
// one that is to next with the caller's Auth in its context (see FromContext).
//
// clientID and nodeID are the caller this endpoint accepts; an empty one accepts
// any non-empty value, which is core-go's own behaviour — upstream checks that
// the two fields are present and never that they are anybody in particular.
// Pinning them is this package's addition and the better default for a custom
// service: such a service is typically reachable by exactly one sibling for
// exactly one purpose, and "any service on the instance may drive this endpoint"
// is a decision worth writing down as Guard("", "", …) rather than inheriting.
//
// deny handles the refusal and may be nil, which installs Deny. It is given the
// request with the reason in its context, so a service can log what failed while
// still answering with its own error page; see Reason.
func Guard(clientID, nodeID string, deny http.HandlerFunc) func(http.Handler) http.Handler {
	if deny == nil {
		deny = Deny
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			auth, err := Identify(r, clientID, nodeID)
			if err != nil {
				deny(w, r.WithContext(context.WithValue(r.Context(), reasonKey, err)))
				return
			}
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authKey, auth)))
		})
	}
}

// Verify is the check without the middleware, for a service that routes
// internal calls itself. It is Identify with the caller's identity dropped.
func Verify(r *http.Request, clientID, nodeID string) error {
	_, err := Identify(r, clientID, nodeID)
	return err
}

// Identify runs the check and returns who the caller says it is.
//
// The order is fixed and both checks are required — this is core-go's rule and
// the reason to state it here is that the two are not redundant and neither is
// sufficient. The token is the credential: only a service that has the shared
// [sr.ht]network-key can mint one, and it is what actually proves the caller is
// part of the instance. The address check is defence in depth for the case that
// makes the difference — an internal route accidentally published through the
// public ingress — and it is much weaker than it looks, because behind a reverse
// proxy the address it sees is the proxy's, which is internal for every request
// the proxy forwards. That is exactly why it is not allowed to stand alone.
//
// The address is RemoteAddr and never X-Forwarded-For. A forwarded header is
// written by whoever is in front, is trivially set by a client, and honouring it
// would turn the weaker of the two checks into one an outsider can pass by
// asking. core-go reads X-Forwarded-For too, but only to record the route it
// came by, never to decide with.
func Identify(r *http.Request, clientID, nodeID string) (Auth, error) {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		// Not host:port; a bare address is what a unix socket or a test writes.
		host = r.RemoteAddr
	}
	ip := net.ParseIP(host)
	if ip == nil {
		// core-go panics here. A request whose RemoteAddr does not parse is not
		// a programmer error on this side, and 401 is the same answer the next
		// line would give it anyway.
		return Auth{}, fmt.Errorf("%w: %q does not parse", ErrSourceIP, host)
	}
	if !config.IsInternalIP(ip) {
		return Auth{}, fmt.Errorf("%w: %s", ErrSourceIP, ip)
	}

	scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
	if !ok || !strings.EqualFold(scheme, Scheme) {
		return Auth{}, ErrMissing
	}
	payload, err := open([]byte(token))
	if err != nil {
		return Auth{}, err
	}
	if payload == nil {
		return Auth{}, ErrToken
	}

	var auth Auth
	if err := json.Unmarshal(payload, &auth); err != nil {
		// core-go panics here as well, on the grounds that a payload it could
		// decrypt is one a sibling service wrote. True, and still a 500 handed
		// to whoever holds the key: a peer minting the wrong shape takes the
		// receiver's handler down with it. Refuse it instead.
		return Auth{}, fmt.Errorf("%w: %v", ErrPayload, err)
	}
	if auth.ClientID == "" || auth.NodeID == "" {
		return Auth{}, fmt.Errorf("%w: client_id and node_id are both required", ErrPayload)
	}
	if clientID != "" && auth.ClientID != clientID {
		return Auth{}, fmt.Errorf("%w: client_id is %q, want %q", ErrPeer, auth.ClientID, clientID)
	}
	if nodeID != "" && auth.NodeID != nodeID {
		return Auth{}, fmt.Errorf("%w: node_id is %q, want %q", ErrPeer, auth.NodeID, nodeID)
	}
	return auth, nil
}

// Authorization mints the header for the calling side: the whole value,
// "Internal <token>", ready for r.Header.Set("Authorization", …).
//
// It refuses exactly what Identify refuses — an empty client or node id — so
// that a caller finds out at the call site rather than from a 403 out of a
// service that will not say which field was missing.
func Authorization(clientID, nodeID string) (string, error) {
	return AuthorizationAs("", clientID, nodeID)
}

// AuthorizationAs mints the header for a call made on behalf of a user, which is
// what core-go's client.Do does for every GraphQL call it makes: the username
// travels in the token's name field and the receiving core-go service resolves
// its whole auth context from it.
//
// A custom service calling a core-go one needs this; a custom service calling
// another custom one usually does not, because the guard here does not resolve
// anything from the name. Passing a username the receiver has never heard of is
// not this side's error to catch.
func AuthorizationAs(username, clientID, nodeID string) (string, error) {
	if clientID == "" || nodeID == "" {
		return "", fmt.Errorf("%w: client_id and node_id are both required", ErrPayload)
	}
	blob, err := json.Marshal(Auth{Name: username, ClientID: clientID, NodeID: nodeID})
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrPayload, err)
	}
	token, err := seal(blob)
	if err != nil {
		return "", err
	}
	return Scheme + " " + string(token), nil
}

// seal and open wrap the two core-go crypto calls, whose failure mode with no
// key installed is a nil dereference inside fernet rather than an error.
//
// Recovering it is worth the ugliness because of where the mint side runs: the
// caller in production is a git hook, in a process that has just enough of an
// instance to have loaded a config, and an unconfigured network key there should
// cost a companion database, not the push. The hook already guards its own
// InitCrypto call this way (that one log.Fatals, which recover cannot catch);
// this covers the case where InitCrypto was simply never reached.
func seal(payload []byte) (tok []byte, err error) {
	defer func() {
		if v := recover(); v != nil {
			tok, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v)
		}
	}()
	return crypto.Encrypt(payload), nil
}

func open(tok []byte) (payload []byte, err error) {
	defer func() {
		if v := recover(); v != nil {
			payload, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v)
		}
	}()
	return crypto.DecryptWithExpiration(tok, Expiry), nil
}

// Status maps a refusal to the status code core-go and dolt.sr.ht already answer
// with, so adopting this package changes no response a caller is switching on.
//
// 401 for the two failures that mean nothing was presented — a request from
// outside, or one with no Internal header — and 403 for a presented credential
// that was refused. ErrNetworkKey is the receiver's own misconfiguration and is
// the only 500. Anything unrecognised is 403 rather than 200, so a caller that
// hands this an error it did not come from still refuses.
func Status(err error) int {
	switch {
	case errors.Is(err, ErrSourceIP), errors.Is(err, ErrMissing):
		return http.StatusUnauthorized
	case errors.Is(err, ErrNetworkKey):
		return http.StatusInternalServerError
	default:
		return http.StatusForbidden
	}
}

// Deny is the refusal Guard installs when it is given none: the mapped status
// and the reason as plain text.
//
// The reason is safe to return. Every string in it comes from this package or
// from a token that opened with the instance's own key, so the only detail it
// discloses to a stranger is which of the two checks they failed — and the one
// they can reach without the key is the address check, whose answer they already
// know.
func Deny(w http.ResponseWriter, r *http.Request) {
	err := Reason(r.Context())
	if err == nil {
		err = ErrMissing
	}
	http.Error(w, err.Error(), Status(err))
}

type ctxKey int

const (
	authKey ctxKey = iota
	reasonKey
)

// FromContext returns the verified caller of the request Guard admitted. It is
// how a handler behind the guard finds out which sibling service called and on
// whose behalf, without parsing the header again.
func FromContext(ctx context.Context) (Auth, bool) {
	auth, ok := ctx.Value(authKey).(Auth)
	return auth, ok
}

// Reason returns the refusal Guard is calling a deny handler about, or nil if
// this context is not one. It exists so that a service can supply a deny handler
// that renders its own error page and still log which check failed — the thing
// this protocol is most often debugged by.
func Reason(ctx context.Context) error {
	err, _ := ctx.Value(reasonKey).(error)
	return err
}