// Package internalauth is the service-to-service authentication of a SourceHut // instance: the "Authorization: Internal " 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 ", 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 }