1018 lines
33 KiB
Go
1018 lines
33 KiB
Go
package authz
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/chain"
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
"github.com/lixenwraith/logwisp/internal/tlsx"
|
|
"github.com/lixenwraith/logwisp/internal/tokenbucket"
|
|
|
|
"github.com/lixenwraith/auth"
|
|
"github.com/lixenwraith/toml"
|
|
)
|
|
|
|
const (
|
|
// ExchangeTimeout bounds a whole SCRAM exchange on either side
|
|
ExchangeTimeout = 10 * time.Second
|
|
// DefaultTokenLifetime is how long a bearer token from /auth stays valid
|
|
DefaultTokenLifetime = 15 * time.Minute
|
|
// Token lifetime bounds: exp has whole seconds, a sub-second expires_in
|
|
// reads as unknown, and 10 s leaves room for renewal ahead of expiry
|
|
MinTokenLifetime = 10 * time.Second
|
|
MaxTokenLifetime = 24 * time.Hour
|
|
|
|
maxAuthLine = 4096 // pre-auth lines and /auth bodies come from unauthenticated peers
|
|
streamBuffer = 64 * 1024
|
|
|
|
limitBurst = 10 // failed or abandoned exchanges per address before throttling
|
|
limitRate = 1 // per second
|
|
limitPending = 4 // unfinished exchanges per address
|
|
limitPeers = 65536
|
|
limitIdle = time.Minute
|
|
)
|
|
|
|
// The dialer's floor on the Argon2 cost a server may ask for. A hostile server
|
|
// could otherwise request a cheaply guessable proof; tests lower it.
|
|
var minArgonTime, minArgonMemory uint32 = auth.DefaultArgonTime, auth.DefaultArgonMemory
|
|
|
|
var errPinMismatch = errors.New("server certificate differs from the one the SCRAM login was bound to")
|
|
|
|
// authStep is every SCRAM message after the hello: TCP lines and HTTP bodies.
|
|
// Binding is the client's view of the server certificate, used only to tell
|
|
// TLS interception apart from a wrong password in the listener's log.
|
|
type authStep struct {
|
|
Error string `json:"error,omitempty"`
|
|
Challenge *auth.ServerFirstMessage `json:"challenge,omitempty"`
|
|
Proof *auth.ClientFinalRequest `json:"proof,omitempty"`
|
|
Binding string `json:"binding,omitempty"`
|
|
Final *auth.ServerFinalMessage `json:"final,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
ExpiresIn int64 `json:"expires_in,omitempty"`
|
|
Session string `json:"session,omitempty"` // "cookie": proxy mode, token in a cookie
|
|
Logout bool `json:"logout,omitempty"`
|
|
}
|
|
|
|
// Credentials is a parsed credentials file: the verifiers a listener accepts,
|
|
// never passwords, and the key that keeps unknown-user challenges stable.
|
|
type Credentials struct {
|
|
DecoyKey []byte
|
|
Users []*auth.Credential
|
|
}
|
|
|
|
var credentialKeys = map[string]bool{
|
|
"username": true, "salt": true, "argon_time": true, "argon_memory": true,
|
|
"argon_threads": true, "stored_key": true, "server_key": true,
|
|
}
|
|
|
|
// LoadCredentials reads and validates a credentials file
|
|
func LoadCredentials(path string) (*Credentials, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth: credentials_file: %w", err)
|
|
}
|
|
c, err := ParseCredentials(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth: credentials_file %s: %w", path, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// ParseCredentials validates a whole file: a decoy key, at least one user,
|
|
// unique names, one shared KDF profile (mixed profiles would reveal which
|
|
// accounts exist) and well-formed verifiers.
|
|
func ParseCredentials(data []byte) (*Credentials, error) {
|
|
root, err := toml.NewParser(data).Parse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k := range root {
|
|
if k != "decoy_key" && k != "users" {
|
|
return nil, fmt.Errorf("unknown key %q", k)
|
|
}
|
|
}
|
|
key, _ := root["decoy_key"].(string)
|
|
decoy, err := base64.StdEncoding.Strict().DecodeString(key)
|
|
if err != nil || len(decoy) < 32 {
|
|
return nil, errors.New("decoy_key must hold at least 32 base64-encoded bytes")
|
|
}
|
|
var users []map[string]any
|
|
switch v := root["users"].(type) {
|
|
case []map[string]any:
|
|
users = v
|
|
case []any:
|
|
for _, e := range v {
|
|
if u, ok := e.(map[string]any); ok {
|
|
users = append(users, u)
|
|
}
|
|
}
|
|
}
|
|
if len(users) == 0 {
|
|
return nil, errors.New("no users")
|
|
}
|
|
c := &Credentials{DecoyKey: decoy}
|
|
seen := make(map[string]bool, len(users))
|
|
for i, u := range users {
|
|
for k := range u {
|
|
if !credentialKeys[k] {
|
|
return nil, fmt.Errorf("users[%d]: unknown key %q", i, k)
|
|
}
|
|
}
|
|
cred, err := auth.ImportCredential(u)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("users[%d]: %w", i, err)
|
|
}
|
|
if seen[cred.Username] {
|
|
return nil, fmt.Errorf("users[%d]: duplicate user %q", i, cred.Username)
|
|
}
|
|
seen[cred.Username] = true
|
|
if f := c.Users; len(f) > 0 && (f[0].ArgonTime != cred.ArgonTime || f[0].ArgonMemory != cred.ArgonMemory ||
|
|
f[0].ArgonThreads != cred.ArgonThreads || len(f[0].Salt) != len(cred.Salt)) {
|
|
return nil, fmt.Errorf("users[%d] %q: Argon2 profile differs from %q's; all users must share one", i, cred.Username, f[0].Username)
|
|
}
|
|
c.Users = append(c.Users, cred)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Marshal renders the file in its canonical form
|
|
func (c *Credentials) Marshal() ([]byte, error) {
|
|
users := make([]map[string]any, len(c.Users))
|
|
for i, u := range c.Users {
|
|
users[i] = u.Export()
|
|
}
|
|
body, err := toml.Marshal(map[string]any{
|
|
"decoy_key": base64.StdEncoding.EncodeToString(c.DecoyKey),
|
|
"users": users,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
header := "# logwisp SCRAM verifiers, written by `lw auth add-user`. Keep it private.\n"
|
|
return append([]byte(header), body...), nil
|
|
}
|
|
|
|
// ReadPassword reads a password file, trimming one trailing line break so a
|
|
// file from an editor and one from `lw auth add-user` agree.
|
|
func ReadPassword(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("auth: password_file: %w", err)
|
|
}
|
|
pw := strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r")
|
|
if pw == "" || len(pw) > auth.MaxPasswordLen {
|
|
return "", fmt.Errorf("auth: password_file %s: password must be 1-%d bytes", path, auth.MaxPasswordLen)
|
|
}
|
|
return pw, nil
|
|
}
|
|
|
|
// --- Listener ---
|
|
|
|
type scramListener struct {
|
|
creds *Credentials
|
|
cb []byte // SHA-256 of this listener's certificate, the channel binding; nil behind proxies
|
|
tokens *auth.JWT // HTTP listeners; a per-instance key, so a reload revokes every token
|
|
lifetime time.Duration
|
|
proxy *proxyMode
|
|
server atomic.Pointer[auth.ScramServer]
|
|
limit limiter
|
|
|
|
throttled atomic.Uint64
|
|
busy atomic.Uint64
|
|
bindingMismatch atomic.Uint64
|
|
}
|
|
|
|
func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
|
if len(o.Allow) > 0 || len(o.AllowPatterns) > 0 {
|
|
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the credentials file is the allow list", MethodMTLS)
|
|
}
|
|
if p.role == RoleDialer {
|
|
return p.compileSCRAMDialer(o)
|
|
}
|
|
switch {
|
|
case o.Username != "" || o.PasswordFile != "":
|
|
return errors.New("auth: username and password_file apply only to dialers")
|
|
case o.CredentialsFile == "":
|
|
return fmt.Errorf("auth: type %q requires credentials_file", MethodSCRAM)
|
|
case o.TokenLifetimeMS != 0 && (o.TokenLifetimeMS < MinTokenLifetime.Milliseconds() || o.TokenLifetimeMS > MaxTokenLifetime.Milliseconds()):
|
|
return fmt.Errorf("auth: token_lifetime_ms %d is outside %d (%s) to %d (%s)", o.TokenLifetimeMS,
|
|
MinTokenLifetime.Milliseconds(), MinTokenLifetime, MaxTokenLifetime.Milliseconds(), MaxTokenLifetime)
|
|
case o.TokenLifetimeMS > 0 && p.transport != HTTP:
|
|
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
|
|
case len(o.TrustedProxies) > 0 && (p.role != RoleListener || p.transport != HTTP):
|
|
return errors.New("auth: trusted_proxies applies only to the http sink")
|
|
case len(o.TrustedProxies) > 0 && o.Identity != "":
|
|
return errors.New("auth: identity binds a client certificate, which a TLS-terminating proxy does not pass on; drop it or trusted_proxies")
|
|
}
|
|
if o.Identity != "" {
|
|
// Binds the certificate to the user: a peer needs its own of both
|
|
if tlsCfg.ClientAuth != tls.RequireAndVerifyClientCert {
|
|
return fmt.Errorf("auth: identity under type %q binds the client certificate to the user and requires tls.client_auth", MethodSCRAM)
|
|
}
|
|
var err error
|
|
if p.identity, err = identityMode(o.Identity); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
l := &scramListener{}
|
|
var err error
|
|
if len(o.TrustedProxies) > 0 {
|
|
if l.proxy, err = parseProxies(o.TrustedProxies); err != nil {
|
|
return err
|
|
}
|
|
l.proxy.plaintext = tlsCfg == nil
|
|
} else {
|
|
if len(tlsCfg.Certificates) == 0 || len(tlsCfg.Certificates[0].Certificate) == 0 {
|
|
return errors.New("auth: type scram needs the listener certificate for channel binding")
|
|
}
|
|
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
|
|
l.cb = cb[:]
|
|
}
|
|
if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil {
|
|
return err
|
|
}
|
|
if p.transport == HTTP {
|
|
l.lifetime = DefaultTokenLifetime
|
|
if o.TokenLifetimeMS > 0 {
|
|
l.lifetime = time.Duration(o.TokenLifetimeMS) * time.Millisecond
|
|
}
|
|
key := make([]byte, 32)
|
|
rand.Read(key)
|
|
if l.tokens, err = auth.NewJWT(key, auth.WithIssuer("logwisp"),
|
|
auth.WithTokenLifetime(l.lifetime), auth.WithLeeway(0)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
p.listener = l
|
|
p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile})
|
|
return nil
|
|
}
|
|
|
|
// Start brings up the SCRAM server. Plugins call it from Start, so a policy
|
|
// that is constructed but never started holds no goroutine.
|
|
func (p *Policy) Start() error {
|
|
if p == nil || p.listener == nil {
|
|
return nil
|
|
}
|
|
l := p.listener
|
|
s, err := auth.NewScramServerWithDecoyKey(l.creds.DecoyKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, c := range l.creds.Users {
|
|
if err := s.AddCredential(c); err != nil {
|
|
s.Stop()
|
|
return fmt.Errorf("auth: user %q: %w", c.Username, err)
|
|
}
|
|
}
|
|
l.server.Store(s)
|
|
return nil
|
|
}
|
|
|
|
// Close stops the SCRAM server; exchanges in flight fail
|
|
func (p *Policy) Close() {
|
|
if p == nil || p.listener == nil {
|
|
return
|
|
}
|
|
if s := p.listener.server.Swap(nil); s != nil {
|
|
s.Stop()
|
|
}
|
|
}
|
|
|
|
// begin opens an exchange for a peer: throttling, then the challenge. On
|
|
// failure, status is the HTTP answer and public the only detail sent.
|
|
func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerFirstMessage, status int, public string, err error) {
|
|
l := p.listener
|
|
var req auth.ClientFirstRequest
|
|
if err := json.Unmarshal(first, &req); err != nil {
|
|
p.rejected.Add(1)
|
|
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: malformed client-first message", ErrRefused)
|
|
}
|
|
if !l.limit.start(ip) {
|
|
l.throttled.Add(1)
|
|
return challenge, http.StatusTooManyRequests, "too many attempts", fmt.Errorf("%w: %s throttled", ErrRefused, ip)
|
|
}
|
|
s := l.server.Load()
|
|
if s == nil {
|
|
l.limit.release(ip)
|
|
l.busy.Add(1)
|
|
return challenge, http.StatusServiceUnavailable, "busy", errors.New("auth: scram server is not running")
|
|
}
|
|
challenge, err = s.ProcessClientFirstMessage(req.Username, req.ClientNonce)
|
|
if err != nil {
|
|
l.limit.release(ip)
|
|
}
|
|
switch {
|
|
case errors.Is(err, auth.ErrSCRAMTooManyHandshakes), errors.Is(err, auth.ErrSCRAMStopped):
|
|
l.busy.Add(1)
|
|
return challenge, http.StatusServiceUnavailable, "busy", err
|
|
case err != nil:
|
|
p.rejected.Add(1)
|
|
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
l.limit.track(ip, challenge.FullNonce)
|
|
return challenge, 0, "", nil
|
|
}
|
|
|
|
// finish verifies a proof, binding it to this listener's certificate, then
|
|
// the client certificate to the user when configured.
|
|
func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState) (auth.ServerFinalMessage, error) {
|
|
l := p.listener
|
|
l.limit.done(ip, nonce)
|
|
s := l.server.Load()
|
|
if s == nil {
|
|
l.busy.Add(1)
|
|
return auth.ServerFinalMessage{}, errors.New("auth: scram server is not running")
|
|
}
|
|
var bind auth.ExchangeOption // nil, so unbound, behind proxies
|
|
if l.cb != nil {
|
|
bind = auth.WithChannelBinding(l.cb)
|
|
}
|
|
final, err := s.ProcessClientFinalMessage(nonce, step.Proof.ClientProof, bind)
|
|
if err == nil {
|
|
err = p.bindCertificate(cs, final.Username)
|
|
}
|
|
if err != nil {
|
|
p.rejected.Add(1)
|
|
switch {
|
|
case step.Binding != "" && l.proxy != nil:
|
|
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token -unbound)", err)
|
|
case step.Binding == "" && l.cb != nil:
|
|
err = fmt.Errorf("%w; the client sent an unbound proof (-unbound), but this listener binds logins to its certificate", err)
|
|
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
|
|
l.bindingMismatch.Add(1)
|
|
err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err)
|
|
}
|
|
return auth.ServerFinalMessage{}, fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
p.allowed.Add(1)
|
|
l.limit.succeeded(ip)
|
|
return final, nil
|
|
}
|
|
|
|
func (p *Policy) bindCertificate(cs *tls.ConnectionState, username string) error {
|
|
if p.identity == "" {
|
|
return nil
|
|
}
|
|
if cs == nil {
|
|
return errors.New("no client certificate")
|
|
}
|
|
if got := tlsx.PeerIdentity(*cs, p.identity); got != username {
|
|
return fmt.Errorf("certificate %s %q does not match user %q", p.identity, got, username)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// exchangeTCP runs the listener side after the hello and returns the final
|
|
// line for Accept. An exchange abandoned after its challenge is released from
|
|
// the auth table at once rather than holding a slot for its timeout.
|
|
func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, []byte, error) {
|
|
ip := throttleKey(remoteIP(a.conn.RemoteAddr().String()))
|
|
challenge, _, public, err := p.begin(ip, a.Hello.Scram)
|
|
if err != nil {
|
|
writeStep(a.conn, authStep{Error: public})
|
|
return Identity{}, nil, err
|
|
}
|
|
settled := false
|
|
defer func() {
|
|
if !settled {
|
|
p.listener.limit.done(ip, challenge.FullNonce)
|
|
if s := p.listener.server.Load(); s != nil {
|
|
s.ProcessClientFinalMessage(challenge.FullNonce, "")
|
|
}
|
|
}
|
|
}()
|
|
if err := writeStep(a.conn, authStep{Challenge: &challenge}); err != nil {
|
|
return Identity{}, nil, err
|
|
}
|
|
line, err := readLine(a.Reader)
|
|
if err != nil {
|
|
return Identity{}, nil, fmt.Errorf("read proof: %w", err)
|
|
}
|
|
var step authStep
|
|
if err := json.Unmarshal(line, &step); err != nil || step.Proof == nil {
|
|
p.rejected.Add(1)
|
|
writeStep(a.conn, authStep{Error: "malformed proof"})
|
|
return Identity{}, nil, fmt.Errorf("%w: malformed proof", ErrRefused)
|
|
}
|
|
settled = true
|
|
final, err := p.finish(ip, challenge.FullNonce, step, cs)
|
|
if err != nil {
|
|
writeStep(a.conn, authStep{Error: "authentication failed"})
|
|
return Identity{}, nil, err
|
|
}
|
|
out, err := json.Marshal(authStep{Final: &final})
|
|
if err != nil {
|
|
return Identity{}, nil, err
|
|
}
|
|
return Identity{Name: final.Username, Method: MethodSCRAM}, append(out, '\n'), nil
|
|
}
|
|
|
|
// ServeAuth answers POST /auth on an HTTP scram listener: a hello gets a
|
|
// challenge, a proof gets the server-final message and a bearer token (in
|
|
// proxy mode, a session cookie on request), a logout ends a session. It
|
|
// returns the identity of a completed login, or the refusal, for the
|
|
// plugin's log; other steps return neither.
|
|
func (p *Policy) ServeAuth(w http.ResponseWriter, r *http.Request) (Identity, error) {
|
|
if p == nil || p.listener == nil || p.listener.tokens == nil {
|
|
http.NotFound(w, r)
|
|
return Identity{}, nil
|
|
}
|
|
l := p.listener
|
|
client, err := p.ClientAddr(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusForbidden, authStep{Error: "forbidden"})
|
|
return Identity{}, err
|
|
}
|
|
ip := throttleKey(client)
|
|
// A cross-origin page cannot send JSON without a preflight nobody answers
|
|
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "application/json" {
|
|
p.rejected.Add(1)
|
|
writeJSON(w, http.StatusUnsupportedMediaType, authStep{Error: "malformed request"})
|
|
return Identity{}, errors.New("auth request: Content-Type is not application/json")
|
|
}
|
|
rc := http.NewResponseController(w)
|
|
rc.SetReadDeadline(time.Now().Add(ExchangeTimeout))
|
|
rc.SetWriteDeadline(time.Now().Add(ExchangeTimeout))
|
|
var req struct {
|
|
chain.Hello
|
|
authStep
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthLine)).Decode(&req); err != nil {
|
|
p.rejected.Add(1)
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
writeJSON(w, http.StatusRequestEntityTooLarge, authStep{Error: "request too large"})
|
|
} else {
|
|
writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"})
|
|
}
|
|
return Identity{}, fmt.Errorf("auth request: %w", err)
|
|
}
|
|
cookie := req.Session == "cookie"
|
|
switch {
|
|
case cookie && l.proxy == nil, req.Session != "" && !cookie, req.Logout && l.proxy == nil:
|
|
p.rejected.Add(1)
|
|
writeJSON(w, http.StatusBadRequest, authStep{Error: "sessions and logout need trusted_proxies"})
|
|
return Identity{}, errors.New("auth request: a browser session outside proxy mode")
|
|
case req.Logout:
|
|
return Identity{}, p.logout(w, r)
|
|
case len(req.Scram) > 0 && req.LogWisp == chain.ProtocolVersion:
|
|
challenge, status, public, err := p.begin(ip, req.Scram)
|
|
if err != nil {
|
|
writeJSON(w, status, authStep{Error: public})
|
|
return Identity{}, err
|
|
}
|
|
writeJSON(w, http.StatusOK, authStep{Challenge: &challenge})
|
|
return Identity{}, nil
|
|
case req.Proof != nil:
|
|
final, err := p.finish(ip, req.Proof.FullNonce, req.authStep, r.TLS)
|
|
if err != nil {
|
|
status := http.StatusUnauthorized
|
|
if !errors.Is(err, ErrRefused) {
|
|
status = http.StatusServiceUnavailable
|
|
}
|
|
writeJSON(w, status, authStep{Error: "authentication failed"})
|
|
return Identity{}, err
|
|
}
|
|
token, err := l.tokens.GenerateToken(final.Username, nil)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, authStep{Error: "token unavailable"})
|
|
return Identity{}, err
|
|
}
|
|
answer := authStep{Final: &final, ExpiresIn: int64(l.lifetime / time.Second)}
|
|
if cookie {
|
|
http.SetCookie(w, sessionCookie(token, int(l.lifetime/time.Second)))
|
|
} else {
|
|
answer.Token = token
|
|
}
|
|
writeJSON(w, http.StatusOK, answer)
|
|
return Identity{Name: final.Username, Method: MethodSCRAM}, nil
|
|
}
|
|
p.rejected.Add(1)
|
|
writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"})
|
|
return Identity{}, errors.New("auth request: neither a hello nor a proof")
|
|
}
|
|
|
|
func (p *Policy) authorizeToken(r *http.Request) (Identity, int, error) {
|
|
l := p.listener
|
|
if l.tokens == nil {
|
|
p.rejected.Add(1)
|
|
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: no token issuer on this listener", ErrRefused)
|
|
}
|
|
if _, err := p.ClientAddr(r); err != nil {
|
|
return Identity{}, http.StatusForbidden, err
|
|
}
|
|
token, err := p.presentedToken(r)
|
|
if err != nil {
|
|
p.rejected.Add(1)
|
|
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
user, _, err := l.tokens.ValidateToken(token)
|
|
if err == nil && l.proxy != nil && l.proxy.isRevoked(token) {
|
|
err = errors.New("token ended by logout")
|
|
}
|
|
if err != nil {
|
|
p.rejected.Add(1)
|
|
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: bearer %w", ErrRefused, err)
|
|
}
|
|
if err := p.bindCertificate(r.TLS, user); err != nil {
|
|
p.rejected.Add(1)
|
|
return Identity{}, http.StatusForbidden, fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
return Identity{Name: user, Method: MethodSCRAM}, 0, nil
|
|
}
|
|
|
|
func (l *scramListener) stats(d map[string]any) {
|
|
d["auth_users"] = len(l.creds.Users)
|
|
d["auth_throttled"] = l.throttled.Load()
|
|
d["auth_busy"] = l.busy.Load()
|
|
d["auth_binding_mismatch"] = l.bindingMismatch.Load()
|
|
if l.tokens != nil {
|
|
d["auth_token_lifetime_ms"] = l.lifetime.Milliseconds()
|
|
}
|
|
if l.proxy != nil {
|
|
proxies := make([]string, len(l.proxy.trusted))
|
|
for i, p := range l.proxy.trusted {
|
|
proxies[i] = p.String()
|
|
}
|
|
d["auth_trusted_proxies"] = proxies
|
|
}
|
|
}
|
|
|
|
// --- Dialer ---
|
|
|
|
type scramDialer struct {
|
|
username string
|
|
password string
|
|
unbound bool // HTTP logins to a listener behind a TLS-terminating proxy
|
|
token atomic.Pointer[string]
|
|
renewAt atomic.Int64 // unix nanoseconds; Prepare logs in again from then
|
|
expires atomic.Int64 // unix nanoseconds; 0 = lifetime unknown
|
|
pin atomic.Pointer[[]byte] // HTTP: certificate the token's login was bound to
|
|
failures atomic.Uint64
|
|
lastErr atomic.Pointer[string]
|
|
}
|
|
|
|
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
|
|
switch {
|
|
case o.CredentialsFile != "", o.TokenLifetimeMS != 0, len(o.TrustedProxies) > 0:
|
|
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
|
|
case o.Identity != "":
|
|
return fmt.Errorf("auth: identity on a dialer pins the server and applies only to type %q", MethodMTLS)
|
|
case o.Username == "" || o.PasswordFile == "":
|
|
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", MethodSCRAM)
|
|
}
|
|
password, err := ReadPassword(o.PasswordFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d := &scramDialer{username: o.Username, password: password}
|
|
if _, err := d.client().StartAuthentication(); err != nil {
|
|
return fmt.Errorf("auth: username %q: %w", o.Username, err)
|
|
}
|
|
p.dialer = d
|
|
p.secrets = append(p.secrets, secretFile{"auth.password_file", o.PasswordFile})
|
|
return nil
|
|
}
|
|
|
|
// Unbind makes a dialer's HTTP logins unbound, for an http sink behind a
|
|
// TLS-terminating proxy whose certificate logwisp never sees
|
|
func (p *Policy) Unbind() {
|
|
if p != nil && p.dialer != nil {
|
|
p.dialer.unbound = true
|
|
}
|
|
}
|
|
|
|
func (d *scramDialer) client() *auth.ScramClient {
|
|
return auth.NewScramClient(d.username, d.password, auth.WithMinArgonCost(minArgonTime, minArgonMemory))
|
|
}
|
|
|
|
// exchangeTCP runs the dialer side on conn; nothing is trusted until the
|
|
// server's final signature proves it holds this user's verifier.
|
|
func (d *scramDialer) exchangeTCP(conn net.Conn, r *bufio.Reader, node string) (err error) {
|
|
defer func() { d.record(err) }()
|
|
tc, ok := conn.(*tls.Conn)
|
|
if !ok {
|
|
return errors.New("auth: scram requires TLS")
|
|
}
|
|
c := d.client()
|
|
first, err := c.StartAuthentication()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scram, err := json.Marshal(first)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
line, err := chain.EncodeHello(chain.Hello{Node: node, Scram: scram})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := conn.Write(line); err != nil {
|
|
return fmt.Errorf("hello: %w", err)
|
|
}
|
|
step, err := readStep(r)
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
return fmt.Errorf("%w: no challenge within %v (older logwisp, or not a scram listener)", ErrRefused, ExchangeTimeout)
|
|
}
|
|
return err
|
|
}
|
|
if step.Challenge == nil {
|
|
return errors.New("auth: server sent no challenge")
|
|
}
|
|
cb := certHash(tc.ConnectionState())
|
|
proof, err := c.ProcessServerFirstMessage(*step.Challenge, auth.WithChannelBinding(cb))
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
if err := writeStep(conn, authStep{Proof: &proof, Binding: base64.StdEncoding.EncodeToString(cb)}); err != nil {
|
|
return err
|
|
}
|
|
if step, err = readStep(r); err != nil {
|
|
return err
|
|
}
|
|
return d.verifyFinal(c, step)
|
|
}
|
|
|
|
func (d *scramDialer) verifyFinal(c *auth.ScramClient, step authStep) error {
|
|
if step.Final == nil {
|
|
return errors.New("auth: server sent no final message")
|
|
}
|
|
if err := c.VerifyServerFinalMessage(*step.Final); err != nil {
|
|
return fmt.Errorf("%w: the server could not prove it holds this user's verifier: %w", ErrRefused, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Token logs in over HTTP and returns a fresh bearer token: for the CLI, and
|
|
// behind Prepare. The certificate of the first answer is pinned before the
|
|
// proof is sent, so neither the proof nor the token reaches another server.
|
|
func (p *Policy) Token(ctx context.Context, client *http.Client, baseURL string) (token string, err error) {
|
|
if p == nil || p.dialer == nil {
|
|
return "", fmt.Errorf("auth: tokens need type %q on a dialer", MethodSCRAM)
|
|
}
|
|
d := p.dialer
|
|
defer func() { d.record(err) }()
|
|
d.dropToken() // a rotated server certificate must be able to bind anew
|
|
ctx, cancel := context.WithTimeout(ctx, ExchangeTimeout)
|
|
defer cancel()
|
|
url := baseURL + chain.AuthPath
|
|
|
|
c := d.client()
|
|
first, err := c.StartAuthentication()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scram, err := json.Marshal(first)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hello, err := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
step, resp, err := postStep(ctx, client, url, hello)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if step.Challenge == nil || resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
|
return "", errors.New("auth: no challenge over TLS")
|
|
}
|
|
cb := certHash(*resp.TLS)
|
|
d.pin.Store(&cb) // unbound too: both requests must reach the same server
|
|
var bind auth.ExchangeOption
|
|
var binding string
|
|
if !d.unbound {
|
|
bind, binding = auth.WithChannelBinding(cb), base64.StdEncoding.EncodeToString(cb)
|
|
}
|
|
proof, err := c.ProcessServerFirstMessage(*step.Challenge, bind)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
body, err := json.Marshal(authStep{Proof: &proof, Binding: binding})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if step, _, err = postStep(ctx, client, url, body); err != nil {
|
|
return "", err
|
|
}
|
|
if err := d.verifyFinal(c, step); err != nil {
|
|
return "", err
|
|
}
|
|
if step.Token == "" {
|
|
return "", errors.New("auth: server issued no token")
|
|
}
|
|
now, lifetime := time.Now(), time.Duration(step.ExpiresIn)*time.Second
|
|
d.renewAt.Store(renewAt(now, lifetime).UnixNano())
|
|
d.expires.Store(0)
|
|
if lifetime > 0 {
|
|
d.expires.Store(now.Add(lifetime).UnixNano())
|
|
}
|
|
d.token.Store(&step.Token)
|
|
return step.Token, nil
|
|
}
|
|
|
|
// renewAt schedules a fresh login ahead of expiry, so a healthy link never
|
|
// pays a refused request each lifetime; 401 stays the fallback for reloads.
|
|
func renewAt(now time.Time, lifetime time.Duration) time.Time {
|
|
if lifetime <= 0 {
|
|
return now.Add(100 * 365 * 24 * time.Hour) // lifetime unknown: renew on 401 only
|
|
}
|
|
margin := min(max(5*time.Second, lifetime/10), lifetime/2)
|
|
return now.Add(lifetime - margin)
|
|
}
|
|
|
|
func (d *scramDialer) dropToken() {
|
|
d.token.Store(nil)
|
|
d.pin.Store(nil)
|
|
}
|
|
|
|
func (d *scramDialer) verifyPin(cs tls.ConnectionState) error {
|
|
pin := d.pin.Load()
|
|
if pin == nil {
|
|
return nil
|
|
}
|
|
if len(cs.PeerCertificates) == 0 || !bytes.Equal(certHash(cs), *pin) {
|
|
return errPinMismatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *scramDialer) record(err error) {
|
|
msg := ""
|
|
if err != nil {
|
|
d.failures.Add(1)
|
|
msg = err.Error()
|
|
}
|
|
d.lastErr.Store(&msg)
|
|
}
|
|
|
|
func (d *scramDialer) stats(m map[string]any) {
|
|
m["auth_username"] = d.username
|
|
m["auth_failures"] = d.failures.Load()
|
|
if e := d.lastErr.Load(); e != nil {
|
|
m["last_auth_error"] = *e
|
|
}
|
|
}
|
|
|
|
// putOff marks a login the server deferred (throttled, busy) rather than
|
|
// refused: a token still held stays usable meanwhile.
|
|
type putOff struct{ error }
|
|
|
|
func (e putOff) Unwrap() error { return e.error }
|
|
|
|
// postStep sends one /auth request; any answer but 200 is a refusal
|
|
func postStep(ctx context.Context, client *http.Client, url string, body []byte) (authStep, *http.Response, error) {
|
|
var step authStep
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return step, nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return step, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthLine))
|
|
if err != nil {
|
|
return step, resp, err
|
|
}
|
|
decodeErr := json.Unmarshal(data, &step)
|
|
switch {
|
|
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed:
|
|
return step, resp, fmt.Errorf("%w: no auth endpoint at %s (older logwisp, or not a scram listener)", ErrRefused, url)
|
|
case resp.StatusCode != http.StatusOK:
|
|
msg := step.Error
|
|
if msg == "" {
|
|
msg = resp.Status
|
|
}
|
|
err := fmt.Errorf("%w: server: %s", ErrRefused, msg)
|
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
|
|
err = putOff{err}
|
|
}
|
|
return step, resp, err
|
|
case decodeErr != nil:
|
|
return step, resp, fmt.Errorf("auth: malformed answer: %w", decodeErr)
|
|
}
|
|
return step, resp, nil
|
|
}
|
|
|
|
// AwaitClose blocks until a listener ends a link after its admission. Chain
|
|
// sources never write once a link is up, so a line is a refusal (ErrRefused,
|
|
// sent to dialers without scram that never read it) and EOF a close.
|
|
func AwaitClose(r *bufio.Reader) error {
|
|
line, err := readLine(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var step authStep
|
|
if json.Unmarshal(line, &step) == nil && step.Error != "" {
|
|
return fmt.Errorf("%w: server: %s", ErrRefused, step.Error)
|
|
}
|
|
return errors.New("unexpected data from server")
|
|
}
|
|
|
|
// --- Wire helpers ---
|
|
|
|
// readLine refuses a line over maxAuthLine as soon as it arrives: r buffers
|
|
// far more, for the stream after the exchange.
|
|
func readLine(r *bufio.Reader) ([]byte, error) {
|
|
for {
|
|
b, _ := r.Peek(r.Buffered())
|
|
if i := bytes.IndexByte(b, '\n'); i >= 0 && i < maxAuthLine {
|
|
line := bytes.Clone(bytes.TrimRight(b[:i+1], "\r\n"))
|
|
r.Discard(i + 1)
|
|
return line, nil
|
|
}
|
|
if len(b) >= maxAuthLine {
|
|
return nil, fmt.Errorf("line exceeds %d bytes", maxAuthLine)
|
|
}
|
|
if _, err := r.Peek(len(b) + 1); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func readStep(r *bufio.Reader) (authStep, error) {
|
|
var step authStep
|
|
line, err := readLine(r)
|
|
if err != nil {
|
|
return step, err
|
|
}
|
|
if err := json.Unmarshal(line, &step); err != nil {
|
|
return step, fmt.Errorf("auth: malformed answer: %w", err)
|
|
}
|
|
if step.Error != "" {
|
|
return step, fmt.Errorf("%w: server: %s", ErrRefused, step.Error)
|
|
}
|
|
return step, nil
|
|
}
|
|
|
|
func writeStep(w io.Writer, step authStep) error {
|
|
line, err := json.Marshal(step)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(append(line, '\n'))
|
|
return err
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, step authStep) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
writeStep(w, step)
|
|
}
|
|
|
|
func certHash(cs tls.ConnectionState) []byte {
|
|
if len(cs.PeerCertificates) == 0 {
|
|
return nil
|
|
}
|
|
h := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
|
return h[:]
|
|
}
|
|
|
|
func remoteIP(addr string) string {
|
|
if host, _, err := net.SplitHostPort(addr); err == nil {
|
|
return host
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// --- Throttling ---
|
|
|
|
// throttleKey is what the limiter counts: an address, or for IPv6 its /64,
|
|
// which one host usually holds whole. Link-local too, per zone: a peer picks
|
|
// any fe80::/64 address, so per address it would escape its budget.
|
|
func throttleKey(ip string) string {
|
|
addr, err := netip.ParseAddr(ip)
|
|
if err != nil || addr.Is4() {
|
|
return ip
|
|
}
|
|
key := netip.PrefixFrom(addr, 64).Masked().String()
|
|
if zone := addr.Zone(); zone != "" {
|
|
key += "%" + zone
|
|
}
|
|
return key
|
|
}
|
|
|
|
// limiter bounds SCRAM attempts per remote address: failed or abandoned
|
|
// exchanges drain a token bucket (successes are refunded) and at most
|
|
// limitPending may be unfinished. A full table fails closed.
|
|
type limiter struct {
|
|
mu sync.Mutex
|
|
peers map[string]*peerLimit
|
|
lastSweep time.Time
|
|
}
|
|
|
|
type peerLimit struct {
|
|
bucket *tokenbucket.TokenBucket
|
|
pending map[string]time.Time // full nonce -> expiry
|
|
reserved int // started, challenge not yet issued
|
|
seen time.Time
|
|
}
|
|
|
|
func (l *limiter) start(ip string) bool {
|
|
now := time.Now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.peers == nil {
|
|
l.peers = make(map[string]*peerLimit)
|
|
}
|
|
if now.Sub(l.lastSweep) > limitIdle/6 {
|
|
l.sweep(now)
|
|
}
|
|
pl := l.peers[ip]
|
|
if pl == nil {
|
|
if len(l.peers) >= limitPeers {
|
|
return false
|
|
}
|
|
pl = &peerLimit{bucket: tokenbucket.New(limitBurst, limitRate), pending: make(map[string]time.Time)}
|
|
l.peers[ip] = pl
|
|
}
|
|
pl.seen = now
|
|
pl.expire(now)
|
|
// Reserved under this lock: concurrent hellos cannot all pass the check
|
|
if len(pl.pending)+pl.reserved >= limitPending || !pl.bucket.Allow() {
|
|
return false
|
|
}
|
|
pl.reserved++
|
|
return true
|
|
}
|
|
|
|
// track turns start's reservation into the exchange's nonce
|
|
func (l *limiter) track(ip, nonce string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if pl := l.peers[ip]; pl != nil {
|
|
pl.reserved--
|
|
pl.pending[nonce] = time.Now().Add(auth.ScramHandshakeTimeout)
|
|
}
|
|
}
|
|
|
|
// release frees start's reservation when no challenge was issued
|
|
func (l *limiter) release(ip string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if pl := l.peers[ip]; pl != nil {
|
|
pl.reserved--
|
|
}
|
|
}
|
|
|
|
func (l *limiter) done(ip, nonce string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if pl := l.peers[ip]; pl != nil {
|
|
delete(pl.pending, nonce)
|
|
}
|
|
}
|
|
|
|
func (l *limiter) succeeded(ip string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if pl := l.peers[ip]; pl != nil {
|
|
pl.bucket.Refund(1)
|
|
}
|
|
}
|
|
|
|
func (pl *peerLimit) expire(now time.Time) {
|
|
for nonce, expiry := range pl.pending {
|
|
if now.After(expiry) {
|
|
delete(pl.pending, nonce)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sweep drops addresses idle long enough for their bucket to be full again
|
|
func (l *limiter) sweep(now time.Time) {
|
|
l.lastSweep = now
|
|
for ip, pl := range l.peers {
|
|
pl.expire(now) // an abandoned HTTP challenge has no done
|
|
if now.Sub(pl.seen) > limitIdle && len(pl.pending) == 0 && pl.reserved == 0 {
|
|
delete(l.peers, ip)
|
|
}
|
|
}
|
|
}
|