| 1 |
|
// Package internalauth is the service-to-service authentication of a SourceHut |
| 2 |
|
// instance: the "Authorization: Internal <fernet token>" header one service |
| 3 |
|
// presents to another, and the check the receiving service runs on it. |
| 4 |
|
// |
| 5 |
|
// Both halves live here, and that is the package rather than a convenience. |
| 6 |
|
// core-go implements the check unexported, inside auth.Middleware, so a service |
| 7 |
|
// that wants the guard without the rest of that middleware — an endpoint whose |
| 8 |
|
// subject comes from the request body, with no session to resolve and no cookie |
| 9 |
|
// to fall back to — writes the thirty lines again. dolt.sr.ht did, in |
| 10 |
|
// web/handlers_internal.go. The program that mints the header for it is not even |
| 11 |
|
// in the same package: it is cmd/dolt-git-hook, git.sr.ht's post-update hook, |
| 12 |
|
// which spells the payload out by hand. So the two ends of one protocol sat in |
| 13 |
|
// two files that shared no type, no constant and no test. Change the payload |
| 14 |
|
// shape, the header scheme or the expiry at one end and nothing fails to |
| 15 |
|
// compile, nothing fails a test, and the symptom is that provisioning quietly |
| 16 |
|
// stops happening on the next push. Here the two ends are two functions over one |
| 17 |
|
// struct, and that change breaks the build on both of them. |
| 18 |
|
// |
| 19 |
|
// The other half of the argument is what a copy loses rather than what it drifts |
| 20 |
|
// from. The check is two checks and both must pass: the source address must be |
| 21 |
|
// inside [sr.ht]internal-ipnet, and the header must be a fernet token sealed |
| 22 |
|
// with the shared [sr.ht]network-key and less than Expiry old. A copy that keeps |
| 23 |
|
// only one of them, or that widens the window while someone is debugging, still |
| 24 |
|
// works — it works for everybody — and nothing about it looks wrong until the |
| 25 |
|
// endpoint is reached from outside. That is not a class of change a code review |
| 26 |
|
// of the fourth copy is going to catch. |
| 27 |
|
// |
| 28 |
|
// Receiving side: |
| 29 |
|
// |
| 30 |
|
// guard := internalauth.Guard("git.sr.ht", "dolt-git-hook", nil) |
| 31 |
|
// mux.Handle("/internal/repos", guard(http.HandlerFunc(a.handleInternalCreate))) |
| 32 |
|
// |
| 33 |
|
// Calling side: |
| 34 |
|
// |
| 35 |
|
// header, err := internalauth.Authorization("git.sr.ht", "dolt-git-hook") |
| 36 |
|
// ... |
| 37 |
|
// req.Header.Set("Authorization", header) |
| 38 |
|
// |
| 39 |
|
// Two process-global preconditions, both core-go's and neither checked here at |
| 40 |
|
// request time. crypto.InitCrypto must have run, or there is no network key to |
| 41 |
|
// seal or open a token with. config.LoadConfig must have run, or the internal |
| 42 |
|
// network list is empty and every address on earth is external — which is the |
| 43 |
|
// safe direction to fail in, but it fails as "source address is not internal" |
| 44 |
|
// for callers that are, which is worth knowing before debugging one. |
| 45 |
|
package internalauth |
| 46 |
|
|
| 47 |
|
import ( |
| 48 |
|
"context" |
| 49 |
|
"encoding/json" |
| 50 |
|
"errors" |
| 51 |
|
"fmt" |
| 52 |
|
"net" |
| 53 |
|
"net/http" |
| 54 |
|
"strings" |
| 55 |
|
"time" |
| 56 |
|
|
| 57 |
|
"sourcecraft.dev/bigbes/sr-ht-core/config" |
| 58 |
|
"sourcecraft.dev/bigbes/sr-ht-core/crypto" |
| 59 |
|
) |
| 60 |
|
|
| 61 |
|
// Scheme is the authorization scheme of this protocol. It is matched |
| 62 |
|
// case-insensitively on the way in, as RFC 7235 requires, and spelled this way |
| 63 |
|
// on the way out. |
| 64 |
|
const Scheme = "Internal" |
| 65 |
|
|
| 66 |
|
// Expiry is how old a token may be. It is core-go's 30 seconds, unchanged, and |
| 67 |
|
// this is the one constant in the package worth not touching. |
| 68 |
|
// |
| 69 |
|
// The window has to cover the clock skew between two hosts of one instance plus |
| 70 |
|
// the latency of a single request, and nothing else: the token is minted for one |
| 71 |
|
// call and is never stored, so there is no legitimate reason for it to be |
| 72 |
|
// presented a minute later. What the window costs is replay — fernet has no |
| 73 |
|
// nonce and this package keeps no seen-token set, so anyone who can read a token |
| 74 |
|
// off the wire can present it again until it ages out. Thirty seconds is short |
| 75 |
|
// enough that this is only reachable by something already inside the internal |
| 76 |
|
// network with the traffic in front of it, and long enough that a peer whose |
| 77 |
|
// clock is a few seconds off still gets through. |
| 78 |
|
// |
| 79 |
|
// Fernet widens this at the other end and there is nothing here that can narrow |
| 80 |
|
// it: its verifier also refuses a token dated more than 60 seconds in the |
| 81 |
|
// future, and accepts everything below that. The real acceptance window is |
| 82 |
|
// therefore [now-Expiry, now+60s], and shortening Expiry does not shorten the |
| 83 |
|
// forward half. |
| 84 |
|
const Expiry = 30 * time.Second |
| 85 |
|
|
| 86 |
|
// The refusals. A caller distinguishes them with errors.Is, and the distinction |
| 87 |
|
// that matters most is the first one against the last: ErrSourceIP means the |
| 88 |
|
// request did not come from the instance at all, ErrPeer means it did, with a |
| 89 |
|
// token this instance's own key sealed, but on behalf of a service this endpoint |
| 90 |
|
// does not serve. The first is somebody knocking; the second is a provisioning |
| 91 |
|
// bug, a stale deployment, or a service calling an endpoint it was not meant to, |
| 92 |
|
// and it wants a different log line and probably a different alert. |
| 93 |
|
var ( |
| 94 |
|
// ErrSourceIP is a request from an address outside [sr.ht]internal-ipnet, or |
| 95 |
|
// from a RemoteAddr that does not parse as an address at all. |
| 96 |
|
ErrSourceIP = errors.New("internalauth: source address is not internal") |
| 97 |
|
|
| 98 |
|
// ErrMissing is a request with no Authorization header, or one that does not |
| 99 |
|
// carry the Internal scheme. It is deliberately not distinguished from a |
| 100 |
|
// Bearer or Basic header: to this endpoint they are all "no internal |
| 101 |
|
// authorization was presented". |
| 102 |
|
ErrMissing = errors.New("internalauth: Internal authorization is required") |
| 103 |
|
|
| 104 |
|
// ErrToken is a token that does not open with the network key: corrupt, |
| 105 |
|
// truncated, sealed with a different key, or older than Expiry. Fernet gives |
| 106 |
|
// one answer for all of those and this package does not invent more — a |
| 107 |
|
// forged token and an expired one are the same event from here, and telling |
| 108 |
|
// a caller which it was is telling an attacker whether they have the key. |
| 109 |
|
ErrToken = errors.New("internalauth: token does not open, or has expired") |
| 110 |
|
|
| 111 |
|
// ErrPayload is a token that opened but does not hold an Auth: not JSON, or |
| 112 |
|
// missing the client or node id. Only a holder of the network key can |
| 113 |
|
// produce one, so it means a peer that is minting the wrong shape, not an |
| 114 |
|
// attacker. |
| 115 |
|
ErrPayload = errors.New("internalauth: token payload is not an internal auth") |
| 116 |
|
|
| 117 |
|
// ErrPeer is a valid, unexpired token from the instance, naming a client or |
| 118 |
|
// node other than the one this endpoint accepts. |
| 119 |
|
ErrPeer = errors.New("internalauth: token names a different caller") |
| 120 |
|
|
| 121 |
|
// ErrNetworkKey is this process, not the request: crypto.InitCrypto has not |
| 122 |
|
// run, so there is no key to seal or open anything with. It is the only |
| 123 |
|
// refusal here that is a 500. |
| 124 |
|
ErrNetworkKey = errors.New("internalauth: network key is not initialised") |
| 125 |
|
) |
| 126 |
|
|
| 127 |
|
// Auth is the token payload — core-go's client.InternalAuth, wire-compatible |
| 128 |
|
// field for field, because the peers on the other end of this are core-go |
| 129 |
|
// services and the format is theirs. |
| 130 |
|
// |
| 131 |
|
// Name is the user the call is made on behalf of, empty for a call that has no |
| 132 |
|
// user yet (core-go calls that anonymous internal auth and uses it for account |
| 133 |
|
// registration and SSH key lookup). Nothing in this package resolves it: a |
| 134 |
|
// custom service's user table is its own business, and the guard's job is to |
| 135 |
|
// establish that the *caller* is a sibling service, not who they are acting for. |
| 136 |
|
// |
| 137 |
|
// ClientID names the calling service ("git.sr.ht") and NodeID the instance of it |
| 138 |
|
// ("dolt-git-hook", or a hostname). Both are required — an internal call that |
| 139 |
|
// cannot say who is making it is refused even when the seal is perfect, which is |
| 140 |
|
// upstream's rule and the reason the mint side refuses to produce one. |
| 141 |
|
// |
| 142 |
|
// core-go's auth.InternalAuth carries a fourth field, oauth_client_id, honoured |
| 143 |
|
// only by meta.sr.ht routes that resolve an OAuth client instead of a user. It |
| 144 |
|
// is deliberately absent: no custom service can act on it, and a field that is |
| 145 |
|
// minted but never read is a field that will one day be trusted by accident. |
| 146 |
|
type Auth struct { |
| 147 |
|
Name string `json:"name,omitempty"` |
| 148 |
|
ClientID string `json:"client_id"` |
| 149 |
|
NodeID string `json:"node_id"` |
| 150 |
|
} |
| 151 |
|
|
| 152 |
|
// Guard refuses a request that is not a sibling service calling in, and passes |
| 153 |
|
// one that is to next with the caller's Auth in its context (see FromContext). |
| 154 |
|
// |
| 155 |
|
// clientID and nodeID are the caller this endpoint accepts; an empty one accepts |
| 156 |
|
// any non-empty value, which is core-go's own behaviour — upstream checks that |
| 157 |
|
// the two fields are present and never that they are anybody in particular. |
| 158 |
|
// Pinning them is this package's addition and the better default for a custom |
| 159 |
|
// service: such a service is typically reachable by exactly one sibling for |
| 160 |
|
// exactly one purpose, and "any service on the instance may drive this endpoint" |
| 161 |
|
// is a decision worth writing down as Guard("", "", …) rather than inheriting. |
| 162 |
|
// |
| 163 |
|
// deny handles the refusal and may be nil, which installs Deny. It is given the |
| 164 |
|
// request with the reason in its context, so a service can log what failed while |
| 165 |
|
// still answering with its own error page; see Reason. |
| 166 |
30 |
func Guard(clientID, nodeID string, deny http.HandlerFunc) func(http.Handler) http.Handler { |
| 167 |
30 |
if deny == nil { |
| 168 |
1 |
deny = Deny |
| 169 |
1 |
} |
| 170 |
30 |
return func(next http.Handler) http.Handler { |
| 171 |
30 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 172 |
30 |
auth, err := Identify(r, clientID, nodeID) |
| 173 |
30 |
if err != nil { |
| 174 |
22 |
deny(w, r.WithContext(context.WithValue(r.Context(), reasonKey, err))) |
| 175 |
22 |
return |
| 176 |
22 |
} |
| 177 |
8 |
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authKey, auth))) |
| 178 |
|
}) |
| 179 |
|
} |
| 180 |
|
} |
| 181 |
|
|
| 182 |
|
// Verify is the check without the middleware, for a service that routes |
| 183 |
|
// internal calls itself. It is Identify with the caller's identity dropped. |
| 184 |
2 |
func Verify(r *http.Request, clientID, nodeID string) error { |
| 185 |
2 |
_, err := Identify(r, clientID, nodeID) |
| 186 |
2 |
return err |
| 187 |
2 |
} |
| 188 |
|
|
| 189 |
|
// Identify runs the check and returns who the caller says it is. |
| 190 |
|
// |
| 191 |
|
// The order is fixed and both checks are required — this is core-go's rule and |
| 192 |
|
// the reason to state it here is that the two are not redundant and neither is |
| 193 |
|
// sufficient. The token is the credential: only a service that has the shared |
| 194 |
|
// [sr.ht]network-key can mint one, and it is what actually proves the caller is |
| 195 |
|
// part of the instance. The address check is defence in depth for the case that |
| 196 |
|
// makes the difference — an internal route accidentally published through the |
| 197 |
|
// public ingress — and it is much weaker than it looks, because behind a reverse |
| 198 |
|
// proxy the address it sees is the proxy's, which is internal for every request |
| 199 |
|
// the proxy forwards. That is exactly why it is not allowed to stand alone. |
| 200 |
|
// |
| 201 |
|
// The address is RemoteAddr and never X-Forwarded-For. A forwarded header is |
| 202 |
|
// written by whoever is in front, is trivially set by a client, and honouring it |
| 203 |
|
// would turn the weaker of the two checks into one an outsider can pass by |
| 204 |
|
// asking. core-go reads X-Forwarded-For too, but only to record the route it |
| 205 |
|
// came by, never to decide with. |
| 206 |
32 |
func Identify(r *http.Request, clientID, nodeID string) (Auth, error) { |
| 207 |
32 |
host, _, err := net.SplitHostPort(r.RemoteAddr) |
| 208 |
32 |
if err != nil { |
| 209 |
1 |
// Not host:port; a bare address is what a unix socket or a test writes. |
| 210 |
1 |
host = r.RemoteAddr |
| 211 |
1 |
} |
| 212 |
32 |
ip := net.ParseIP(host) |
| 213 |
32 |
if ip == nil { |
| 214 |
1 |
// core-go panics here. A request whose RemoteAddr does not parse is not |
| 215 |
1 |
// a programmer error on this side, and 401 is the same answer the next |
| 216 |
1 |
// line would give it anyway. |
| 217 |
1 |
return Auth{}, fmt.Errorf("%w: %q does not parse", ErrSourceIP, host) |
| 218 |
1 |
} |
| 219 |
31 |
if !config.IsInternalIP(ip) { |
| 220 |
5 |
return Auth{}, fmt.Errorf("%w: %s", ErrSourceIP, ip) |
| 221 |
5 |
} |
| 222 |
|
|
| 223 |
26 |
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") |
| 224 |
26 |
if !ok || !strings.EqualFold(scheme, Scheme) { |
| 225 |
4 |
return Auth{}, ErrMissing |
| 226 |
4 |
} |
| 227 |
22 |
payload, err := open([]byte(token)) |
| 228 |
22 |
if err != nil { |
| 229 |
0 |
return Auth{}, err |
| 230 |
0 |
} |
| 231 |
22 |
if payload == nil { |
| 232 |
5 |
return Auth{}, ErrToken |
| 233 |
5 |
} |
| 234 |
|
|
| 235 |
17 |
var auth Auth |
| 236 |
17 |
if err := json.Unmarshal(payload, &auth); err != nil { |
| 237 |
2 |
// core-go panics here as well, on the grounds that a payload it could |
| 238 |
2 |
// decrypt is one a sibling service wrote. True, and still a 500 handed |
| 239 |
2 |
// to whoever holds the key: a peer minting the wrong shape takes the |
| 240 |
2 |
// receiver's handler down with it. Refuse it instead. |
| 241 |
2 |
return Auth{}, fmt.Errorf("%w: %v", ErrPayload, err) |
| 242 |
2 |
} |
| 243 |
15 |
if auth.ClientID == "" || auth.NodeID == "" { |
| 244 |
3 |
return Auth{}, fmt.Errorf("%w: client_id and node_id are both required", ErrPayload) |
| 245 |
3 |
} |
| 246 |
12 |
if clientID != "" && auth.ClientID != clientID { |
| 247 |
2 |
return Auth{}, fmt.Errorf("%w: client_id is %q, want %q", ErrPeer, auth.ClientID, clientID) |
| 248 |
2 |
} |
| 249 |
10 |
if nodeID != "" && auth.NodeID != nodeID { |
| 250 |
1 |
return Auth{}, fmt.Errorf("%w: node_id is %q, want %q", ErrPeer, auth.NodeID, nodeID) |
| 251 |
1 |
} |
| 252 |
9 |
return auth, nil |
| 253 |
|
} |
| 254 |
|
|
| 255 |
|
// Authorization mints the header for the calling side: the whole value, |
| 256 |
|
// "Internal <token>", ready for r.Header.Set("Authorization", …). |
| 257 |
|
// |
| 258 |
|
// It refuses exactly what Identify refuses — an empty client or node id — so |
| 259 |
|
// that a caller finds out at the call site rather than from a 403 out of a |
| 260 |
|
// service that will not say which field was missing. |
| 261 |
19 |
func Authorization(clientID, nodeID string) (string, error) { |
| 262 |
19 |
return AuthorizationAs("", clientID, nodeID) |
| 263 |
19 |
} |
| 264 |
|
|
| 265 |
|
// AuthorizationAs mints the header for a call made on behalf of a user, which is |
| 266 |
|
// what core-go's client.Do does for every GraphQL call it makes: the username |
| 267 |
|
// travels in the token's name field and the receiving core-go service resolves |
| 268 |
|
// its whole auth context from it. |
| 269 |
|
// |
| 270 |
|
// A custom service calling a core-go one needs this; a custom service calling |
| 271 |
|
// another custom one usually does not, because the guard here does not resolve |
| 272 |
|
// anything from the name. Passing a username the receiver has never heard of is |
| 273 |
|
// not this side's error to catch. |
| 274 |
21 |
func AuthorizationAs(username, clientID, nodeID string) (string, error) { |
| 275 |
21 |
if clientID == "" || nodeID == "" { |
| 276 |
4 |
return "", fmt.Errorf("%w: client_id and node_id are both required", ErrPayload) |
| 277 |
4 |
} |
| 278 |
17 |
blob, err := json.Marshal(Auth{Name: username, ClientID: clientID, NodeID: nodeID}) |
| 279 |
17 |
if err != nil { |
| 280 |
0 |
return "", fmt.Errorf("%w: %v", ErrPayload, err) |
| 281 |
0 |
} |
| 282 |
17 |
token, err := seal(blob) |
| 283 |
17 |
if err != nil { |
| 284 |
0 |
return "", err |
| 285 |
0 |
} |
| 286 |
17 |
return Scheme + " " + string(token), nil |
| 287 |
|
} |
| 288 |
|
|
| 289 |
|
// seal and open wrap the two core-go crypto calls, whose failure mode with no |
| 290 |
|
// key installed is a nil dereference inside fernet rather than an error. |
| 291 |
|
// |
| 292 |
|
// Recovering it is worth the ugliness because of where the mint side runs: the |
| 293 |
|
// caller in production is a git hook, in a process that has just enough of an |
| 294 |
|
// instance to have loaded a config, and an unconfigured network key there should |
| 295 |
|
// cost a companion database, not the push. The hook already guards its own |
| 296 |
|
// InitCrypto call this way (that one log.Fatals, which recover cannot catch); |
| 297 |
|
// this covers the case where InitCrypto was simply never reached. |
| 298 |
17 |
func seal(payload []byte) (tok []byte, err error) { |
| 299 |
17 |
defer func() { |
| 300 |
17 |
if v := recover(); v != nil { |
| 301 |
0 |
tok, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v) |
| 302 |
0 |
} |
| 303 |
|
}() |
| 304 |
17 |
return crypto.Encrypt(payload), nil |
| 305 |
|
} |
| 306 |
|
|
| 307 |
22 |
func open(tok []byte) (payload []byte, err error) { |
| 308 |
22 |
defer func() { |
| 309 |
22 |
if v := recover(); v != nil { |
| 310 |
0 |
payload, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v) |
| 311 |
0 |
} |
| 312 |
|
}() |
| 313 |
22 |
return crypto.DecryptWithExpiration(tok, Expiry), nil |
| 314 |
|
} |
| 315 |
|
|
| 316 |
|
// Status maps a refusal to the status code core-go and dolt.sr.ht already answer |
| 317 |
|
// with, so adopting this package changes no response a caller is switching on. |
| 318 |
|
// |
| 319 |
|
// 401 for the two failures that mean nothing was presented — a request from |
| 320 |
|
// outside, or one with no Internal header — and 403 for a presented credential |
| 321 |
|
// that was refused. ErrNetworkKey is the receiver's own misconfiguration and is |
| 322 |
|
// the only 500. Anything unrecognised is 403 rather than 200, so a caller that |
| 323 |
|
// hands this an error it did not come from still refuses. |
| 324 |
30 |
func Status(err error) int { |
| 325 |
30 |
switch { |
| 326 |
11 |
case errors.Is(err, ErrSourceIP), errors.Is(err, ErrMissing): |
| 327 |
11 |
return http.StatusUnauthorized |
| 328 |
1 |
case errors.Is(err, ErrNetworkKey): |
| 329 |
1 |
return http.StatusInternalServerError |
| 330 |
18 |
default: |
| 331 |
18 |
return http.StatusForbidden |
| 332 |
|
} |
| 333 |
|
} |
| 334 |
|
|
| 335 |
|
// Deny is the refusal Guard installs when it is given none: the mapped status |
| 336 |
|
// and the reason as plain text. |
| 337 |
|
// |
| 338 |
|
// The reason is safe to return. Every string in it comes from this package or |
| 339 |
|
// from a token that opened with the instance's own key, so the only detail it |
| 340 |
|
// discloses to a stranger is which of the two checks they failed — and the one |
| 341 |
|
// they can reach without the key is the address check, whose answer they already |
| 342 |
|
// know. |
| 343 |
22 |
func Deny(w http.ResponseWriter, r *http.Request) { |
| 344 |
22 |
err := Reason(r.Context()) |
| 345 |
22 |
if err == nil { |
| 346 |
0 |
err = ErrMissing |
| 347 |
0 |
} |
| 348 |
22 |
http.Error(w, err.Error(), Status(err)) |
| 349 |
|
} |
| 350 |
|
|
| 351 |
|
type ctxKey int |
| 352 |
|
|
| 353 |
|
const ( |
| 354 |
|
authKey ctxKey = iota |
| 355 |
|
reasonKey |
| 356 |
|
) |
| 357 |
|
|
| 358 |
|
// FromContext returns the verified caller of the request Guard admitted. It is |
| 359 |
|
// how a handler behind the guard finds out which sibling service called and on |
| 360 |
|
// whose behalf, without parsing the header again. |
| 361 |
9 |
func FromContext(ctx context.Context) (Auth, bool) { |
| 362 |
9 |
auth, ok := ctx.Value(authKey).(Auth) |
| 363 |
9 |
return auth, ok |
| 364 |
9 |
} |
| 365 |
|
|
| 366 |
|
// Reason returns the refusal Guard is calling a deny handler about, or nil if |
| 367 |
|
// this context is not one. It exists so that a service can supply a deny handler |
| 368 |
|
// that renders its own error page and still log which check failed — the thing |
| 369 |
|
// this protocol is most often debugged by. |
| 370 |
44 |
func Reason(ctx context.Context) error { |
| 371 |
44 |
err, _ := ctx.Value(reasonKey).(error) |
| 372 |
44 |
return err |
| 373 |
44 |
} |