// 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
}